2021/09/27

WebRTC API

Web Real-Time Communication API 是提供網頁進行視訊或語音串流通訊的技術,可以直接使用瀏覽器進行資料交換。WebRTC 可提供瀏覽器在不需要安裝外掛程式或第三方軟體下,分享應用程式的資料和進行電話會議。

因為 WebRTC 還在發展當中,各 browser 有不同程度的 codec 支援以及 WebRTC 功能。故建議使用 adapter ,這是 Google 提供,減少跨瀏覽器差異的 library。

browser compatability matrix 是不同瀏覽器的相容表。

WebRTC 有多種功能,包含 audio, video conferencing,檔案交換,screen sharing,identity management,支援傳統交換機的 DTMF signals,兩端點之間的連線不需要特殊的 drivers/plugins,也可不需要中間 server。

兩個 peers 之間是以 RTCPeerConnection 產生連線,當連線建立後,就能夠使用 MediaStream 或 RTCDataChannel。

MediaStream 包含了數個 media information tracks,是由 MediaStreamTrack 介面表示的物件,可包含多種 media data: audio, video, text,大部分的 streams 包含至少一條 audio track,一條 video track,可用來傳送 live media,或將 media 存檔。

也可以用 RTCDataChannel 介面在兩端之間,傳遞任意的 binray data,這邊可用來傳送 channel-information, 交換 metadata,game status, file transfer。

WebRTC References

Connection setup and management

Interfaces

Interfaces Description
RTCPeerConnection local 與 remote peer 之間的 WebRTC connection
RTCDataChannel connection 的兩個 peers 之間的 bi-directional data channel
RTCDataChannelEvent 將 RTCDataChannel 接到 RTCPerrConnection 的 event
RTCSessionDescription session 的參數,裡面包含了 description type,代表它所描述的部分 offer/answer negotiation process 以及該 seesion的 SDP descriptor
RTCStatsReport connection 或是 tack on connection 的統計資訊。以 RTCPeerConnection.getStats() 取得。使用 WebRTC 的統計資料有另外的 WebRTC Statistics API
RTCIceCandidate 代表一個可用來建立 RTCPeerConnection 的 candidate ICE server
RTCIceTransport ICE transport 的資訊
RTCPeerConnectionIceEvent 在 RTCPeerConnection 中,有關 ICE candidates 的相關 events。event 是使用 type: icecandidate
RTCRtpSender 在 RTCPeerConnection 裡面的 MediaStreamTrack,用來encoding 傳輸資料
RTCRtpReceiver 在 RTCPeerConnection 裡面的 MediaStreamTrack,用來decoding 傳輸資料
RTCTrackEvent track event 介面。通常是 RTCRtpReceiver 接到 RTCPeerConnection 時發生,代表 RTCPeerConenction 產生了新的 MediaStreamTrack
RTCSctpTransport SCTP transport 的資訊,可在 RTCPeerConnection 的 data channel 收送資料時,透過 SCTP packets,存取Datagram Transport Layer Security(DTLS)

Dictionaries

Dictionaries Description
RTCConfiguration RTCPeerConnection 的 conifguration options
RTCIceServer 定義如何連接 ICE Sever,例如 Stun or Turn server
RTCRtpContributingSource 有關 contributing source (SCRC) 的資訊,包含該 source 被播放了一個最新的 packet 的 most recent time

ex:

var configuration = { iceServers: [{
                          urls: "stun:stun.services.mozilla.com",
                          username: "louis@mozilla.com",
                          credential: "webrtcdemo"
                      }, {
                          urls: ["stun:stun.example.com", "stun:stun-1.example.com"]
                      }]
};

var pc = new RTCPeerConnection(configuration);

Events

Events Description
bufferedamountlow data channel 目前 buffered 的資料量,也就是 bufferedAmount property 已經低於 bufferedAmountLowThreshold 這個 minimum buffered data size
close data channel 已結束 process,進入 closed state,data transport 已經完全 closed,在沒有完全 closed 的時候,會發送 closing event
closing RTCDataChannel 已轉換到 closing state,如果完全停止則是 close event
connectionstatechange 連線狀態(connectionState) 改變
datachannel 有新的 RTCDataChannel 可以使用,事件類別為 RTCDataChannelEvent
error data channel 發生錯誤 RTCErrorEvent
error RTCDtlsTransport 發生錯誤 RTCErrorEvent,可能是 dtls-failure 或是 fingerprint-failure
gatheringstatechange RTCIceTransport 的 gathering state 已改變
icecandidate RTCPeerConnectonIceEvent
當 local device 識別到一個新的 ICE candidate,並需要 local peer 去呼叫 setLocalDescription() 時,會產生此 event
icecandidateerror RTCPeerConnectionIceErrorEvent
在 gathering ICE candidates 發生錯誤時
iceconnectionstatechange 當 ICE gathering state (屬性 icegatheringstate) 改變時,會發送給 RTCPeerConnection
message data channel 收到一個訊息,event type 為 MessageEvent
negotiationneeded 通知 RTCPeerconnection 需要處理 session negotiation,可在 setLocalDescription() 後,呼叫 createOffer()
open RTCDataChannel 的 data transport 已經 opened 或 re-opened
selectedcandidatepairchane RTCIceTransport 目前選用的 ICE candidates 改變
track 當新的 track 在成功協調 media streaming 後,會產生 RTCTrackevent 發送給 RTCPeerConnection
signalingstatechange 當 signalingstate 改變時,送給 peer connection。這會發生在呼叫 setLocalDescription() 或 setRemoteDescription() 之後
statechange RTCDtlsTransport 的狀態改變
statechange RTCIceTransport 的狀態改變
statechange RTCSctpTransport 的狀態改變

Types

