01-WebRTC一对一通话

音视频文章汇总,上一篇文章《00-WebRTC入门》介绍了nodejs作为信令服务器,客户端和服务器端的交互选择websocket作为通信协议。本文从代码层面实现一对一视频通话。

1.一对一通话原理

主要分为四大块:
I.信令设计:进入房间,离开房间等
II.媒体协商:交换彼此客户端的媒体信息sdp
III.加入Stream/Track
IV.网络协商:Candidate,网络地址,端口号等
先看一张图


image

1.1信令协议设计

采用json封装格式

  1. join 加入房间
  2. respjoin
    当join房间后发现房间已经存在另一个人时则返回另一个人的uid;如果只有自己则不返回
  3. leave 离开房间,服务器收到leave信令则检查同一房间是否有其他人,如果有其他人则通知他有人离开
  4. newpeer
    服务器通知客户端有新人加入,收到newpeer
    则发起连接请求
  5. peerleave
    服务器通知客户端有人离开
  6. offer 转发offer sdp
  7. answer 转发answer sdp
  8. candidate 转发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媒体协商

image

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

var options = {
offerToReceiveAudio: true, // 告诉另一端,你是否想接收音频,默认true
offerToReceiveVideo: true, // 告诉另一端,你是否想接收视频,默认true
iceRestart: false, // 是否在活跃状态重启ICE网络协商
};

iceRestart:只有在处于活跃的时候,iceRestart=false才有作用。
createAnswer
基本格式
aPromise = RTCPeerConnection .createAnswer([ options ]); 目前createAnswer的options是
无效的。
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网络协商

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

candidate

属性 说明
candidate 候选者描述信息
sdpMid 与候选者相关的媒体流的识别标签
sdpMLineIndex 在SDP中 m=的索引值
usernameFragment 包括了远端的唯一标识

Android和Web端不同。

1.5RTCPeerConnection补充

1.5.1构造函数

configuration可选
属性说明
candidate 候选者描述信息
sdpMid 与候选者相关的媒体流的识别标签
sdpMLineIndex 在SDP中 m=的索引值
usernameFragment 包括了远端的唯一标识
bundlePolicy 一般用maxbundle
banlanced:音频与视频轨使用各自的传输通道
maxcompat:
每个轨使用自己的传输通道
maxbundle:
都绑定到同一个传输通道
iceTransportPolicy 一般用all
指定ICE的传输策略
relay:只使用中继候选者
all:可以使用任何类型的候选者
iceServers
其由RTCIceServer组成,每个RTCIceServer都是一个ICE代理的服务器

属性 含义
credential 凭据,只有TURN服务使用
credentialType 凭据类型,可以password或oauth
urls 用于连接服中的ur数组
username 用户名,只有TURN服务使用

rtcpMuxPolicy 一般用require
rtcp的复用策略,该选项在收集ICE候选者时使用

属性 含义
negotiate 收集RTCP与RTP复用的ICE候选者,如果RTCP能复用就与RTP复用,如果不能复用,就将他们单独使用
require 只能收集RTCP与RTP复用的ICE候选者,如果RTCP不能复用,则失败

1.5.2重要事件

onicecandidate 收到候选者时触发的事件
ontrack 获取远端流
onconnectionstatechange PeerConnection的连接状态,参考: 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实现WebRTC音视频通话

开发步骤

  1. 客户端显示界面
  2. 打开摄像头并显示到页面
  3. websocket连接
  4. join、newpeer
    、respjoin
    信令实现
  5. leave、peerleave
    信令实现
  6. offer、answer、candidate信令实现
  7. 综合调试和完善

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="请输入房间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>远端窗口</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信令实现

思路:(1)点击加入开妞;
(2)响应加入按钮事件;
(3)将join发送给服务器;
(4)服务器 根据当前房间的人数
做处理,如果房间已经有人则通知房间里面的人有新人加入(newpeer),并通知自己房间里面是什么人(respjoin)。

1.6.5 leave、peerleave信令实现

思路:(1)点击离开按钮;
(2)响应离开按钮事件;
(3)将leave发送给服务器;
(4)服务器处理leave,将发送者删除并通知房间(peerleave)的其他人;
(5)房间的其他人在客户端响应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信令实现

思路:
(1)收到newpeer
(handleRemoteNewPeer处理),作为发起者创建RTCPeerConnection,绑定事件响应函数,
加入本地流;
(2)创建offer sdp,设置本地sdp,并将offer sdp发送到服务器;
(3)服务器收到offer sdp 转发给指定的remoteClient;
(4)接收者收到offer,也创建RTCPeerConnection,绑定事件响应函数,加入本地流;
(5)接收者设置远程sdp,并创建answer sdp,然后设置本地sdp并将answer sdp发送到服务器;
(6)服务器收到answer sdp 转发给指定的remoteClient;
(7)发起者收到answer sdp,则设置远程sdp;
(8)发起者和接收者都收到ontrack回调事件,获取到对方码流的对象句柄;
(9)发起者和接收者都开始请求打洞,通过onIceCandidate获取到打洞信息(candidate)并发送给对方
(10)如果P2P能成功则进行P2P通话,如果P2P不成功则进行中继转发通话。

1.6.7 综合调试和完善

思路:
(1)点击离开时,要将RTCPeerConnection关闭(close);
(2)点击离开时,要将本地摄像头和麦克风关闭;
(3)检测到客户端退出时,服务器再次检测该客户端是否已经退出房间。
(4)RTCPeerConnection时传入ICE server的参数,以便当在公网环境下可以进行正常通话。
客户端代码

"use strict";

// join 主动加入房间
// leave 主动离开房间
// new-peer 有人加入房间,通知已经在房间的人
// peer-leave 有人离开房间,通知已经在房间的人
// offer 发送offer给对端peer
// answer发送offer给对端peer
// candidate 发送candidate给对端peer
const SIGNAL_TYPE_JOIN = "join";
const SIGNAL_TYPE_RESP_JOIN = "resp-join"; // 告知加入者对方是谁
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; //对端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) {
    //不为空才发送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 {
    //不再去请求打洞了
    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数组测试效果,需要进行封装
    iceServers: [
      {
        urls: [
          "turn:192.168.1.102:3478?transport=udp",
          "turn:192.168.1.102:3478?transport=tcp", // 可以插入多个进行备选
        ],
        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) {
  //设置wbsocket url
  this.wsUrl = wsUrl;
  //websocket对象
  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; //远程对象置空
  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() {
  //创建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); //发信令给服务器离开
  console.info("doLeave message: " + message);
  hangup(); //挂断
}
function hangup() {
  localVideo.srcObject = null; //0.关闭自己的本地显示
  remoteVideo.srcObject = null; //1.关闭远端的流
  closeLocalStream(); //2.关闭本地流,摄像头关闭,麦克风关闭
  if (pc != null) {
    //3.关闭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 == "请输入房间ID") {
    alert("请输入房间ID");
    return;
  }
  console.log("加入按钮被点击,roomId:" + roomId);
  //初始化本地码流
  initLocalStream();
};

document.getElementById("leaveBtn").onclick = function () {
  console.log("离开按钮被点击");
  doLeave();
};

服务端代码

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

// join 主动加入房间
// leave 主动离开房间
// new-peer 有人加入房间,通知已经在房间的人
// peer-leave 有人离开房间,通知已经在房间的人
// offer 发送offer给对端peer
// answer发送offer给对端peer
// candidate 发送candidate给对端peer
const SIGNAL_TYPE_JOIN = "join";
const SIGNAL_TYPE_RESP_JOIN = "resp-join";  // 告知加入者对方是谁
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;
        }
    };
    // 根据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内key-value的数量
    this.size = function () {
        return this._entrys.length;
    };
    // 获取所有的key
    this.getEntrys = function () {
        return this._entrys;
    };
    // 内部函数
    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;
    };
}

//总的房间号
var roomTableMap = new ZeroRTCMap();

function Client(uid,conn,roomId){
    this.uid = uid;//用户所属的id
    this.conn = conn;//uid对应的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);
     //查找房间目前是否已经存在了
     var roomMap = roomTableMap.get(roomId);
     if(roomMap == null){//房间不存在
        roomMap = new ZeroRTCMap();
        roomTableMap.put(roomId,roomMap);
     }

     //房间已经有两个人了
     if(roomMap.size() >= 2){
       console.error("roomId:" + roomId + "已经有两个人存在,请使用其他房间");
       //加信令通知客户端,房间已满
       return null;
     }
    var client = new Client(uid,conn,roomId);
    roomMap.put(uid,client);
    if(roomMap.size() > 1){
        //房间里面已经有人了,加上新进来的人,那就是>=2了,所以要通知对方
        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);
                //新加入人之后,重新通知远程的对方
                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);
    //查找房间目前是否已经存在了
    var roomMap = roomTableMap.get(roomId);
    if(roomMap == null){//房间不存在
       console.warn("can't find the roomId: " + roomId);
       return;
    }
    roomMap.remove(uid);//删除发送者
    //退出房间通知其他人
    if(roomMap.size() >= 1){
        var clients = roomMap.getEntrys();
        for(var i in clients){
            var jsonMsg = {
                'cmd': SIGNAL_TYPE_PEER_LEAVE,
                'remoteUid': uid//谁离开就填写谁
                };
            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.先查找房间号
    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.走到这一步,客户端没有正常离开,我们要执行离开程序
    console.info("handleForceLeave uid:" + uid + " force leave roomId: " + roomId);
    roomMap.remove(uid);//删除发送者
    //退出房间通知其他人
    if(roomMap.size() >= 1){
        var clients = roomMap.getEntrys();
        for(var i in clients){
            var jsonMsg = {
                'cmd': SIGNAL_TYPE_PEER_LEAVE,
                'remoteUid': uid//谁离开就填写谁
                };
            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("创建一个新的连接---------")
    conn.client = null;//对应的客户端信息
    // 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("连接关闭 code: " + code + ", reason: " + reason);
        if(conn.client != null){
            //强制让客户端从房间退出
            handleForceLeave(conn.client);
        }
    });

    conn.on("error",function(err){
        console.info("监听到错误:" + err);
    });
}).listen(port);
图片.png

启动coturn

# nohup是重定向命令,输出都将附加到当前目录的 nohup.out 文件中; 命令后加 & ,后台执行起来后按
ctr+c,不会停止
sudo nohup turnserver ‐L 0.0.0.0 ‐a ‐u lqf:123456 ‐v ‐f ‐r nort.gov &
# 前台启动
sudo turnserver ‐L 0.0.0.0 ‐a ‐u lqf:123456 ‐v ‐f ‐r nort.gov
#然后查看相应的端口号3478是否存在进程
sudo lsof ‐i:3478

设置configuration,先设置为relay中继模式,只有relay中继模式可用的时候,才能部署到公网去(部署到公网后也先测试relay)。


function createPeerConnection() {
  var defaultConfiguration = {
    bundlePolicy: "max-bundle",
    rtcpMuxPolicy: "require",
    iceTransportPolicy: "relay", //relay or all
    // 修改ice数组测试效果,需要进行封装
    iceServers: [
      {
        urls: [
          "turn:192.168.1.102:3478?transport=udp",
          "turn:192.168.1.102:3478?transport=tcp", // 可以插入多个进行备选
        ],
        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模式:局域网可用优先走局域网,通过命令sar -n DEV 1查看一秒钟的网络传输量发现为0

image

图片.png

relay模式:走中继服务器模式,不会走局域网,网络传输量不为0
图片.png

image

image

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

编译和启动nginx

sudo apt‐get update
#安装依赖:gcc、g++依赖库
sudo apt‐get install build‐essential libtool
#安装 pcre依赖库(http://www.pcre.org/)
sudo apt‐get install libpcre3 libpcre3‐dev
#安装 zlib依赖库(http://www.zlib.net)
sudo apt‐get install zlib1g‐dev
#安装ssl依赖库
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

默认安装目录:/usr/local/nginx
启动: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服务器

(1)配置自己的证书
ssl_certificate /root/cert/cert.pem; // 注意证书所在的路径
ssl_certificate_key /root/cert/key.pem;
(2)配置主机域名或者主机IP server_name 192.168.1.103;
(3)web页面所在目录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页面所在目录
     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,本身是安全的访问,不能降级做不安全的访问。


image

ws协议和wss协议两个均是WebSocket协议的SCHEM,两者一个是非安全的,一个是安全的。也是统一的资源标志
符。就好比HTTP协议和HTTPS协议的差别。
Nginx主要是提供wss连接的支持,https必须调用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; #配置点1
     proxy_read_timeout 60000000s; #配置点2,如果没效,可以考虑这个时间配置长一点
     proxy_send_timeout 60000000s; #配置点3
     proxy_set_header Upgrade $http_upgrade;
     proxy_set_header Connection $connection_upgrade;
  }
}

wss://192.168.221.134:8098/ws 端口是跟着IP后面
信令服务器后台执行nohup node ./signal_server.js &如果退出终端信令服务器会停止,需exit退出终端或安装forever和pm2,才能保持服务器在后台执行

解决websocket自动断开(这是重点!!!!!设置超时时间无效。。。)

我们在通话时,出现60秒后客户端自动断开的问题,是因为经过nginx代理时,如果websocket长时间没有收发消息
则该websocket将会被断开。我们这里可以修改收发消息的时间间隔。
proxy_connect_timeout :后端服务器连接的超时时间发起握手等候响应超时时间
proxy_read_timeout:连接成功后
等候后端服务器响应时间其实已经进入后端的排队之中等候处理(也可以说是
后端服务器处理请求的时间)
proxy_send_timeout :后端服务器数据回传时间
就是在规定时间之内后端服务器必须传完所有的数据
nginx使用proxy模块时,默认的读取超时时间是60s
完整配置文件:/usr/local/nginx/conf/conf.d/webrtcwebsocketproxy.conf

心跳(待补充)维持心跳才能保证WebSocket连接不会被断开,前面设置超时时间都无效,90秒后WebSocket连接还是会断开

客户端 服务器 信令:心跳包
keeplive 间隔5秒发送一次给信令服务器,说明客户端一直处于活跃的状态。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容