從本篇起,我們將邁入新的領域:網絡傳輸。首先我們看看 P2P 連接的建立過程,以及 DataChannel 的使用,最終我們會利用 DataChannel 實現一個 P2P 的文字聊天功能。
P2P 連接過程
首先總結一下 WebRTC 建立 P2P 連接的過程(就是喜歡手稿):
我們先來一個簡單的名詞解釋。
SDP
SDP 全稱 Session Description Protocol,顧名思義,它是一種描述會話的協議。一次電話會議,一次網絡電話,一次視頻流傳輸等等,都是一次會話。那會話需要哪些描述呢?最基礎的有多媒體數據格式和網絡傳輸地址,當然還包括很多其他的配置信息。1
為什么需要描述會話?因為參與會話的各個成員能力不對等。大家可能會想到使用所有人都支持的媒體格式,我們暫且不考慮這樣的格式是否存在,我們思考另一個問題:如果參與本次會話的成員都比較牛,可以支持更高質量的通話,那使用通用的、普通質量的格式,是不是很虧?既然無法使用固定的配置,那對會話的描述就很有必要了。
最后,一次會話用什么配置,也不是由某一個人說了算,必須所有人的意見達成一致,這樣才能保證所有人都能參與會話。那這就涉及到一個協商的過程了,會話發起者先提出一些建議(offer),其他人參與者再根據 offer 給出自己的選擇(answer),最終意見達成一致后,才能開始會話。2
上面只是對 SDP 以及協商過程的一個極簡理解,詳細的定義還得查閱相關的 RFC 文檔。
回到 P2P 連接的建立過程,offer 和 answer 其實都是 SDP,而 local/remote 則是相對的,offer 是會話發起者的 local SDP,是會話加入者的 remote SDP,answer 則是會話發起者的 remote SDP,是會話加入者的 local SDP。
SDP 實際上就是一個字符串,它的具體格式定義,可以參考 RFC 文檔。它的拼接過程,native 和 Java 代碼都有分布,native 代碼調用棧還比較深,這里就不展開了,createOffer 主要邏輯就是根據創建 PeerConnection 對象時指定的 MediaConstraints,以及在 createOffer 調用前添加的 VideoTrack/AudioTrack/DataChannel 情況,拼出初始 SDP,最后在 PeerConnectionClient.SDPObserver#onCreateSuccess 中會添加 codec 相關的值。createAnswer 則還會參考 offer SDP 的值。
ICE
ICE 是用於 UDP 媒體傳輸的 NAT 穿透協議(適當擴展也能支持 TCP 協議),是對 Offer/Answer 模型的擴展,它會利用 STUN、TURN 協議完成工作。ICE 會在 SDP 中增加傳輸地址記錄值(IP + port + 協議),然后對其進行連通性測試,測試通過之后就可以用於發送媒體數據了。3
candidate
每個傳輸地址記錄值都叫做一個 candidate,candidate 可能有三種:
- 客戶端從本機網絡接口上獲取的地址(host);
- STUN server 看到的該客戶端的地址(server reflexive,縮寫為 srflx);
- TURN server 為該客戶端分配的中繼地址(relayed);
兩個客戶端上述 candidate 的任意組合也許都能連通,但實際上很多組合都不可用,例如 L R 兩個客戶端處於兩個不同的 NAT 網絡后面時,網絡接口地址都是內網地址,顯然無法連通。而 ICE 的任務,就是找出哪些組合可以連通。怎么找?也沒有什么黑科技,就是逐個嘗試,只不過是有條理地、按照某種順序去嘗試,而不是一通亂搞。
網絡接口地址對應的端口號是客戶端自己分配的,如果有多個網絡接口地址,那就都要帶着(看,這里就不是瞎猜哪個地址可用了)。TURN server 可以同時取得 reflexive 和 relayed candidate,而 STUN server 則只能取得 reflexive candidate(這下我就清楚 coturn 到底是 STUN server 還是 TURN server 了)。
三種 candidate 的關系如下圖(RFC 畫圖的技術也是比較高超的):
連通性檢查
candidate 收集完畢后,雙方的 candidate 兩兩配對,然后分三步對 candidate 組合進行連通性檢查:
- 把 candidates 組合按優先級排序;
- 按順序發送檢查請求(STUN Binding request),源地址是 candidate 組合的本地 candidate,目的地址是對方 candidate;
- 收到對方的檢查請求后發出響應(STUN Binding response);
每次檢查實際上是一個四步握手的過程:
STUN 請求和 RTP/RTCP 傳輸數據使用的是完全一樣的地址和端口,解多路復用並不是 ICE 的任務,而是 RTP/RTCP 的任務。
客戶端收到的 STUN Binding respose 中也會攜帶對方的公網地址,如果這個地址和發送請求的 request 地址不一致,那 response 里的地址也會作為一個新的 candidate(peer reflexive),參與到連通性檢查中。
如果客戶端收到了對方的檢查請求,除了發送響應外,也會立即對這個 candidate 組合進行檢查,以加快完成一次成功的連通性檢查。
candidate 排序
每個客戶端會為自己的 candidate 設置權值,雙方 candidate 權值之和將作為組合的權值,用於排序。求和的方式確保了雙方排序結果的一致性,這個一致性至關重要,因為通常 NAT 都不會允許外部主機的數據包從某個端口進入內網,除非這個端口有數據包發往過這個主機,因此只有雙方都發送了檢查請求,數據包才可能通過 NAT。
權值的確定,RFC 里面只說明了基本原則:直接的連接比間接的連接要好。但具體如何設置,並沒有具體說明。
收集 candidate
candidate 的收集包括兩部分:一是 host,二是 srflx 和 relayed。第一部分肯定得在本地網絡接口上做文章,第二部分則需要連接 STUN/TURN Server。
WebRTC native 代碼量還是很大的,像我這樣沒什么 C++ 開發經驗的朋友,閱讀代碼將會比較吃力,不過咬咬牙堅持堅持,熟悉起來也就好了,下面簡要描述下幾個重要過程的代碼路徑。
candidate 的收集由設備網絡連接變化觸發:
實際收集 candidate 的過程分為幾個階段:Udp,Relay,Tcp,SslTcp。下面重點分析 Udp 和 Relay 這兩個階段,在這兩個階段里,我們會收集 host,server reflexive 和 relayed 這三種 candidate。
三種 candidate 都會匯報到 BasicPortAllocatorSession::OnCandidateReady 處,從這里最終到達 Java 層的 listener 又還有好幾層關卡呢:
上面的過程主要有三個不直接的東西:
- sig slot:簡言之就是一個信號處理的框架,A 發一個信號,B 能接收處理,二者完全解耦,具體的可以看看官方文檔;
- message:類似於 Java 里面的 Handler 機制,也是提交消息,接收者進行相關處理,為啥有了 sig slot 還要 message 機制呢?sig slot 無法發送延遲消息是原因之一;
- 網絡:STUN/TURN Server 的訪問都是網絡請求,為了實現跨平台,網絡相關的代碼做了不少封裝,並且使用的都是操作系統的 C/C++ 接口,這塊我也還沒有深入看;
另外這里推薦一個 STUN/TURN Server 測試工具:Trickle ICE,用來測試服務器是否正確部署,以便排查問題。
使用 candidate
交換了 candidate 之后,WebRTC 會建立連接,發送 STUN ping 檢查 candidate 連通性。連通性檢查通過后,再交換 DTLS 證書,最后就可以發送音視頻數據了。整個過程涉及的代碼比較多(中間的步驟我也還沒捋得特別清楚),這里就只描述幾個關鍵路徑了:
DataChannel 使用
最艱難的部分終於過去了,現在讓我們來點輕松的,基於 DataChannel 實現一個 P2P 文字聊天功能。
DataChannel 是 WebRTC 提供的任意數據 P2P 傳輸的 API,它使用 SCTP 協議,可以靈活配置是否可靠傳輸。我們可以用它實現文字聊天、文件分享、實時對戰游戲等場景下的數據傳輸,P2P + DTLS 保證了傳輸數據的安全性。
為了使用 DataChannel,我們先得創建 PeerConnection 對象,而且完成 P2P 連接的建立,具體過程經過上面的分析,我們應該已經了然於胸了,下面只摘錄關鍵代碼,完整代碼可以查看這個 GitHub 提交。
// 初始化並創建 factoryPeerConnectionFactory.initializeAndroidGlobals(mAppContext,true);mPeerConnectionFactory=newPeerConnectionFactory(null);// 創建 PC 對象mPeerConnection=mPeerConnectionFactory.createPeerConnection(rtcConfig,newMediaConstraints(),this);// 創建 DataChannelDataChannel.Initinit=newDataChannel.Init();init.ordered=true;init.negotiated=true;// false is okinit.maxRetransmits=-1;init.maxRetransmitTimeMs=-1;init.id=0;// must be set, and >= 0mDataChannel=mPeerConnection.createDataChannel("P2P MSG DC",init);mDataChannel.registerObserver(this);// A,創建 offermPeerConnection.createOffer(MsgPcClient.this,mSdpConstraints);// 在 onCreateSuccess 回調中 setLocalDescription// 在 onSetSuccess 回調中把 offer 發出去mPeerConnection.setLocalDescription(MsgPcClient.this,sdp);// B,收到 offer 后 setRemoteDescriptionmPeerConnection.setRemoteDescription(MsgPcClient.this,sdp);// 創建 answermPeerConnection.createAnswer(MsgPcClient.this,mSdpConstraints);// 在 onCreateSuccess 回調中 setLocalDescription// 在 onSetSuccess 回調中把 answer 發出去mPeerConnection.setLocalDescription(MsgPcClient.this,sdp);// A,收到 answer 后 setRemoteDescriptionmPeerConnection.setRemoteDescription(MsgPcClient.this,sdp);// 在 onIceCandidate 回調中把 candidate 發出去// 收到對方的 candidate 后 addIceCandidatemPeerConnection.addIceCandidate(candidate);// 在 onDataChannel 回調中注冊消息回調dataChannel.registerObserver(this);// 發送消息byte[]msg=message.getBytes();DataChannel.Bufferbuffer=newDataChannel.Buffer(ByteBuffer.wrap(msg),false);mDataChannel.send(buffer);// onMessage 回調中處理消息ByteBufferdata=buffer.data;finalbyte[]bytes=newbyte[data.capacity()];data.get(bytes);Stringmsg=newString(bytes);Logging.d(TAG,"onMessage "+msg);
創建 DataChannel 時可以通過 DataChannel.Init 的 ordered、maxRetransmitTimeMs、maxRetransmits 參數配置配置可靠性:
- ordered:是否保證順序傳輸;
- maxRetransmitTimeMs:重傳允許的最長時間;
- maxRetransmits:重傳允許的最大次數;
prebuilt library
最近 WebRTC 官方團隊已經開始把 CI 系統打包出來的 aar 上傳的 JCenter 了,大家可以盡情享用啦!
腳注
- SDP: Session Description Protocol↩
- JavaScript Session Establishment Protocol↩
- Interactive Connectivity Establishment (ICE): A Protocol for Network Address Translator (NAT) Traversal for Offer/Answer Protocols↩
https://blog.piasy.com/2017/08/30/WebRTC-P2P-part1/
基於webRTC做的web版聊天示例:
https://www.starrtc.com/demo/h5/