Types Description
RTCSctpTransport.state 代表 RTCSctpTrnasport instance 的 state
RTCSessionDescriptionCallback 當要求 create offers 或 answers 時,RTCSessionDescriptionCallback 會傳給 RTCPeerConnection

Identity and security

管理 identity and security,用在連線的認證

identity Description
RTCIdentityProvider user agent,可要求處理 identity assertion
RTCIdentityAssertion 代表 current connection 的 identity of the remote peer。如果目前沒有 peer,就會回傳 null,一但被設定,就不能被修改
RTCIdentityProviderRegistrar Registers and identity provider (idP)
RTCIdentityEvent 代表 identity provider (idP) 產生了 identity assertion。event type 為 identityresult
RTCIdentityErrorEvent 代表 identity provider (idP) 產生了 error。event type 為 idpassertionerror 或 idpvalidationerror
RTCertificate RTCPeerConnection 用來認證的 certificate

Telephony

PSTN 使用的 dialing tone

Interfaces

Interfaces Description
RTCDTMFSender 發送 DTMF signaling
RTCDTMFToneChangeEvent tonechange event 用來表示 DTMF 開始或結束

Events

Events Description
tonechange 當connection 開始播放 DTMF tone,或是 RTCDTMFSender 的 toneBuffer 已經送完時,會產生 event type: RTCDTMFToneChangeEvent

WebRTC Connectivity

Signaling

WebRTC 必須搭配 signaling service 才能使用。用來交換 SDP 資訊,也就是 offer/answer 資料

  • Peer A init connection 產生 Offer
  • 透過 signal channel 發送 Offer 給 Peer B
  • Peer B 接收 Offer,產生 Answer
  • 透過 signal channel 回送 Answer

Session Descriptions

configuration of an endpoint on a WebRTC connection 就稱為 session description

以 SDP 描述,裡面包含 media type, format, transfer protocol, endpoint's IP/Port, 其他資訊

media data exchange 是以 Interactive Connectivity Establishment (ICE) 處理,該協定可讓兩個在 NAT 下的 devices 使用 intermediary 交換 offers, answers

每個 peer 都留存兩個 description:localdescription 及 remote description

當建立 call 或是 某一端要修改 configuration 時,就會透過 offer/answer process 處理,以下是基本的 steps

  1. caller 透過 MediaDevices.getUserMedia 取得 local Media
  2. caller 產生 RTCPeerConnection,呼叫 RTCPeerConnection.addTrack()
  3. caller 呼叫 RTCPeerConnection.createOffer() 產生 offer
  4. caller 呼叫 RTCPeerConnection.setLocalDescription() 發送 offer 到 local description
  5. caller 詢問 STUN servers 用以產生 ice candidates
  6. caller 使用 signaling server 傳送 offer 給 receiver
  7. receiver 接收 offer,呼叫 RTCPeerConnection.setRemoteDescription() 作為 remote description
  8. receiver 設定 call
    • capture local media
    • 呼叫 RTCPeerConnection.addTrack()
  9. receiver 透過 RTCPeerConnection.createAnswer() 產生 answer
  10. receiver 呼叫 RTCPeerConnection.setLocalDescription(),傳入 answer,也就是將 answer 設定為 local description
  11. receiver 使用 signaling server 發送 answer 給 caller
  12. caller 收到 answer
  13. caller 呼叫 RTCPeerConnection.setRemoteDescription() 將 answer 設定為 remote description
  14. 然後就能開始互傳 media

Pending and Current Descriptions

在處理 localDescription 與 remoteDescription 時,可能會因為格式不相容而被 reject,因此需要一個機制能夠 propose a new format,直到另一端接受

current description:由 RTCPeerConnection.currentLocalDescription 及 RTCPeerConenction.currentRemoteDescription 回傳,這也是目前雙方同意使用的 description。

pending description:由 RTCPeerConnection.pendingLocalDescription 及 RTCPeerConnection.pendingRemoteDescription 回傳,代表 setLocalDesciption() 或 setRemoteDescription() 目前考慮中的 description

RTCPeerConnection.localDescription 與 RTCPeerConnection.remoteDescription 回傳的 description,如果在 pending description 狀態,就是回傳 pending description,否則就回傳 current description

呼叫 setLocalDescription() 或 setRemoteDescription() 時,description 會先設定為 pending description,然後 WebRTC layer 開始評估要不要接受,一但 proposed description 被接受,current description 就會改成 pending description 的值,pending description 被改為 null

pendingLocalDescription 包含 offer or answer,還有 local ICE candidates

pendingRemoteDescription 包含 remote ICE candidates,可呼叫 RTCPeerconnection.addIceCandidate() 提供資訊

ICE Candidates

除了交換 media information,還需要交換 network connection 的 informations,也就是 ICE candidate 以及 peer 能夠通訊的方法 (directly or through a TURN server)

通常每個 peer 會先 propose 最佳的 candidates,理想狀態要使用 UDP (速度快,且容易 recover 中斷),但 ICE 標準也允許使用 TCP (當無法使用 UDP 時),另外不是所有瀏覽器都支援 ICE over TCP

UDP candidate types

  • host:實體 IP,可直接連線

  • srflx:Server Reflexive and Relayed Candidates

    host 因為經過 NAT,透過 Stun/Turn server 產生的 candidate。connection 最初要求來自 STUN server 的 candidate,並將 request 傳給 remote peer

  • prflx: Peer Reflexive Candidates

    來自 symmetric NAT,通常是 trickle ICE (也就是 additional candidate 交換,發生在 primary signaling 之後,在 verification 結束以前) 的 additional candidate

    是直接發送給 peer host 產生的 candidate

  • relay

    類似 srflx,但是由 Turn Server 產生的中繼 candidate IP:Port

tcp 有三種:active, passive, so

  • active

    會打開 outbound connection,且不接受 incoming connection request。這是最常見的類型

  • passive

    只會接受 incoming connection

  • so

    會嘗試同時在兩個終端直接打開連線

