01-WebRTC一對(duì)一通話

音視頻文章匯總,上一篇文章《00-WebRTC入門》介紹了nodejs作為信令服務(wù)器棺棵,客戶端和服務(wù)器端的交互選擇websocket作為通信協(xié)議称开。本文從代碼層面實(shí)現(xiàn)一對(duì)一視頻通話蹄殃。

1.一對(duì)一通話原理

主要分為四大塊:
I.信令設(shè)計(jì):進(jìn)入房間敬鬓,離開房間等
II.媒體協(xié)商:交換彼此客戶端的媒體信息sdp
III.加入Stream/Track
IV.網(wǎng)絡(luò)協(xié)商:Candidate雏掠,網(wǎng)絡(luò)地址,端口號(hào)等
先看一張圖


image

1.1信令協(xié)議設(shè)計(jì)

采用json封裝格式

  1. join 加入房間
  2. respjoin
    當(dāng)join房間后發(fā)現(xiàn)房間已經(jīng)存在另一個(gè)人時(shí)則返回另一個(gè)人的uid创橄;如果只有自己則不返回
  3. leave 離開房間腐巢,服務(wù)器收到leave信令則檢查同一房間是否有其他人,如果有其他人則通知他有人離開
  4. newpeer
    服務(wù)器通知客戶端有新人加入腻脏,收到newpeer
    則發(fā)起連接請(qǐng)求
  5. peerleave
    服務(wù)器通知客戶端有人離開
  6. offer 轉(zhuǎn)發(fā)offer sdp
  7. answer 轉(zhuǎn)發(fā)answer sdp
  8. candidate 轉(zhuǎn)發(fā)candidate sdp

Join

var jsonMsg = {
'cmd': 'join',
'roomId': roomId,
'uid': localUserId,
};

respjoin

jsonMsg = {
'cmd': 'resp‐join',
'remoteUid': remoteUid
};

leave

var jsonMsg = {
'cmd': 'leave',
'roomId': roomId,
'uid': localUserId,
};

newpeer

var jsonMsg = {
'cmd': 'new‐peer',
'remoteUid': uid
};

peerleave

var jsonMsg = {
'cmd': 'peer‐leave',
'remoteUid': uid
};

offer

var jsonMsg = {
'cmd': 'offer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};

answer

var jsonMsg = {
'cmd': 'answer',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(sessionDescription)
};

candidate

var jsonMsg = {
'cmd': 'candidate',
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(candidateJson)
};

1.2媒體協(xié)商

image

createOffer
基本格式
aPromise = myPeerConnection.createOffer([options])?
[options]

var options = {
offerToReceiveAudio: true, // 告訴另一端鸦泳,你是否想接收音頻,默認(rèn)true
offerToReceiveVideo: true, // 告訴另一端永品,你是否想接收視頻做鹰,默認(rèn)true
iceRestart: false, // 是否在活躍狀態(tài)重啟ICE網(wǎng)絡(luò)協(xié)商
};

iceRestart:只有在處于活躍的時(shí)候,iceRestart=false才有作用鼎姐。
createAnswer
基本格式
aPromise = RTCPeerConnection .createAnswer([ options ])? 目前createAnswer的options是
無(wú)效的钾麸。
setLocalDescription
基本格式
aPromise = RTCPeerConnection .setLocalDescription(sessionDescription);
setRemoteDescription
基本格式
aPromise = pc.setRemoteDescription(sessionDescription);

1.3加入Stream/Track

addTrack
基本格式
rtpSender = rtcPeerConnection .addTrack(track,stream ...);
track:添加到RTCPeerConnection中的媒體軌(音頻track/視頻track)
stream:getUserMedia中拿到的流炕桨,指定track所在的stream

1.4網(wǎng)絡(luò)協(xié)商

addIceCandidate
基本格式
aPromise = pc.addIceCandidate(候選人);

candidate

屬性 說明
candidate 候選者描述信息
sdpMid 與候選者相關(guān)的媒體流的識(shí)別標(biāo)簽
sdpMLineIndex 在SDP中 m=的索引值
usernameFragment 包括了遠(yuǎn)端的唯一標(biāo)識(shí)

Android和Web端不同饭尝。

1.5RTCPeerConnection補(bǔ)充

1.5.1構(gòu)造函數(shù)

configuration可選
屬性說明
candidate 候選者描述信息
sdpMid 與候選者相關(guān)的媒體流的識(shí)別標(biāo)簽
sdpMLineIndex 在SDP中 m=的索引值
usernameFragment 包括了遠(yuǎn)端的唯一標(biāo)識(shí)
bundlePolicy 一般用maxbundle
banlanced:音頻與視頻軌使用各自的傳輸通道
maxcompat:
每個(gè)軌使用自己的傳輸通道
maxbundle:
都綁定到同一個(gè)傳輸通道
iceTransportPolicy 一般用all
指定ICE的傳輸策略
relay:只使用中繼候選者
all:可以使用任何類型的候選者
iceServers
其由RTCIceServer組成,每個(gè)RTCIceServer都是一個(gè)ICE代理的服務(wù)器

屬性 含義
credential 憑據(jù)献宫,只有TURN服務(wù)使用
credentialType 憑據(jù)類型钥平,可以password或oauth
urls 用于連接服中的ur數(shù)組
username 用戶名,只有TURN服務(wù)使用

rtcpMuxPolicy 一般用require
rtcp的復(fù)用策略姊途,該選項(xiàng)在收集ICE候選者時(shí)使用

屬性 含義
negotiate 收集RTCP與RTP復(fù)用的ICE候選者涉瘾,如果RTCP能復(fù)用就與RTP復(fù)用知态,如果不能復(fù)用,就將他們單獨(dú)使用
require 只能收集RTCP與RTP復(fù)用的ICE候選者立叛,如果RTCP不能復(fù)用负敏,則失敗

1.5.2重要事件

onicecandidate 收到候選者時(shí)觸發(fā)的事件
ontrack 獲取遠(yuǎn)端流
onconnectionstatechange PeerConnection的連接狀態(tài),參考: https://developer.mozilla.org/enUS/
docs/Web/API/RTCPeerConnection/connectionState

pc.onconnectionstatechange = function(event) {
    switch(pc.connectionState) {
    case "connected":
    // The connection has become fully   connected
          break;
    case "disconnected":
    case "failed":
// One or more transports has terminated       unexpectedly or in an error
    break;
    case "closed":
    // The connection has been closed
    break;
  }
}

oniceconnectionstatechange ice連接事件 具體參考:https://developer.mozilla.org/enUS/docs/Web/API/RTCPeerConnection/iceConnectionState

1.6實(shí)現(xiàn)WebRTC音視頻通話

開發(fā)步驟

  1. 客戶端顯示界面
  2. 打開攝像頭并顯示到頁(yè)面
  3. websocket連接
  4. join秘蛇、newpeer
    其做、respjoin
    信令實(shí)現(xiàn)
  5. leave、peerleave
    信令實(shí)現(xiàn)
  6. offer赁还、answer妖泄、candidate信令實(shí)現(xiàn)
  7. 綜合調(diào)試和完善

1.6.1客戶端顯示界面

image
<!DOCTYPE html>
<link rel="shortcut icon" href="#">
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <title>WebRTC Demo</title>
    </head>

    <h1>WebRTC Demo</h1>

    <div id="buttons">
        <input id="zero-RoomId" type="text" placeholder="請(qǐng)輸入房間ID" maxlength="40"/>
        <button id="joinBtn" type="button">加入</button>
        <button id="leaveBtn" type="button">離開</button>
    </div>
    <div id="videos">
        <video id="localVideo" autoplay muted playsinline>本地窗口</video>
        <video id="remoteVideo" autoplay playsinline>遠(yuǎn)端窗口</video>
    </div>
    <script src="js/main.js"></script>
    <script src="js/adapter-latest.js"></script>
</html>

1.6.2打開攝像頭并顯示到界面

image

0

1.6.3WebSocket連接

fishRTCEngine = new FishRTCEngine("wss://192.168.1.102:8098/ws");
fishRTCEngine.createWebSocket();
FishRTCEngine.prototype.createWebSocket = function () {
  fishRTCEngine = this;
  fishRTCEngine.signaling = new WebSocket(this.wsUrl);
  fishRTCEngine.signaling.onopen = function () {
    fishRTCEngine.onOpen();
  };

  fishRTCEngine.signaling.onmessage = function (ev) {
    fishRTCEngine.onMessage(ev);
  };

  fishRTCEngine.signaling.onerror = function (ev) {
    fishRTCEngine.onError(ev);
  };

  fishRTCEngine.signaling.onclose = function (ev) {
    fishRTCEngine.onClose(ev);
  };
};

1.6.4 join、newpeer秽浇、respjoin信令實(shí)現(xiàn)

思路:(1)點(diǎn)擊加入開妞浮庐;
(2)響應(yīng)加入按鈕事件甚负;
(3)將join發(fā)送給服務(wù)器柬焕;
(4)服務(wù)器 根據(jù)當(dāng)前房間的人數(shù)
做處理,如果房間已經(jīng)有人則通知房間里面的人有新人加入(newpeer)梭域,并通知自己房間里面是什么人(respjoin)斑举。

1.6.5 leave、peerleave信令實(shí)現(xiàn)

思路:(1)點(diǎn)擊離開按鈕病涨;
(2)響應(yīng)離開按鈕事件富玷;
(3)將leave發(fā)送給服務(wù)器;
(4)服務(wù)器處理leave既穆,將發(fā)送者刪除并通知房間(peerleave)的其他人赎懦;
(5)房間的其他人在客戶端響應(yīng)peerleave事件。
// One or more transports has terminated unexpectedly or in an error
break;
case "closed":
// The connection has been closed
break;
}
}

1.6.6 offer幻工、answer励两、candidate信令實(shí)現(xiàn)

思路:
(1)收到newpeer
(handleRemoteNewPeer處理),作為發(fā)起者創(chuàng)建RTCPeerConnection囊颅,綁定事件響應(yīng)函數(shù)当悔,
加入本地流;
(2)創(chuàng)建offer sdp踢代,設(shè)置本地sdp盲憎,并將offer sdp發(fā)送到服務(wù)器;
(3)服務(wù)器收到offer sdp 轉(zhuǎn)發(fā)給指定的remoteClient胳挎;
(4)接收者收到offer饼疙,也創(chuàng)建RTCPeerConnection,綁定事件響應(yīng)函數(shù)慕爬,加入本地流宏多;
(5)接收者設(shè)置遠(yuǎn)程sdp儿惫,并創(chuàng)建answer sdp,然后設(shè)置本地sdp并將answer sdp發(fā)送到服務(wù)器伸但;
(6)服務(wù)器收到answer sdp 轉(zhuǎn)發(fā)給指定的remoteClient肾请;
(7)發(fā)起者收到answer sdp,則設(shè)置遠(yuǎn)程sdp更胖;
(8)發(fā)起者和接收者都收到ontrack回調(diào)事件铛铁,獲取到對(duì)方碼流的對(duì)象句柄;
(9)發(fā)起者和接收者都開始請(qǐng)求打洞却妨,通過onIceCandidate獲取到打洞信息(candidate)并發(fā)送給對(duì)方
(10)如果P2P能成功則進(jìn)行P2P通話饵逐,如果P2P不成功則進(jìn)行中繼轉(zhuǎn)發(fā)通話。

1.6.7 綜合調(diào)試和完善

思路:
(1)點(diǎn)擊離開時(shí)彪标,要將RTCPeerConnection關(guān)閉(close)倍权;
(2)點(diǎn)擊離開時(shí),要將本地?cái)z像頭和麥克風(fēng)關(guān)閉捞烟;
(3)檢測(cè)到客戶端退出時(shí)薄声,服務(wù)器再次檢測(cè)該客戶端是否已經(jīng)退出房間。
(4)RTCPeerConnection時(shí)傳入ICE server的參數(shù)题画,以便當(dāng)在公網(wǎng)環(huán)境下可以進(jìn)行正常通話默辨。
客戶端代碼

"use strict";

// join 主動(dòng)加入房間
// leave 主動(dòng)離開房間
// new-peer 有人加入房間,通知已經(jīng)在房間的人
// peer-leave 有人離開房間苍息,通知已經(jīng)在房間的人
// offer 發(fā)送offer給對(duì)端peer
// answer發(fā)送offer給對(duì)端peer
// candidate 發(fā)送candidate給對(duì)端peer
const SIGNAL_TYPE_JOIN = "join";
const SIGNAL_TYPE_RESP_JOIN = "resp-join"; // 告知加入者對(duì)方是誰(shuí)
const SIGNAL_TYPE_LEAVE = "leave";
const SIGNAL_TYPE_NEW_PEER = "new-peer";
const SIGNAL_TYPE_PEER_LEAVE = "peer-leave";
const SIGNAL_TYPE_OFFER = "offer";
const SIGNAL_TYPE_ANSWER = "answer";
const SIGNAL_TYPE_CANDIDATE = "candidate";

var localUserId = Math.random().toString(36).substr(2); //本地uid
var remoteUserId = -1; //對(duì)端uid
var roomId = 0;

var localVideo = document.querySelector("#localVideo");
var remoteVideo = document.querySelector("#remoteVideo");
var localStream = null;
var remoteStream = null;
var pc = null; //RTCPeerConnection

var fishRTCEngine;

function handleIceCandidate(event) {
  console.info("handleIceCandidate");
  if (event.candidate) {
    //不為空才發(fā)送candidate
    var jsonMsg = {
      cmd: "candidate",
      roomId: roomId,
      uid: localUserId,
      remoteUid: remoteUserId,
      msg: JSON.stringify(event.candidate),
    };
    var message = JSON.stringify(jsonMsg);
    fishRTCEngine.sendMessage(message);
    // console.info("handleIceCandidate message: "+message);
    console.info("send Candidate message:");
  } else {
    //不再去請(qǐng)求打洞了
    console.warn("End of candidates");
  }
}

function handleRemoteStreamAdd(event) {
  console.info("handleRemoteStreamAdd");
  remoteStream = event.streams[0];
  remoteVideo.srcObject = remoteStream;
}
function handleConnectionStateChange(){
    if(pc != null){
        console.info("handleConnectionStateChange: " + pc.connectionState);
    }
}
function handleIceConnectionStateChange(){
    if(pc != null){
        console.info("handleIceConnectionStateChange: " + pc.iceConnectionState);
    }
}

function createPeerConnection() {
  var defaultConfiguration = {
    bundlePolicy: "max-bundle",
    rtcpMuxPolicy: "require",
    iceTransportPolicy: "relay", //relay or all
    // 修改ice數(shù)組測(cè)試效果缩幸,需要進(jìn)行封裝
    iceServers: [
      {
        urls: [
          "turn:192.168.1.102:3478?transport=udp",
          "turn:192.168.1.102:3478?transport=tcp", // 可以插入多個(gè)進(jìn)行備選
        ],
        username: "ydy",
        credential: "123456",
      },
      {
        urls: ["stun:192.168.1.102:3478"],
      },
    ],
  };
  pc = new RTCPeerConnection(defaultConfiguration);
  pc.onicecandidate = handleIceCandidate;
  pc.ontrack = handleRemoteStreamAdd;
  pc.oniceconnectionstatechange = handleIceConnectionStateChange;
  pc.onconnectionstatechange = handleConnectionStateChange;
  localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
}

function createOfferAndSendMessage(session) {
  pc.setLocalDescription(session)
    .then(function () {
      var jsonMsg = {
        cmd: "offer",
        roomId: roomId,
        uid: localUserId,
        remoteUid: remoteUserId,
        msg: JSON.stringify(session),
      };
      var message = JSON.stringify(jsonMsg);
      fishRTCEngine.sendMessage(message);
      // console.info("send offer message: "+message);
      console.info("send offer message: ");
    })
    .catch(function (error) {
      console.error("offer setLocalDescription failed: " + error);
    });
}
function handleCreateOfferError(error) {
  console.error("handleCreateOfferError failed: " + error);
}

function createAnswerAndSendMessage(session) {
  console.info("doAnswer createAnswerAndSendMessage");
  pc.setLocalDescription(session)
    .then(function () {
      var jsonMsg = {
        cmd: "answer",
        roomId: roomId,
        uid: localUserId,
        remoteUid: remoteUserId,
        msg: JSON.stringify(session),
      };
      var message = JSON.stringify(jsonMsg);
      fishRTCEngine.sendMessage(message);
      console.info("send answer message: ");
      // console.info("send answer message: "+message);
    })
    .catch(function (error) {
      console.error("answer setLocalDescription failed: " + error);
    });
}
function handleCreateAnswerError(error) {
  console.error("handleCreateAnswerError failed: " + error);
}
var FishRTCEngine = function (wsUrl) {
  this.init(wsUrl);
  fishRTCEngine = this;
  return this;
};

FishRTCEngine.prototype.init = function (wsUrl) {
  //設(shè)置wbsocket url
  this.wsUrl = wsUrl;
  //websocket對(duì)象
  this.signaling = null;
};

FishRTCEngine.prototype.createWebSocket = function () {
  fishRTCEngine = this;
  fishRTCEngine.signaling = new WebSocket(this.wsUrl);
  fishRTCEngine.signaling.onopen = function () {
    fishRTCEngine.onOpen();
  };

  fishRTCEngine.signaling.onmessage = function (ev) {
    fishRTCEngine.onMessage(ev);
  };

  fishRTCEngine.signaling.onerror = function (ev) {
    fishRTCEngine.onError(ev);
  };

  fishRTCEngine.signaling.onclose = function (ev) {
    fishRTCEngine.onClose(ev);
  };
};

FishRTCEngine.prototype.onOpen = function () {
  console.log("websocket open");
};

FishRTCEngine.prototype.onMessage = function (event) {
  // console.info("websocket onMessage:");
  console.log("websocket onMessage:" + event.data);
  var jsonMsg = null;
  try {
    jsonMsg = JSON.parse(event.data);
  } catch (e) {
    console.warn("onMessage parse Json failed: " + e);
    return;
  }
  switch (jsonMsg.cmd) {
    case SIGNAL_TYPE_NEW_PEER:
      handleRemoteNewPeer(jsonMsg);
      break;
    case SIGNAL_TYPE_RESP_JOIN:
      handleResponseJoin(jsonMsg);
      break;
    case SIGNAL_TYPE_PEER_LEAVE:
      handleRemotePeerLeave(jsonMsg);
      break;
    case SIGNAL_TYPE_OFFER:
      handleRemoteOffer(jsonMsg);
      break;
    case SIGNAL_TYPE_ANSWER:
      handleRemoteAnswer(jsonMsg);
      break;
    case SIGNAL_TYPE_CANDIDATE:
      handleRemoteCandidate(jsonMsg);
      break;
  }
};

FishRTCEngine.prototype.onError = function (event) {
  console.log("websocket onError" + event.data);
};

FishRTCEngine.prototype.onClose = function (event) {
  console.log(
    "websocket onClose code:" + event.code + ",reason:" + EventTarget.reason
  );
};

FishRTCEngine.prototype.sendMessage = function (message) {
  this.signaling.send(message);
};
function handleResponseJoin(message) {
  console.info("handleResponseJoin, remoteUid: " + message.remoteUid);
  remoteUserId = message.remoteUid;
  //doOffer();
}
function handleRemotePeerLeave(message) {
  console.info("handleRemotePeerLeave, remoteUid: " + message.remoteUid);
  remoteVideo.srcObject = null; //遠(yuǎn)程對(duì)象置空
  if (pc != null) {
    pc.close();
    pc = null;
  }
}
//新人加入房間保存userId
function handleRemoteNewPeer(message) {
  console.info("handleRemoteNewPeer, remoteUid: " + message.remoteUid);
  remoteUserId = message.remoteUid;
  doOffer();
}
function handleRemoteOffer(message) {
  console.info("handleRemoteOffer");
  if (pc == null) {
    createPeerConnection();
  }
  var desc = JSON.parse(message.msg);
  pc.setRemoteDescription(desc);
  doAnswer();
}
function handleRemoteAnswer(message) {
  console.info("handleRemoteAnswer");
  var desc = JSON.parse(message.msg);
  // console.info("desc: " + desc);
  pc.setRemoteDescription(desc);
}
function handleRemoteCandidate(message) {
  console.info("handleRemoteCandidate");
  var candidate = JSON.parse(message.msg);
  pc.addIceCandidate(candidate).catch((e) => {
    console.error("addIceCandidate failed: " + e.name);
  });
}
function doOffer() {
  //創(chuàng)建RCTPeerConnection
  if (pc == null) {
    createPeerConnection();
  }
  pc.createOffer()
    .then(createOfferAndSendMessage)
    .catch(handleCreateOfferError);
}

function doAnswer() {
  console.info("doAnswer");
  pc.createAnswer()
    .then(createAnswerAndSendMessage)
    .catch(handleCreateAnswerError);
}

function doJoin(roomId) {
  console.info("doJoin roomId:" + roomId);
  var jsonMsg = {
    cmd: "join",
    roomId: roomId,
    uid: localUserId,
  };
  var message = JSON.stringify(jsonMsg);
  fishRTCEngine.sendMessage(message);
  console.info("doJoin message: " + message);
}
function doLeave() {
  var jsonMsg = {
    cmd: "leave",
    roomId: roomId,
    uid: localUserId,
  };
  var message = JSON.stringify(jsonMsg);
  fishRTCEngine.sendMessage(message); //發(fā)信令給服務(wù)器離開
  console.info("doLeave message: " + message);
  hangup(); //掛斷
}
function hangup() {
  localVideo.srcObject = null; //0.關(guān)閉自己的本地顯示
  remoteVideo.srcObject = null; //1.關(guān)閉遠(yuǎn)端的流
  closeLocalStream(); //2.關(guān)閉本地流,攝像頭關(guān)閉竞思,麥克風(fēng)關(guān)閉
  if (pc != null) {
    //3.關(guān)閉RTCPeerConnection
    pc.close();
    pc = null;
  }
}
function closeLocalStream() {
  if (localStream != null) {
    localStream.getTracks().forEach((track) => {
      track.stop();
    });
  }
}

function openLocalStream(stream) {
  console.log("Open Local stream");
  doJoin(roomId);
  localVideo.srcObject = stream;
  localStream = stream;
}

function initLocalStream() {
  navigator.mediaDevices
    .getUserMedia({
      audio: true,
    //   video: true,
      video:{
          width:640,
          height:480
      }
    })
    .then(openLocalStream)
    .catch(function (e) {
      alert("getUserMedia() error" + e.name);
    });
}

