前言

這是 WebRTC 系列的第三篇文章,主要講多人點對點連接。如果你對 WebRTC 還不太瞭解,推薦閱讀我之前的文章。

  • WebRTC 基礎及 1 v 1 對等連接
  • WebRTC 實戰之共享畫板

文章倉庫在 ???? fe-code,歡迎 star

源碼地址 webrtc-stream

線上預覽 https://webrtc-stream-depaadjmes.now.sh

三種模式

簡單介紹一下基於 WebRTC 的多人通信的幾種架構模式。

  • Mesh 架構

我們之前寫過幾個 1 v 1 的栗子,它們的連接模式如下:

這是典型的端到端對等連接,所以當我們要實現多人視頻(實際上也就是多端通信)的時候,我們會很自然的想到在 1 v 1 的基礎上擴充,給每個客戶端創建多個 1 v 1 的對等連接:

這就是所謂的 Mesh 模式,不需要額外的伺服器處理媒體數據(當然,信令伺服器是不可少的),僅僅是基於 WebRTC 自身的點對點連接進行通信,本期的實例也是採用這種模式。

但是這種架構的缺點也是十分明顯的,如果連接的客戶端過多,上行帶寬面臨的壓力將會非常大,相應的視頻通話 。 * Mixer 架構

傳統的視頻會議,一般都是採用 Mixer 架構。以錄播攝像為例,會利用 MCU (多點控制單元) 接收並混合每個客戶端傳入的媒體流。也就是將多個客戶端的音視頻畫面合成單個流,再傳輸給每個參與的客戶端。這樣也可以保證客戶端始終是 1 對 1 的連接,有效緩解了 Mesh 架構的問題。缺點則是依賴服務端,成本比較大,而且服務端處理過多也更容易導致視頻流的延遲。

  • Router 架構

Router 模式和 Mixer 很類似,比較來說,它只是單純的進行數據流的轉發,而不用合成、轉碼等操作。

因此,在實際運用中,使用哪種方式來處理,需要結合項目需求、成本等因素綜合考量。

多人視頻

1 v 1

我們基於 Mesh 模式來做多人視頻的演示,所以需要給每個客戶端創建多個 1 v 1 的對等連接。除了 WebRTC 的基礎知識,還需要用到 Socket.io 和 Koa 來做信令服務。

先複習一下 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 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,歡迎各種技術交流,期待你的加入

後記

如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支持一下作者,感謝??。文中如有不對之處,也歡迎大家指出,共勉。好了,又耽誤大家的時間了,感謝閱讀,下次再見!

  • 文章倉庫 ????fe-code
  • 社交聊天系統(vue + node + mongodb)- ??????Vchat

推薦閱讀:

相關文章