Choosing a candidate pair

ICE layer 會選擇兩個 peers 中的某一個當作 controlling agent。ICE Agent 會決定 connection 最後使用的 candidate pair,另一個 peer 就稱為 controlled agent。可透過 RTCIceCandidate.transport.role 查詢

controlling agent 除了決定 candidate pair 以外,還會透過 STUN,signaling 選用的 controlled agent,更新 offer。controlled agent 只會等待通知使用哪一個 candidate pair

ICE session 可能會讓 controlling agent 選擇多個 candidate pair,每次分享資訊給 controlled agent 時,兩端會重新設定 connection 使用的 candidate pair

一但 ICE session 完成,configuration 就固定了,除非發生 ICE reset

在每個 candidate generation 結束時,會以 RTCIceCandidate 格式發送 end-of-candidates notification,也就是 candidate 屬性為空字串。這個 candidate 還是要呼叫 addIceCandidate() 加入 connection,以便通知 remote peer

如果在 negotiation 時,沒有其他的 candidates,就會發送 end-of-candidates notification,發送 candidate 屬性為 null 的 RTCIceCandidate。這個訊息不需要傳給 remote peer。這是 legacy notification of a state,可用 icegatheringstatechange event 偵測得知

ICE rollbacks

如果不像掛斷既有的 call,可作 renegotiation

ICE rollback 會 restore 上次連線中 signalingState 為 stable 的 SDP offer

發送 description 其 type 為 rollback,就可以 init rollback,該 description 的其他 properties 都會被忽略

當 peer 已經有先前產生的 offer 時,ICE agent 會自動產生 rollback。如果 local peer 的狀態為 have-local-offer,也就是 local pper 有發送過 offer

呼叫 setRemoteDescription() 可收到 received offer triggers rollback

完整的 exchange


Signaling and video calling

Signaling and video calling

discovery 與 negotiation process 用以產生 conenction,稱為 signaling

WebRTC 需要兩個不同網路的端點,能夠互相找到對方,並協調 media format,需要透過第三方 server 處理 signaling

signaling server

兩個 devices 之間的 WebRTC 連線需要先透過 signaling server 協調兩端

WebRTC 不指定 transport mechanism,可使用 WebSocket 或是 XMLHttpRequest

server 不需要了解或是處理 signaling data content (SDP),只需要傳遞。ICE subsystem 要如何傳送 singaling data 給另一個 peer 是比較重要的問題。

準備給 signaling 使用的 chat server

chat server 透過 WebSocket 傳送 JSON string

可用類似方法,做 signaling 與 ICE negotiation

# 將訊息傳送給特定 target 的 function
function sendToOneUser(target, msgString) {
  var isUnique = true;
  var i;

  for (i=0; i < connectionArray.length; i++) {
    if (connectionArray[i].username === target) {
      connectionArray[i].send(msgString);
      break;
    }
  }
}

Designing the signaling protocol

需要一個 protocol 交換 message,以下利用 JSON object 作為範例

Exchange session descriptions

在開始 singaling process 時,user init call 並產生 offer,裡面包含 session description (SDP 格式),該資料要傳給 callee,callee 會以 answer 回應

設計用 video-offer video-answer 分別代表 offer, answer

JSON 欄位 說明
type message type, video-offer or video-answer
name sender's name
target callee's name
sdp SDP string

兩端可知道 codecs, codec parameters,但還不知道如何傳遞 media data。透過 ICE candidates 處理

Exchange ICE candidates

兩端需要交換 ICE candidates 協調真正的 connection,每個 ICE candidate 都會描述一個可使用的溝通管道。peer 會持續發送找到的 candidates,不管 streaming 是否已經開始了。

icecandidate event 會送給 RTCPeerConenction,並以 pc.setLocalDescription(offer) 完成 process

如果有更好的 candidate,stream 可修改 formats

欄位 說明
type new-ice-candidate
target callee's name
candidate SDP candidate string
不需要處理訊息內容,只需要傳給 remote peer

每個 ICE message 會提供 TCP/UDP, IP address, port, connection type (說明是 peer IP 或是 relay server),包含 NAT 資訊

note: 當收到 onicecandidate 時,要將 candidate 透過 signaling connection 傳給另一個 peer。收到 ICE candidate message 時,就呼叫 RTCPeerConnection.addIceCandidate()。建議不要嘗試修改 SDP

note: onicecandidate event 與 createAnswer() 都是 async call,注意 signaling 不要修改處理順序。setRemoteDescription() 後,才能呼叫 addIceCandidate()

Signaling transaction flow

  • Each user's client running within a web browser
  • Each user's web browser
  • The signaling server
  • The web server hosting the chat service

ICE candidate exchange process

在 local ICE layer 收到的 candidate,各自發送給另一端,當兩端同意使用某個 candidate,就會開始傳遞 media

當網路狀況有異動,peer 可能會建議切換到不同 media resolution 或 不同 codec,就會啟動新的 exchange of candidates

client application

兩個 video elements,一個 hangup 按鈕

<div class="flexChild" id="camera-container">
  <div class="camera-box">
    <video id="received_video" autoplay></video>
    <video id="local_video" autoplay muted></video>
    <button id="hangup-button" onclick="hangUpCall();" disabled>
      Hang Up
    </button>
  </div>
</div>
// 透過 WebSocket 發送訊息給 signaling server
function sendToServer(msg) {
  var msgJSON = JSON.stringify(msg);

  connection.send(msgJSON);
}
// users: 每個連線的 user 的 usernames
function handleUserlistMsg(msg) {
  var i;
  var listElem = document.querySelector(".userlistbox");

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  msg.users.forEach(function(username) {
    var item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite, false);

    listElem.appendChild(item);
  });
}
// 當 user click 某個想要呼叫的 username,就呼叫 inivte
var mediaConstraints = {
  audio: true, // We want an audio track
  video: true // ...and we want a video track
};

function invite(evt) {
  if (myPeerConnection) {
    alert("You can't start a call because you already have one open!");
  } else {
    var clickedUsername = evt.target.textContent;

    if (clickedUsername === myUsername) {
      alert("I'm afraid I can't let you talk to yourself. That would be weird.");
      return;
    }

    targetUsername = clickedUsername;
    createPeerConnection();

    navigator.mediaDevices.getUserMedia(mediaConstraints)
    .then(function(localStream) {
      document.getElementById("local_video").srcObject = localStream;
      localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream));
    })
    .catch(handleGetUserMediaError);
  }
}
// 如果 getUserMedia 發生 error 的錯誤處理
function handleGetUserMediaError(e) {
  switch(e.name) {
    case "NotFoundError":
      alert("Unable to open your call because no camera and/or microphone" +
            "were found.");
      break;
    case "SecurityError":
    case "PermissionDeniedError":
      // Do nothing; this is the same as the user canceling the call.
      break;
    default:
      alert("Error opening your camera and/or microphone: " + e.message);
      break;
  }

  closeVideoCall();
}
// 建立 PeerConnection
function createPeerConnection() {
  myPeerConnection = new RTCPeerConnection({
      iceServers: [     // Information about ICE servers - Use your own!
        {
          urls: "stun:stun.stunprotocol.org"
        }
      ]
  });

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.ontrack = handleTrackEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.onremovetrack = handleRemoveTrackEvent;
  myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
}
  • Starting negotiation
// 處理 negotiationneeded event
function handleNegotiationNeededEvent() {
  myPeerConnection.createOffer().then(function(offer) {
    return myPeerConnection.setLocalDescription(offer);
  })
  .then(function() {
    sendToServer({
      name: myUsername,
      target: targetUsername,
      type: "video-offer",
      sdp: myPeerConnection.localDescription
    });
  })
  .catch(reportError);
}
  • session negotiation
    • Handling the invitation
function handleVideoOfferMsg(msg) {
  var localStream = null;

  targetUsername = msg.name;
  createPeerConnection();

  var desc = new RTCSessionDescription(msg.sdp);

  myPeerConnection.setRemoteDescription(desc).then(function () {
    return navigator.mediaDevices.getUserMedia(mediaConstraints);
  })
  .then(function(stream) {
    localStream = stream;
    document.getElementById("local_video").srcObject = localStream;

    localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream));
  })
  .then(function() {
    return myPeerConnection.createAnswer();
  })
  .then(function(answer) {
    return myPeerConnection.setLocalDescription(answer);
  })
  .then(function() {
    var msg = {
      name: myUsername,
      target: targetUsername,
      type: "video-answer",
      sdp: myPeerConnection.localDescription
    };

    sendToServer(msg);
  })
  .catch(handleGetUserMediaError);
}
  • sending ICE candidates
function handleICECandidateEvent(event) {
  if (event.candidate) {
    sendToServer({
      type: "new-ice-candidate",
      target: targetUsername,
      candidate: event.candidate
    });
  }
}
  • receiving ICE candidates
function handleNewICECandidateMsg(msg) {
  var candidate = new RTCIceCandidate(msg.candidate);

  myPeerConnection.addIceCandidate(candidate)
    .catch(reportError);
}
  • receiving new streams
function handleTrackEvent(event) {
  document.getElementById("received_video").srcObject = event.streams[0];
  document.getElementById("hangup-button").disabled = false;
}
  • handling the removal of tracks
function handleRemoveTrackEvent(event) {
  var stream = document.getElementById("received_video").srcObject;
  var trackList = stream.getTracks();

  if (trackList.length == 0) {
    closeVideoCall();
  }
}
  • hangup
function hangUpCall() {
  closeVideoCall();
  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "hang-up"
  });
}
  • end call
function closeVideoCall() {
  var remoteVideo = document.getElementById("received_video");
  var localVideo = document.getElementById("local_video");

  if (myPeerConnection) {
    myPeerConnection.ontrack = null;
    myPeerConnection.onremovetrack = null;
    myPeerConnection.onremovestream = null;
    myPeerConnection.onicecandidate = null;
    myPeerConnection.oniceconnectionstatechange = null;
    myPeerConnection.onsignalingstatechange = null;
    myPeerConnection.onicegatheringstatechange = null;
    myPeerConnection.onnegotiationneeded = null;

    if (remoteVideo.srcObject) {
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
    }

    if (localVideo.srcObject) {
      localVideo.srcObject.getTracks().forEach(track => track.stop());
    }

    myPeerConnection.close();
    myPeerConnection = null;
  }

  remoteVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
  localVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");

  document.getElementById("hangup-button").disabled = true;
  targetUsername = null;
}

ICE connection state

function handleICEConnectionStateChangeEvent(event) {
  switch(myPeerConnection.iceConnectionState) {
    case "closed":
    case "failed":
      closeVideoCall();
      break;
  }
}

ICE signaling state

function handleSignalingStateChangeEvent(event) {
  switch(myPeerConnection.signalingState) {
    case "closed":
      closeVideoCall();
      break;
  }
};

ICE gathering state

function handleICEGatheringStateChangeEvent(event) {
  // Our sample just logs information to console here,
  // but you can do whatever you need.
}

WebRTC Data Channel

Using WebRTC Data Cahnnel

建立 RTCPeerConnection 後,就能收送 media data,也可以使用 data channel,可安全地交換任何資料

因所有 WebRTC components 都要求加密,RTCDataChannel 會自動使用 Datagram Transport Layer Security (DTLS)

產生 data channel