fishRTCEngine = new FishRTCEngine("wss://192.168.1.102:8098/ws");
fishRTCEngine.createWebSocket();
document.getElementById("joinBtn").onclick = function () {
  roomId = document.getElementById("zero-RoomId").value;
  if (roomId == "" || roomId == "請(qǐng)輸入房間ID") {
    alert("請(qǐng)輸入房間ID");
    return;
  }
  console.log("加入按鈕被點(diǎn)擊,roomId:" + roomId);
  //初始化本地碼流
  initLocalStream();
};

document.getElementById("leaveBtn").onclick = function () {
  console.log("離開按鈕被點(diǎn)擊");
  doLeave();
};

服務(wù)端代碼

var ws = require("nodejs-websocket")
var port = 8099;

// join 主動(dòng)加入房間
// leave 主動(dòng)離開房間
// new-peer 有人加入房間表谊,通知已經(jīng)在房間的人
// peer-leave 有人離開房間,通知已經(jīng)在房間的人
// offer 發(fā)送offer給對(duì)端peer
// answer發(fā)送offer給對(duì)端peer
// candidate 發(fā)送candidate給對(duì)端peer
const SIGNAL_TYPE_JOIN = "join";
const SIGNAL_TYPE_RESP_JOIN = "resp-join";  // 告知加入者對(duì)方是誰(shuí)
const SIGNAL_TYPE_LEAVE = "leave";
const SIGNAL_TYPE_NEW_PEER = "new-peer";
const SIGNAL_TYPE_PEER_LEAVE = "peer-leave";
const SIGNAL_TYPE_OFFER = "offer";
const SIGNAL_TYPE_ANSWER = "answer";
const SIGNAL_TYPE_CANDIDATE = "candidate";

/** ----- ZeroRTCMap ----- */
var ZeroRTCMap = function () {
    this._entrys = new Array();
    // 插入
    this.put = function (key, value) {
        if (key == null || key == undefined) {
            return;
        }
        var index = this._getIndex(key);
        if (index == -1) {
            var entry = new Object();
            entry.key = key;
            entry.value = value;
            this._entrys[this._entrys.length] = entry;
        } else {
            this._entrys[index].value = value;
        }
    };
    // 根據(jù)key獲取value
    this.get = function (key) {
        var index = this._getIndex(key);
        return (index != -1) ? this._entrys[index].value : null;
    };
    // 移除key-value
    this.remove = function (key) {
        var index = this._getIndex(key);
        if (index != -1) {
            this._entrys.splice(index, 1);
        }
    };
    // 清空map
    this.clear = function () {
        this._entrys.length = 0;
    };
    // 判斷是否包含key
    this.contains = function (key) {
        var index = this._getIndex(key);
        return (index != -1) ? true : false;
    };
    // map內(nèi)key-value的數(shù)量
    this.size = function () {
        return this._entrys.length;
    };
    // 獲取所有的key
    this.getEntrys = function () {
        return this._entrys;
    };
    // 內(nèi)部函數(shù)
    this._getIndex = function (key) {
        if (key == null || key == undefined) {
            return -1;
        }
        var _length = this._entrys.length;
        for (var i = 0; i < _length; i++) {
            var entry = this._entrys[i];
            if (entry == null || entry == undefined) {
                continue;
            }
            if (entry.key === key) {// equal
                return i;
            }
        }
        return -1;
    };
}

//總的房間號(hào)
var roomTableMap = new ZeroRTCMap();

function Client(uid,conn,roomId){
    this.uid = uid;//用戶所屬的id
    this.conn = conn;//uid對(duì)應(yīng)的websocket連接
    this.roomId = roomId;//用戶所在的房間
}

function handleJoin(message,conn){
     var roomId = message.roomId;
     var uid = message.uid;
     console.info("uid" + uid + " try to join roomId: " + roomId);
     //查找房間目前是否已經(jīng)存在了
     var roomMap = roomTableMap.get(roomId);
     if(roomMap == null){//房間不存在
        roomMap = new ZeroRTCMap();
        roomTableMap.put(roomId,roomMap);
     }

     //房間已經(jīng)有兩個(gè)人了
     if(roomMap.size() >= 2){
       console.error("roomId:" + roomId + "已經(jīng)有兩個(gè)人存在,請(qǐng)使用其他房間");
       //加信令通知客戶端盖喷,房間已滿
       return null;
     }
    var client = new Client(uid,conn,roomId);
    roomMap.put(uid,client);
    if(roomMap.size() > 1){
        //房間里面已經(jīng)有人了爆办,加上新進(jìn)來的人,那就是>=2了传蹈,所以要通知對(duì)方
        var clients = roomMap.getEntrys();
        for(var i in clients){
            var remoteUid = clients[i].key;
            if(remoteUid != uid){
                var jsonMsg = {
                   'cmd':SIGNAL_TYPE_NEW_PEER,
                   'remoteUid':uid
                };
                var msg = JSON.stringify(jsonMsg);
                var remoteClient = roomMap.get(remoteUid);
                console.info("new-peer: " + msg);
                //新加入人之后押逼,重新通知遠(yuǎn)程的對(duì)方
                remoteClient.conn.sendText(msg);

                jsonMsg = {
                    'cmd':SIGNAL_TYPE_RESP_JOIN,
                    'remoteUid':remoteUid
                };
                msg = JSON.stringify(jsonMsg);
                console.info("resp-join: " + msg);
                //新加入人之后,通知自己惦界,有人加入了
                conn.sendText(msg);
            }
        }
    }
    return client;
}

function handleLeave(message){
    var roomId = message.roomId;
    var uid = message.uid;
    console.info("handleLeave uid:" + uid + " leave roomId: " + roomId);
    //查找房間目前是否已經(jīng)存在了
    var roomMap = roomTableMap.get(roomId);
    if(roomMap == null){//房間不存在
       console.warn("can't find the roomId: " + roomId);
       return;
    }
    roomMap.remove(uid);//刪除發(fā)送者
    //退出房間通知其他人
    if(roomMap.size() >= 1){
        var clients = roomMap.getEntrys();
        for(var i in clients){
            var jsonMsg = {
                'cmd': SIGNAL_TYPE_PEER_LEAVE,
                'remoteUid': uid//誰(shuí)離開就填寫誰(shuí)
                };
            var msg = JSON.stringify(jsonMsg);
            var remoteUid = clients[i].key;
            var remoteClient = roomMap.get(remoteUid);
            if(remoteClient){
                //通知此uid離開了房間
                console.info("notify peer:" + remoteClient.uid + " , uid: " + uid + " leave");
                remoteClient.conn.sendText(msg);
            }
        }
    }
}
function handleForceLeave(client){
    var roomId = client.roomId;
    var uid = client.uid;
    //1.先查找房間號(hào)
    var roomMap = roomTableMap.get(roomId);
    if(roomMap == null){//房間不存在
       console.warn("handleForceLeave can't find the roomId: " + roomId);
       return;
    }
    //2.判斷uid是否在房間
    if(!roomMap.contains(uid)){
        console.info("uid: " + uid + " have leave roomId: " + roomId);
        return;
    }
    //3.走到這一步挑格,客戶端沒有正常離開,我們要執(zhí)行離開程序
    console.info("handleForceLeave uid:" + uid + " force leave roomId: " + roomId);
    roomMap.remove(uid);//刪除發(fā)送者
    //退出房間通知其他人
    if(roomMap.size() >= 1){
        var clients = roomMap.getEntrys();
        for(var i in clients){
            var jsonMsg = {
                'cmd': SIGNAL_TYPE_PEER_LEAVE,
                'remoteUid': uid//誰(shuí)離開就填寫誰(shuí)
                };
            var msg = JSON.stringify(jsonMsg);
            var remoteUid = clients[i].key;
            var remoteClient = roomMap.get(remoteUid);
            if(remoteClient){
                //通知此uid離開了房間
                console.info("notify peer:" + remoteClient.uid + " , uid: " + uid + " leave");
                remoteClient.conn.sendText(msg);
            }
        }
    }
}
function handleOffer(message){
    var roomId = message.roomId;
    var uid = message.uid;
    var remoteUid = message.remoteUid;

    console.info("handleOffer uid: " + uid + " transfer offer to remoteUid: " + remoteUid);
    var roomMap = roomTableMap.get(roomId);
    if(roomMap == null){//房間不存在
       console.error("handleOffer can't find the roomId: " + roomId);
       return;
    }
    if(roomMap.get(uid) == null){//人不存在
        console.error("handleOffer can't find the uid: " + uid);
        return;
    }

    var remoteClient = roomMap.get(remoteUid);
    if(remoteClient){
        var msg = JSON.stringify(message);
        remoteClient.conn.sendText(msg);
    }else{
        console.error("handleOffer can't find remoteUid: " + remoteUid);
    }
}

function handleAnswer(message){
    var roomId = message.roomId;
    var uid = message.uid;
    var remoteUid = message.remoteUid;

    console.info("handleAnswer uid: " + uid + " transfer answer to remoteUid: " + remoteUid);
    var roomMap = roomTableMap.get(roomId);
    if(roomMap == null){//房間不存在
       console.error("handleAnswer can't find the roomId: " + roomId);
       return;
    }
    if(roomMap.get(uid) == null){//人不存在
        console.error("handleAnswer can't find the uid: " + uid);
        return;
    }

    var remoteClient = roomMap.get(remoteUid);
    if(remoteClient){
        var msg = JSON.stringify(message);
        remoteClient.conn.sendText(msg);
    }else{
        console.error("handleAnswer can't find remoteUid: " + remoteUid);
    }
}

function handleCandidate(message){
    var roomId = message.roomId;
    var uid = message.uid;
    var remoteUid = message.remoteUid;

    console.info("handleCandidate uid: " + uid + " transfer candidate to remoteUid: " + remoteUid);
    var roomMap = roomTableMap.get(roomId);
    if(roomMap == null){//房間不存在
       console.error("handleCandidate can't find the roomId: " + roomId);
       return;
    }
    if(roomMap.get(uid) == null){//人不存在
        console.error("handleCandidate can't find the uid: " + uid);
        return;
    }

    var remoteClient = roomMap.get(remoteUid);
    if(remoteClient){
        var msg = JSON.stringify(message);
        remoteClient.conn.sendText(msg);
    }else{
        console.error("handleCandidate can't find remoteUid: " + remoteUid);
    }
}

var server = ws.createServer(function(conn){
    console.log("創(chuàng)建一個(gè)新的連接---------")
    conn.client = null;//對(duì)應(yīng)的客戶端信息
    // conn.sendText("我收到你的連接了......");
    conn.on("text",function(str){
    // console.info("recv msg:" + str);
       var jsonMsg = JSON.parse(str);
       switch(jsonMsg.cmd){
           case SIGNAL_TYPE_JOIN:
               conn.client = handleJoin(jsonMsg,conn);
               break;
           case SIGNAL_TYPE_LEAVE:
               handleLeave(jsonMsg);
               break;
           case SIGNAL_TYPE_OFFER:
                handleOffer(jsonMsg);
                break;
           case SIGNAL_TYPE_ANSWER:
                handleAnswer(jsonMsg);
                break;
           case SIGNAL_TYPE_CANDIDATE:
                handleCandidate(jsonMsg);
                break;
       }
    });

    conn.on("close",function(code,reason){
        console.info("連接關(guān)閉 code: " + code + ", reason: " + reason);
        if(conn.client != null){
            //強(qiáng)制讓客戶端從房間退出
            handleForceLeave(conn.client);
        }
    });

    conn.on("error",function(err){
        console.info("監(jiān)聽到錯(cuò)誤:" + err);
    });
}).listen(port);
圖片.png

啟動(dòng)coturn

# nohup是重定向命令沾歪,輸出都將附加到當(dāng)前目錄的 nohup.out 文件中漂彤; 命令后加 & ,后臺(tái)執(zhí)行起來后按
ctr+c,不會(huì)停止
sudo nohup turnserver ‐L 0.0.0.0 ‐a ‐u lqf:123456 ‐v ‐f ‐r nort.gov &
# 前臺(tái)啟動(dòng)
sudo turnserver ‐L 0.0.0.0 ‐a ‐u lqf:123456 ‐v ‐f ‐r nort.gov
#然后查看相應(yīng)的端口號(hào)3478是否存在進(jìn)程
sudo lsof ‐i:3478

設(shè)置configuration,先設(shè)置為relay中繼模式,只有relay中繼模式可用的時(shí)候挫望,才能部署到公網(wǎng)去(部署到公網(wǎng)后也先測(cè)試relay)立润。


function createPeerConnection() {
  var defaultConfiguration = {
    bundlePolicy: "max-bundle",
    rtcpMuxPolicy: "require",
    iceTransportPolicy: "relay", //relay or all
    // 修改ice數(shù)組測(cè)試效果,需要進(jìn)行封裝
    iceServers: [
      {
        urls: [
          "turn:192.168.1.102:3478?transport=udp",
          "turn:192.168.1.102:3478?transport=tcp", // 可以插入多個(gè)進(jìn)行備選
        ],
        username: "ydy",
        credential: "123456",
      },
      {
        urls: ["stun:192.168.1.102:3478"],
      },
    ],
  };
  pc = new RTCPeerConnection(defaultConfiguration);
  pc.onicecandidate = handleIceCandidate;
  pc.ontrack = handleRemoteStreamAdd;
  pc.oniceconnectionstatechange = handleIceConnectionStateChange;
  pc.onconnectionstatechange = handleConnectionStateChange;
  localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
}

all模式:局域網(wǎng)可用優(yōu)先走局域網(wǎng)媳板,通過命令sar -n DEV 1查看一秒鐘的網(wǎng)絡(luò)傳輸量發(fā)現(xiàn)為0

image

圖片.png

relay模式:走中繼服務(wù)器模式桑腮,不會(huì)走局域網(wǎng),網(wǎng)絡(luò)傳輸量不為0
圖片.png

image

image

21297F0B-100C-439D-B73D-975EA0E73821

編譯和啟動(dòng)nginx

sudo apt‐get update
#安裝依賴:gcc蛉幸、g++依賴庫(kù)
sudo apt‐get install build‐essential libtool
#安裝 pcre依賴庫(kù)(http://www.pcre.org/)
sudo apt‐get install libpcre3 libpcre3‐dev
#安裝 zlib依賴庫(kù)(http://www.zlib.net)
sudo apt‐get install zlib1g‐dev
#安裝ssl依賴庫(kù)
sudo apt‐get install openssl
#下載nginx 1.20.1版本
wget http://nginx.org/download/nginx‐1.15.8.tar.gz
tar xvzf nginx‐1.20.1.tar.gz
cd nginx‐1.15.8/
# 配置破讨,一定要支持https
./configure ‐‐with‐http_ssl_module
# 編譯
make
#安裝
sudo make install

默認(rèn)安裝目錄:/usr/local/nginx
啟動(dòng):sudo /usr/local/nginx/sbin/nginx
停止:sudo /usr/local/nginx/sbin/nginx s
stop
重新加載配置文件:sudo /usr/local/nginx/sbin/nginx s
reload

生成證書

mkdir ‐p ~/cert
cd ~/cert
# CA私鑰
openssl genrsa ‐out key.pem 2048
# 自簽名證書
openssl req ‐new ‐x509 ‐key key.pem ‐out cert.pem ‐days 1095
image

配置Web服務(wù)器

(1)配置自己的證書
ssl_certificate /root/cert/cert.pem? // 注意證書所在的路徑
ssl_certificate_key /root/cert/key.pem?
(2)配置主機(jī)域名或者主機(jī)IP server_name 192.168.1.103?
(3)web頁(yè)面所在目錄root /mnt/WebRTC/src/04/6.4/client?
完整配置文件:/usr/local/nginx/conf/conf.d/webrtchttps.conf

server {
  listen 443 ssl;
  ssl_certificate /root/cert/cert.pem;
  ssl_certificate_key /root/cert/key.pem;
  charset utf‐8;
  # ip地址或者域名
  server_name 192.168.1.103;
    location / {
     add_header 'Access‐Control‐Allow‐Origin' '*';
     add_header 'Access‐Control‐Allow‐Credentials' 'true';
     add_header 'Access‐Control‐Allow‐Methods' '*';
     add_header 'Access‐Control‐Allow‐Headers' 'Origin, X‐Requested‐With, Content‐Type,
     Accept';
     # web頁(yè)面所在目錄
     root /mnt/WebRTC/src/04/6.4/client;
     index index.php index.html index.htm;
   }
}

編輯nginx.conf文件,在末尾}之前添加包含文件include /usr/local/nginx/conf/conf.d/*.conf;

image

配置websocket代理

ws 不安全的連接 類似http
wss是安全的連接奕纫,類似https
https不能訪問ws提陶,本身是安全的訪問,不能降級(jí)做不安全的訪問匹层。


image

ws協(xié)議和wss協(xié)議兩個(gè)均是WebSocket協(xié)議的SCHEM,兩者一個(gè)是非安全的,一個(gè)是安全的隙笆。也是統(tǒng)一的資源標(biāo)志
符。就好比HTTP協(xié)議和HTTPS協(xié)議的差別升筏。
Nginx主要是提供wss連接的支持撑柔,https必須調(diào)用wss的連接。
完整配置文件:/usr/local/nginx/conf/conf.d/webrtcwebsocketproxy.
conf

map $http_upgrade $connection_upgrade {
default upgrade;
  '' close;
}
upstream websocket {
  server 192.168.1.103:8099;
}
server {
listen 8098 ssl;
  #ssl on;
  ssl_certificate /root/cert/cert.pem;
  ssl_certificate_key /root/cert/key.pem;
  server_name 192.168.1.103;
  location /ws {
     proxy_pass http://websocket;
     proxy_http_version 1.1;
     keepalive_timeout 6000000000s;
     proxy_connect_timeout 400000000s; #配置點(diǎn)1
     proxy_read_timeout 60000000s; #配置點(diǎn)2仰冠,如果沒效乏冀,可以考慮這個(gè)時(shí)間配置長(zhǎng)一點(diǎn)
     proxy_send_timeout 60000000s; #配置點(diǎn)3
     proxy_set_header Upgrade $http_upgrade;
     proxy_set_header Connection $connection_upgrade;
  }
}

wss://192.168.221.134:8098/ws 端口是跟著IP后面
信令服務(wù)器后臺(tái)執(zhí)行nohup node ./signal_server.js &如果退出終端信令服務(wù)器會(huì)停止蝶糯,需exit退出終端或安裝forever和pm2,才能保持服務(wù)器在后臺(tái)執(zhí)行

解決websocket自動(dòng)斷開(這是重點(diǎn)Q笾弧!V绾础J缎椤!設(shè)置超時(shí)時(shí)間無(wú)效妒茬。担锤。。)

我們?cè)谕ㄔ挄r(shí)乍钻,出現(xiàn)60秒后客戶端自動(dòng)斷開的問題肛循,是因?yàn)榻?jīng)過nginx代理時(shí),如果websocket長(zhǎng)時(shí)間沒有收發(fā)消息
則該websocket將會(huì)被斷開银择。我們這里可以修改收發(fā)消息的時(shí)間間隔多糠。
proxy_connect_timeout :后端服務(wù)器連接的超時(shí)時(shí)間發(fā)起握手等候響應(yīng)超時(shí)時(shí)間
proxy_read_timeout:連接成功后
等候后端服務(wù)器響應(yīng)時(shí)間其實(shí)已經(jīng)進(jìn)入后端的排隊(duì)之中等候處理(也可以說是
后端服務(wù)器處理請(qǐng)求的時(shí)間)
proxy_send_timeout :后端服務(wù)器數(shù)據(jù)回傳時(shí)間
就是在規(guī)定時(shí)間之內(nèi)后端服務(wù)器必須傳完所有的數(shù)據(jù)
nginx使用proxy模塊時(shí),默認(rèn)的讀取超時(shí)時(shí)間是60s
完整配置文件:/usr/local/nginx/conf/conf.d/webrtcwebsocketproxy.conf

心跳(待補(bǔ)充)維持心跳才能保證WebSocket連接不會(huì)被斷開浩考,前面設(shè)置超時(shí)時(shí)間都無(wú)效夹孔,90秒后WebSocket連接還是會(huì)斷開

客戶端 服務(wù)器 信令:心跳包
keeplive 間隔5秒發(fā)送一次給信令服務(wù)器,說明客戶端一直處于活躍的狀態(tài)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末搭伤,一起剝皮案震驚了整個(gè)濱河市只怎,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌怜俐,老刑警劉巖身堡,帶你破解...
    沈念sama閱讀 217,826評(píng)論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異拍鲤,居然都是意外死亡盾沫,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,968評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門殿漠,熙熙樓的掌柜王于貴愁眉苦臉地迎上來赴精,“玉大人,你說我怎么就攤上這事绞幌±儆矗” “怎么了?”我有些...
    開封第一講書人閱讀 164,234評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵莲蜘,是天一觀的道長(zhǎng)谭确。 經(jīng)常有香客問我,道長(zhǎng)票渠,這世上最難降的妖魔是什么逐哈? 我笑而不...
    開封第一講書人閱讀 58,562評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮问顷,結(jié)果婚禮上昂秃,老公的妹妹穿的比我還像新娘。我一直安慰自己杜窄,他們只是感情好肠骆,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,611評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著塞耕,像睡著了一般蚀腿。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上扫外,一...
    開封第一講書人閱讀 51,482評(píng)論 1 302
  • 那天莉钙,我揣著相機(jī)與錄音,去河邊找鬼筛谚。 笑死磁玉,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的刻获。 我是一名探鬼主播蜀涨,決...
    沈念sama閱讀 40,271評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼瞎嬉,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼尉咕!你這毒婦竟也來了串前?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,166評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤曙旭,失蹤者是張志新(化名)和其女友劉穎别垮,沒想到半個(gè)月后便监,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,608評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡碳想,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,814評(píng)論 3 336
  • 正文 我和宋清朗相戀三年烧董,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片胧奔。...
    茶點(diǎn)故事閱讀 39,926評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡逊移,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出龙填,到底是詐尸還是另有隱情胳泉,我是刑警寧澤,帶...
    沈念sama閱讀 35,644評(píng)論 5 346
  • 正文 年R本政府宣布岩遗,位于F島的核電站扇商,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏宿礁。R本人自食惡果不足惜案铺,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,249評(píng)論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望梆靖。 院中可真熱鬧控汉,春花似錦、人聲如沸涤姊。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,866評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)思喊。三九已至,卻和暖如春次酌,著一層夾襖步出監(jiān)牢的瞬間恨课,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,991評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工岳服, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留剂公,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,063評(píng)論 3 370
  • 正文 我出身青樓吊宋,卻偏偏與公主長(zhǎng)得像纲辽,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,871評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容