浅聊WebRTC视频通话

3,145 阅读10分钟

WebRTC 提供了一套标准 API,使 Web 应用可以直接提供实时音视频通信功能。大部分浏览器及操作系统都支持 WebRTC,直接可以在浏览器端发起实时音视频通话,本文以 WebRTC 初学者的视角去完成一个 1V1 网页版实时音视频通话。

完成音视频通话需要了解四个模块:音视频采集、STUN/TURN 服务器、信令服务器、端与端之间 P2P 连接。使用 WebRTC 的 API 完成音视频采集,配合信令服务器和 WebRTC 的RTCPeerConnection方法能实现 1V1 通话,简易流程如下图:

接下来依次讲解它们的作用和核心 API。

音视频采集

通话的人像和声音采集和播放

WebRTC 使用getUserMedia获取摄像头与话筒对应的媒体流对象MediaStream,媒体流可以通过 WebRTC 进行传输,并在多个对等端之间共享。将流对象赋值给视频元素的 srcObject,实现本地播放音视频

属性
含义
width
视频的宽度
height
视频的高度
aspectRatio
比例
frameRate
帧率
facingMode
镜像模式
resizeMode
大小模式
API:navigator.mediaDevices.getUserMedia
参数:constraints
返回:promise,方法调用成功得到MediaStream对象。

const localVideo = document.querySelector("video");

function gotLocalMediaStream(mediaStream) {
  localVideo.srcObject = mediaStream;
}

navigator.mediaDevices
  .getUserMedia({
      video: {
        width: 640,
        height: 480,
        frameRate:15,
        facingMode: 'enviroment', // 设置为后置摄像头
        deviceId : deviceId ? {exact:deviceId} : undefined
      },
      audio: false
   })
  .then(gotLocalMediaStream)
  .catch((error) => console.log("navigator.getUserMedia error: ", error));

连接管理

知道怎么捕获本地音视频,接下来了解怎么与另一端建立连接传输音视频数据。

RTCPeerConnection是 WebRTC 实现网络连接、媒体管理、数据管理的统一接口。建立 P2P 连接需要用到RTCPeerConnection中的几个重要类:SDPICESTUN/TURN

  1. 会话描述信息 RTCSessionDescription(SDP)

SDP 是各端的能力,包括音频编解码器类型、传输协议等。这些信息是建立连接是必须传递的,双方知道视频是否支持音频、编码方式是什么,都能通过 SDP 获得。

比如进行视频传输,我的编码是 H264 对方只能解 H265,就没法进行通信了。

SDP 描述分为两部分,分别是会话级别的描述(session level)和媒体级别的描述(media level),其具体的组成可参考 RFC4566,带星号 (*) 的是可选的。常见的内容如下:

Session description(会话级别描述)
    v= (protocol version)
    o= (originator and session identifier)
    s= (session name)
    c=* (connection information -- not required if included in all media) One or more Time descriptions ("t=" and "r=" lines; see below)
    a=* (zero or more session attribute lines) Zero or more Media descriptions
Time description
    t= (time the session is active)

Media description(媒体级别描述), if present
    m= (media name and transport address)
    c=* (connection information -- optional if included at session level)
    a=* (zero or more media attribute lines)

SDP 解析时,每个 SDP Line 都是以 key=... 形式,解析出 key 是 a 后,可能有两种方式,可参考 RFC4566

a=<attribute>
a=<attribute>:<value>

有时候并非冒号 (:) 就一定是 <attribute>:<value>,实际上 value 里面也会有冒号,比如:

a=fingerprint:sha-256 7C:93:85:40:01:07:91:BE
a=extmap:2 urn:ietf:params:rtp-hdrext:toffset
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=ssrc:2527104241 msid:gLzQPGuagv3xXolwPiiGAULOwOLNItvl8LyS

看一下具体例子:

alert(pc.remoteDescription.sdp);

 v=0
 o=alice 2890844526 2890844526 IN IP4 host.anywhere.com
 s=
 c=IN IP4 host.anywhere.com
 t=0 0
 //下面的媒体描述,在媒体描述部分包括音频和视频两路媒体
 m=audio 49170 RTP/AVP 0
 a=fmtp:111 minptime=10;useinbandfec=1 //对格式参数的描述
 a=rtpmap:0 PCMU/8000 //对RTP数据的描述
...
 //上面是音频媒体描述,下面是视频媒体描述
 m=video 51372 RTP/AVP 31
 a=rtpmap:31 H261/90000
 ...
 m=video 53000 RTP/AVP 32
 a=rtpmap:32 MPV/90000
  1. ICE 候选者 RTCIceCandidate

WebRTC 点对点连接最方便的方法是双方 IP 直连,但是在实际的应用中,双方会隔着NAT设备给获取地址造成了麻烦。

WebRTC 通过ICE框架确定两端建立网络连接的最佳路径,为开发者者屏蔽了复杂的技术细节。

(NAT 及ICE框架对于使用 WebRTC 的开发者是一个黑盒,为了优化阅读体验将这部分放在最后作为补充知识)

开发者需要了解

  1. 原理

两个节点交换 ICE 候选来协商他们自己具体如何连接,一旦两端同意了一个互相兼容的候选,该候选的 SDP 就被用来创建并打开一个连接,通过该连接媒体流就开始运转。

  1. 两个 API

onicecandidate:本地代理创建 SDP Offer 并调用 setLocalDescription(offer) 后触发,在 eventHandler 中通过信令服务器将候选信息传递给远端。

addIceCandidate:接收到信令服务器发送过来的候选信息后调用,为本机添加 ICE 代理。

API:pc.onicecandidate = eventHandler
pc.onicecandidate = function(event) {
  if (event.candidate) {
    // Send the candidate to the remote peer
  } else {
    // All ICE candidates have been sent
  }
}


API:pc.addIceCandidate
pc.addIceCandidate(candidate).then(_=>{
  // Do stuff when the candidate is successfully passed to the ICE agent
}).catch(e=>{
  console.log("Error: Failure during addIceCandidate()");
});

信令服务器

WebRTC 的 SDP 和 ICE 信息需要依赖信令服务器进行消息传输与交换、建立 P2P 连接,之后才能进行音视频通话、传输文本信息。如果没有信令服务器,WebRTC 无法进行通信。

通常使用socket.io实时通信的能力来构建信令服务器。socket.io跨平台、跨终端、跨语言,方便我们在各个端上去实现信令的各个端,去与我们的服务端进行连接。

这张图就表达了信令服务器在整个通话过程中它起到的作用。

用代码看一下如何建立 socket.io 信令服务器

var express = require("express");
var app = express();
var http = require("http");
const { Server } = require("socket.io");
const httpServer = http.createServer(app);
const io = new Server(httpServer);

io.on("connection", (socket) => {
    console.log("a user connected");
    socket.on("message", (room, data) => {
      logger.debug("message, room: " + room + ", data, type:" + data.type);
      socket.to(room).emit("message", room, data);
    })
    socket.on("join", (room) => {
      socket.join(room);
    })
});

端与端之间 P2P 连接

  1. 连接过程

A 和 B 建立网络连接的过程如图:

  • A 向 B 发起 WebRTC 呼叫
  • 创建 peerConnection 对象,在参数中指定 Turn/Stun 的地址
var pcConfig = {
  iceServers: [
    {
      urls: "turn:stun.al.learningrtc.cn:3478",
      credential: "mypasswd",
      username: "garrylea",
    },
    {
      urls:[
        "stun:stun.example.com",
        "stun:stun-1.example.com"
      ]
    }
  ],
};

pc = new RTCPeerConnection(pcConfig);
  • A 调用createOffer方法创建本地会话描述(SDP offer),SDP offer 包含有关已附加到 WebRTC 会话,浏览器支持的编解码器和选项的所有MediaStreamTrack信息,以及ICE代理,目的是通过信令信道发送给潜在远程端点,以请求连接或更新现有连接的配置。
  • A 调用setLocalDescription方法将提案设置为本地会话描述,并传递给 ICE 层。之后通过信令服务器将会话描述发送给 B
API:pc.createOffer
参数:无
返回:SDP Offer

API:pc. setLocalDescription
参数:offer
返回:Promise<null>

function sendMessage(roomid, data) {
  if (!socket) {
    console.log("socket is null");
  }
  socket.emit("message", roomid, data);
}

const offer = await pc.createOffer()
await pc.setLocalDescription(offer).catch(handleOfferError);
message.log(`传输发起方本地SDP`);
sendMessage(roomid, offer);
  • A 端 pc.setLocalDescription(offer)创建后,一个 icecandidate 事件就被发送到RTCPeerConnectiononicecandidate事件会被触发。B 端接收到一个从远端页面通过信令发来的新的 ICE 候选地址信息,本机可以通过调用RTCPeerConnection.addIceCandidate() 来添加一个 ICE 代理。
//A端
pc.onicecandidate = (event) => {
  if (!event.candidate) return;
  sendMessage(roomid, {
    type: "candidate",
    label: event.candidate.sdpMLineIndex,
    id: event.candidate.sdpMid,
    candidate: event.candidate.candidate,
  });
};


//B端
socket.onmessage = e => {
 if (e.data.hasOwnProperty("type") && e.data.type === "candidate") {
  var candidate = new RTCIceCandidate({
    sdpMLineIndex: data.label,
    candidate: data.candidate,
  });
  pc.addIceCandidate(candidate)
    .then(() => {
      console.log("Successed to add ice candidate");
    })
    .catch((err) => {
      console.error(err);
    });
 }
}
  • A 作为呼叫方获取本地媒体流,调用addtrack方法将音视频流流加入RTCPeerConnection对象中传输给另一端,加入时另一端触发ontrack事件。
媒体流加入媒体轨道

API:stream.getTracks
参数:无
返回:媒体轨道对象数组

const pc = new RTCPeerConnection();
stream.getTracks().forEach((track) => {
  pc.addTrack(track, stream);
});

const remoteVideo = document.querySelector("#remote-video");
pc.ontrack = (e) => {
  if (e && e.streams) {
    message.log("收到对方音频/视频流数据...");
    remoteVideo.srcObject = e.streams[0];
  }
};
  • B 作为呼叫方,从信令服务器收到 A 发过来的会话信息,调用setRemoteDescription方法将提案传递到 ICE 层,调用 addTrack 方法加入 RTCPeerConnction
  • B 调用 createAnswer 方法创建应答,调用setLocalDeacription方法应答设置为本地会话并传递给 ICE 层。
socket.onmessage = e => {
    message.log("接收到发送方SDP");
    await pc.setRemoteDescription(new RTCSessionDescription(e.data));
    message.log("创建接收方(应答)SDP");
    const answer = await pc.createAnswer();
    message.log(`传输接收方(应答)SDP`);
    sendMessage(roomid, answer);
    await pc.setLocalDescription(answer);
}

  • AB 都有了自己和对方的 SDP,媒体交换方面达成一致,收集的 ICE 完成连通性检测建立最连接方式,P2P 连接建立,获得对方的音视频媒体流。
pc.ontrack = (e) => {
  if (e && e.streams) {
    message.log("收到对方音频/视频流数据...");
    remoteVideo.srcObject = e.streams[0];
  }
};
  1. 双向数据通道连接

RTCDataChannelton 通过 RTCPeerConnection API 可以建立点对点 P2P 互联,不需要中介服务器,延迟更低。

一端建立 datachannel, 另一端通过 ondatachannel 获取 datachannel 对象

API:pc.createDataChannel
参数: label  通道名
      options?  通道参数
