WebRTC 在创建点对点(P2P)的连接之前,会先通过信令服务器交换两端的 SDP 和 ICE Candidate,取两者的交集,决定最终的音视频参数、传输协议、NAT 打洞方式等信息。
  在完成媒体协商,并且两端网络连通之后,就可以开始传输数据了。
  本文示例代码已上传至[Github](https://github.com/pwstrick/webrtc),有需要的可以随意下载。
## 一、术语
  在实现一个简单的视频通话之前,还需要了解一些相关术语。
**1)SDP**
  SDP(Session Description Protocal)是一个描述会话元数据(Session Metadata)、网络(Network)、流(Stream)、安全(Security)和服务质量(Qos,Grouping)的[WebRTC协议](https://www.ietf.org/archive/id/draft-nandakumar-rtcweb-sdp-08.txt),下图是 SDP 各语义和字段之间的包含关系。
  换句话说,它就是一个用文本描述各端能力的协议,这些能力包括支持的音视频编解码器、传输协议、编解码器参数(例如音频通道数,采样率等)等信息。
:-: ![](https://img.kancloud.cn/33/39/3339b2be05120ff6b96954e74fb9d3ad_1140x1934.png =600x)
  下面是一个典型的[SDP](https://developer.mozilla.org/zh-CN/docs/Glossary/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
~~~
  RTP 协议的作用是让数据包有序,它是应用层传输协议的一种,与 HTTP/HTTPS 同级。
  除了 RTP 之外,还有一种 RTCP 协议,与 RTP 处于同一级,并且可对其做丢包控制。
**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 地址,以及通过信令服务器完成媒体协商的简易过程。
:-: ![](https://img.kancloud.cn/71/45/7145c9f8d8f4c39caf7fb5b4ce98d8b1_651x619.jpeg =600x)
  TURN(Traversal Using Relay NAT,通过 Relay 方式穿越 NAT),是一种数据传输协议,允许通过 TCP 或 UDP 穿透 NAT。
  TURN 也是一个 Client/Server 协议,其穿透方法与 STUN 类似,但终端必须在通讯开始前与 TURN 服务器进行交互。
  下图描绘了通过 TURN 服务器实现 P2P 数据传输。
:-: ![](https://img.kancloud.cn/dd/ea/ddea4f9ca4f0d4067e805a52b4cc455f_2684x1574.jpeg =800x)
  CoTurn 是一款免费开源的 TURN 和 STUN 服务器,可以到[GitHub](https://github.com/coturn/coturn)上下载源码编译安装。
## 二、信令服务器
  通信双方彼此是不知道对方的,但是它们可以先与信令服务器(Signal Server)连接,然后通过它来互传信息。
  可以将信令服务器想象成一个中间人,由他来安排两端进入一个房间中,然后在房间中可以他们就能随意的交换手上的情报了。
  本文会通过 Node.js 和[socket.io](https://socket.io/)实现一个简单的信令服务器,完成的功能仅仅是用于实验,保存在 server.js 文件中。
  如果对 socket.io 不是很熟悉,可以参考我之前分享的一篇[博文](https://www.cnblogs.com/strick/p/16358972.html),对其有比较完整的说明。
**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 静态页面,结构如下所示。
~~~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 属性是为了避免[报错](https://stackoverflow.com/questions/49930680/how-to-handle-uncaught-in-promise-domexception-play-failed-because-the-use):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 信息。
:-: ![](https://img.kancloud.cn/2b/68/2b68d0bbb2ddcb07c187a48d551da591_1058x370.png =600x)
  Alice 先调用[createOffer()](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/createOffer)创建一个 Offer 类型的 SDP,然后调用[setLocalDescription()](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription)配置本地描述。
  Bob 接收发送过来的 Offer,调用[setRemoteDescription()](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/setRemoteDescription)配置远端描述。
  再调用[createAnswer()](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createAnswer)创建一个 Answer 类型的 SDP,最后调用 setLocalDescription() 配置本地描述。
  而 Bob 也会接收 Answer 并调用 setRemoteDescription() 配置远端描述。后面的代码会实现上述过程。
**2)RTCPeerConnection**
  在 WebRTC 中创建连接,需要先初始化[RTCPeerConnection](https://developer.mozilla.org/zh-CN/docs/Web/API/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](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCSessionDescription)描述 SDP 信息,用[RTCIceCandidate](https://developer.mozilla.org/en-US/docs/Web/API/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,在一个浏览器中点击开播,如下图所示。
:-: ![](https://img.kancloud.cn/e9/62/e962803c60b6f47658bf1bdaee042dfd_1368x666.png =600x)
  在另一个浏览器的 remoteVideo 中,就可以看到推送过来的画面。
:-: ![](https://img.kancloud.cn/78/e7/78e79040f8e4dde6e29d3b9ec228cf1b_1368x666.png =600x)
  下面用一张时序图来完整的描述整个连接过程,具体内容不再赘述。
:-: ![](https://img.kancloud.cn/3f/83/3f83595949cefee5bea3117071b5d4f6_836x957.png)
参考资料:
[What is WebRTC and How to Setup STUN/TURN Server for WebRTC Communication?](https://medium.com/av-transcode/what-is-webrtc-and-how-to-setup-stun-turn-server-for-webrtc-communication-63314728b9d0)
[WebRTC音视频传输基础:NAT穿透](https://blog.jianchihu.net/webrtc-av-transport-basis-nat-traversal.html)
*****
> 原文出处:
[博客园-HTML躬行记](https://www.cnblogs.com/strick/category/1770829.html)
[知乎专栏-HTML躬行记](https://zhuanlan.zhihu.com/c_1250826149041238016)
已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎阅读。
![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200)
推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、扩展运算符和剩余参数
- 3、解构
- 4、模板字面量
- 5、对象字面量的扩展
- 6、Symbol
- 7、代码模块化
- 8、数字
- 9、字符串
- 10、正则表达式
- 11、对象
- 12、数组
- 13、类型化数组
- 14、函数
- 15、箭头函数和尾调用优化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、类
- 21、类的继承
- 22、Promise
- 23、Promise的静态方法和应用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基础实践
- 3、WebRTC视频通话
- 4、Web音视频基础
- CSS进阶
- 1、CSS基础拾遗
- 2、伪类和伪元素
- 3、CSS属性拾遗
- 4、浮动形状
- 5、渐变
- 6、滤镜
- 7、合成
- 8、裁剪和遮罩
- 9、网格布局
- 10、CSS方法论
- 11、管理后台响应式改造
- React
- 1、函数式编程
- 2、JSX
- 3、组件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表单
- 8、样式
- 9、组件通信
- 10、高阶组件
- 11、Redux基础
- 12、Redux中间件
- 13、React Router
- 14、测试框架
- 15、React Hooks
- 16、React源码分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基础
- 4、webpack进阶
- 5、Git
- 6、Fiddler
- 7、自制脚手架
- 8、VSCode插件研发
- 9、WebView中的页面调试方法
- Vue.js
- 1、数据绑定
- 2、指令
- 3、样式和表单
- 4、组件
- 5、组件通信
- 6、内容分发
- 7、渲染函数和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、数据类型
- 2、接口
- 3、类
- 4、泛型
- 5、类型兼容性
- 6、高级类型
- 7、命名空间
- 8、装饰器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系统和网络
- 3、命令行工具
- 4、自建前端监控系统
- 5、定时任务的调试
- 6、自制短链系统
- 7、定时任务的进化史
- 8、通用接口
- 9、微前端实践
- 10、接口日志查询
- 11、E2E测试
- 12、BFF
- 13、MySQL归档
- 14、压力测试
- 15、活动规则引擎
- 16、活动配置化
- 17、UmiJS版本升级
- 18、半吊子的可视化搭建系统
- 19、KOA源码分析(上)
- 20、KOA源码分析(下)
- 21、花10分钟入门Node.js
- 22、Node环境升级日志
- 23、Worker threads
- 24、低代码
- 25、Web自动化测试
- 26、接口拦截和页面回放实验
- 27、接口管理
- 28、Cypress自动化测试实践
- 29、基于Electron的开播助手
- Node.js精进
- 1、模块化
- 2、异步编程
- 3、流
- 4、事件触发器
- 5、HTTP
- 6、文件
- 7、日志
- 8、错误处理
- 9、性能监控(上)
- 10、性能监控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 监控系统
- 1、SDK
- 2、存储和分析
- 3、性能监控
- 4、内存泄漏
- 5、小程序
- 6、较长的白屏时间
- 7、页面奔溃
- 8、shin-monitor源码分析
- 前端性能精进
- 1、优化方法论之测量
- 2、优化方法论之分析
- 3、浏览器之图像
- 4、浏览器之呈现
- 5、浏览器之JavaScript
- 6、网络
- 7、构建
- 前端体验优化
- 1、概述
- 2、基建
- 3、后端
- 4、数据
- 5、后台
- Web优化
- 1、CSS优化
- 2、JavaScript优化
- 3、图像和网络
- 4、用户体验和工具
- 5、网站优化
- 6、优化闭环实践
- 数据结构与算法
- 1、链表
- 2、栈、队列、散列表和位运算
- 3、二叉树
- 4、二分查找
- 5、回溯算法
- 6、贪心算法
- 7、分治算法
- 8、动态规划
- 程序员之路
- 大学
- 2011年
- 2012年
- 2013年
- 2014年
- 项目反思
- 前端基础学习分享
- 2015年
- 再一次项目反思
- 然并卵
- PC网站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端学习之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 日志
- 2020