隨着5G技術的推廣,可以預見在不久的將來網速將得到極大提升,實時音視頻互動這類對網絡傳輸質量要求較高的應用將是最直接的受益者。而且伴隨着webrtc技術的成熟,該領域可能將成為下一個技術熱點,但是傳統的webrtc應用開發存在一定的復雜性,本文將介紹如何利用peerjs這一開源框架來簡化webrtc開發。
一、webrtc回顧
WebRTC(Web Real-Time Communication)即:網頁即時通信。 簡單點講,它可以實現瀏覽器網頁與網頁之間的音視頻實時通信(或傳輸其它任何數據),目前主流瀏覽器都支持該API,WebRTC現在已經納入W3C標准。
1.1 媒體協商
通信的主要目的之一是彼此交換信息。打個比方:“張三”跟“李四”打了一通電話(語音通訊),整個過程中“張三”說的話被“李四”聽到了,“李四”說的話被“張三”聽到了,雙方交換了語音信息。類似的,一個瀏覽器要與另一個瀏覽器發起實時音視頻通信,需要交換哪些信息呢? 除了音視頻信息外,至少還有2個關鍵信息要交換:媒體信息和網絡信息。
如上圖:通常某個瀏覽器所在的電腦,都會連接具體的多媒體設備(比如:麥克風、攝像頭)。如果A電腦上的攝像頭只支持VP8,H264格式,而另一台電腦上的攝像頭只支持H264、MPEG-4格式,它倆要能正常播放彼此的視頻,肯定會選擇雙方都能識別的H264格式。這就好比:2個不同國籍的人要相互交流,A會說英語、中文;而B只會說英語,毫無懸念,他倆肯定會用雙方都能聽懂的“英語”來溝通。
網絡情況也是類似的,二個瀏覽器所在的電腦可能在不同的網絡環境中,假如A機器具備公網+192內網網段,而B機器只有192+198內網網段,二台電腦要能相互連接,很容易想到,使用雙方都能連通的公共192內網網段通信最為方便。
在webrtc中,有一個特定的協議用於描述媒體信息、網絡信息和其它一些關鍵信息,稱為SDP(Session Description Protocol-會話描述協議)。而上述介紹的交換媒體信息、網絡信息的過程,也被稱為媒體協商,即:交換SDP.
這是一張媒體協商過程的經典圖例, Amy要跟Bob通信, 要先發一個Offer(即: 描述Amy自己會話的SDP), Bob收到后,做出Answer回應(即:描述Bob自己會話的SDP), 雙方完成SDP交換后, 根據前面的分析,取出二份SDP的交集, 即完成了媒體協商.
1.2 主要處理過程
這是mozilla開發者官網上的一張圖, 大致描述了webrtc的處理過程:
- A通過STUN服務器,收集自己的網絡信息
- A創建Offer SDP,通過Signal Channel(信令服務器)給到B
- B做出回應生成Answer SDP,通過Signal Channel給到A
- B通過STUN收集自己的網絡信息,通過Signal Channel給到A
注:如果A,B之間無法直接穿透(即:無法建立點對點的P2P直連),將通過TURN服務器中轉。
二、peerjs介紹
從上面的回顧可以看出,要創建一個真正的webrtc應用還是有些小復雜的,特別是SDP交換(createOffer及createAnswer)、網絡候選信息收集(ICE candidate),這些都需要開發人員對webrtc的機制有足夠的了解,對webrtc初學者來講有一定的開發門檻。
而peerjs開源項目簡化了webrtc的開發過程,把SDP交換、ICE candidate這些偏底層的細節都做了封裝,開發人員只需要關注應用本身就行了。
peerjs的核心對象Peer,它有幾個常用方法:
- peer.connect 創建點對點的連接
- peer.call 向另1個peer端發起音視頻實時通信
- peer.on 對各種事件的監控回調
- peer.disconnect 斷開連接
- peer.reconnect 重新連接
- peer.destroy 銷毀對象
另外還有二個重要對象DataConnection、MediaConnection,其中:
- DataConnection用於收發數據(對應於webrtc中的DataChannel),它的所有方法中有一個重要的send方法,用於向另一個peer端發送數據;
- MediaConnection用於處理媒體流,它有一個重要的stream屬性,表示關聯的媒體流。
更多細節可查閱peerjs的api在線文檔 (注:peerjs的所有api只有一頁,估計15分鍾左右就全部看一圈)
peerjs的服務端(即信令服務器)很簡單,只需要下面這段nodejs代碼即可:
var fs = require('fs'); var PeerServer = require('peer').PeerServer; var options = { //webrtc要求SSL安全傳輸,所以要設置證書 key: fs.readFileSync('key/server.key'), cert: fs.readFileSync('key/server.crt') } var server = PeerServer({ port: 9000, ssl: options, path:"/" });
本地啟用成功后,瀏覽https://localhost:9000 可以看到
三、實戰練習
下面選幾個常用的場景,利用peerjs實戰一番(文末最后有示例源碼鏈接) - 注:建議使用chrome谷歌瀏覽器運行下面的示例。
3.1 文本聊天
運行效果如下(假設有Jack、Rose二個用戶在各自的瀏覽器頁面上相互聊天)
主要流程:
- Jack和Rose先連接到PeerJs服務器
- Rose指定要建立p2p連接的對方名稱(即:Jack),然后發送消息
- Jack在自己的頁面上,可以實時收到Rose發送過來的文字,並回復
客戶端的js代碼如下:(不到100行)
var txtSelfId = document.querySelector("input#txtSelfId"); var txtTargetId = document.querySelector("input#txtTargetId"); var txtMsg = document.querySelector("input#txtMsg"); var tdBox = document.querySelector("td#tdBox"); var btnRegister = document.querySelector("button#btnRegister"); var btnSend = document.querySelector("button#btnSend"); let peer = null; let conn = null; //peer連接時,id不允許有中文,所以轉換成hashcode數字 hashCode = function (str) { var hash = 0; if (str.length == 0) return hash; for (i = 0; i < str.length; i++) { char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash; } sendMessage = function (message) { conn.send(JSON.stringify(message)); console.log(message); tdBox.innerHTML = tdBox.innerHTML += "<div class='align_left'>" + message.from + " : " + message.body + "</div>"; } window.onload = function () { //peerserver的連接選項(debug:3表示打開調試,將在瀏覽器的console輸出詳細日志) let connOption = { host: 'localhost', port: 9000, path: '/', debug: 3 }; //register處理 btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } //創建peer實例 peer = new Peer(hashCode(txtSelfId.value), connOption); //register成功的回調 peer.on('open', function (id) { tdBox.innerHTML = tdBox.innerHTML += "<div class='align_right'>system : register success " + id + "</div>"; }); peer.on('connection', (conn) => { //收到對方消息的回調 conn.on('data', (data) => { var msg = JSON.parse(data); tdBox.innerHTML = tdBox.innerHTML += "<div class='align_right'>" + msg.from + " : " + msg.body + "</div>"; if (txtTargetId.value.length == 0) { txtTargetId.value = msg.from; } }); }); } } //發送消息處理 btnSend.onclick = function () { //消息體 var message = { "from": txtSelfId.value, "to": txtTargetId.value, "body": txtMsg.value }; if (!conn) { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } if (txtMsg.value.length == 0) { alert("please input message"); txtMsg.focus(); return; } //創建到對方的連接 conn = peer.connect(hashCode(txtTargetId.value)); conn.on('open', () => { //首次發送消息 sendMessage(message); }); } //發送消息 if (conn.open) { sendMessage(message); } } }
有幾點說明一下:
- 89行首次發送消息,這時conn還沒有准備好(open狀態為false),此時send不會成功,參考下面的調試截圖
要在conn.on('open',{...})事件回調里完成首次消息的發送,這時候open狀態是true,send才能成功
- 從瀏覽器的console控制台日志可以清楚的看到peerjs,已經把createOffer、createAnswer,以及ICE candidate這些細節都內部消化掉了。
這是Rose端的日志
這是Jack端的日志
從日志可以看到,剛開始Rose→Create Offer->Jack,然后Jack→Create Answer→ Rose,Rose→Jack的連接建立好了; Jack收到第一句話"how are you"后,回復"fine, thank you"時, 過程反過來 Jack → Create Offer → Rose,然后Rose → Create Answer → Jack, Jack→Rose的連接也建好了,后面再聊天,就可以直接相互send文字消息了。另外ICE candidate 、set localDescription、set remoteDescription這些peerjs也一並幫我們做掉了,對普通開發人員而言,不再需要關心這些細節。強烈建議大家將這2份日志與“第1部分Amy與Bob交換SDP"那張圖對照體會一下。
另外,雖然這個示例是在本機運行的,但是原理跟2台不同的電腦之間(或不同的網絡環境,比如Rose在美國、Jack在中國)端對端通信是完全相同的,只不過如果二端的瀏覽器如果不在一個網段,需要配置stun或turn服務器,參考下面的配置:
var peer = new Peer({ config: {'iceServers': [ { url: 'stun:stun.l.google.com:19302' }, { url: 'turn:homeo@turn.bistri.com:80', credential: 'homeo' } ]} /* Sample servers, please use appropriate ones */ });
注:關於stun或turn的細節,建議閱讀本文最后的參考文章。
3.2 視頻通話
運行效果如下(視頻轉成gif文件尺寸太大,這里就只截了幾張運行中的關鍵圖片)
注:為了模擬2個人分別在不同的頁面實時視頻通話, 我在本機插了2個USB攝像頭(1個橫着放,1個豎着放),打開2個瀏覽器頁面並啟用攝像頭后,1個頁面選擇攝像頭1,另1個頁面選擇攝像頭2(通過下圖中攝像頭下拉框切換)。
如上圖,在1個頁面上輸入”張三“並點擊register,同時允許使用攝像頭,然后在另1個頁面輸入”李四“,也點擊register,並允許使用攝像頭,然后把攝像頭切換到另1個,這樣2個頁面看到的本地視頻就不一樣了(相當於2個端各自的視頻流)。然后在"李四"的頁面上,target name這里輸入"張三",並點擊call按鈕發起視頻通話,此時"張三"的頁面上會馬上收到邀請確認(如下圖)
”張三“選擇Accept同意后,二端就相互建立連接,開始實時視頻通話。
注:首次運行時,瀏覽器會彈出類似下圖的提示框詢問是否同意啟用攝像頭/麥克風(出於安全隱私考慮),如果手一抖選擇了不允許,就算刷新頁面,也不會再彈出提示框。
對於chrome瀏覽器,可在"設置→ 高級→ 內容設置→ 攝像頭/麥克風" 手動重新設置。
從上面這一系列的運行截圖可以看到,“李四”與“張三”在發起視頻通話過程中涉及到一些交互(即:“李四”發起,“張三”可以選擇同意或拒絕),這些交互的指令(也稱為"信令")可以通過上一個場景"文字聊天"中的聊天消息Message作為載體,簡單起見,message可以用一個json格式來表示:
{ "from": "李四", "to": "張三", "action": "call" }
action代表具體的指令動作類型,在這個場景中有3個:call(發起視頻通話),accept(對方同意視頻通話),accept-ok(發起方通知對方接收媒體流)-注:指令類型的名字可以隨便起,不一定非得叫call/accept/accept-ok,容易理解即可。
關鍵的幾處代碼如下:call按鈕的處理邏輯
btnCall.onclick = function () { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } sendMessage(txtSelfId.value, txtTargetId.value, "call"); }
其中sendMessage即發送消息
function sendMessage(from, to, action) { var message = { "from": from, "to": to, "action": action }; if (!localConn) { localConn = peer.connect(hashCode(to)); localConn.on('open', () => { localConn.send(JSON.stringify(message)); console.log(message); }); } if (localConn.open){ localConn.send(JSON.stringify(message)); console.log(message); } }
register按鈕處理邏輯:
//register處理 btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } peer = new Peer(hashCode(txtSelfId.value), connOption); peer.on('open', function (id) { console.log("register success. " + id); }); peer.on('call', function (call) { call.answer(localStream); }); peer.on('connection', (conn) => { conn.on('data', (data) => { var msg = JSON.parse(data); console.log(msg); //“接收方“收到邀請時,彈出詢問對話框 if (msg.action === "call") { lblFrom.innerText = msg.from; txtTargetId.value = msg.from; $("#dialog-confirm").dialog({ resizable: false, height: "auto", width: 400, modal: true, buttons: { "Accept": function () { $(this).dialog("close"); sendMessage(msg.to, msg.from, "accept"); }, Cancel: function () { $(this).dialog("close"); } } }); } //“發起方“發起視頻call,並綁定媒體流 if (msg.action === "accept") { console.log("accept call => " + JSON.stringify(msg)); var call = peer.call(hashCode(msg.from), localStream); call.on('stream', function (stream) { console.log('received remote stream'); remoteVideo.srcObject = stream; sendMessage(msg.to, msg.from, "accept-ok"); }); } //"接收方"發起視頻call,並綁定媒體流 if (msg.action === "accept-ok") { console.log("accept-ok call => " + JSON.stringify(msg)); var call = peer.call(hashCode(msg.from), localStream); call.on('stream', function (stream) { console.log('received remote stream'); remoteVideo.srcObject = stream; }); } }); }); } }
3.3 白板共享
運行效果如下:在2個頁面上,仍然模擬2個用戶“張三”與“李四”,都register到peerjs服務器后,輸入對方的名稱,然后點擊share,就可以在canvas上共享白板一起塗鴉了。
關鍵點:send方法不僅僅可以用來發送文字消息,同樣也可以發送其它內容,每次在canvas上的的塗鴉,本質上就是調用canvas的api在一系列的坐標點上連續畫線。只要把1個頁面上畫線經過的坐標點發送到另1個頁面上,再還原出來就可以了。
核心代碼:
window.onload = function () { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { console.log('webrtc is not supported!'); alert("webrtc is not supported!"); return; } let connOption = { host: 'localhost', port: 9000, path: '/', debug: 3 }; context = demoCanvas.getContext('2d'); //canvas鼠標按下的處理 demoCanvas.onmousedown = function (e) { e.preventDefault(); context.strokeStyle='#00f'; context.beginPath(); started = true; buffer.push({ "x": e.offsetX, "y": e.offsetY }); } //canvas鼠標移動的處理 demoCanvas.onmousemove = function (e) { if (started) { context.lineTo(e.offsetX, e.offsetY); context.stroke(); buffer.push({ "x": e.offsetX, "y": e.offsetY }); } } //canvas鼠標抬起的處理 demoCanvas.onmouseup = function (e) { if (started) { started = false; //鼠標抬起時,發送坐標數據 sendData(txtSelfId.value, txtTargetId.value, buffer); buffer = []; } } //register按鈕處理 btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } peer = new Peer(hashCode(txtSelfId.value), connOption); peer.on('open', function (id) { console.log("register success. " + id); }); peer.on('connection', (conn) => { conn.on('data', (data) => { let msg = JSON.parse(data); console.log(msg); txtTargetId.value = msg.from; //還原canvas context.strokeStyle='#f00'; context.beginPath(); context.moveTo(msg.data[0].x,msg.data[0].y); for (const pos in msg.data) { context.lineTo(msg.data[pos].x,msg.data[pos].y); } context.stroke(); }); }); } } //share按鈕處理 btnShare.onclick = function () { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } } start(); }
其中sendData方法如下:
function sendData(from, to, data) { if (from.length == 0 || to.length == 0 || data.length == 0) { return; } let message = { "from": from, "to": to, "data": data }; if (!localConn) { localConn = peer.connect(hashCode(to)); localConn.on('open', () => { localConn.send(JSON.stringify(message)); console.log(message); }); } if (localConn.open) { localConn.send(JSON.stringify(message)); console.log(message); } }
說明一下:這里我們用一個buffer數組來保存每次畫線的坐數,然后在畫線結束時,再調用sendData發送到對方。
3.4 圖片傳輸
運行效果:在2個瀏覽器頁面上,分別register2個用戶,然后在其中1個頁面上,輸入對方的名字,然后選擇一張圖片,另1個頁面將會收到傳過來的圖片。
核心仍然利用的是DataConnection的send方法,只不過發送的內容里包含了圖片對應的blob對象,核心代碼如下:
btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } peer = new Peer(hashCode(txtSelfId.value), connOption); peer.on('open', function (id) { console.log("register success. " + id); lblStatus.innerHTML = "scoket open" }); peer.on('connection', (conn) => { conn.on('data', (data) => { console.log("receive remote data"); lblStatus.innerHTML = "receive data from " + data.from; txtTargetId.value = data.from if (data.filetype.includes('image')) { lblStatus.innerHTML = data.filename + "(" + data.filetype + ") from:" + data.from const bytes = new Uint8Array(data.file) //用base64編碼,還原圖片 img.src = 'data:image/png;base64,' + encode(bytes) } }); }); } } //文件變化時,觸發sendFile inputFile.onchange = function (event) { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } const file = event.target.files[0] //構造圖片對應的blob對象 const blob = new Blob(event.target.files, { type: file.type }); img.src = window.URL.createObjectURL(file); sendFile(txtSelfId.value, txtTargetId.value, blob, file.name, file.type); }
sendFile方法如下:
function sendFile(from, to, blob, fileName, fileType) { var message = { "from": from, "to": to, "file": blob, "filename": fileName, "filetype": fileType }; if (!localConn) { localConn = peer.connect(hashCode(to)); localConn.on('open', () => { localConn.send(message); console.log('onopen sendfile'); }); } localConn.send(message); console.log('send file'); }
上述示例的源碼已上傳至github,地址:https://github.com/yjmyzz/peerjs-sample
參考文章: