上一篇中咱們簡單的介紹了WebRTC
的一些歷史和API
的用法。在這一篇中,咱們繼續來學習一些關於WebRTC
的架構、協議,以及在真實網絡狀況下WebRTC
是如何進行通訊的。javascript
WebRTC
是一個點對點通信的框架,它的架構實現聽從 JESP
( JavaScript Session Establishment Protocol),如圖html
在圖中,咱們能夠看到咱們上一篇說到的會話描述(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
那麼他們是徹底能夠兼容的,由於大家用的是同樣的的語言
進行會話。java
從圖中咱們還看到另外一個東西,那就是信令(signaling)。這裏我不得不感嘆前輩的翻譯,這個翻譯真的是信達雅的典範。信令簡單來講,就是傳輸各類鏈接過程當中的信息。它在這裏傳遞了WebRTC
3個重要信息,也就是上一篇咱們提到的offer
、answer
和candidate
。offer
和answer
其實就是用於建立和交換雙方的會話描述,格式就是上面提到的SDP
,這裏就不展開說了。而candidate
也是來源與一個規範ICE framework,在創建通信以前,咱們須要得到雙方的網絡信息,例如 IP
、端口等,而這一個框架就是用於規範這一個過程,candidate
即是用於保存這些東西的。通常candidate
是有多個的,由於咱們的網絡環境一般是很複雜的,按照個人理解每通過一次NAT
都會又一個candidate
。在WebRTC
中,通常須要這樣操做git
A
建立了一個註冊了onicecandidate
響應方法的 RTCPeerConnection
對象candidates
準備好後調用A
經過傳輸信令的通道發送了一個字符串化的的candidate
數據到B
B
得到A
穿過來的candidate
信息後,他須要調用addIceCandidate
把candidate
添加到遠程的節點描述值得注意的是JSEP
支持 ICE Candidate Trickling,這意味着,它容許在offer
初始化以後繼續添加candidate
,而且應答方也無需等待全部的candidate
發送完畢纔開始嘗試創建鏈接,畢竟我能夠一直加嘛,這個比較好理解。下面是一個candidate
的主要內容,包含協議、IP
、端口等github
candidate:3885250869 1 udp 2122260223 172.17.0.1 37648 typ host generation 0 ufrag /Fde network-id 1 network-cost 50.
複製代碼
接下來,咱們就開始創建咱們的信令服務吧。web
既然咱們的信令服務器本質上就是用於傳遞文本信息給雙方。那咱們就能夠用任意通信協議,包裝咱們須要信令的信息,而後發送給對方就好。前提是這個通信須要是雙向的,你能夠用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
。bash
create and join
,當客戶端發送create and join
事件時,後臺對應的handler
方法會響應,而且試圖得到這個房間的人數。
0
,則這客戶端是建立者,加入房間併發送建立的log
到客戶端,最後發送一個created
的事件到客戶端log
到客戶端,接着發送一個joined
的事件到客戶端,最後發送一個ready
的事件到房間,讓房間的全部客戶端收到。1
了,則房間滿員,直接發送full
事件到客戶端message
,客戶端發送message
事件,對應方法會響應。這裏因爲咱們前端寫死了一個房間,所以,這裏直接建立一個廣播的message
事件,把消息直接廣播給全部人,也就是通信雙方了。服務端咱們搞定了,而後咱們看看前端是這麼處理的。
<!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
的支持,其他跟上一篇是同樣的,不過這裏爲了簡單,咱們把按鈕去掉了,也就是說一打開頁面就進行初始化,而且第一個客戶端等待第二個客戶端的加入
咱們先看初始化的部分,首先是鏈接咱們的服務端,而後建立和加入房間,也就是往服務端發送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
吧。
謝謝各位的閱讀。
代碼和參考文檔