RTCDataChannel 使用的 data transport 可用兩種方法產生

  • 以 WebRTC 產生 transport,並宣告為 remote peer (會收到 datachannel event),這種方法比較簡單且常用,但比較沒有彈性
  • 自己寫 code 協調 data transport,自己 signal 到另一端 peer,且要連接到 new channel

Automatic negotiation

通常可讓 peer connection 協調處理 RTCDataChannel connection

呼叫 createDataChannel() 且不設定 negotiated 屬性,或是設定為 false。就會讓 RTCPeerConnection 自動處理 RTCDataChannel

可透過傳給 RTCDataChannel 的 open event 判斷是否有成功連線

let dataChannel = pc.createDataChannel("MyApp Channel");

dataChannel.addEventListener("open", (event) => {
  beginTransmission(dataChannel);
});

Manual negotiation

先用 RTCPeerConnection.createDataChannel() 產生一個新的 RTCDataChannel,設定 negotiated 為 true

使用 webserver or others,要能 signal to the remote peer,並讓他產生自己的 RTCDataChannel,且 negotiated 為 true

let dataChannel = pc.createDataChannel("MyApp Channel", {
  negotiated: true
});

dataChannel.addEventListener("open", (event) => {
  beginTransmission(dataChannel);
});

requestRemoteChannel(dataChannel.id);

上面的 requestRemoteChannel 就是用來 trigger negotiation,產生 remote channel with the same ID as the local channel

Buffering

WebRTC data channel 支援 outbound data 的 buffering,會自動處理,且無法控制 buffer size。

使用 bufferedAmount, bufferedAmountLowThreshold, onbufferedamountlow, and bufferedamountlow

了解 message size limits

網路傳輸的封包有 size 限制

Firefox, Chrome 使用 usrsctp library 實作 SCTP,但兩個使用方法不同,firefox 會用多個 SCTP message 傳送 large message (deprecated),chrome 是用 series of messages

少於 16 kB 的訊息不需要考慮此問題

Concerns

目前不適合使用 RTCDataChannel 傳送超過 64kB 的 message,因為 SCTP 最初用在 signaling protocol,這邊假設 message 都很小,要支援超過網路 MTU 的 message 是後來增加的功能。這個技術需要訊息編上有順序的號碼,要依序傳送。

因為有越多越多 application 傳送大 message,造成重要的 signaling message 阻塞的問題。

目前 browser 利用 end-of-record (EOR) flag 支援 larger message,這表示 message 是 last one in a series。在支援 EOR 的狀況下,RTCDataChannel 可傳送大 message (可超過 256 kB,firefox 實作可傳送 1GB),在 256 kB 的狀況下,會發生 noticeable delays。

要解決此問題,新的 stream scheduler (SCTP ndata specification) 可 interleave 在不同 stream 傳送的 message,proposal 目前還在 IETF draft,如果實作,理論上可傳送任意大小的 message

firefox 在 bug 1381145 支援 ndata

chrome 在 bug 5696

Security

透過 WebRTC 傳送的資料都會被加密,RTCDataChannel 是用 Datagram Transport Layer Security (DTLS) 加密,based on TLS (https)

WebRTC 在兩個 agents 之間做 peer-to-peer conenction,資料不會傳給 web/application server

SCTP NAT

解决iptables nat sctp协议无效的问题

A----->B-----C

IP如下:

A:1.1.1.1

B:1.1.1.2; 2.2.2.1

C:2.2.2.2

需求為,A 需要使用sctp連通C

在B機器上添加iptables規則為:

iptables -t nat -I PREROUTING -d 1.1.1.2 -p sctp --dport 11111 -j DNAT --to-destination  2.2.2.2:11111

就是把A發出的目的地址:Port 由1.1.1.2:11111轉變為2.2.2.2:11111

同時在C的接口上使用tcpdump抓包,發現並沒有接收到sctp message,為檢驗網絡是否正常(包括路由等配置),僅將上述規則中的sctp改為tcp進行tcp的連通測試

iptables -t nat -I PREROUTING -d 1.1.1.1 -p tcp --dport 11111 -j DNAT --to-destination  2.2.2.2:11111

發現C機器上可以抓到tcp報文,說明網絡沒有問題,iptables的規則也沒有問題。

因為tcpdump抓包解包並不需要系統支持特定的協議,懷疑可能是iptables規則因為某種原因沒有生效,借助google發現瞭解決辦法:iptables-nat-not-work-for-sctp

載入nfconntrackproto_sctp即可,該模塊用來對sctp進行連接跟蹤

modprobe nf_conntrack_proto_sctp

連接跟蹤模塊可以參見:nf_conntrack連接跟蹤模塊

Why is SCTP not much used/known

  • NAT: Doesn't cross NAT very well/at all (less than 1% internet home & enterprise routers do NAT on SCTP).

sctp over udp encapsulation does not work under NAT network


ORTC

Object Real-Time Communication 是新的規格,提供開發 WebRTC application 高階 API,ORTC 不使用 SDP,也不支援 Offer/Answer state machine。ORTC 使用 "sender", "receiver", "transport" 物件及設定參數,用以描述該物件的功能。"Tracks" 是由 sender 編碼,透過 transport 傳送,在 receiver 解碼。而 "data channels"是直接透過 transport 傳送。

ORTC 最初由 MS Edge 實作支援,但目前 ORTC 很多的物件定義,已經被吸收進入 WebRTC 1.0 核心標準中。

WebRTC, ORTC difference

ref: ORTC draft spec

ref: WebRTC的现状和未来(上)

References

WebRTC API

samples

WebRTC wiki

30-26之 WebRTC 的 P2P 即時通信與小範例

2021/09/13

ICE: Interactive Connectivity Establishment

