利用peerjs輕鬆玩轉webrtc

  • 2019 年 10 月 3 日
  • 筆記

 隨著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實戰一番(文末最後有示例源碼鏈接) – 註:建議使用chromeGoogle瀏覽器運行下面的示例。

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:[email protected]: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

 

參考文章: