webRTC实践

1,362 阅读6分钟

前言

这篇文章与其他类似文章最大的区别在于:有可运行demo做对照:
demo地址: www.fffuture.top/testRTC
回望整个实践过程,比较绕的部分在于:

  1. 搭建conturn服务器,提供turn/stun服务
  2. 域名备案与https配置
  3. 云服务器购买、配置
  4. webrtc连接流程比较麻烦 本文只记录第4点。
    注意: 如果只在本地简单运行,https/云服务器可以不用配置(webRTC需要https/localhost环境)。我也搭建了coturn服务器,有想尝试的可以使用demo里的turn/stun地址。

demo演示

某天,Lucy非常想念Ace,于是Lucy向Ace发起了聊天:

sequenceDiagram
Lucy->>Lucy: 输入名称:Lucy
Ace->>Ace: 输入名称:Ace
Lucy->>Lucy: 输入接收者'Ace'
Lucy->>Ace: 发送消息:'来视频'
Ace->>Ace: 接收到了Lucy的消息
Ace->>Ace: 输入接收者'Lucy'
Ace->>Lucy: 回复消息: "好的"

demo表现如下:

微信图片_20211010173457.png 然后,他们发起了视频聊天:

sequenceDiagram
Lucy->>Lucy: 初始化webRTC
Ace->>Ace: 初始化webRTC
Lucy->>Ace: 发送RTCOffer
Ace-->>Lucy:自动回复answerOffer
Ace-->Lucy: 视频通话中...
Lucy->>Lucy: 关闭webRTC
Ace->>Ace: 关闭webRTC

demo表现如下:

4_副本.png

信令服务端搭建

webRTC的连接和整个通讯过程中都需要信令的参与,比如offer的发起、ICECandidate的交换等。

两个设备之间建立WebRTC连接需要一个信令服务器来实现双方通过网络进行连接。信令服务器的作用是作为一个中间人帮助双方在尽可能少的暴露隐私的情况下建立连接。

webRTC并不限制信令服务器类型,目的是交换信息,能达到目的都可以。在此我选择koa2框架搭配websocket。

WebRTC并没有提供信令传递机制,你可以使用任何你喜欢的方式如WebSocket或者XMLHttpRequest等等,来交换彼此的令牌信息。

websocket后端

使用koa启动一个配置https的后端服务,关键代码:

    var https = require('https');
    const sslify = require('koa-sslify').default;
    const Koa = require('koa');
    const app = new Koa();
    
    var options = {
        key: ssl证书.key, 
        cert: ssl证书.pem
    };
    
    app.use(sslify());
    
    var server = https.createServer(options, app.callback());
    server.listen(443, (err) => {
        if (err) {
          console.log('服务启动出错', err);
        } else {
          console.log('IM运行在https443端口');
        }	
    });

接下来在koa中集成websocket,并做基本的消息处理:

  • ws连接初始化
  • ws消息转发 注意:因为websocket初始化是通过TCP升级得来,无法携带参数,所以需要单独的初始化消息。
    const WebSocket = require('ws');
    const wss = new WebSocket.Server({ server });
    
    let wsPool = {};
    
    wss.on('connection', wss => {
        wss.on('message', msg => {
            msg = JSON.parse(msg);
            if(msg.type === 'init') { //初始化时候将ws实例挂载到wsPool下
              wsPool[msg.sender] = ws;
            }else { //其他消息类型直接做转发
              if(Reflect.has(wsPool, msg.recipient))
                wsPool[msg.recipient].send(JSON.stringify(msg));
            }
        }
    });

到此简单的服务端就完成了。

websocket前端

接下来,简单写个前端页面:

image.png

继续,前端页面的websocket初始化,关键代码:

/**
* @description 初始化websocket
* @return {Object} websocket实例
*/
function initWS() {
    if(!Reflect.has(window, "WebSocket")) {
        console.log("浏览器不支持websocket!!");
        return;
    };

    let WS = new WebSocket("wss://www.fffuture.top");

    WS.onopen = () => console.log("---成功连接websocket---");
    WS.onmessage = envelope => {
        //收到消息,直接显示在 “历史消息框” 中
        msgHistory = document.querySelector("#msgHistory");
        msgHistory.innerText += `${envelope.data}\n`;
    }
    WS.onclose = () => console.log("---已断开webSocket---");
    WS.onerror = error => console.error("---websoket发生错误: ", error);
    return WS;
}

有了WS连接之后,还需要发送消息函数:

/**
 * @description 对websocket发送事件做封装
 * @param {Object} data 数据结构: {type: String, content: String}
*/
function wsSend(data) {
    let sender = document.querySelector("#sender").value,    // 发送者
    recipient = document.querySelector("#recipient").value,  // 接收者
    msgHistory = document.querySelector("#msgHistory");      // 历史消息框

    //特殊情况: 初始化(init)时候不需要接收者
    if(!sender || (!recipient && data.type !== "init")) {
        alert(`${sender ? '接收者' : '发送者'}不能为空!`);
        return;
    }

    let letter = JSON.stringify({...data, sender, recipient});
    msgHistory.innerText += `${letter}\n`
    WS.send(letter);
}

我期望‘消息’的数据结构:

{
    sender: string
    recipient: string,
    type: string,
    content: string
 }

测试下:

image.png

websocket部分到此结束。

webRTC连接

到此,只剩下前端工作:

准备工作

首先我们用firefox浏览器进入trickle-ice 测试stun、turn服务是否正常:

image.png

搭建webRTC

首先,继续完善前端网页:

image.png

接下来补充相应事件:

  1. 初始化webRTC 初始化webRTC本质就是创建peerConnection(端到端连接对象), 并配置相应方法,例如:获取本地/接收远程媒体流 并将其展示在video标签上。
function initRTC() {
  • 初始化peerconnection
    需要传入RTCConfiguration对象,在此只设置iceServers。这里就是turn/stun服务器的作用:用来确定两个对等端之间的通信时使用的最佳路由和协议。

iceServers是描述 ICE 层的STUN和/或TURN服务器的对象数组,在尝试在呼叫者和被呼叫者之间建立路由时使用。这些服务器用于确定在对等端之间通信时要使用的最佳路由和协议,即使它们位于防火墙后面或使用 NAT

    const config = {
        iceServers: [
            {
                urls: "stun:139.224.75.6:3478",
                username:"",
                credential:""
            },
            {
                urls: "turn:139.224.75.6:3478",
                username: "wsj",
                credential: "123456"
            }
        ],
        iceTransportPolicy:"all",
        iceCandidatePoolSize:"0"
    };

RTCPeerConnection接口代表一个由本地计算机到远端的WebRTC连接。该接口提供了创建,保持,监控,关闭连接的方法的实现。

    pc = new RTCPeerConnection(config);
  • 获取本地媒体流
    navigator.mediaDevices.getUserMedia({audio: true, video: true})
    .then(function(localStream) {
        let videoSelf = document.querySelector("#video-self");
        videoSelf.srcObject = localStream;
        localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
    })
    .catch(E => {
        ...
    });
  • 当接收到远程媒体流时
    pc.ontrack = media => {
        document.getElementById("video").srcObject = media.streams[0];
    }
  • 重点:当onicecandidate触发时,将这个本地的候选者ICEcandidate发送给对方:这个事件会活跃在整个视频/语音通话过程中,它不会不断提供icecancdidate(交互式连接候选者),以保证整个通话过程中的最佳质量:

使用 pc.setLocalDescription(offer) 添加本地描述符后,一个 icecandidate 事件将被发送到RTCPeerConnection。

ICECandidate: 交互式连接建立候选者

The RTCIceCandidate interface—part of the WebRTC API—represents a candidate Interactive Connectivity Establishment (ICE) configuration which may be used to establish an RTCPeerConnection.

    pc.onicecandidate = wapper => {
        if(!wapper.candidate) return;
        wsSend({type: "candidate", content: wapper.candidate});
    }
    pc.onicecandidateerror = error => {
        console.error("---获取候选者出错: ", error);
    };
  • 当本地webRTC准备就绪会调用这个方法。demo中使用按钮发送offer,所以就用不到这个事件:

一旦调用者创建了其 RTCPeerConnection ,创建了媒体流,并将其磁道添加到连接中,浏览器将向RTCPeerConnection传递一个 negotiationneeded 事件,以指示它已准备好开始与其他对等方协商。

    pc.onnegotiationneeded = () => {
        console.log("---协商连接事件----")
    }
}
  1. 发送Offer 对应“发送RTCOffer”按钮事件,使用websocket来转发消息,是信令服务的主要作用:
/**
 * @description 发送offer
*/
function invite() {
    const offerOptions = { offerToReceiveVideo: 1, offerToReceiveAudio: 1};
    pc.createOffer(offerOptions)
    .then(gotDescription,noDescription );

    function gotDescription(desc) { //desc: RTCSessionDescription -> sdp
        pc.setLocalDescription(desc)
        .then(() => {
            console.warn("----本地准备就绪,准备发送offer----");
            wsSend({type:"offer", content: pc.localDescription});
        });
    }

    function noDescription(error) {
        console.log('Error creating offer: ', error);
    }

}

RTCSessionDescription 接口描述连接或潜在连接的一端的配置方式。 每一个RTCSessionDescription 由一个描述类型组成,该描述类型指示它所描述的请求/应答协商过程的SDP 协议的相关描述。

RTCSessionDescription 在两个对等点之间协商连接的过程涉及来回交换对象,每个描述都表示描述的发送者支持的连接配置选项的一个组合。一旦两个对等方就连接的配置达成一致,协商就完成了。

当远程方接收到邀请Offer,自动回复应答Offer:

/**
 * @description 回复offer
 * @param {Object} data
*/
function answerOffer(data) {
    const remoteDesc = new RTCSessionDescription(data.content);
    pc.setRemoteDescription(remoteDesc)
    .then(function() { pc.createAnswer() })
    .then(function(answer) { return pc.setLocalDescription(answer) })
    .then(function() {
        console.log("----发送应答offer-----");
        wsSend({type:"offerAnswer", content: pc.localDescription});
    })
    .catch(err => { 
        console.warn("--应答offer发生错误: ", err) 
    })
}

当本地方接收到answerOffer时,将远程的sdp设置到本地:

/**
 * @description 将接收到的awseroffer的SDP设置到本地pc上
 * @param {Object} data
*/
function setRemoteSDP(data) {
    const remoteDesc = new RTCSessionDescription(data.content);
    pc.setRemoteDescription(remoteDesc)
    .then(()=>{ console.log("---invite成功设置远程SDP"); });
}
  1. 关闭webRTC:
function closeRTC() {
  var remoteVideo = document.querySelector("#video");
  var localVideo = document.querySelector("#video-self");

  if (pc) {
    pc.ontrack = null;
    pc.onremovetrack = null;
    pc.onremovestream = null;
    pc.onicecandidate = null;
    pc.oniceconnectionstatechange = null;
    pc.onsignalingstatechange = null;
    pc.onicegatheringstatechange = null;
    pc.onnegotiationneeded = null;

    if (remoteVideo.srcObject) 
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());

    if (localVideo.srcObject) 
      localVideo.srcObject.getTracks().forEach(track => track.stop());
    
    pc.close();
    pc = null;
  }

  remoteVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
  localVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
}

结语

以上只是简单实践了webRTC连接建立。demo中大多事件需要用户手动触发,显然不合理,更自然的交互方式可以查看我另一个IM应用:
支持视频通话,在线聊天,主题切换,多语言等
体验地址:fffuture.top
github: github.com/fffuture/IM

引用