ICE 是以 P2P 連線為前提的一種協調方法,主要是 VOIP、P2P視訊在使用。因為現在的網路終端,通常都在 Firewall 裡面,透過 NAT 上網,導致 P2P 連線的困難度大增,因此 ICE 透過 Stun 的技術,判讀 NAT 類型,當遇到特殊的 NAT 類型,無法處理 P2P 時,就搭配 Turn Server 為兩個終端進行資料中繼傳輸。

ICE

ref: Introduction to WebRTC protocols

主要用在 UDP 多媒體 session,為了解決 NAT 的問題,ICE 可嘗試找到多個 candidates,取得最佳的連線方法。

ICE 透過 stun server 查詢 NAT 類型,以及取得自己對外的 IP:Port。

如果無法直接 p2p 連線,就會改用 Turn Server 中繼,互相傳遞資料。

SDP

P2P通訊標準協議ICE

RFC4566 SDP 是用來協調 session 傳輸所要交換的資訊,內容可能會有這些

會話描述:
     v=  (protocol version)
     o=  (originator and session identifier)
     s=  (session name)
     i=* (session information)
     u=* (URI of description)
     e=* (email address)
     p=* (phone number)
     c=* (connection information -- not required if included in
          all media)
     b=* (zero or more bandwidth information lines)
     One or more time descriptions ("t=" and "r=" lines; see below)
     z=* (time zone adjustments)
     k=* (encryption key)
     a=* (zero or more session attribute lines)
     Zero or more media descriptions

時間資訊描述:
     t=  (time the session is active)
     r=* (zero or more repeat times)

多媒體資訊描述(如果有的話):
     m=  (media name and transport address)
     i=* (media title)
     c=* (connection information -- optional if included at
          session level)
     b=* (zero or more bandwidth information lines)
     k=* (encryption key)
     a=* (zero or more media attribute lines)

ex:

      v=0
      o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
      s=SDP Seminar
      i=A Seminar on the session description protocol
      u=http://www.example.com/seminars/sdp.pdf
      e=j.doe@example.com (Jane Doe)
      c=IN IP4 224.2.17.12/127
      t=2873397496 2873404696
      a=recvonly
      m=audio 49170 RTP/AVP 0
      m=video 51372 RTP/AVP 99
      a=rtpmap:99 h263-1998/90000

NAT

依據 NAT的限制方式,可分為四種:

  • Full Cone NAT(one-to-one)

    • 只是單純的做位址轉換,並未對進出的封包設限
  • Address-Restricted Cone NAT

    • 從內部送出之封包的目的地 IP 位址會被記住。只有這些曾經收過這些封包的位址可以送封包進入內部 IP。由其他位址送進來的封包,都會被拒絕。
  • Port-Restricted cone NAT

    • 封包進出比 Restricted Cone 增加了一個限制, 從內部送出之封包的目的地的IP 位址及 Port Number 會被記住。 由外部送進來的封包,除了由那些接收過內部所送出的封包的IP 位址及 Port Number 所送來的封包之外,都會被拒絕。
  • Symmetric NAT

    • 前三種NAT在做位址轉換時,無論封包是送往哪裡, NAT內部同一內部位址都對應到同一個外部位址:Port,但 Symmetric NAT 內則每一內部位址對於不同的目的地,會對應到不同的外部 IP address:Port。
    • Symmetric NAT只允許先由私有網域內的機器,發送封包到網際網路中的接收端可以回傳封包

判斷方法

NAT Test 提供一種方式,可以判斷是否為 Symmetric NAT,因為 Understanding Different NAT Types and Hole-Punching 的說明,只要有一端為 Symmetric NAT,就無法直接 p2p 連線。

判斷的方法是利用 RTCPeerConnection 對 google stun server 建立連線,由 onicecandidate 判讀, port 跟 rport 是否一樣。

// JScript File

function parseCandidate(line) {
    console.log("parseCandidate="+line);
    // candidate:842163049 1 udp 1677729535 220.132.127.162 42280 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag 1cTw network-cost 999

    var parts;
    // Parse both variants.
    // 兩種:a=candidate:  or  candidate:
    if (line.indexOf('a=candidate:') === 0) {
        parts = line.substring(12).split(' ');
    } else {
        parts = line.substring(10).split(' ');
    }

    var candidate = {
        foundation: parts[0],
        component: parts[1],
        protocol: parts[2].toLowerCase(),
        priority: parseInt(parts[3], 10),
        ip: parts[4],
        port: parseInt(parts[5], 10),
        // skip parts[6] == 'typ'
        type: parts[7]
    };

    for (var i = 8; i < parts.length; i += 2) {
        switch (parts[i]) {
            case 'raddr':
                candidate.relatedAddress = parts[i + 1];
                break;
            case 'rport':
                candidate.relatedPort = parseInt(parts[i + 1], 10);
                break;
            case 'tcptype':
                candidate.tcpType = parts[i + 1];
                break;
            default: // Unknown extensions are silently ignored.
                break;
        }
    }

    console.log("candidate=",candidate);
    // {
    //     "foundation": "842163049",
    //     "component": "1",
    //     "protocol": "udp",
    //     "priority": 1677729535,
    //     "ip": "220.132.127.162",
    //     "port": 42280,
    //     "type": "srflx",
    //     "relatedAddress": "0.0.0.0",
    //     "relatedPort": 0
    // }
    return candidate;
};

function testNat() {
    //reset lblResult
    document.getElementById("MainContent_lblResult").style.color = "#696969";
    document.getElementById("MainContent_lblResult").innerHTML = "Test started " + new Date().toLocaleString() + "<br>";

    var candidates = {};
    var pc = new RTCPeerConnection({
        iceServers: [
            { urls: 'stun:stun1.l.google.com:19302' },
            { urls: 'stun:stun2.l.google.com:19302' }
        ]
    });
    document.getElementById("MainContent_lblResult").innerHTML += "Create data channel to Google STUN servers<br>";
    pc.createDataChannel("foo");
    document.getElementById("MainContent_lblResult").innerHTML += "Testing...Please Wait...<br>";
    pc.onicecandidate = function (e) {
        console.log(e);

        if (e.candidate && e.candidate.candidate.indexOf('srflx') !== -1) {
            console.log("e.candidate.candidate="+e.candidate.candidate);

            var cand = parseCandidate(e.candidate.candidate);

            // console.log("candidates[cand.relatedPort]=",candidates[cand.relatedPort]);
            if (!candidates[cand.relatedPort]) candidates[cand.relatedPort] = [];
            // console.log("candidates[cand.relatedPort]=",candidates[cand.relatedPort]);
            candidates[cand.relatedPort].push(cand.port);
            // console.log("candidates[cand.relatedPort]=",candidates[cand.relatedPort]);

        } else if (!e.candidate) {
            // All ICE candidates have been sent
            console.log("candidates=",candidates);
            // {
            //     "0": [
            //         42280
            //     ]
            // }
            if (Object.keys(candidates).length === 1) {
                var ports = candidates[Object.keys(candidates)[0]];
                console.log("ports", ports);

                //window.alert(ports.length === 1 ? 'normal nat' : 'symmetric nat');
                if (ports.length === 1) {
                    document.getElementById("MainContent_lblResult2").innerHTML = "Completed: Normal NAT";
                } else {
                    document.getElementById("MainContent_lblResult2").innerHTML = "Completed: Symmetric NAT";
                }
                document.getElementById("MainContent_lblResult2").style.color = "green";
            }
        }
    };
    pc.createOffer()
        .then(offer => pc.setLocalDescription(offer))

};

另一個判斷的 TypeScript: isNormalNat.ts

ICE协议下NAT穿越的实现(STUN&TURN) 這邊有說明,stun server 是如何判斷 NAT 類型的

candidate type

ref: RFC 5245

ref: WebRTC connectivity

udp 有四種:host, srflx, prflx, relay

  • host:實體 IP,可直接連線

  • srflx:Server Reflexive and Relayed Candidates

    host 因為經過 NAT,透過 Stun/Turn server 產生的 candidate

  • prflx: Peer Reflexive Candidates

    其中一端來自 symmetric NAT

  • relay

    由 Turn Server 產生的中繼 IP:Port

tcp 有三種:active, passive, so

  • active

    會打開 outbound connection,且不接受 incoming connection request。這是最常見的類型

  • passive

    只會接受 incoming connection

  • so

    會嘗試同時在兩個終端直接打開連線

References

互動式連接建立

P2P通訊標準協議ICE

ICE (Interactive Connectivity Establishment)

你需要知道关于ICE的三件事

30-29之 WebRTC 的 P2P 打洞術 ( ICE )

[知識篇]WebRTC - ICE(STUN/TURN)

Peer-to-Peer Communication Across Network Address Translators

about 82% of the NATs tested support hole punching for UDP, and about 64% support hole punching for TCP streams

Trickle ICE

RFC 5389 - STUN 協定介紹

[筆記] WebRTC 網路影音-通訊協定篇(protocol of media, video and audio)

NAT 及防火牆之來源

Understanding Different NAT Types and Hole-Punching

30-28之 WebRTC 連線前傳 - 為什麼 P2P 連線很麻煩 ? ( NAT )

NAT -- 基本概念

WebRTC ICE 狀態與提名處理

測試 NAT 類型

SIP穿越NAT的rport機制

WebRTC ICE candidate里面的raddr和rport表示什么?

Network Configuration for VoIP Providers

ICE介绍 (RFC 5245)

rfc 5245 笔记

C ICE implementation library

libice

libnice

WebRtc音视频实时通信--libnice库介绍

How to play libnice-ly with your NAT

libnice for iOS and Android.

libjuice

2021/09/06

GraphQL in java

GraphQL 是由 Facebook 在 2012 提出,2015 開源,2018 移交給 GraphQL 基金會。他跟 SQL 不同,並不是隸屬於某一種資料庫的查詢語言,GraphQL 也不是圖形資料庫,他單純是一種簡化 web API 的開發的方法。一般來說在網頁開發時,如果需要使用後端資料庫的資料,就會撰寫 rest 或 web service 的介面,根據資料對應產生許多 rest API,當頁面內的資料來源越多,rest API 就越複雜。GraphQL 透過資料定義,能夠用統一的 rest 介面,提供查詢多樣化資料的功能,也能避免大量冗餘資料傳給網頁。

GraphQL 除了查詢功能,支援了讀取、寫入、資料變更訂閱的功能。

以下根據 Getting started with GraphQL Java and Spring Boot 初步了解 GraphQL

專案目標

根據這個 GraphQL schema 定義

type Query {
  bookById(id: ID): Book 
}

type Book {
  id: ID
  name: String
  pageCount: Int
  author: Author
}

type Author {
  id: ID
  firstName: String
  lastName: String
}

製作一個提供這個 schema 的 GraphQL Server,可透過網頁 API 用以下語法查詢

{
  bookById(id: "book-1"){
    id
    name
    pageCount
    author {
      firstName
      lastName
    }
  }
}

得到的查詢結果為

{ 
  "bookById":
  {
    "id":"book-1",
    "name":"Harry Potter and the Philosopher's Stone",
    "pageCount":223,
    "author": {
      "firstName":"Joanne",
      "lastName":"Rowling"
    }
  }
}

Project

首先產生一個 Java Maven Project,修改 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.graphqljava.tutorial</groupId>
    <artifactId>bookdetails</artifactId>
    <version>1.0.0</version>

    <dependencies>
        <dependency>
            <groupId>com.graphql-java</groupId>
            <artifactId>graphql-java</artifactId>
            <version>11.0</version>
        </dependency>

        <dependency>
            <groupId>com.graphql-java</groupId>
            <artifactId>graphql-java-spring-boot-starter-webmvc</artifactId>
            <version>1.0</version>
        </dependency>

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>26.0-jre</version>
        </dependency>

        <dependency>
            <groupId>com.graphql-java</groupId>
            <artifactId>graphql-java-spring-boot-starter-webmvc</artifactId>
            <version>1.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.1.2.RELEASE</version>
        </dependency>

        <!-- testing facilities -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <version>2.1.2.RELEASE</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.6.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