返回:RTCDataChannel


function receivemsg(e) {
  var msg = e.data;
  if (msg) {
    message.log("-> " + msg + "\r\n");
  } else {
    console.error("received msg is null");
  }
}

const dc = pc.createDataChannel("chat");
dc.onmessage = receivemsg;
dc.onopen = function () {
  console.log("datachannel open");
};
dc.onclose = function () {
  console.log("datachannel close");
};


pc.ondatachannel = e => {
  if(!dc){
    dc = e.channel;
    dc.onmessage = receivemsg;
    dc.onopen = dataChannelStateChange;
    dc.opclose = dataChannelStateChange;
  }
}; //当对接创建数据通道时会回调该方法。

NAT 及 ICE 框架(补充)

上文提到ICE集成了多种 NAT 穿越技术,比如 STUN、TURN,可以实现NAT穿透,在主机之间发现 P2P 传输路径机制。接下来简单介绍下 NAT、STUN、TURN 是什么。

  1. 网络地址转换( NAT)

NAT常部署在一个组织的网络出口位置。网络分为私网和公网两个部分,NAT 网关设置在私网到公网的路由出口位置,私网与公网间的双向数据必须都要经过 NAT 网关。组织内部的大量设备,通过 NAT 就可以共享一个公网 IP 地址,解决了 IPv4 地址不足的问题。

如下图所示,有两个组织,每个组织的 NAT 分配一个公网 IP,分别是 1.2.3.4 以及 1.2.3.5。每个组织私网设备通过 NAT 将内网地址转换为公网地址,然后加入互联网

NAT 对待 UDP 的实现方式有 4 种,分别为:完全圆锥型、地址受限锥型、端口受限锥型、对称型。

  1. Session Traversal Utilities for NAT (STUN)

STUN允许位于 NAT(或多重 NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的 NAT 之后以及 NAT 为某一个本地端口所绑定的公网端端口。

STUN是 C/S 模式的协议,由客户端发送 STUN 请求、STUN 服务响应告知由NAT分配给主机的 IP 地址和端口号,也是一种 Request/Response 的协议,默认端口号是 3478。

想让内网主机知道它的外网 IP,需要在公网上架设一台 STUN server,并向这台服务器发送 Request,服务器就会返回它的公网 IP 了。

下面是抓取的一对 STUN 绑定请求和响应。首先客户端向地址为 216.93.246.18 的 STUN 服务器发送 Binding Request(STUN 绑定请求)。

服务器回了 Binding Response,返回公网 IP:

  1. traversal Using Relay NAT(TURN)

TURN是一种数据传输协议。允许通过 TCP 或 UDP 方式穿透 NAT 或防火墙。TURN 是一个 Client/Server 协议。TURN 的 NAT 穿透方法与 STUN 类似,都是通过取得应用层中的公网地址达到 NAT 穿透

  1. ICE 收集

ICE两端并不知道所处的网络的位置和 NAT 类型,通过ICE能够动态的发现最优的传输路径。ICE 端收集本地地址、通过STUN服务收集 NAT 外网地址、通过TURN收集中继地址,所以会有三种候选地址:

host 类型,即本机内网的 IP 和端口;

srflx 类型, 即本机 NAT 映射后的外网的 IP 和端口;

relay 类型,即中继服务器的 IP 和端口。

{
    IP: xxx.xxx.xxx.xxx,
    port: number,
    type: host/srflx/relay,
    priority: number,
    protocol: UDP/TCP,
    usernameFragment: string
    ...
 }

下图中,Alice 与 Bob 通过 STUN 以及 TURN 服务器收集了三种类型的 candidate。

ICE 收集 candidate 后进行连通性检测,确定主机之间 P2P 最佳传输路径。

效果

demo 项目:code.byted.org/yuanyuan.wa…

参考

developer.mozilla.org/zh-CN/docs/…

github.com/shushushv/w…

www.cnblogs.com/pannengzhi/…