書接上回
上一篇中我們簡單的介紹了WebRTC
的一些歷史和API
的用法。在這一篇中,我們繼續來學習一些關於WebRTC
的架構、協議,以及在真實網路情況下WebRTC
是如何進行通訊的。
架構
WebRTC
是一個點對點通訊的框架,它的架構實現遵從 JESP
( JavaScript Session Establishment Protocol),如圖
在圖中,我們可以看到我們上一篇說到的會話描述(SessionDescription)
用於描述雙方的會話資訊,它也是一個標準格式,稱為 Session Description Protoco,簡稱 SDP
,這一個SDP
物件序列化之後的樣子。
v=0
o=- 3445510214506992100 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE video
a=msid-semantic: WMS EeiMAMV43kTkrOafBzAUtKcLGJupxSVVrbI4
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 123 127 122 125 107 108 109 124
c=IN IP4 0.0.0.0
...
a=ssrc:506345433 label:084a2d08-ec72-40ca-aeaa-6146cbe26fd9
複製程式碼
簡單來說,就是我們的應用把會話描述
交給WebRTC
然後就會幫我們把P2P
通訊啥的都搞定。我們只有呼叫API
獲得我們最終需要的資訊即可。那這裡可以會有小夥伴問了,為啥要用SDP
呢,看起來這麼奇怪,谷歌完全可以自己做一套呢?答案當然是為了相容性跟不重複造輪子,試想如果別的公司也弄了一個RTC
框架,只要用的也是SDP
那麼他們是完全可以相容的,因為你們用的是一樣的的語言
進行會話。
信令 signaling
從圖中我們還看到另一個東西,那就是信令(signaling)。這裡我不得不感嘆前輩的翻譯,這個翻譯真的是信達雅的典範。信令簡單來說,就是傳輸各種連線過程中的資訊。它在這裡傳遞了WebRTC
3個重要資訊,也就是上一篇我們提到的offer
、answer
和candidate
。offer
和answer
其實就是用於建立和交換雙方的會話描述,格式就是上面提到的SDP
,這裡就不展開說了。而candidate
也是來源與一個規範ICE framework,在建立通訊之前,我們需要獲得雙方的網路資訊,例如 IP
、埠等,而這一個框架就是用於規範這一個過程,candidate
便是用於儲存這些東西的。一般candidate
是有多個的,因為我們的網路環境通常是很複雜的,按照我的理解每經過一次NAT
都會又一個candidate
。在WebRTC
中,一般需要這樣操作
A
建立了一個註冊了onicecandidate
響應方法的RTCPeerConnection
物件- 這個響應方法在網路
candidates
準備好後呼叫 - 這個響應方法,
A
通過傳輸信令的通道傳送了一個字串化的的candidate
資料到B
- 當
B
獲得A
穿過來的candidate
資訊後,他需要呼叫addIceCandidate
把candidate
新增到遠端的節點描述
值得注意的是JSEP
支援 ICE Candidate Trickling,這意味著,它允許在offer
初始化之後繼續新增candidate
,並且應答方也無需等待所有的candidate
傳送完畢才開始嘗試建立連線,畢竟我可以一直加嘛,這個比較好理解。下面是一個candidate
的主要內容,包含協議、IP
、埠等
candidate:3885250869 1 udp 2122260223 172.17.0.1 37648 typ host generation 0 ufrag /Fde network-id 1 network-cost 50.
複製程式碼
接下來,我們就開始建立我們的信令服務吧。
建立信令伺服器
既然我們的信令伺服器本質上就是用於傳遞文字資訊給雙方。那我們就可以用任意通訊協議,包裝我們需要信令的資訊,然後傳送給對方就好。前提是這個通訊需要是雙向的,你可以用Websocket
也可以用Ajax
+輪詢的方式。怎麼順手怎麼來。下面的例子我們用了socket.io
,這個庫的好處是,它可以模擬socket
支援雙向通訊,並且相容各個瀏覽器,還有就是它原生支援房間(room
)的概念,也就是隻要我往房間發資料,所有在這這個房間的客戶端都能收到訊息(廣播),這種機制,給我們交換資訊提供方便。
建立服務的程式碼我們就跳過了,直接看訊息的處理部分。
io.sockets.on('connection', socket => {
// 列印 log 到客戶端
function log() {
var array = ['伺服器訊息:'];
array.push.apply(array, arguments);
socket.emit('log', array);
}
socket.on('message', message => {
log('客戶端訊息:', message);
// 廣播訊息,真正的使用應該只發到指定的 room 而不是廣播
socket.broadcast.emit('message', message);
});
socket.on("create or join", room => {
log('接受到建立或者加入房間請求:' + room);
var clientsInRoom = io.sockets.adapter.rooms[room];
var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
log('Room ' + room + ' 現在有 ' + numClients + ' 個客戶端');
if (numClients === 0) {
socket.join(room);
log('客戶端 ID: ' + socket.id + ' 建立了房間:' + room);
socket.emit('created', room, socket.id);
} else if (numClients === 1) {
log('客戶端 ID: ' + socket.id + ' 加入了房間: ' + room);
io.sockets.in(room).emit('join', room);
socket.join(room);
socket.emit('joined', room, socket.id);
io.sockets.in(room).emit('ready');
} else { // 一個房間只能容納兩個客戶端
socket.emit('full', room);
}
});
});
複製程式碼
主要是兩個關鍵事件的響應create and join
和message
。
create and join
,當客戶端傳送create and join
事件時,後臺對應的handler
方法會響應,並且試圖獲得這個房間的人數。- 如果是
0
,則這客戶端是建立者,加入房間併傳送建立的log
到客戶端,最後傳送一個created
的事件到客戶端 - 如果當前已經有一個客戶端了,則加入房間併傳送加入的
log
到客戶端,接著傳送一個joined
的事件到客戶端,最後傳送一個ready
的事件到房間,讓房間的所有客戶端收到。 - 如果已經大於
1
了,則房間滿員,直接傳送full
事件到客戶端
- 如果是
message
,客戶端傳送message
事件,對應方法會響應。這裡由於我們前端寫死了一個房間,因此,這裡直接建立一個廣播的message
事件,把訊息直接廣播給所有人,也就是通訊雙方了。
客戶端
服務端我們搞定了,然後我們看看前端是這麼處理的。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<h1>帶信令伺服器的 WebRTC</h1>
<div id="videos">
<video id="localVideo" autoplay muted playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
</div>
<!-- 墊片,用於統一瀏覽器 API -->
<script src="js/adapter.js"></script>
<!-- socket.io 支援-->
<script src="/socket.io/socket.io.js"></script>
<script src="js/main.js"></script>
</body>
</html>
複製程式碼
除了加入了socket.io
的支援,其餘跟上一篇是一樣的,不過這裡為了簡單,我們把按鈕去掉了,也就是說一開啟頁面就進行初始化,並且第一個客戶端等待第二個客戶端的加入
JavaScript
我們先看初始化的部分,首先是連線我們的服務端,然後建立和加入房間,也就是往服務端傳送create or join
事件。注意,這裡為了簡單我把加入的房間寫死成foo
了
var room = 'foo';
var socket = io.connect();
// 建立或加入房間
if (room !== "") {
socket.emit('create or join', room);
console.log('嘗試或加入房間: ' + room);
}
複製程式碼
接著我們往下看,這裡很熟悉,就是獲得媒體裝置部分,在獲得媒體裝置成功的回撥中,我們主要關注兩個方法的呼叫sendMessage
和maybeStart
。
var localVideo = document.querySelector('#localVideo');
var remoteVideo = document.querySelector('#remoteVideo');
navigator.mediaDevices.getUserMedia({
audio: false,
video: true})
.then(gotStream)
.catch(function(e) {
alert('獲得媒體錯誤: ' + e.name);
});
function gotStream(stream) {
console.log('正在新增本地流');
localStream = stream;
localVideo.srcObject = stream;
sendMessage('got user media');
}
複製程式碼
這裡sendMessage
傳送了got user media
到服務端。服務端收到資訊後,會把建立message
事件把訊息重新傳送到所有的客戶端,這裡可以回去看上面關於服務端訊息響應的程式碼解釋。
function sendMessage(message) {
console.log('客戶端傳送訊息: ', message);
socket.emit('message', message);
}
複製程式碼
現在我們接著看客戶端message
事件的響應。
// 訊息處理
socket.on('message', function(message) {
console.log('客戶端接收到訊息:', message);
if (message === 'got user media') {
maybeStart();
} else if (message.type === 'offer') {
...
...
});
複製程式碼
這裡是統一的訊息處理,忽略其他,我們先看got user media
訊息的處理,這裡其實就是簡單的呼叫了一下maybeStart
方法,所以我們來看一下這個方法做了什麼
function maybeStart() {
console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady);
if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) {
console.log('>>>>>> 正在建立 peer connection');
createPeerConnection();
pc.addStream(localStream);
isStarted = true;
console.log('isInitiator', isInitiator);
if (isInitiator) {
doCall();
}
}
}
複製程式碼
在maybeStart
方法中,如果當前狀態isStarted=false
,isChannelReady=true
和localStream
準備好了 就會建立了我們的RTCPeerConnection
物件,把icecandidate
,onaddstream
,removestream
註冊上,然後把本地的媒體流(localStream
)加入RTCPeerConnection
物件中。
function createPeerConnection() {
try {
pc = new RTCPeerConnection(null);
pc.onicecandidate = handleIceCandidate;
pc.onaddstream = handleRemoteStreamAdded;
pc.onremovestream = handleRemoteStreamRemoved;
console.log('RTCPeerConnnection 已建立');
} catch (e) {
console.log('建立失敗 PeerConnection, exception: ' + e.message);
alert('RTCPeerConnection 建立失敗');
}
}
複製程式碼
最後,把isStart
設定成true
避免再次初始化,然後如果當前房間建立者就開始呼叫doCall
開始發起通訊。
看到這裡,有些同學可能可能注意到了,這些isInitiator
,isChannelReady
是在哪裡設定的呢。那讓我們回頭看socket
的件響應方法把,下面的程式碼片段,就是在加入建立或房間的幾個事件中,把狀態相關的標識isInitiator
,isChannelReady
設定好。
socket.on('created', function(room, clientId) {
isInitiator = true;
console.log('建立房間:' + room + ' 成功')
});
socket.on('full', function(room) {
console.log('房間 ' + room + ' 已滿');
});
socket.on('join', function (room){
console.log('另一個節點請求加入: ' + room);
console.log('當前節點為房間 ' + room + ' 的建立者!');
isChannelReady = true;
});
socket.on('joined', function(room) {
console.log('已加入: ' + room);
isChannelReady = true;
});
複製程式碼
isChannelReady
,在join
或joined
事件響應中設定,也就是在有客戶端加入房間時isInitiator
,在created
事件響應,也就是建立房間成功時
所以,我們回頭看maybeStart
方法,其實它是在雙方進入房間之後才會真正的執行建立RTCPeerConnection
等操作的,因為此時,isChannelReady
才會是true
。
大家不要暈,接下來就是doCall
方法了,這方法很簡單,終於建立我們的offer
啦。
function doCall() {
console.log('傳送 offer 到節點');
pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}
function setLocalAndSendMessage(sessionDescription) {
pc.setLocalDescription(sessionDescription);
console.log('setLocalAndSendMessage 正在傳送訊息', sessionDescription);
sendMessage(sessionDescription);
}
複製程式碼
建立offer
成功後,就是常規操作,把它存到本地,呼叫setLocalDescription
,最後呼叫sendMessage
方法,通過我們的服務,發給對方。接下來我們繼續看下訊息的處理
// 訊息處理
socket.on('message', function(message) {
console.log('客戶端接收到訊息:', message);
...
} else if (message.type === 'offer') {
if (!isInitiator && !isStarted) {
maybeStart();
}
pc.setRemoteDescription(new RTCSessionDescription(message));
doAnswer();
} else if (message.type === 'answer' && isStarted) {
...
...
});
複製程式碼
這裡當接收方收到offer
之後,會首先判斷有沒有初始化(isStarted
)。否則呼叫maybeStart
進行初始化。初始化結束後,呼叫setRemoteDescription
把offer
儲存到。接著就是呼叫doAnswer
來answer
了,這邊跟doOffer
的方法流程基本一樣
function doAnswer() {
console.log('傳送 answer 到節點.');
pc.createAnswer().then(
setLocalAndSendMessage,
onCreateSessionDescriptionError
);
}
複製程式碼
接下來,我們回到發起端,看看它拿到 answer
訊息之後的處理
// 訊息處理
socket.on('message', function(message) {
console.log('客戶端接收到訊息:', message);
...
} else if (message.type === 'answer' && isStarted) {
pc.setRemoteDescription(new RTCSessionDescription(message));
} else if (message.type === 'candidate' && isStarted) {
...
..
});
複製程式碼
嗯,比較簡單。就是把answer
存起來了
到現在為止,我們的offer
跟answer
已經交換好了,接著我們繼續看candidate
的交換。先看oncandidate
的響應handleIceCandidate
。這個方法會在網路準備好之後,方法會一般多次呼叫,因為我們的網路環境通常是複雜的。這個方法把我們的candidate
包裝成我們需要的格式,然後傳送給對方。
function handleIceCandidate(event) {
console.log('icecandidate event: ', event);
if (event.candidate) {
sendMessage({
type: 'candidate',
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate
});
} else {
console.log('End of candidates.');
}
}
複製程式碼
好的,已經發出去了,然後就是訊息處理
// 訊息處理
socket.on('message', function(message) {
console.log('客戶端接收到訊息:', message);
...
...
} else if (message.type === 'candidate' && isStarted) {
var candidate = new RTCIceCandidate({
sdpMLineIndex: message.label,
candidate: message.candidate
});
pc.addIceCandidate(candidate);
} else if (message === 'bye' && isStarted) {
...
});
複製程式碼
很簡單,把訊息包裝成RTCIceCandidate
物件,然後呼叫addIceCandidate
儲存起來。
終於,我們所有必要的訊息都準備好了,WebRTC
就會為我們建立連線。然後通過offer
跟answer
的會話描述得到媒體流的資訊,並且回撥onaddstream
註冊的方法,把媒體流賦予給remoteVideo
的video
標籤
function handleRemoteStreamAdded(event) {
console.log('遠端媒體流設定.');
remoteStream = event.stream;
remoteVideo.srcObject = remoteStream;
}
複製程式碼
現在,我們可以開始愉快的視訊了。開啟兩個瀏覽器並且用https
訪問。因為上一篇提到過,在Chrome
的新版本,必須要用安全連線才能開啟媒體裝置。
或者PC
與手機通訊
大成功!!
總結
到這一篇為止,我們已經基本瞭解了WebRTC
的架構和用法,並且實現了不同平臺間的P2P
通訊。遺憾的是,現在這個Demo
僅僅能在區域網內運作。對於真實的的世界,有各種複雜的網路配置,還有防火牆。下一篇,我們來了解下在網際網路中,我們怎麼通過STUN
跟TURN
來實現WebRTC
吧。
謝謝各位的閱讀。
程式碼和參考文件