這是 WebRTC 系列的第三篇文章,主要講多人點對點連接。如果你對 WebRTC 還不太瞭解,推薦閱讀我之前的文章。
文章倉庫在 ???? fe-code,歡迎 star。
源碼地址 webrtc-stream
線上預覽 https://webrtc-stream-depaadjmes.now.sh
簡單介紹一下基於 WebRTC 的多人通信的幾種架構模式。
我們之前寫過幾個 1 v 1 的栗子,它們的連接模式如下:
這是典型的端到端對等連接,所以當我們要實現多人視頻(實際上也就是多端通信)的時候,我們會很自然的想到在 1 v 1 的基礎上擴充,給每個客戶端創建多個 1 v 1 的對等連接:
這就是所謂的 Mesh 模式,不需要額外的伺服器處理媒體數據(當然,信令伺服器是不可少的),僅僅是基於 WebRTC 自身的點對點連接進行通信,本期的實例也是採用這種模式。
但是這種架構的缺點也是十分明顯的,如果連接的客戶端過多,上行帶寬面臨的壓力將會非常大,相應的視頻通話 。 * Mixer 架構
傳統的視頻會議,一般都是採用 Mixer 架構。以錄播攝像為例,會利用 MCU (多點控制單元) 接收並混合每個客戶端傳入的媒體流。也就是將多個客戶端的音視頻畫面合成單個流,再傳輸給每個參與的客戶端。這樣也可以保證客戶端始終是 1 對 1 的連接,有效緩解了 Mesh 架構的問題。缺點則是依賴服務端,成本比較大,而且服務端處理過多也更容易導致視頻流的延遲。
Router 模式和 Mixer 很類似,比較來說,它只是單純的進行數據流的轉發,而不用合成、轉碼等操作。
因此,在實際運用中,使用哪種方式來處理,需要結合項目需求、成本等因素綜合考量。
我們基於 Mesh 模式來做多人視頻的演示,所以需要給每個客戶端創建多個 1 v 1 的對等連接。除了 WebRTC 的基礎知識,還需要用到 Socket.io 和 Koa 來做信令服務。
Socket.io
先複習一下 1 v 1 的連接過程:
A 創建 offer 信息後,先調用 setLocalDescription 存儲本地 offer 描述,再將其發送給 B。 B 收到 offer 後,先調用 setRemoteDescription 存儲遠端 offer 描述; 然後又創建 answer 信息,同樣需要調用 setLocalDescription 存儲本地 answer 描述,再返回給 A A 拿到 answer 後,再次調用 setRemoteDescription 設置遠端 answer 描述。
當然,NAT 穿越和候選信息交換也是必不可少的。
本地 ICE 候選信息採集完成後,通過信令服務進行交換。 這一步也是在創建 Peer 之後,但與 offer 的發送沒有先後關係。
我們平時觀看直播實際上就是 1 v 多,也就是隻有一端輸出視頻流,其他觀看端只需要接收就好了。但是這種形式,一般不會採用點對點連接,而是用傳統的直播方式,服務端進行媒體流的轉發。有些直播可以和主播進行互動,這裡的原理大致和上篇文章中的共享畫板類似。
這裡只是給大家介紹一下這種直播模式,所以具體的就不細說了。
其實這種情況,主要用於視頻會議或者多人視頻通話,類似於微信的視頻通話一樣。
我們剛剛回憶過 1 v 1 的連接流程,也知道要基於 Mesh 架構來做,那麼到底該如何去做呢?這裡先提煉兩個要點: 如何給每個客戶端創建多個點對點連接? 如何確認連接的順序?
我們以 3 個客戶端 A、B、C 為例。A 最先打開瀏覽器或者說 A 是第一個加入房間的,那麼 A 進入的時候房間內沒有其他人,這個時候要做什麼?只需要初始化一下自己的視頻畫面就好,並不需要進行任何連接操作,因為這個時候沒有第二個人,也就沒有連接的對象。
什麼時候需要進行連接?等 B 加入房間的時候。這裡又一個問題,B 加入房間時,誰發送 Offer ? 因為都參與通話,B 加入的時候首先也會初始化自己的視頻流,那麼此時 A 和 B 都可以 createOffer 。這也是和之前 1 v 1 的區別所在,因為 1 v 1 我們有明確的 呼叫端 和 接收端,不需要考慮這個問題。所以,為了避免連接混亂,我們只用後加入的成員,向房間內所有已加入成員分別發送 Offer,也就是說 B 加入時,給 A 發;C 加入時,再給 A 和 B 分別發。 以此來保證連接的有序性,這是第二個問題。
那麼如何在一個端建立多個點對點連接呢?我採用的策略是,兩兩之間的連接,都是單獨創建的 Peer 實例。也就是說,A ——> B 、A ——> C 的連接中,A 會創建兩個 Peer 實例,用來分別與 B、C 做連接,同樣的 B、C 也會創建多個 Peer 實例。但是我們需要確保每個端之間的 Peer 是一一對應的,簡單來說,就是 A 的 PeerA-B 必須和 B 的 peerA-B 連接。很明顯,這裡需要一個唯一性標識。
// loginname 唯一 // 假設 A 的 loginname 是 A;B 的 loginname 是 B; // 在客戶端 A 中 let arr = [A, B]; let id = arr.sort().join(-); // 排序後再連接 A-B this.PeerList[id] = Peer; // 將創建的 peer 以鍵值對形式都存放到 PeerList 中 // PS: 在客戶端 B 中,操作一樣
其實實現多人通信的主要思路剛剛已經講完了,我習慣於先將思路理清楚,再講代碼實現。個人覺得這樣比大家直接看代碼注釋效果要好,大家有什麼好的意見也可以在評論區提出,我們一起討論。
我們先做一個加入房間的過渡頁,簡單的 Vue 寫法,沒啥好說的。
<div class="center"> 登錄名:<input type="text" v-model="account"> <br> 房間號:<input type="text" v-model="roomid"> <br> <button @click="join">加入房間</button> </div>
// ··· methods: { join() { if (this.account && this.roomid) { this.$router.push({name: room, params: {roomid: this.roomid, account: this.account}}) } // 參數是路由形式的,如 room/id/account } }
初始化步驟和前兩期 1 v 1 的栗子沒有區別,視頻通話首先當然是獲取視頻流。
getUserMedia() { // 獲取媒體流 let myVideo = this.$refs[video-mine]; // 默認播放自己視頻流的 video let getUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia); //獲取本地的媒體流,並綁定到一個video標籤上輸出 return new Promise((resolve, reject) => { getUserMedia.call(navigator, { "audio": true, "video": true }, (stream) => { //綁定本地媒體流到video標籤用於輸出 myVideo.srcObject = stream; this.localStream = stream; resolve(); }, function(error){ reject(error); // console.log(error); //處理媒體流創建失敗錯誤 }); }) }
大家還記不記得,在 1 v 1 中,我們創建 Peer 實例的時機是: 接收端 點擊同意通話後,初始化自己的 Peer 實例;呼叫端 收到對方同意申請的通知後,初始化 Peer 實例,並向其發送 Offer。剛剛分析過,多人通信思路有些不一樣,但是 初始化方法是差不多的,我們先寫個初始化方法。
getPeerConnection(v) { let videoBox = this.$refs[video-box]; // 用於向 box 中添加新加入的成員視頻 let iceServer = { // stun 服務,如果要做到 NAT 穿透,還需要 turn 服務 "iceServers": [ { "url": "stun:stun.l.google.com:19302" } ] }; let PeerConnection = (window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection); // 創建 peer 實例 let peer = new PeerConnection(iceServer); //向PeerConnection中加入需要發送的流 peer.addStream(this.localStream);
// 如果檢測到媒體流連接到本地,將其綁定到一個video標籤上輸出 // v.account 就是上面提到的 A-B peer.onaddstream = function(event){ let videos = document.querySelector(# + v.account); if (videos) { // 如果頁面上有這個標識的播放器,就直接賦值 src videos.srcObject = event.stream; } else { let video = document.createElement(video); video.controls = true; video.autoplay = autoplay; video.srcObject = event.stream; video.id = v.account; // video加上對應標識,這樣在對應客戶端斷開連接後,可以移除相應的video videoBox.append(video); } }; // 發送ICE候選到其他客戶端 peer.onicecandidate = (event) => { if (event.candidate) { // ··· 發送 ICE } }; this.peerList[v.account] = peer; // 存儲 Peer }
創建 Peer 的時候用到了 account 標識來做保存,這裡也涉及到我們建立點對點連接的時機問題。現在我們來看看,之前分析的第二個問題如何體現在代碼上呢?
// data 是後端返回的房間內所有成員列表 // account 是本次新加入成員 loginname socket.on(joined, (data, account) => { // joined 在每次有人加入房間時觸發,自己加入時,自己也會收到 if (data.length> 1) { // 成員數大於1,也就是前面提到的從第二個開始,每個新加入成員發送 Offer data.forEach(v => { let obj = {}; let arr = [v.account, this.$route.params.account]; obj.account = arr.sort().join(-); // 組合 Peer 的標識 if (!this.peerList[obj.account] && v.account !== this.$route.params.account) { // 如果列表中沒有這個標識的 Peer ,則創建 Peer實例 // 如果是自己,就不創建,否則就重複了 // 比如所有成員列表中,有 A 和 B,我自己就是 A,如果不排除,就會創建兩個 A-B this.getPeerConnection(obj); } }); if (account === this.$route.params.account) { // 如果新加入成員是自己,則給所有已加入成員發送 Offer for (let k in this.peerList) { this.createOffer(k, this.peerList[k]); } } } });
我們在初始化 Peer 實例的時候,還做了一個發送 ICE 的操作。那我們就以 ICE 接收為例,看一下這種加了唯一標識的處理和之前有什麼區別。
getPeerConnection(v) { // ··· 部分代碼省略 // 發送ICE候選到其他客戶端 peer.onicecandidate = (event) => { if (event.candidate) { socket.emit(__ice_candidate, {candidate: event.candidate, roomid: this.$route.params.roomid, account: v.account}); // 將標識 v.account 也放進數據中轉發給對方,用於匹配對應的 Peer } }; }
// 在mounted 方法中接收 socket.on(__ice_candidate, v => { //如果是一個ICE的候選,則將其加入到PeerConnection中 if (v.candidate) { // 利用傳過來的唯一標識匹配對應的 Peer,並添加 Ice this.peerList[v.account] && this.peerList[v.account].addIceCandidate(v.candidate).catch((e) => { console.log(err, e) }); } });
其實區別就是,我們把標識(A-B)也放進了信令交互的數據中,這樣才能在兩端之前匹配到對應的 Peer 實例,而不至於混亂。
最後,後端代碼比較簡單,看一下需要注意的點就好。
const users = {}; app._io.on( connection, sock => { sock.on(join, data=>{ sock.join(data.roomid, () => { if (!users[data.roomid]) { users[data.roomid] = []; } // 因為多房間,採用了這種格式保存房間成員 // {room1: [userA, userB, userC]} userA 包含loginname 和 sock.id let obj = { account: data.account, id: sock.id }; let arr = users[data.roomid].filter(v => v.account === data.account); if (!arr.length) { users[data.roomid].push(obj); } app._io.in(data.roomid).emit(joined, users[data.roomid], data.account, sock.id); // 新成員加入時,把房間內成員列表發給房間內所有人 }); }); sock.on(offer, data=>{ // 轉發 Offer sock.to(data.roomid).emit(offer,data); }); // 這裡轉發是直接轉發到房間了,也可以轉發到指定的客戶端 // 看過上一篇共享畫板的同學應該有印象,沒看過的可以去看看,這裡就不再多說 sock.on(answer, data=>{ // 轉發 Answer sock.to(data.roomid).emit(answer,data); }); sock.on(__ice_candidate, data=>{ // 轉發ICE sock.to(data.roomid).emit(__ice_candidate,data); }); })
app._io.on(disconnect, (sock) => { // 斷開連接時,刪除對應的客戶端數據 for (let k in users) { users[k] = users[k].filter(v => v.id !== sock.id); } console.log(`disconnect id => ${users}`); });
到這裡,主要流程就講完了。另外關於 Offer、Answer 的創建和交換和 1 v 1 的區別也只在於多加了一個標識,跟上面講的 ICE 傳輸一樣。所以,就不貼代碼了,有需要的同學可以去代碼倉庫看 完整代碼。
qq前端交流羣:960807765,歡迎各種技術交流,期待你的加入
如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支持一下作者,感謝??。文中如有不對之處,也歡迎大家指出,共勉。好了,又耽誤大家的時間了,感謝閱讀,下次再見!
推薦閱讀: