WebRTC 在创建点对点(P2P)的连接之前,会先通过信令服务器交换两端的 SDP 和 ICE Candidate,取两者的交集,决定最终的音视频参数、传输协议、NAT 打洞方式等信息。

  在完成媒体协商,并且两端网络连通之后,就可以开始传输数据了。

  本文示例代码已上传至 Github,有需要的可以随意下载。

一、术语

  在实现一个简单的视频通话之前,还需要了解一些相关术语。

1)SDP

  SDP(Session Description Protocal)是一个描述会话元数据(Session Metadata)、网络(Network)、流(Stream)、安全(Security)和服务质量(Qos,Grouping)的 WebRTC协议,下图是 SDP 各语义和字段之间的包含关系。

  换句话说,它就是一个用文本描述各端能力的协议,这些能力包括支持的音视频编解码器、传输协议、编解码器参数(例如音频通道数,采样率等)等信息。

  

  下面是一个典型的 SDP 信息示例,其中 RTP(Real-time Transport Protocol)是一种网络协议,描述了如何以实时方式将各种媒体从一端传输到另一端。

=================会话描述======================
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=rtpmap:0 PCMU/8000
================视频流描述=====================
m=video 51372 RTP/AVP 31
a=rtpmap:31 H261/90000

2)ICE Candidate

  ICE 候选者描述了 WebRTC 能够与远程设备通信所需的协议、IP、端口、优先级、候选者类型(包括 host、srflx 和 relay)等连接信息。

  host 是本机候选者,srflx 是从 STUN 服务器获得的候选者,relay 是从 TURN 服务器获得的中继候选者。

  在每一端都会提供许多候选者,例如有两块网卡,那么每块网卡的不同端口都是一个候选者。

  WebRTC 会按照优先级倒序的进行连通性测试,当连通性测试成功后,通信的双方就建立起了连接。

3)NAT打洞

  在收集到候选者信息后,WebRTC 会判断两端是否在同一个局域网中,若是,则可以直接建立链接。

  若不是,那么 WebRTC 就会尝试 NAT 打洞。WebRTC 将 NAT 分为 4 种类型:完全锥型、IP 限制型、端口限制型和对称型。

  前文候选者类型中曾提到 STUN 和 TURN 两种协议,接下来会对它们做简单的说明。

  STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,允许位于 NAT 后的客户端找出自己的公网地址,当前 NAT 类型和 NAT 为某一个本地端口所绑定的公网端口。

  这些信息让两个同时处于 NAT 路由器之后的主机之间建立 UDP 通信,STUN 是一种 Client/Server 的协议,也是一种 Request/Response 的协议。

  下图描绘了通过 STUN 服务器获取公网的 IP 地址,以及通过信令服务器完成媒体协商的简易过程。

  

  TURN(Traversal Using Relay NAT,通过 Relay 方式穿越 NAT),是一种数据传输协议,允许通过 TCP 或 UDP 穿透 NAT。

  TURN 也是一个 Client/Server 协议,其穿透方法与 STUN 类似,但终端必须在通讯开始前与 TURN 服务器进行交互。

  下图描绘了通过 TURN 服务器实现 P2P 数据传输。

  

  CoTurn 是一款免费开源的 TURN 和 STUN 服务器,可以到 GitHub 上下载源码编译安装。

二、信令服务器

  通信双方彼此是不知道对方的,但是它们可以先与信令服务器(Signal Server)连接,然后通过它来互传信息。

  可以将信令服务器想象成一个中间人,由他来安排两端进入一个房间中,然后在房间中可以他们就能随意的交换手上的情报了。

  本文会通过 Node.js 和 socket.io 实现一个简单的信令服务器,完成的功能仅仅是用于实验,保存在 server.js 文件中。

  如果对 socket.io 不是很熟悉,可以参考我之前分享的一篇博文,对其有比较完整的说明。

1)HTTP 服务器

  为了实现视频通话的功能,需要先搭建一个简易的 HTTP 服务器,挂载静态页面。

  注意,在实际场景中,这块可以在另一个项目中执行,本处只是为了方便演示。

const http = require('http');
const fs = require('fs');
const { Server } = require("socket.io"); // HTTP服务器
const server = http.createServer((req, res) => {
// 实例化 URL 类
const url = new URL(req.url, 'http://localhost:1234');
const { pathname } = url;
// 路由
if(pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(fs.readFileSync('./index.html'));
}else if(pathname === '/socket.io.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(fs.readFileSync('./socket.io.js'));
}else if(pathname === '/client.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(fs.readFileSync('./client.js'));
}
});
// 监控端口
server.listen(1234);

  在上面的代码中,实现了最简易的路由分发,当访问 http://localhost:1234 时,读取 index.html 静态页面,结构如下所示。

<video id="localVideo"></video>
<button id="btn">开播</button>
<video id="remoteVideo" muted="muted"></video>
<script src="./socket.io.js"></script>
<script src="./client.js"></script>

  socket.io.js 是官方的 socket.io 库,client.js 是客户端的脚本逻辑。

  在 remoteVideo 中附带 muted 属性是为了避免报错:DOMException: The play() request was interrupted by a new load request。

  最后就可以通过 node server.js 命令,开启 HTTP 服务器。

2)长连接

  为了便于演示,指定了一个房间,当与信令服务器连接时,默认就会被安排进 living room。

  并且只提供了一个 message 事件,这是交换各端信息的关键代码,将一个客户端发送来的消息中继给其他各端。

const io = new Server(server);
const roomId = 'living room';
io.on('connection', (socket) => {
// 指定房间
socket.join(roomId);
// 发送消息
socket.on('message', (data) => {
// 发消息给房间内的其他人
socket.to(roomId).emit('message', data);
});
});

  因为默认是在本机演示,所以也不会安装 CoTurn,有兴趣的可以自行实现。

三、客户端

  在之前的 HTML 结构中,可以看到两个 video 元素和一个 button 元素。

const btn = document.getElementById('btn');   // 开播按钮
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const size = 300;

  在两个 video 元素中,第一个是接收本地的音视频流,第二个是接收远端的音视频流。

1)媒体协商

  在下图中,Alice 和 Bob 通过信令服务器在交换 SDP 信息。

  

  Alice 先调用 createOffer() 创建一个 Offer 类型的 SDP,然后调用 setLocalDescription() 配置本地描述。

  Bob 接收发送过来的 Offer,调用 setRemoteDescription() 配置远端描述。

  再调用 createAnswer() 创建一个 Answer 类型的 SDP,最后调用 setLocalDescription() 配置本地描述。

  而 Bob 也会接收 Answer 并调用 setRemoteDescription() 配置远端描述。后面的代码会实现上述过程。

2)RTCPeerConnection

  在 WebRTC 中创建连接,需要先初始化 RTCPeerConnection 类,其构造函数可以接收 STUN/TURN 服务器的配置信息。

// STUN/TURN Servers
const pcConfig = {
// 'iceServers': [{
// 'urls': '',
// 'credential': "",
// 'username': ""
// }]
};
// 实例化 RTCPeerConnection
const pc = new RTCPeerConnection(pcConfig);

  然后注册 icecandidate 事件,将本机的网络信息发送给信令服务器,sendMessage() 函数后面会介绍。

pc.onicecandidate = function(e) {
if(!e.candidate) {
return;
}
// 发送 ICE Candidate
sendMessage({
type: 'candidate',
label: e.candidate.sdpMLineIndex,
id: e.candidate.sdpMid,
candidate: e.candidate.candidate
});
};

  最后注册 track 事件,接收远端的音视频流。

pc.ontrack = function(e) {
remoteVideo.srcObject = e.streams[0];
remoteVideo.play();
};

3)长连接

  在客户端中,已经引入了 socket.io 库,所以只需要调用 io() 函数就能建立长连接。

  sendMessage() 函数就是发送信息给服务器的 message 事件。

const socket = io("http://localhost:1234");
// 发送消息
function sendMessage(data){
socket.emit('message', data);
}

  本地也有个 message 事件,会接收从服务端发送来的消息,其实就是那些转发的消息。

  data 对象有个 type 属性,可创建和接收远端的 Answer 类型的 SDP 信息,以及接收远端的 ICE 候选者信息。

socket.on("message", function (data) {
switch (data.type) {
case "offer":
// 配置远端描述
pc.setRemoteDescription(new RTCSessionDescription(data));
// 创建 Answer 类型的 SDP 信息
pc.createAnswer().then((desc) => {
pc.setLocalDescription(desc);
sendMessage(desc);
});
break;
case "answer":
// 接收远端的 Answer 类型的 SDP 信息
pc.setRemoteDescription(new RTCSessionDescription(data));
break;
case "candidate":
// 实例化 RTCIceCandidate
const candidate = new RTCIceCandidate({
sdpMLineIndex: data.label,
candidate: data.candidate
});
pc.addIceCandidate(candidate);
break;
}
});

  在代码中,用 RTCSessionDescription 描述 SDP 信息,用 RTCIceCandidate 描述 ICE 候选者信息。

4)开播

  为开播按钮注册点击事件,在事件中,首先通过 getUserMedia() 获取本地的音视频流。

btn.addEventListener("click", function (e) {
// 获取音视频流
navigator.mediaDevices
.getUserMedia({
video: {
width: size,
height: size
},
audio: true
})
.then((stream) => {
localVideo.srcObject = stream;
localStream = stream;
// 将 Track 与 RTCPeerConnection 绑定
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
// 创建 Offer 类型的 SDP 信息
pc.createOffer({
offerToRecieveAudio: 1,
offerToRecieveVideo: 1
}).then((desc) => {
// 配置本地描述
pc.setLocalDescription(desc);
// 发送 Offer 类型的 SDP 信息
sendMessage(desc);
});
localVideo.play();
});
btn.disabled = true;
});

  然后在 then() 方法中,让 localVideo 接收音视频流,并且将 Track 与 RTCPeerConnection 绑定。

  这一步很关键,没有这一步就无法将音视频流推给远端。

  然后创建 Offer 类型的 SDP 信息,配置本地描述,并通过信令服务器发送给远端。

  接着可以在两个浏览器(例如 Chrome 和 Edge)中分别访问 http://localhost:1234,在一个浏览器中点击开播,如下图所示。

  

  在另一个浏览器的 remoteVideo 中,就可以看到推送过来的画面。

  

  下面用一张时序图来完整的描述整个连接过程,具体内容不再赘述。

  

参考资料:

What is WebRTC and How to Setup STUN/TURN Server for WebRTC Communication?

WebRTC音视频传输基础:NAT穿透