schema.graphqls

將 GraphQL schema 放在 src/main/resources/schema.graphqls

type Query {
    bookById(id: ID): Book
}

type Book {
    id: ID
    name: String
    pageCount: Int
    author: Author
}

type Author {
    id: ID
    firstName: String
    lastName: String
}

GraphQLProvider.java

利用 schema.graphqls,製作 com.graphqljava.tutorial.bookdetails.GraphQLProvider.java

package com.graphqljava.tutorial.bookdetails;

import com.google.common.base.Charsets;
import com.google.common.io.Resources;
import graphql.GraphQL;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.net.URL;

import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring;

@Component
public class GraphQLProvider {


    @Autowired
    GraphQLDataFetchers graphQLDataFetchers;

    private GraphQL graphQL;

    @PostConstruct
    public void init() throws IOException {
        // 以 Resources 讀取 schema.graphqls 產生 GraphQLSchema, GraphQL
        URL url = Resources.getResource("schema.graphqls");
        String sdl = Resources.toString(url, Charsets.UTF_8);
        GraphQLSchema graphQLSchema = buildSchema(sdl);
        this.graphQL = GraphQL.newGraphQL(graphQLSchema).build();
    }

    // 實作 buildSchema 產生 GraphQLSchema
    private GraphQLSchema buildSchema(String sdl) {
        TypeDefinitionRegistry typeRegistry = new SchemaParser().parse(sdl);
        RuntimeWiring runtimeWiring = buildWiring();
        SchemaGenerator schemaGenerator = new SchemaGenerator();
        return schemaGenerator.makeExecutableSchema(typeRegistry, runtimeWiring);
    }

    // 透過 graphQLDataFetchers,註冊兩個 dataFetcher
    private RuntimeWiring buildWiring() {
        return RuntimeWiring.newRuntimeWiring()
                .type(newTypeWiring("Query")
                        .dataFetcher("bookById", graphQLDataFetchers.getBookByIdDataFetcher()))
                .type(newTypeWiring("Book")
                        .dataFetcher("author", graphQLDataFetchers.getAuthorDataFetcher()))
                .build();
    }

    @Bean
    public GraphQL graphQL() {
        return graphQL;
    }

}

以下是 GraphQL 與 GraphQLSchema 的關係圖

GraphQLDataFetchers.java

com.graphqljava.tutorial.bookdetails.GraphQLDataFetchers.java

package com.graphqljava.tutorial.bookdetails;

import com.google.common.collect.ImmutableMap;
import graphql.schema.DataFetcher;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.List;
import java.util.Map;

@Component
public class GraphQLDataFetchers {

    private static List<Map<String, String>> books = Arrays.asList(
            ImmutableMap.of("id", "book-1",
                    "name", "Harry Potter and the Philosopher's Stone",
                    "pageCount", "223",
                    "authorId", "author-1"),
            ImmutableMap.of("id", "book-2",
                    "name", "Moby Dick",
                    "pageCount", "635",
                    "authorId", "author-2"),
            ImmutableMap.of("id", "book-3",
                    "name", "Interview with the vampire",
                    "pageCount", "371",
                    "authorId", "author-3")
    );

    private static List<Map<String, String>> authors = Arrays.asList(
            ImmutableMap.of("id", "author-1",
                    "firstName", "Joanne",
                    "lastName", "Rowling"),
            ImmutableMap.of("id", "author-2",
                    "firstName", "Herman",
                    "lastName", "Melville"),
            ImmutableMap.of("id", "author-3",
                    "firstName", "Anne",
                    "lastName", "Rice")
    );

    public DataFetcher getBookByIdDataFetcher() {
        return dataFetchingEnvironment -> {
            String bookId = dataFetchingEnvironment.getArgument("id");
            return books
                    .stream()
                    .filter(book -> book.get("id").equals(bookId))
                    .findFirst()
                    .orElse(null);
        };
    }

    public DataFetcher getAuthorDataFetcher() {
        return dataFetchingEnvironment -> {
            Map<String, String> book = dataFetchingEnvironment.getSource();
            String authorId = book.get("authorId");
            return authors
                    .stream()
                    .filter(author -> author.get("id").equals(authorId))
                    .findFirst()
                    .orElse(null);
        };
    }
}

BookDetailsApplication.java

製作主程式 com.graphqljava.tutorial.bookdetails.BookDetailsApplication.java

package com.graphqljava.tutorial.bookdetails;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BookDetailsApplication {

    public static void main(String[] args) {
        SpringApplication.run(BookDetailsApplication.class, args);
    }

}

Postman

在 Postman 的 APIs 可填寫 graphql schema 定義,在進行 API 查詢時,Postman 就能根據 schema 提供語法 hint

在 Request 中,將 Data Type 改為 GraphQL,就能進行 GraphQL 查詢並得到結果

{
  bookById(id: "book-1"){
    id
    name
    pageCount
    author {
      firstName
      lastName
    }
  }
}

References

GraphQL

GraphQL or gRPC in Java

Build and Test a CRUD API using GraphQL, Spring Boot and MongoDB

GraphQL, MongoDB and Java: An introduction

Spring Boot + GraphQL + MongoDB example with Spring Data & graphql-java

GraphQL Java 从Schema文件到GraphQL实例源码解析

GraphQL Java从入门到实践

Netflix DGS framework

An Advanced GraphQL with Spring Boot and Netflix DGS