這是 WebRTC 系列的第三篇文章,主要講多人點對點鏈接。若是你對 WebRTC 還不太瞭解,推薦閱讀我以前的文章。javascript
文章倉庫在 🍹🍰 fe-code,歡迎 star。html
源碼地址 webrtc-stream前端
線上預覽 webrtc-stream-depaadjmes.now.shvue
簡單介紹一下基於 WebRTC 的多人通訊的幾種架構模式。java
咱們以前寫過幾個 1 v 1 的栗子,它們的鏈接模式以下:node
這是典型的端到端對等鏈接,因此當咱們要實現多人視頻(實際上也就是多端通訊)的時候,咱們會很天然的想到在 1 v 1 的基礎上擴充,給每一個客戶端建立多個 1 v 1 的對等鏈接:git
這就是所謂的 Mesh 模式,不須要額外的服務器處理媒體數據(固然,信令服務器是不可少的),僅僅是基於 WebRTC 自身的點對點鏈接進行通訊,本期的實例也是採用這種模式。github
可是這種架構的缺點也是十分明顯的,若是鏈接的客戶端過多,上行帶寬面臨的壓力將會很是大,相應的視頻通話 。web
傳統的視頻會議,通常都是採用 Mixer 架構。以錄播攝像爲例,會利用 MCU (多點控制單元) 接收並混合每一個客戶端傳入的媒體流。也就是將多個客戶端的音視頻畫面合成單個流,再傳輸給每一個參與的客戶端。這樣也能夠保證客戶端始終是 1 對 1 的鏈接,有效緩解了 Mesh 架構的問題。缺點則是依賴服務端,成本比較大,並且服務端處理過多也更容易致使視頻流的延遲。mongodb
Router 模式和 Mixer 很相似,比較來講,它只是單純的進行數據流的轉發,而不用合成、轉碼等操做。
所以,在實際運用中,使用哪一種方式來處理,須要結合項目需求、成本等因素綜合考量。
咱們基於 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 的鏈接流程,也知道要基於 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,歡迎各類技術交流,期待你的加入
若是你看到了這裏,且本文對你有一點幫助的話,但願你能夠動動小手支持一下做者,感謝🍻。文中若有不對之處,也歡迎你們指出,共勉。好了,又耽誤你們的時間了,感謝閱讀,下次再見!
更多文章:
前端進階之路系列
從頭到腳實戰系列