HTML躬行记(3)——WebRTC视频通话的更多相关文章

  1. ES6躬行记(1)——let和const

    古语云:“纸上得来终觉浅,绝知此事要躬行”.的确,不管看了多少本书,如果自己不实践,那么就很难领会其中的精髓.自己研读过许多ES6相关的书籍和资料,平时工作中也会用到,但在用到时经常需要上搜索引擎中查 ...

  2. ES6躬行记 笔记

    ES6躬行记(18)--迭代器 要实现以下接口## next() ,return,throw 可以用for-of保证迭代对象的正确性 例如 var str = "向

  3. HTML躬行记(2)——WebRTC基础实践

    WebRTC (Web Real-Time Communications) 是一项实时通讯技术,在 2011 年由 Google 提出,经过 10 年的发展,W3C 于 2021 年正式发布 WebR ...

  4. CSS躬行记(2)——伪类和伪元素

    一.伪类选择器 伪选择器弥补了常规选择器的不足,能够实现一些特殊情况下的样式,例如在鼠标悬停时或只给字符串中的第一个字符指定样式.与类选择器类似,可以从HTML元素的class属性中查看到,但伪选择器 ...

  5. ES6躬行记(21)——类的继承

    ES6的继承依然是基于原型的继承,但语法更为简洁清晰.通过一个extends关键字,就能描述两个类之间的继承关系(如下代码所示),在此关键字之前的Man是子类(即派生类),而在其之后的People是父 ...

  6. ES6躬行记(13)——类型化数组

    类型化数组(Typed Array)是一种处理二进制数据的特殊数组,它可像C语言那样直接操纵字节,不过得先用ArrayBuffer对象创建数组缓冲区(Array Buffer),再映射到指定格式的视图 ...

  7. ES6躬行记(3)——解构

    解构(destructuring)是一种赋值语法,可从数组中提取元素或从对象中提取属性,将其值赋给对应的变量或另一个对象的属性.解构地目的是简化提取数据的过程,增强代码的可读性.有两种解构语法,分别是 ...

  8. ES6躬行记(7)——代码模块化

    在ES6之前,由于ECMAScript不具备模块化管理的能力,因此往往需要借助第三方类库(例如遵守AMD规范的RequireJS或遵循CMD规范的SeaJS等)才能实现模块加载.而自从ES6引入了模块 ...

  9. ES6躬行记(4)——模板字面量

    模板字面量(Template Literal)是一种能够嵌入表达式的格式化字符串,有别于普通字符串,它使用反引号(`)包裹字符序列,而不是双引号或单引号.模板字面量包含特定形式的占位符(${expre ...

随机推荐

  1. 【SWIFT】从零开始的SWIFT语言学习笔记-2:简单值、数组与字典

    1.0.3 简单值.数组与字典 知识点: 使用var创建变量 var myVariable = 65 myVariable = myVariable + 1 使用let创建常量 let myConst ...

  2. bat-MD文件转CSV文件

    目录 1. bat文件里面写死文件名 2. 拖入文件 注意:每个单元格不能出现字符[|.$.;] 1. bat文件里面写死文件名 @echo off && setlocal enabl ...

  3. python自学笔记10:while循环和for循环

    条件控制和循环控制是两种典型的流程控制方法,前面我们写了 if 条件控制,这节讲 for 循环和 while 循环. 循环是另一种控制流程的方式,一个循环体中的代码在程序中只需要编写一次,但可能会连续 ...

  4. 区块相隔虽一线,俱在支付同冶熔,Vue3.0+Tornado6前后端分离集成Web3.0之Metamask区块链虚拟三方支付功能

    最近几年区块链技术的使用外延持续扩展,去中心化的节点认证机制可以大幅度改进传统的支付结算模式的经营效率,降低交易者的成本并提高收益.但不能否认的是,区块链技术也存在着极大的风险,所谓身怀利器,杀心自起 ...

  5. liunx系统docker部署.net core3.1

    此篇文章演示基本的基于docker部署.netcore服务,liunx系统腾讯云ubuntu,.net core版本3.1. 1.安装docker apt install docker.io 2.拉取 ...

  6. Java基础——01

    今日学习 2020-2-27 Java多态 多态性格式 /* 代码中体现多态性 其实就是一句话:父类指向子类对象 格式: 父类名称 对象名= new 子类名称(): 或者 接口名称 对象名 = new ...

  7. hadoop 文件参数配置

    准备环境(省略) 上传实验所需的压缩包 配置网络信息 修改主机名 配置域名解析 关闭防火墙与SELinux(在所有节点上执行)代码如下: systemctl disable --now firewal ...

  8. 使用『jQuery』『原生js』制作一个导航栏动效 —— { }

    效果 HTML部分 <body> <nav> <div id="nav1">导航1</div> <div id="n ...

  9. 为什么最近每份 Android 简历都说 “熟悉 MQTT 协议”?

    请点赞关注,你的支持对我意义重大. Hi,我是小彭.本文已收录到 GitHub · AndroidFamily 中.这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] ...

  10. Windows编程之线程同步

    本笔记整理自:<Windows核心编程(第五版)> 目录 什么是线程同步 用户方式中的线程同步 原子访问:Interlocked系列函数 CRITICAL_SECTION:关键段 内核对象 ...