WebRTC
浏览器 API 与协议,第 18 章
Web Real-Time Communication(WebRTC)是一套标准、协议和 JavaScript API 的集合,它们共同实现了浏览器(对等端)之间的点对点音频、视频和数据共享。WebRTC 不依赖第三方插件或专有软件,而是将实时通信转变为任何 Web 应用都可以通过简单 JavaScript API 利用的标准功能。
交付丰富的、高质量的实时通信应用(如音视频会议和点对点数据交换)需要在浏览器中引入大量新功能:音视频处理能力、新的应用 API,以及对半打新网络协议的支持。幸运的是,浏览器将大部分复杂性抽象为三个主要 API:
- MediaStream:获取音频和视频流
- RTCPeerConnection:传输音频和视频数据
- RTCDataChannel:传输任意应用数据
只需十几行 JavaScript 代码,任何 Web 应用就能实现丰富的视频会议体验和点对点数据传输。这就是 WebRTC 的承诺与力量!然而,列出的 API 只是冰山一角:信令、对等端发现、连接协商、安全性以及整个新协议层,都是将其整合在一起所需的组件。
不出所料,支撑 WebRTC 的架构和协议也决定了其性能特征:连接建立延迟、协议开销和传输语义等。事实上,与所有其他浏览器通信不同,WebRTC 通过 UDP 传输数据。然而,UDP 只是一个起点。要在浏览器中实现实时通信,需要的远不止原始 UDP。让我们深入了解。
WebRTC 现已覆盖数十亿用户:Chrome、Firefox、Safari 和 Edge 等主流浏览器都为其所有用户提供了 WebRTC 支持!尽管如此,WebRTC 仍在积极演进中,无论是在浏览器 API 层面,还是在传输和协议层面。因此,以下章节讨论的具体 API 和协议未来可能仍会有变化。
标准与 WebRTC 发展
在浏览器中实现实时通信是一项雄心勃勃的举措,可以说是自 Web 平台诞生以来最重要的新增功能之一。WebRTC 打破了熟悉的客户端-服务器通信模式,这导致浏览器网络层的全面重新设计,同时也带来了全新的媒体栈,这是实现高效实时音视频处理所必需的。
因此,WebRTC 架构由十几个不同的标准组成,涵盖应用和浏览器 API,以及使其工作所需的许多不同协议和数据格式:
- Web Real-Time Communications (WEBRTC) W3C 工作组负责定义浏览器 API。
- Real-Time Communication in Web-browsers (RTCWEB) IETF 工作组负责定义协议、数据格式、安全性以及在浏览器中实现点对点通信所需的所有其他方面。
WebRTC 并非从零开始的标准。虽然其主要目的是实现浏览器之间的实时通信,但它也被设计为可以与现有通信系统集成:IP 语音 (VoIP)、各种 SIP 客户端,甚至公共交换电话网络 (PSTN) 等。WebRTC 标准没有定义任何特定的互操作性要求或 API,但尽可能重用相同的概念和协议。
换句话说,WebRTC 不仅是将实时通信带入浏览器,也是将 Web 的所有能力带入电信世界——2012 年这是一个 4.7 万亿美元的产业!不出所料,这是一个重大的发展,许多现有电信供应商、企业和初创公司都在密切关注。WebRTC 远不止是一个浏览器 API。
音频与视频引擎
在浏览器中实现丰富的视频会议体验需要浏览器能够访问系统硬件来捕获音频和视频——无需第三方插件或自定义驱动程序,只需一个简单一致的 API。然而,原始音视频流本身也不够:每个流必须经过处理以提高质量、同步,并且输出比特率必须根据客户端之间不断波动的带宽和延迟进行调整。
在接收端,过程相反,客户端必须实时解码流,并能够适应网络抖动和延迟。简而言之,捕获和处理音视频是一个复杂的问题。然而,好消息是 WebRTC 为浏览器带来了功能齐全的音频和视频引擎(图 18-1),它们代表我们处理所有信号处理等工作。
图 18-1. WebRTC 音频和视频引擎
音频和视频引擎的完整实现和技术细节很容易成为一本专著的主题,超出了我们的讨论范围。想了解更多,请访问 https://www.webrtc.org 。
获取的音频流经过降噪和回声消除处理,然后使用优化的窄带或宽带音频编解码器自动编码。最后,使用特殊的错误隐藏算法来掩盖网络抖动和丢包的负面影响——这只是亮点!视频引擎通过优化图像质量、选择最佳压缩和编解码器设置、应用抖动和丢包隐藏等执行类似的处理。
所有处理都由浏览器直接完成,更重要的是,浏览器动态调整其处理管道以应对音视频流和网络条件的持续变化参数。完成所有这些工作后,Web 应用接收优化的媒体流,然后可以输出到本地屏幕和扬声器、转发给对等端,或使用 HTML5 媒体 API 进行后处理!
使用 getUserMedia 获取音视频
Media Capture and Streams W3C 规范定义了一组新的 JavaScript API,使应用能够从平台请求音频和视频流,以及一组用于操作和处理获取的媒体流的 API。MediaStream 对象(图 18-2)是实现所有这些功能的主要接口。
图 18-2. MediaStream 携带一个或多个同步轨道
- MediaStream 对象由一个或多个独立轨道(MediaStreamTrack)组成。
- MediaStream 对象内的轨道彼此同步。
- 输入源可以是物理设备(如麦克风、摄像头)或用户硬盘上的本地或远程文件,或远程网络对等端。
- MediaStream 的输出可以发送到一个或多个目的地:本地视频或音频元素、用于后处理的 JavaScript 代码,或远程对等端。
MediaStream 对象代表实时媒体流,允许应用代码获取数据、操作单个轨道并指定输出。所有音视频处理(如降噪、均衡、图像增强等)都由音频和视频引擎自动处理。
然而,获取的媒体流的特性受输入源能力的限制:麦克风只能发出音频流,某些摄像头可以比其他摄像头产生更高分辨率的视频流。因此,在浏览器中请求媒体流时,getUserMedia() API 允许我们指定一系列强制和可选约束以匹配应用需求:
<video autoplay></video>
<script>
const constraints = {
audio: true,
video: {
width: { min: 320 },
height: { min: 180 },
width: { max: 1280 },
frameRate: 30,
facingMode: "user"
}
};
navigator.mediaDevices.getUserMedia(constraints)
.then(gotStream)
.catch(logError);
function gotStream(stream) {
const video = document.querySelector('video');
video.srcObject = stream;
}
function logError(error) { ... }
</script>
这个例子展示了一个更复杂的场景:我们请求音频和视频轨道,并指定必须使用的最小分辨率和摄像头类型,以及 720p HD 视频的可选约束列表!getUserMedia() API 负责向用户请求访问麦克风和摄像头,并获取符合指定约束的流——这就是快速概览。
提供的 API 还使应用能够操作单个轨道、克隆它们、修改约束等。此外,一旦获取流,我们可以将其输入各种其他浏览器 API:
- Web Audio API 支持在浏览器中处理音频。
- Canvas API 支持捕获和后处理单个视频帧。
- CSS3 和 WebGL API 可以对输出流应用各种 2D/3D 效果。
简而言之,getUserMedia() 是一个从底层平台获取音频和视频流的简单 API。媒体由 WebRTC 音频和视频引擎自动优化、编码和解码,然后路由到一个或多个输出。至此,我们完成了构建实时会议应用的一半工作——我们只需要将数据路由到对等端!
有关 Media Capture and Streams API 的完整功能列表,请访问官方 W3C 标准。
音频 (OPUS) 和视频 (VP8/VP9/AV1) 比特率
从浏览器请求音视频时,请密切关注流的大小和质量。虽然硬件可能能够捕获 HD 质量的流,但 CPU 和带宽必须能够跟上!当前 WebRTC 实现使用 Opus 和 VP8/VP9/AV1 编解码器:
- Opus 编解码器用于音频,支持恒定和可变比特率编码,需要 6–510 Kbit/s 的带宽。好消息是该编解码器可以无缝切换并适应可变带宽。
- VP8 编解码器用于视频编码,需要 100–2,000+ Kbit/s 的带宽,比特率取决于流的质量:
- 720p @ 30 FPS: 1.0–2.0 Mbps
- 360p @ 30 FPS: 0.5–1.0 Mbps
- 180p @ 30 FPS: 0.1–0.5 Mbps
- VP9 和 AV1 提供比 VP8 更好的压缩效率,在相同质量下需要更少的带宽,或在相同比特率下提供更好的质量。
因此,单方的 HD 通话可能需要高达 2.5+ Mbps 的网络带宽。再添加几个对等端,质量必须下降以适应额外的带宽以及 CPU、GPU 和内存处理需求。
实时网络传输
实时通信对时间敏感;这不足为奇。因此,音视频流应用被设计为能够容忍间歇性丢包:音视频编解码器可以填补小的数据间隙,通常对输出质量影响很小。同样,应用必须实现自己的逻辑来恢复丢失或延迟的携带其他类型应用数据的包。及时性和低延迟可能比可靠性更重要。
特别是音视频流必须适应我们大脑的独特属性。事实证明,我们非常擅长填补空白,但对延迟延迟高度敏感。在音频流中添加一些可变延迟,“感觉就不对”,但在中间丢弃几个样本,我们大多数人甚至不会注意到! w 对及时性而非可靠性的要求是 UDP 协议成为实时数据传输首选传输的主要原因。TCP 提供可靠、有序的数据流:如果中间包丢失,TCP 会缓冲其后的所有包,等待重传,然后按顺序将流交付给应用。相比之下,UDP 提供以下”非服务”:
不保证消息传递
- 无确认、重传或超时。
不保证传递顺序
- 无包序列号、无重新排序、无队头阻塞。
无连接状态跟踪
- 无连接建立或拆除状态机。
无拥塞控制
- 无内置客户端或网络反馈机制。
UDP 不承诺数据的可靠性或顺序,并在包到达时立即将其交付给应用。实际上,它只是对我们网络栈 IP 层提供的尽力交付模型的薄包装。
WebRTC 在传输层使用 UDP:延迟和及时性至关重要。至此,我们可以直接发送音频、视频和应用 UDP 包,然后就万事大吉了,对吧?嗯,不完全是。我们还需要机制来穿越多层 NAT 和防火墙、协商每个流的参数、提供用户数据加密、实现拥塞和流量控制等等!
UDP 是浏览器中实时通信的基础,但要满足 WebRTC 的所有需求,浏览器还需要在其之上大量协议和服务的支持(图 18-3)。
图 18-3. WebRTC 协议栈
- ICE: Interactive Connectivity Establishment (RFC 5245)
- STUN: Session Traversal Utilities for NAT (RFC 5389)
- TURN: Traversal Using Relays around NAT (RFC 5766)
- SDP: Session Description Protocol (RFC 4566)
- DTLS: Datagram Transport Layer Security (RFC 6347)
- SCTP: Stream Control Transport Protocol (RFC 4960)
- SRTP: Secure Real-Time Transport Protocol (RFC 3711)
ICE、STUN 和 TURN 是通过 UDP 建立和维护点对点连接所必需的。DTLS 用于保护对等端之间的所有数据传输;加密是 WebRTC 的强制功能。最后,SCTP 和 SRTP 是应用协议,用于多路复用不同的流、提供拥塞和流量控制,以及在 UDP 之上提供部分可靠交付和其他附加服务。
是的,这是一个复杂的协议栈,不出所料,在讨论端到端性能之前,我们需要了解每个协议在底层是如何工作的。这将是一个快速概览,但这是我们本章剩余部分的重点。让我们深入探讨。
我们没有忘记 SDP!正如我们将看到的,SDP 是一种数据格式,用于协商点对点连接的参数。然而,SDP “offer” 和 “answer” 是带外通信的,这就是为什么 SDP 在协议图中缺失的原因。
RTCPeerConnection API 简介
尽管在设置和维护点对点连接时涉及许多协议,但浏览器暴露的应用 API 相对简单。RTCPeerConnection 接口(图 18-4)负责管理每个点对点连接的完整生命周期。
图 18-4. RTCPeerConnection API
- RTCPeerConnection 管理 NAT 穿越的完整 ICE 工作流。
- RTCPeerConnection 在对等端之间发送自动(STUN)保活。
- RTCPeerConnection 跟踪本地流。
- RTCPeerConnection 跟踪远程流。
- RTCPeerConnection 根据需要触发自动流重新协商。
- RTCPeerConnection 提供必要的 API 来生成连接 offer、接受 answer、允许我们查询连接的当前状态等。
简而言之,RTCPeerConnection 将所有连接设置、管理和状态封装在一个接口中。然而,在我们深入了解 RTCPeerConnection API 的每个配置选项之前,我们需要了解信令和协商、offer-answer 工作流和 ICE 穿越。让我们一步一步来。
DataChannel
DataChannel API 实现对等端之间任意应用数据的交换——想想 WebSocket,但是点对点的,并且具有底层传输的可定制交付属性。每个 DataChannel 可以配置为提供:
- 已发送消息的可靠或部分可靠交付
- 已发送消息的按序或乱序交付
不可靠、乱序交付等同于原始 UDP 语义。消息可能送达,也可能没有,顺序不重要。然而,我们也可以将通道配置为”部分可靠”,通过指定最大重传次数或设置重传时间限制:WebRTC 协议栈将处理确认和超时!
通道的每种配置都有其自己的性能特征和限制,这是我们稍后将深入讨论的主题。让我们继续。
建立点对点连接
发起点对点连接需要比打开 XHR、EventSource 或新 WebSocket 会话更多的工作:后三者依赖定义良好的 HTTP 握手机制来协商连接参数,并且三者都隐式假设目标服务器可由客户端访问——即服务器具有可公开路由的 IP 地址或客户端和服务器位于同一内部网络。
相比之下,两个 WebRTC 对等端很可能位于各自不同的私有网络中,并位于一层或多层 NAT 之后。因此,没有一个对等端可以直接被另一个访问。要发起会话,我们必须首先为每个对等端收集可能的 IP 和端口候选,穿越 NAT,然后运行连接检查以找到有效的候选,即便如此,也不能保证我们会成功。
有关 NAT 对 UDP 和特别是点对点通信带来的挑战的深入讨论,请参阅 UDP 和网络地址转换器。
然而,虽然 NAT 穿越是我们必须处理的问题,但我们可能已经超前了。当我们向服务器打开 HTTP 连接时,有一个隐式假设,即服务器正在监听我们的握手;它可能希望拒绝,但它始终在监听新连接。不幸的是,对于远程对等端不能说同样的话:对等端可能离线或无法访问、忙碌,或只是对发起与另一方的连接不感兴趣。
因此,为了建立成功的点对点连接,我们必须首先解决几个额外的问题:
- 我们必须通知另一个对等端打开点对点连接的意图,使其知道开始监听传入包。
- 我们必须识别连接两侧点对点连接的潜在路由路径,并在对等端之间中继这些信息。
- 我们必须交换有关不同媒体和数据流参数的必要信息——使用的协议、编码等。
好消息是 WebRTC 为我们解决了其中一个问题:内置的 ICE 协议执行必要的路由和连接检查。然而,通知(信令)和初始会话协商的交付留给应用处理。
信令与会话协商
在任何连接检查或会话协商发生之前,我们必须找出另一个对等端是否可达以及是否愿意建立连接。我们必须发出 offer,对等端必须返回 answer(图 18-5)。然而,现在我们有一个困境:如果另一个对等端没有监听传入包,我们如何通知它我们的意图?至少,我们需要一个共享的信令通道。
图 18-5. 共享信令通道
WebRTC 将信令传输和协议的选择留给应用;标准有意不为信令栈提供任何建议或实现。为什么?这允许与为现有通信基础设施提供动力的各种其他信令协议互操作,例如:
Session Initiation Protocol (SIP)
- 应用层信令协议,广泛用于 IP 网络上的 IP 语音 (VoIP) 和视频会议。
Jingle
- XMPP 协议的信令扩展,用于 IP 网络上 IP 语音和视频会议会话控制。
ISDN User Part (ISUP)
- 用于全球许多公共交换电话网络中电话呼叫建立信令协议。
“信令通道”可以像房间里的喊叫一样简单——也就是说,如果你的预期对等端在喊叫距离内!信令介质和协议的选择留给应用。
WebRTC 应用可以选择使用任何现有信令协议和网关(图 18-6)与现有通信系统协商呼叫或视频会议——例如,与 PSTN 客户端发起”电话”呼叫!或者,它可以选择使用自定义协议实现自己的信令服务。
图 18-6. SIP、Jingle、ISUP 和自定义信令网关
信令服务器可以作为现有通信网络的网关,在这种情况下,通知目标对等端连接 offer 并将 answer 路由回发起交换的 WebRTC 客户端是网络的责任。或者,应用也可以使用自己的自定义信令通道,可能由一个或多个服务器和用于通信消息的自定义协议组成:如果两个对等端都连接到同一信令服务,则该服务可以在它们之间传递消息。
Skype 是具有自定义信令的点对点系统的绝佳示例:音频和视频通信是点对点的,但 Skype 用户必须连接到 Skype 的信令服务器,这些服务器使用其专有协议来帮助发起点对点连接。
选择信令服务
WebRTC 实现点对点通信,但每个 WebRTC 应用也需要信令服务器来协商和建立连接。我们有哪些选择?
有越来越多的现有通信网关可以与 WebRTC 互操作。例如,Asterisk 是一个流行的免费开源框架,全球个人业务和大运营商都用于其电信需求。作为一种选择,Asterisk 有 WebSocket 模块,允许 SIP 用作信令协议:浏览器与 Asterisk 网关建立 WebSocket 连接,两者交换 SIP 消息来协商会话!
或者,如果不需要与其他网络互操作,应用可以轻松开发和部署自定义信令网关。例如,网站可能选择为其用户提供点对点音频、视频和数据交换:网站已经在跟踪哪些用户已登录,并且可以为所有在线用户保持信令连接打开。然后,当两个对等端想要发起点对点会话时,网站的服务器可以在客户端之间中继信令消息。
信令网关没有单一的正确选择:选择取决于应用的需求。然而,在你开始发明自己的之前,先调查可用的商业和开源选项!当然,密切关注底层信令传输,因为它可能对信令通道的延迟以及客户端和服务器开销产生重大影响;请参阅应用 API 和协议。
会话描述协议 (SDP)
假设应用实现了共享信令通道,我们现在可以执行发起 WebRTC 连接所需的第一步:
const signalingChannel = new SignalingChannel();
const pc = new RTCPeerConnection({});
navigator.mediaDevices.getUserMedia({ audio: true })
.then(gotStream)
.catch(logError);
function gotStream(stream) {
stream.getTracks().forEach(track => pc.addTrack(track, stream));
pc.createOffer()
.then(offer => {
pc.setLocalDescription(offer);
signalingChannel.send(offer);
});
}
function logError() { ... }
WebRTC 使用会话描述协议 (SDP) 来描述点对点连接的参数。SDP 本身不传输任何媒体;相反,它用于描述”会话配置文件”,代表连接属性列表:要交换的媒体类型(音频、视频和应用数据)、网络传输、使用的编解码器及其设置、带宽信息和其他元数据。
在前面的例子中,一旦本地音频流注册到 RTCPeerConnection 对象,我们调用 createOffer() 来生成预期会话的 SDP 描述。生成的 SDP 包含什么?让我们看看:
(... snip ...)
m=audio 1 RTP/SAVPF 111 ...
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=candidate:1862263974 1 udp 2113937151 192.168.1.73 60834 typ host ...
a=mid:audio
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10
(... snip ...)
SDP 是一个简单的基于文本的协议(RFC 4568),用于描述预期会话的属性;在前面的例子中,它提供了获取的音频流的描述。好消息是,WebRTC 应用不必直接处理 SDP。JavaScript Session Establishment Protocol (JSEP) 将所有 SDP 的内部工作抽象为 RTCPeerConnection 对象上的几个简单方法调用。
一旦生成 offer,它可以通过信令通道发送给远程对等端。同样,SDP 的编码方式由应用决定:SDP 字符串可以直接传输(如前面所示,作为简单的文本 blob),也可以编码为任何其他格式——例如,Jingle 协议提供从 SDP 到 XMPP(XML)节的映射。
要建立点对点连接,两个对等端必须遵循对称工作流(图 18-7)来交换各自音频、视频和其他数据流的 SDP 描述。
图 18-7. 对等端之间的 offer/answer SDP 交换
- 发起者(Amy)将一个或多个流注册到她的本地 RTCPeerConnection 对象,创建 offer,并将其设置为她的会话”本地描述”。
- Amy 然后将生成的会话 offer 发送给另一个对等端(Bob)。
- 一旦 Bob 收到 offer,他将 Amy 的描述设置为会话的”远程描述”,将自己的流注册到自己的 RTCPeerConnection 对象,生成”answer” SDP 描述,并将其设置为会话的”本地描述”——呼!
- Bob 然后将生成的会话 answer 发送回 Amy。
- 一旦 Bob 的 SDP answer 被 Amy 收到,她将他的 answer 设置为她原始会话的”远程描述”。
至此,一旦 SDP 会话描述通过信令通道交换,双方就协商了要交换的流类型及其设置。我们几乎准备好开始点对点通信了!现在,还有一个细节需要处理:连接检查和 NAT 穿越。
交互式连接建立 (ICE)
为了建立点对点连接,根据定义,对等端必须能够相互路由包。表面上这是一个简单的陈述,但由于大多数对等端之间存在众多防火墙和 NAT 设备层,实际上很难实现;请参阅 UDP 和网络地址转换器。
首先,让我们考虑简单的情况,两个对等端位于同一内部网络,它们之间没有防火墙或 NAT。要建立连接,每个对等端可以简单地查询其操作系统获取其 IP 地址(如果有多个网络接口,可能有多个),将提供的 IP 和端口元组附加到生成的 SDP 字符串,并转发给另一个对等端。一旦 SDP 交换完成,两个对等端都可以发起直接点对点连接。
前面的 SDP 例子说明了前面的场景:a=candidate 行列出了发起会话的对等端的私有(192.168.x.x)IP 地址;请参阅保留私有网络范围。
到目前为止,一切顺利。然而,如果一个或两个对等端位于不同的私有网络上会发生什么?我们可以重复前面的工作流,发现并嵌入每个对等端的私有 IP 地址,但点对点连接显然会失败!我们需要的是对等端之间的公共路由路径。幸运的是,WebRTC 框架为我们管理了大部分复杂性:
- 每个 RTCPeerConnection 连接对象包含一个”ICE 代理”。
- ICE 代理负责收集本地 IP、端口元组(候选)。
- ICE 代理负责执行对等端之间的连接检查。
- ICE 代理负责发送连接保活。
一旦设置会话描述(本地或远程),本地 ICE 代理自动开始发现本地对等端所有可能的候选 IP、端口元组的过程:
- ICE 代理查询操作系统获取本地 IP 地址。
- 如果配置,ICE 代理查询外部 STUN 服务器以检索对等端的公共 IP 和端口元组。
- 如果配置,ICE 代理将 TURN 服务器附加为最后候选。如果点对点连接失败,数据将通过指定的中介中继。
如果你曾经必须回答”我的公共 IP 地址是什么?“这个问题,那么你就有效地执行了手动”STUN 查找”。STUN 协议允许浏览器了解它是否在 NAT 后面并发现其公共 IP 和端口;请参阅 STUN、TURN 和 ICE。
每当发现新候选(IP、端口元组)时,代理自动将其注册到 RTCPeerConnection 对象并通过回调函数(onicecandidate)通知应用。一旦 ICE 收集完成,相同的回调被触发以通知应用。让我们扩展前面的例子以使用 ICE:
const ice = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:turnserver.com", username: "user", credential: "pass" }
]
};
const signalingChannel = new SignalingChannel();
const pc = new RTCPeerConnection(ice);
navigator.mediaDevices.getUserMedia({ audio: true })
.then(gotStream)
.catch(logError);
function gotStream(stream) {
stream.getTracks().forEach(track => pc.addTrack(track, stream));
pc.createOffer().then(offer => {
pc.setLocalDescription(offer);
});
}
pc.onicecandidate = (evt) => {
if (evt.candidate) {
signalingChannel.send(evt.candidate);
}
};
pc.onicegatheringstatechange = (evt) => {
if (pc.iceGatheringState === "complete") {
console.log("ICE gathering complete");
}
};
// Offer with ICE candidates:
// a=candidate:1862263974 1 udp 2113937151 192.168.1.73 60834 typ host ...
// a=candidate:2565840242 1 udp 1845501695 50.76.44.100 60834 typ srflx ...
前面的例子使用 Google 的公共演示 STUN 服务器。不幸的是,仅 STUN 可能不够(请参阅实践中的 STUN 和 TURN),你可能还需要提供 TURN 服务器以保证无法建立直接点对点连接的对等端的连接(约 8-10% 的用户)。
正如例子所示,ICE 代理为我们处理了大部分复杂性:ICE 收集过程自动触发,STUN 查找在后台执行,发现的候选注册到 RTCPeerConnection 对象。一旦过程完成,我们可以生成 SDP offer 并使用信令通道将其传递给另一个对等端。
然后,一旦 ICE 候选被另一个对等端收到,我们就准备好开始建立点对点连接的第二阶段:一旦远程会话描述设置在 RTCPeerConnection 对象上,现在包含另一个对等端的候选 IP 和端口元组列表,ICE 代理开始连接检查(图 18-8)以查看它是否可以到达另一方。
图 18-8. WireShark 捕获的点对点 STUN 绑定请求和响应
ICE 代理发送消息(STUN 绑定请求),另一方必须用成功的 STUN 响应确认。如果这完成了,那么我们终于有了点对点连接的路由路径!相反,如果所有候选都失败,则 RTCPeerConnection 被标记为失败,或连接回退到 TURN 中继服务器以建立连接。
ICE 代理自动排序并优先执行候选连接检查的顺序:首先检查本地 IP 地址,然后是公共地址,TURN 用作最后手段。一旦建立连接,ICE 代理继续向另一个对等端发出定期 STUN 请求。这用作连接保活。
呼!正如我们在本节开头所说,发起点对点连接需要比打开 XHR、EventSource 或新 WebSocket 会话更多的工作。好消息是,大部分工作由浏览器为我们完成。然而,出于性能原因,重要的是要记住,在我们开始传输数据之前,该过程可能在 STUN 服务器之间以及各个对等端之间产生多次往返——也就是说,假设 ICE 协商成功。
增量候选收集 (Trickle ICE)
ICE 收集过程绝不是瞬时的:检索本地 IP 地址很快,但查询 STUN 服务器需要往返外部服务器,然后是个别对等端之间的另一轮 STUN 连接检查。Trickle ICE 是 ICE 协议的扩展,允许对等端之间的增量收集和连接检查。核心思想非常简单:
- 两个对等端交换不带 ICE 候选的 SDP offer。
- ICE 候选通过信令通道在发现时发送。
- 一旦新的候选描述可用,就运行 ICE 连接检查。
简而言之,我们不等待 ICE 收集过程完成,而是依赖信令通道向另一个对等端传递增量更新,这有助于加速过程。WebRTC 实现也相当简单:
const ice = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:turnserver.com", username: "user", credential: "pass" }
]
};
const pc = new RTCPeerConnection(ice);
navigator.mediaDevices.getUserMedia({ audio: true })
.then(gotStream)
.catch(logError);
function gotStream(stream) {
stream.getTracks().forEach(track => pc.addTrack(track, stream));
pc.createOffer().then(offer => {
pc.setLocalDescription(offer);
signalingChannel.send(offer); // 发送不带 ICE 候选的 SDP offer
});
}
pc.onicecandidate = (evt) => {
if (evt.candidate) {
signalingChannel.send(evt.candidate); // 发送单个 ICE 候选
}
};
signalingChannel.onmessage = (msg) => {
if (msg.candidate) {
pc.addIceCandidate(msg.candidate); // 注册远程 ICE 候选并开始连接检查
}
};
Trickle ICE 在信令通道上产生更多流量,但它可以显著改善发起点对点连接所需的时间。因此,它也是所有 WebRTC 应用的推荐策略:尽快发送 offer,然后在发现 ICE 候选时逐步传递。
跟踪 ICE 收集与连接状态
内置的 ICE 框架管理候选发现、连接检查、保活等。如果一切正常,所有这些工作对应用完全透明:我们唯一需要做的是在初始化 RTCPeerConnection 对象时指定 STUN 和 TURN 服务器。然而,并非所有连接都会成功,能够隔离和解决问题很重要。为此,我们可以查询 ICE 代理的状态并订阅其通知:
const ice = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:turnserver.com", username: "user", credential: "pass" }
]
};
const pc = new RTCPeerConnection(ice);
console.log("ICE gathering state: " + pc.iceGatheringState);
pc.onicegatheringstatechange = (evt) => {
console.log("ICE gathering state change: " + pc.iceGatheringState);
};
console.log("ICE connection state: " + pc.iceConnectionState);
pc.oniceconnectionstatechange = (evt) => {
console.log("ICE connection state change: " + pc.iceConnectionState);
};
iceGatheringState 属性顾名思义报告本地对等端的候选收集过程状态。因此,它可以处于三种不同状态:
new
- 对象刚创建,尚未发生网络活动。
gathering
- ICE 代理正在收集本地候选的过程中。
complete
- ICE 代理已完成收集过程。
另一方面,iceConnectionState 属性报告点对点连接的状态(图 18-9),可能处于七种可能状态之一:
new
- ICE 代理正在收集候选和/或等待提供远程候选。
checking
- ICE 代理已收到至少一个组件的远程候选,正在检查候选对但尚未找到连接。除了检查,它可能仍在收集。
connected
- ICE 代理已为所有组件找到可用连接,但仍在检查其他候选对以查看是否有更好的连接。它可能仍在收集。
completed
- ICE 代理已完成收集和检查,并为所有组件找到连接。
failed
- ICE 代理已完成检查所有候选对,未能为至少一个组件找到连接。可能已为某些组件找到连接。
disconnected
- 一个或多个组件的活性检查失败。这比 failed 更激进,可能在不稳定网络上间歇性触发(并自行解决)而无需操作。
closed
- ICE 代理已关闭,不再响应 STUN 请求。
图 18-9. ICE 代理连接状态和转换
WebRTC 会话可能需要多个流来传输音频、视频和应用数据。因此,成功连接是指能够为所有请求的流建立连接的连接。此外,由于点对点连接的不可靠性,不能保证一旦建立连接就会保持:连接可能在 ICE 代理尝试找到重新建立连接的最佳路径时在连接和断开状态之间定期切换。
ICE 代理的首要目标是识别对等端之间的可行路由路径。然而,它并不止于此。即使连接后,ICE 代理也可能定期尝试其他候选,以查看是否可以通过替代路由提供更好的性能。
使用 Chrome 检查 WebRTC 连接状态
Google Chrome 提供了一个简单且非常有用的工具来调查任何 WebRTC 连接的整个工作流和状态:打开新标签页并加载 chrome://webrtc-internals。在那里,你可以检查(图 18-10)所有打开的点对点连接、检查交换的 SDP 描述等。
图 18-10. chrome://webrtc-internals
Chrome 还会报告每个流的许多统计信息,如可用带宽、延迟、编码视频和音频流的比特率等。即使你不是在开发 WebRTC 应用,也可以与朋友之间或在多个浏览器窗口之间启动 WebRTC 会话,然后访问 chrome://webrtc-internals;这是熟悉 WebRTC 内部不可或缺的工具。
完整流程
我们已经涵盖了很多内容:我们讨论了信令、offer-answer 工作流、使用 SDP 的会话参数协商,并深入探讨了建立点对点连接所需的 ICE 协议内部工作。最后,我们现在拥有通过 WebRTC 发起点对点连接的所有必要部分。
发起 WebRTC 连接
我们一直在前面的页面中逐步填充所有必要的部分,但现在让我们看看负责发起 WebRTC 连接的对等端的完整示例:
<video id="local_video" autoplay playsinline></video>
<video id="remote_video" autoplay playsinline></video>
<script>
const ice = {
iceServers: [
{ urls: "stun:stunserver.com:12345" },
{ urls: "turn:turnserver.com", username: "user", credential: "pass" }
]
};
const signalingChannel = new SignalingChannel();
const pc = new RTCPeerConnection(ice);
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then(gotStream)
.catch(logError);
function gotStream(stream) {
stream.getTracks().forEach(track => pc.addTrack(track, stream));
const localVideo = document.getElementById('local_video');
localVideo.srcObject = stream;
pc.createOffer().then(offer => {
pc.setLocalDescription(offer);
signalingChannel.send(offer);
});
}
pc.onicecandidate = (evt) => {
if (evt.candidate) {
signalingChannel.send(evt.candidate);
}
};
signalingChannel.onmessage = (msg) => {
if (msg.candidate) {
pc.addIceCandidate(msg.candidate);
} else if (msg.answer) {
pc.setRemoteDescription(msg.answer);
}
};
pc.ontrack = (evt) => {
const remoteVideo = document.getElementById('remote_video');
if (!remoteVideo.srcObject) {
remoteVideo.srcObject = evt.streams[0];
}
};
function logError() { ... }
</script>
整个过程第一次看可能有点令人生畏,但现在我们了解了所有部分的工作原理,它相当简单:初始化对等连接和信令通道、获取和注册媒体流、发送 offer、逐步传递 ICE 候选,最后输出获取的媒体流。更完整的实现还可以注册额外的回调来跟踪 ICE 收集和连接状态,并向用户提供更多反馈。
一旦建立连接,应用仍可以从 RTCPeerConnection 对象添加和删除流。每次发生这种情况时,都会调用自动 SDP 重新协商,并重复相同的初始化过程。
响应 WebRTC 连接
响应新 WebRTC 连接请求的过程非常相似,唯一的主要区别是当信令通道传递 SDP offer 时执行大部分逻辑。让我们实际看看:
<video id="local_video" autoplay playsinline></video>
<video id="remote_video" autoplay playsinline></video>
<script>
const signalingChannel = new SignalingChannel();
let pc = null;
const ice = {
iceServers: [
{ urls: "stun:stunserver.com:12345" },
{ urls: "turn:turnserver.com", username: "user", credential: "pass" }
]
};
signalingChannel.onmessage = (msg) => {
if (msg.offer) {
pc = new RTCPeerConnection(ice);
pc.setRemoteDescription(msg.offer);
pc.onicecandidate = (evt) => {
if (evt.candidate) {
signalingChannel.send(evt.candidate);
}
};
pc.ontrack = (evt) => {
const remoteVideo = document.getElementById('remote_video');
if (!remoteVideo.srcObject) {
remoteVideo.srcObject = evt.streams[0];
}
};
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then(gotStream)
.catch(logError);
} else if (msg.candidate) {
pc.addIceCandidate(msg.candidate);
}
};
function gotStream(stream) {
stream.getTracks().forEach(track => pc.addTrack(track, stream));
const localVideo = document.getElementById('local_video');
localVideo.srcObject = stream;
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer);
signalingChannel.send(answer);
});
}
function logError() { ... }
</script>
不出所料,代码看起来非常相似。唯一的主要区别,除了基于共享信令通道传递的 offer 消息启动对等连接工作流之外,是前面的代码生成 SDP answer(通过 createAnswer)而不是 offer 对象。否则,过程是对称的:初始化对等连接、获取和注册媒体流、发送 answer、逐步传递 ICE 候选,最后输出获取的媒体流。
至此,我们可以复制代码,添加信令通道的实现,我们就有了一个在浏览器中运行的实时点对点视频会议应用——不到 100 行 JavaScript 代码,不错!
传输媒体与应用数据
建立点对点连接需要相当多的工作。然而,即使客户端完成 answer-offer 工作流并且每个客户端执行其 NAT 穿越和 STUN 连接检查后,我们仍然只到达 WebRTC 协议栈的一半(图 18-3)。此时,两个对等端都有彼此的原始 UDP 连接打开,这提供了无装饰的数据报传输,但正如我们所知,这本身是不够的;请参阅 UDP 优化。
没有流量控制、拥塞控制、错误检查以及带宽和延迟估计机制,我们很容易使网络过载,这会导致两个对等端及其周围用户的性能下降。此外,UDP 以明文传输数据,而 WebRTC 要求我们必须加密所有通信!为解决此问题,WebRTC 在 UDP 之上分层了几个额外的协议来填补空白:
- Datagram Transport Layer Security (DTLS) 用于协商加密媒体数据和应用数据安全传输的密钥。
- Secure Real-Time Transport (SRTP) 用于传输音频和视频流。
- Stream Control Transport Protocol (SCTP) 用于传输应用数据。
使用 DTLS 实现安全通信
WebRTC 规范要求所有传输的数据——音频、视频和自定义应用负载——在传输过程中必须加密。传输层安全 (TLS) 协议当然是完美的选择,只是它不能用于 UDP,因为它依赖 TCP 提供的可靠有序交付。相反,WebRTC 使用 DTLS,它提供等效的安全保证。
DTLS 被故意设计为尽可能与 TLS 相似。事实上,DTLS 就是 TLS,但进行了最少的修改以使其与 UDP 提供的数据报传输兼容。具体来说,DTLS 解决了以下问题:
- TLS 需要可靠、有序且对分段友好的握手记录交付来协商隧道。
- 如果记录跨多个包分段,TLS 完整性检查可能失败。
- 如果记录无序处理,TLS 完整性检查可能失败。
有关握手序列和记录协议布局的完整讨论,请参阅 TLS 握手和 TLS 记录协议。
修复 TLS 握手序列没有简单的解决方法:每个记录都有用途,每个必须按握手算法要求的精确顺序发送,有些记录可能很容易跨越多个包。因此,DTLS 为握手序列实现了一个”迷你 TCP”(图 18-11)。
图 18-11. DTLS 握手记录携带序列和片段偏移
DTLS 通过为每个握手记录添加显式片段偏移和序列号来扩展基础 TLS 记录协议。这解决了有序交付需求,并允许大记录跨包分段并由另一个对等端重新组装。DTLS 握手记录按 TLS 协议指定的精确顺序传输;任何其他顺序都是错误。最后,DTLS 还必须处理丢包:双方都使用简单的计时器,如果在预期间隔内未收到回复,则重传握手记录。
记录序列号、偏移和重传计时器的组合允许 DTLS 通过 UDP 执行握手(图 18-12)。为完成此序列,两个网络对等端生成自签名证书,然后遵循常规 TLS 握手协议。
图 18-12. 通过 DTLS 的点对点握手
DTLS 握手需要两次往返才能完成——这是一个需要记住的重要方面,因为它增加了点对点连接建立的额外延迟。
WebRTC 客户端为每个对等端自动生成自签名证书。因此,没有证书链需要验证。DTLS 提供加密和完整性,但将身份验证推迟给应用;请参阅加密、身份验证和完整性。最后,满足握手要求后,DTLS 添加两条重要规则以应对常规记录可能的碎片化和无序处理:
- DTLS 记录必须适合单个网络包。
- 必须使用块密码来加密记录数据。
常规 TLS 记录最大可达 16 KB。TCP 处理分段和重新组装,但 UDP 不提供此类服务。因此,为保留 UDP 协议的乱序和尽力交付语义,携带应用数据的每个 DTLS 记录必须适合单个 UDP 包。同样,流密码被禁止,因为它们隐式依赖记录数据的有序交付。
使用 SRTP 和 SRTCP 传输媒体
WebRTC 提供媒体获取和交付作为完全托管的服务:从摄像头到网络,从网络到屏幕。WebRTC 应用指定获取流的媒体约束,然后将其注册到 RTCPeerConnection 对象(图 18-13)。从那里开始,其余由浏览器提供的 WebRTC 媒体和网络引擎处理:编码优化、处理丢包、网络抖动、错误恢复、流量控制等。
图 18-13. 通过 UDP 上的 SRTP 传输音频和视频
这种架构的含义是,除了指定获取媒体流的初始约束(例如,720p 与 360p 视频)之外,应用对视频如何优化或交付给另一个对等端没有任何直接控制。这种设计选择是故意的:通过具有波动带宽和包延迟的不可靠传输交付高质量实时音视频流是一个非平凡的问题。浏览器为我们解决了这个问题:
- 无论提供的媒体流的质量和大小如何,网络栈都实现自己的流量和拥塞控制算法:每个连接都以低比特率(<500 Kbps)流式传输音频和视频,然后开始调整流的质量以匹配可用带宽。
- 媒体和网络引擎在连接的整个生命周期内动态调整流的质量,以适应不断变化的网络状况:带宽波动、丢包和网络抖动。换句话说,WebRTC 实现了自己的自适应流变体(请参阅自适应比特率流)。
WebRTC 网络引擎不能保证应用提供的 HD 视频流将以最高质量交付:对等端之间可能带宽不足或丢包率高。相反,引擎将尝试调整提供的流以匹配当前网络状况。
音频或视频流可能以低于应用获取的原始流的质量交付。然而,反过来不成立:WebRTC 不会提高流的质量。如果应用提供 360p 视频约束,则这作为将使用的带宽上限。
WebRTC 如何优化和调整每个媒体流的质量?事实证明,WebRTC 不是第一个遇到在 IP 网络上实现实时音视频交付挑战的应用。因此,WebRTC 重用了 VoIP 电话、通信网关和众多商业及开源通信服务使用的现有传输协议:
Secure Real-time Transport Protocol (SRTP)
- 通过 IP 网络交付实时数据(如音频和视频)的标准化格式的安全配置文件。
Secure Real-time Control Transport Protocol (SRTCP)
- 为 SRTP 流交付发送方和接收方统计信息及控制信息的安全配置文件控制协议。
实时传输协议 (RTP) 由 RFC 3550 定义。然而,WebRTC 要求所有通信在传输过程中必须加密。因此,WebRTC 使用 RTP 的”安全配置文件”(RFC 3711)——因此 SRTP 和 SRTCP 中的 S。
SRTP 定义了通过 IP 网络交付音频和视频的标准包格式(图 18-14)。SRTP 本身不提供任何机制或保证来确保传输数据的及时性、可靠性或错误恢复。相反,它只是用额外的元数据包装数字音频样本和视频帧,以帮助接收方处理每个流。
图 18-14. SRTP 头部(12 字节 + 负载和可选字段)
- 每个 SRTP 包携带自动递增的序列号,使接收方能够检测和解释媒体数据的乱序交付。
- 每个 SRTP 包携带时间戳,代表媒体负载第一个字节的采样时间。此时间戳用于同步不同的媒体流——例如,音频和视频轨道。
- 每个 SRTP 包携带 SSRC 标识符,这是一个唯一的流 ID,用于将每个包与单个媒体流关联。
- 每个 SRTP 包可能包含其他可选元数据。
- 每个 SRTP 包携带加密的媒体负载和认证标签,验证交付包的完整性。
SRTP 包提供媒体引擎实时播放流所需的所有基本信息。然而,控制如何交付单个 SRTP 包的责任落在 SRTCP 协议上,它为每个媒体流实现独立的带外反馈通道。
SRTCP 跟踪发送和丢失的字节和包数、最后接收的序列号、每个 SRTP 包的到达间隔抖动以及其他 SRTP 统计信息。然后,双方定期交换这些数据,并用它来调整发送速率、编码质量和每个流的其他参数。
简而言之,SRTP 和 SRTCP 直接在 UDP 上运行,协同工作以适应和优化应用提供的实时音视频流交付。WebRTC 应用永远不会暴露于 SRTP 或 SRTCP 协议的内部:如果你正在构建自定义 WebRTC 客户端,那么你必须直接处理这些协议,否则浏览器代表你实现所有必要的基础设施。
好奇想查看 WebRTC 会话的 SRTCP 统计信息?在 Chrome 中检查延迟、比特率和带宽报告;请参阅使用 Chrome 检查 WebRTC 连接状态。
使用 SCTP 传输应用数据
除了传输音频和视频数据外,WebRTC 还允许通过 DataChannel API 点对点传输任意应用数据。我们在上一节介绍的 SRTP 协议专门用于媒体传输,不幸的是不适合作为应用数据的传输。因此,DataChannel 依赖流控制传输协议 (SCTP),它在已建立的 DTLS 隧道之上运行(图 18-3)。
然而,在我们深入了解 SCTP 协议本身之前,让我们首先检查 WebRTC 对 RTCDataChannel 接口及其传输协议的要求:
- 传输必须支持多个独立通道的多路复用。
- 每个通道必须支持按序或乱序交付。
- 每个通道必须支持可靠或不可靠交付。
- 每个通道可以有应用定义的优先级。
- 传输必须提供面向消息的 API。
- 每个应用消息可以由传输分段和重新组装。
- 传输必须实现流量和拥塞控制机制。
- 传输必须提供传输数据的机密性和完整性。
好消息是,使用 DTLS 已经满足了最后一个标准:所有应用数据都加密在记录的有效负载内,机密性和完整性得到保证。然而,其余要求是一组非平凡的要求!UDP 提供不可靠、乱序的数据报交付,但我们还需要类似 TCP 的可靠交付、通道多路复用、优先级支持、消息分段等。这就是 SCTP 的用武之地。
| 特性 | TCP | UDP | SCTP |
|---|---|---|---|
| 可靠性 | 可靠 | 不可靠 | 可配置 |
| 交付顺序 | 有序 | 无序 | 可配置 |
| 传输方式 | 面向字节 | 面向消息 | 面向消息 |
| 流量控制 | 是 | 否 | 是 |
| 拥塞控制 | 是 | 否 | 是 |
表 18-1. TCP vs. UDP vs. SCTP 比较
SCTP 是一种传输协议,类似于 TCP 和 UDP,可以直接在 IP 协议之上运行。然而,在 WebRTC 的情况下,SCTP 通过安全的 DTLS 隧道传输,而 DTLS 本身在 UDP 之上运行。
SCTP 提供 TCP 和 UDP 的最佳特性:面向消息的 API、可配置的可靠性和交付语义,以及内置的流量和拥塞控制机制。对该协议的完整分析超出了我们的讨论范围,但简要介绍一下,让我们介绍一些 SCTP 概念和术语:
Association
- 连接的同义词。
Stream
- 应用消息按顺序传递的单向通道,除非通道配置为使用无序交付服务。
Message
- 提交给协议的应用数据。
Chunk
- SCTP 包内通信的最小单元。
两个端点之间的单个 SCTP 关联可以携带多个独立流,每个流通过传输应用消息进行通信。反过来,每个消息可以分成一个或多个块,这些块在 SCTP 包(图 18-15)中交付,然后在另一端重新组装。
这个描述听起来熟悉吗?它绝对应该!术语不同,但核心概念与 HTTP/2 分帧层相同;请参阅流、消息和帧。这里的区别是 SCTP 在”更低层”实现此功能,这使得任意应用数据的高效传输和多路复用成为可能。
图 18-15. SCTP 头部和数据块
SCTP 包由通用头部和一个或多个控制或数据块组成。头部携带 12 字节数据,标识源和目标端口、为当前 SCTP 关联随机生成的验证标签以及整个包的校验和。头部之后,包携带一个或多个控制或数据块;前面的图显示了一个带有单个数据块的 SCTP 包:
- 所有数据块都有 0×0 数据类型。
- 无序 (U) 位指示这是否是无序 DATA 块。
- B 和 E 位用于指示跨多个块分段的消息的开始和结束:B=1, E=0 表示消息的第一个片段;B=0, E=0 表示中间片段;B=0, E=1 表示最后一个片段;B=1, E=1 表示未分段的消息。
- 长度指示 DATA 块的大小,包括头部——即块头部 16 字节,加上负载数据大小。
- 传输序列号 (TSN) 是 SCTP 内部使用的 32 位数字,用于确认包的接收和检测重复交付。
- 流标识符指示块所属的流。
- 流序列号是关联流的自动递增消息号;分段消息携带相同的序列号。
- 负载协议标识符 (PPID) 是应用填充的自定义字段,用于传达有关传输块的附加元数据。
DataChannel 使用 SCTP 头部中的 PPID 字段来传达传输数据的类型:UTF-8 为 0×51,二进制应用负载为 0×52。
一次吸收很多细节。让我们再次回顾一下,这次是在前面 WebRTC 和 DataChannel API 要求的背景下:
- SCTP 头部包含一些冗余字段:我们通过 UDP 传输 SCTP,UDP 已经指定了源和目标端口(图 3-2)。
- SCTP 借助头部中的 B、E 和 TSN 字段处理消息分段:每个块指示其位置(第一、中间或最后),TSN 值用于排序中间块。
- SCTP 支持流多路复用:每个流有唯一的流标识符,用于将每个数据块与活动流之一关联。
- SCTP 为每个应用消息分配单独的序列号,这使其能够提供按序交付语义。可选地,如果设置了无序位,则 SCTP 继续使用序列号处理消息分段,但可以乱序交付单个消息。
总共,SCTP 为每个数据块增加 28 字节开销:通用头部 12 字节,数据块头部 16 字节,后跟应用负载。
SCTP 如何协商关联的起始参数?每个 SCTP 连接需要类似 TCP 的握手序列!同样,SCTP 也实现 TCP 友好的流量和拥塞控制机制:两种协议使用相同的初始拥塞窗口大小,并实现类似的逻辑,在连接进入拥塞避免阶段后增长和减少拥塞窗口。
有关 TCP 握手延迟、慢启动和流量控制的回顾,请参阅 TCP 构建块。WebRTC 使用的 SCTP 握手和拥塞及流量控制算法不同,但目的相同,具有类似的成本和性能影响。
我们即将满足所有 WebRTC 要求,但不幸的是,即使有了所有这些功能,我们仍然缺少一些必需的功能:
- 基础 SCTP 标准(RFC 4960)提供消息乱序交付的机制,但没有配置每个消息可靠性的设施。为解决此问题,WebRTC 客户端还必须使用”部分可靠性扩展”(RFC 3758),它扩展了 SCTP 协议并允许发送方实现自定义交付保证,这是 DataChannel 的关键功能。
- SCTP 不提供任何优先处理单个流的设施;协议内没有携带优先级的字段。因此,此功能必须在协议栈更高层实现。
简而言之,SCTP 提供与 TCP 类似的服务,但由于它通过 UDP 传输并由 WebRTC 客户端实现,它提供了更强大的 API:按序和乱序交付、部分可靠性、面向消息的 API 等。同时,SCTP 也受握手延迟、慢启动以及流量和拥塞控制的影响——所有在考虑 DataChannel API 性能时需要考虑的关键组件。
DataChannel
DataChannel 实现对等端之间任意应用数据的双向交换——想想 WebSocket,但是点对点的,并且具有底层传输的可定制交付属性。一旦建立 RTCPeerConnection,连接的对等端可以打开一个或多个通道来交换文本或二进制数据:
function handleChannel(chan) {
chan.onerror = (error) => { ... };
chan.onclose = () => { ... };
chan.onopen = (evt) => {
chan.send("DataChannel connection established. Hello peer!");
};
chan.onmessage = (msg) => {
if (msg.data instanceof Blob) {
processBlob(msg.data);
} else {
processText(msg.data);
}
};
}
const signalingChannel = new SignalingChannel();
const pc = new RTCPeerConnection(iceConfig);
const dc = pc.createDataChannel("namedChannel", { reliable: false });
...
handleChannel(dc);
pc.ondatachannel = handleChannel;
DataChannel API 故意镜像 WebSocket:每个建立的通道触发相同的 onerror、onclose、onopen 和 onmessage 回调,并在通道上暴露相同的 binaryType、bufferedAmount 和 protocol 字段。
然而,因为 DataChannel 是点对点的,并且在更灵活的传输协议上运行,它还提供了 WebSocket 不可用的一些附加功能。前面的代码示例突出了一些最重要的区别:
- 与期望 WebSocket 服务器 URL 的 WebSocket 构造函数不同,DataChannel 是 RTCPeerConnection 对象上的工厂方法。
- 与 WebSocket 不同,任一对等端都可以发起新的 DataChannel 会话:当建立新的 DataChannel 会话时触发 ondatachannel 回调。
- 与在可靠有序的 TCP 传输之上运行的 WebSocket 不同,每个 DataChannel 可以配置自定义交付和可靠性语义。
DataChannel vs. WebSocket API
DataChannel API 是 WebSocket API 的超集。因此,我们之前关于 WebSocket 回调、标志、文本和二进制数据处理优化以及子协议协商的所有讨论都直接适用于 DataChannel API;请参阅 WebSocket API。
| 特性 | WebSocket | DataChannel |
|---|---|---|
| 加密 | 可配置 | 始终 |
| 可靠性 | 可靠 | 可配置 |
| 交付顺序 | 有序 | 可配置 |
| 多路复用 | 否(扩展) | 是 |
| 传输方式 | 面向消息 | 面向消息 |
| 二进制传输 | 是 | 是 |
| UTF-8 传输 | 是 | 是 |
| 压缩 | 否(扩展) | 否 |
表 18-2. WebSocket vs. DataChannel
WebSocket 和 DataChannel 之间最大的区别当然是底层传输。WebSocket 在 TCP 之上运行,提供每条消息的可靠有序交付,而 DataChannel 分层在三个协议之上:
- UDP 提供点对点连接。
- DTLS 提供传输数据的加密。
- SCTP 提供多路复用、流量和拥塞控制等功能。
DataChannel 可以配置为提供与 WebSocket 相同的可靠性和有序消息保证。然而,更重要的是,DataChannel 的真正力量恰恰在于它不必遵循有序和可靠交付语义!每个通道可以指定自己的交付和可靠性要求,数据可以直接点对点传输。
设置与协商
无论传输数据的类型如何——音频、视频或应用数据——两个参与的对等端必须首先完成完整的 offer/answer 工作流,协商使用的协议和端口,并成功完成其连接检查;请参阅建立点对点连接。
事实上,正如我们现在所知,媒体传输在 SRTP 上运行,而 DataChannel 使用 SCTP 协议。因此,当发起对等端首次发出连接 offer,或当另一个对等端生成 answer 时,两者必须在生成的 SDP 字符串中专门通告 SCTP 关联的参数:
(... snip ...)
m=application 1 DTLS/SCTP 5000
c=IN IP4 0.0.0.0
a=mid:data
a=fmtp:5000 protocol=webrtc-datachannel; streams=10
(... snip ...)
与之前一样,只要其中一个对等端在生成会话的 SDP 描述之前注册了 DataChannel,RTCPeerConnection 对象就会处理所有必要的 SDP 参数生成。事实上,应用可以通过设置显式约束来禁用音频和视频传输,从而建立纯数据点对点连接:
const signalingChannel = new SignalingChannel();
const pc = new RTCPeerConnection(iceConfig);
const dc = pc.createDataChannel("namedChannel", { reliable: false });
const mediaConstraints = {
offerToReceiveAudio: false,
offerToReceiveVideo: false
};
pc.createOffer(mediaConstraints)
.then(offer => { ... });
...
SCTP 参数在对等端之间协商后,我们几乎准备好开始交换应用数据。请注意,我们之前看到的 SDP 片段没有提到任何关于每个 DataChannel 的参数——例如,协议、可靠性或有序或无序标志。因此,在发送任何应用数据之前,发起连接的 WebRTC 客户端还发送 DATA_CHANNEL_OPEN 消息(图 18-16),该消息描述通道的类型、可靠性、使用的应用协议和其他参数。
图 18-16. DATA_CHANNEL_OPEN 消息发起新通道
DATA_CHANNEL_OPEN 消息类似于 HTTP/2 中的 HEADERS 帧:它隐式打开新流,数据帧可以立即在其后发送;请参阅发起新流。有关 DataChannel 协议的更多信息,请参阅 https://datatracker.ietf.org/doc/html/draft-ietf-rtcweb-data-protocol 。
一旦通道参数通信完成,两个对等端就可以开始交换应用数据。在底层,每个建立的通道作为独立的 SCTP 流交付:通道在同一 SCTP 关联上多路复用,这避免了不同流之间的队头阻塞,并允许在同一 SCTP 关联上同时交付多个通道。
带外通道协商
DataChannel 还允许通道参数的带外协商。调用 createDataChannel 方法时,应用可以将 negotiated 参数设置为 true,这会跳过 DATA_CHANNEL_OPEN 消息的自动发送。然而,这样做时,两个对等端还必须指定相同的 id 参数,否则由浏览器自动生成:
signalingChannel.send({
newchannel: true,
label: "negotiated channel",
options: {
negotiated: true,
id: 10,
reliable: true,
ordered: true,
protocol: "appProtocol-v3"
}
});
signalingChannel.onmessage = (msg) => {
if (msg.newchannel) {
dc = pc.createDataChannel(msg.label, msg.options);
}
};
在实践中,对于少数参与的对等端,使用带外协商没有额外的性能优势。让 RTCPeerConnection 对象为你处理协商。然而,这种工作流有用的地方是在有许多参与对等端的情况下,信令服务器可以生成相同的描述并同时分发给所有参与方。
配置消息顺序与可靠性
DataChannel 通过 WebSocket 兼容 API 实现对等端之间任意应用数据的点对点传输:这本身就是一个独特而强大的功能。然而,DataChannel 还提供更灵活的传输,允许我们自定义每个通道的交付语义以匹配应用要求和传输的数据类型。
- DataChannel 可以提供消息的有序或乱序交付。
- DataChannel 可以提供消息的可靠或部分可靠交付。
将通道配置为使用有序和可靠交付当然等同于 TCP:与常规 WebSocket 连接相同的交付保证。然而,这就是开始变得真正有趣的地方,DataChannel 还提供两种不同的策略来配置每个通道的部分可靠性:
基于重传的部分可靠交付
- 消息重传次数不会超过应用指定的次数。
基于超时的部分可靠交付
- 消息在应用指定的生命周期(毫秒)后不再重传。
两种策略都由 WebRTC 客户端实现,这意味着应用所要做的就是决定适当的交付模型并在通道上设置正确的参数。无需管理应用计时器或重传计数器。让我们仔细看看我们的配置选项:
| 配置 | 有序 | 可靠 | 部分可靠性策略 |
|---|---|---|---|
| 有序 + 可靠 | 是 | 是 | n/a |
| 无序 + 可靠 | 否 | 是 | n/a |
| 有序 + 部分可靠(重传) | 是 | 部分 | 重传次数 |
| 无序 + 部分可靠(重传) | 否 | 部分 | 重传次数 |
| 有序 + 部分可靠(超时) | 是 | 部分 | 超时(毫秒) |
| 无序 + 部分可靠(超时) | 否 | 部分 | 超时(毫秒) |
表 18-3. DataChannel 可靠性和交付配置
有序和可靠交付是不言自明的:就是 TCP。另一方面,无序和可靠交付已经更有趣了——它是 TCP,但没有队头阻塞问题;请参阅队头阻塞。
配置部分可靠通道时,重要的是要记住两种重传策略是互斥的。应用可以指定超时或重传次数,但不能同时指定两者;这样做会引发错误。让我们看看配置通道的 JavaScript API:
conf = {}; // 默认为有序可靠交付(TCP)
conf = { ordered: false }; // 可靠,无序交付
conf = { ordered: true, maxRetransmits: customNum }; // 有序,部分可靠,自定义重传次数
conf = { ordered: false, maxRetransmits: customNum }; // 无序,部分可靠,自定义重传次数
conf = { ordered: true, maxRetransmitTime: customMs }; // 有序,部分可靠,自定义重传超时
conf = { ordered: false, maxRetransmitTime: customMs };// 无序,部分可靠,自定义重传超时
conf = { ordered: false, maxRetransmits: 0 }; // 无序,不可靠交付(UDP)
const signalingChannel = new SignalingChannel();
const pc = new RTCPeerConnection(iceConfig);
...
const dc = pc.createDataChannel("namedChannel", conf);
if (dc.reliable) {
...
} else {
...
}
一旦初始化 DataChannel,应用可以将 maxRetransmits 和 maxRetransmitTime 作为只读属性访问。此外,为方便起见,DataChannel 提供 reliable 属性,如果使用任一部分可靠性策略,则返回 false。
每个 DataChannel 可以配置自定义顺序和可靠性参数,对等端可以打开多个通道,所有通道都在同一 SCTP 关联上多路复用。因此,每个通道独立于其他通道,对等端可以为不同类型的数据使用不同的通道——例如,点对点聊天的可靠有序交付和瞬态或低优先级应用更新的部分可靠和乱序交付。
部分可靠交付与消息大小
使用部分可靠通道需要应用进行额外的设计考虑。具体来说,应用必须密切关注消息大小:没有什么阻止应用传递将在多个包中分段的大消息,但这样做可能产生非常糟糕的结果。为了说明这一点,让我们假设以下场景:
- 两个对等端已协商乱序、不可靠的 DataChannel。
- 通道配置为 maxRetransmits 设置为 0, aka 纯 UDP。
- 对等端之间的丢包率约为 1%。
- 其中一个对等端试图发送一个大的 120 KB 消息。
WebRTC 客户端将 SCTP 包的最大传输单元设置为 1,280 字节,这是 IPv6 包的最小和推荐 MTU。但我们还必须考虑 IP、UDP、DTLS 和 SCTP 协议的开销:分别为 20–40 字节、8 字节、20–40 字节和 28 字节。让我们四舍五入到约 130 字节开销,这为我们每个包留下约 1,150 字节的有效负载数据,总共需要 107 个包来交付 120 KB 应用消息。
到目前为止一切顺利,但每个单独包的丢包概率为 1%。因此,如果我们通过不可靠通道发送所有 107 个包,我们现在面临很高的概率在途中丢失至少一个包!在这种情况下会发生什么?即使除了一个包之外的所有包都送达,整个消息也会被丢弃。
为解决此问题,应用有两种策略:它可以添加重传策略(基于次数或超时),并且可以减小传输消息的大小。事实上,为获得最佳效果,它应该两者都做。
- 使用不可靠通道时,理想情况下每条消息应适合单个包;消息应小于 1,150 字节。
- 如果消息无法适合单个包,则应使用重传策略来提高交付消息的几率。
对等端之间的丢包率和延迟是不可预测的,并根据当前网络状况而变化。因此,对于重传次数或超时值没有单一的最佳设置。为在不可靠通道上提供最佳结果,请保持消息尽可能小。
WebRTC 用例与性能
实现低延迟点对点传输是一项非平凡的工程挑战:有 NAT 穿越和连接检查、信令、安全性、拥塞控制以及无数其他细节需要处理。WebRTC 为我们处理上述所有内容,这就是它可以说是自 Web 平台诞生以来最重要的新增功能之一的原因。事实上,它不仅是 WebRTC 提供的各个部分,而且所有组件协同工作以提供构建浏览器中点对点应用的简单统一 API。
然而,即使有了所有内置服务,设计高效和高性能的点对点应用仍然需要大量的仔细思考和规划:点对点本身并不意味着高性能。如果有什么不同的话,对等端之间带宽和延迟的增加可变性、媒体传输的高需求以及不可靠交付的特性使其成为更难的工程挑战。
音频、视频与数据流
点对点音频和视频流是 WebRTC 的核心用例之一:getUserMedia API 使应用能够获取媒体流,内置的音频和视频引擎处理优化、错误恢复和流之间的同步。然而,重要的是要记住,即使经过积极的优化和压缩,音频和视频交付仍可能受延迟和带宽限制:
- HD 质量流需要 1–2 Mbps 带宽;请参阅音频 (OPUS) 和视频 (VP8/VP9/AV1) 比特率。
- 截至 2024 年,全球平均带宽约为 100+ Mbps,但移动网络仍可能受限。
- HD 流至少需要稳定的 4G/5G 连接。
好消息是全球平均带宽容量持续增长:用户正在转向宽带,4G/5G 采用正在加速。然而,即使乐观的增长预测,虽然 HD 流现在变得可行,但这不是保证!同样,延迟是一个长期存在的问题,特别是对于实时交付,对于移动客户端来说更是如此。5G 肯定有帮助,但 4G 网络也不会很快消失。
为使问题进一步复杂化,大多数 ISP 和移动运营商提供的连接不是对称的:大多数用户的下行吞吐量明显高于上行吞吐量。事实上,10:1 的关系并不少见——例如,100 Mbps 下行,10 Mbps 上行。
最终结果是你不应该惊讶地看到单个点对点音视频流占用用户带宽的很大一部分,特别是对于移动客户端。考虑提供多方流?你可能需要对可用带宽进行一些仔细规划:
- 移动客户端可能能够下载 HD 质量流(1 Mbps+),但由于较低的上行吞吐量可能需要发送较低质量的流;不同方可以以不同比特率流式传输。
- 音频和视频流可能需要与其他应用和数据传输共享带宽——例如,一个或多个 DataChannel 会话。
- 无论连接类型——有线或无线——或网络代际如何,带宽和延迟总是在变化,应用必须能够适应这些条件。
好消息是 WebRTC 音频和视频引擎与底层网络传输协同工作,探测可用带宽并优化媒体流的交付。然而,DataChannel 传输需要额外的应用逻辑:应用必须监控缓冲数据量并随时准备根据需要调整。
获取音频和视频流时,确保设置视频约束以匹配用例;请参阅使用 getUserMedia 获取音频和视频。
多方架构
单个点对点连接与双向 HD 媒体流可以轻松占用用户带宽的很大一部分。因此,多方应用应仔细考虑各个流如何在对等端之间聚合和分发的架构(图 18-17)。
图 18-17. N 方通话的分发架构
一对一连接易于管理和部署:对等端直接相互通信,无需进一步优化。然而,将相同策略扩展到 N 方通话,其中每个对等端负责连接到其他每个方(网状网络),将导致每个对等端的连接数,以及总连接数!如果带宽是宝贵的,由于上行速度通常低得多,那么这种架构只需几个参与者就会迅速使大多数用户的链路饱和。
虽然网状网络易于设置,但对于多方系统通常效率低下。为解决此问题,替代策略是使用”星型”拓扑,其中各个对等端连接到”超级节点”,然后负责将流分发给所有连接方。这样只有一个对等端必须支付处理和分发流的成本,其他所有人都直接与超级节点通信。
超级节点可以是另一个对等端,也可以是专门为处理和分发实时数据而优化的专用服务;哪种策略更合适取决于上下文和应用。在最简单的情况下,发起者可以充当超级节点——简单,而且可能有效。更好的策略可能是选择具有最佳可用吞吐量的对等端,但这也需要额外的”选举”和信令机制。
选择超级节点的标准和过程留给应用,这本身就是一个巨大的工程挑战。WebRTC 不提供任何基础设施来协助此过程。
最后,超级节点可以是专用的,甚至是第三方服务。WebRTC 实现点对点通信,但这并不意味着没有集中式基础设施的空间!各个对等端可以与代理服务器建立对等连接,仍然可以获得 WebRTC 传输基础设施和服务器提供的附加服务的好处。
点对点优化即服务
许多现有视频会议解决方案(例如 Google Meet)依赖”代理服务器”来聚合各个媒体流、合成它们,然后将优化版本分发给所有连接方。交付单个流减少了每个对等端所需的带宽以及 CPU 和 GPU 资源量;每个客户端只看到单个流而不是 N 个!
同样,游戏服务器可以聚合所有玩家的更新,并过滤和分发仅必要的更新;例如,它不会发送视野外或以其他方式不影响其他玩家的玩家更新。
双方流式传输简单高效,而多方架构需要更多思考和规划。尽管 WebRTC 是关于实现直接点对点通信的,它也是各种服务的催化剂,无论是商业还是开源,都将帮助使其更高效和功能丰富。
基础设施与容量规划
除了规划和预测单个对等连接的带宽需求外,每个 WebRTC 应用都需要一些集中式基础设施用于信令、NAT 和防火墙穿越、身份验证以及应用提供的其他附加服务。
WebRTC 将所有信令推迟给应用,这意味着应用必须至少具备向另一个对等端发送和接收消息的能力。发送的信令数据量将因用户数量、协议、数据编码和更新频率而异。同样,信令服务的延迟将对”呼叫设置”时间和其他信令交换产生重大影响。
- 使用低延迟传输,如 WebSocket 或 SSE 配合 XHR。
- 估算并配置足够的容量来处理应用所有用户的必要信令速率。
- 可选地,一旦建立对等连接,对等端可以切换到 DataChannel 进行信令。这有助于减少中央服务器必须处理的信令流量,并降低信令通信的延迟。
由于 NAT 和防火墙的普遍存在,大多数 WebRTC 应用需要 STUN 服务器来执行建立点对点连接所需的 IP 查找。好消息是 STUN 服务器仅用于连接设置,但尽管如此,它必须说 STUN 协议并配置为处理必要的查询负载。
- 除非 WebRTC 应用专门设计为在同一内部网络中使用,否则在启动 RTCPeerConnection 对象时始终提供 STUN 服务器;否则大多数连接将直接失败。
- 与可以使用任何协议的信令服务器不同,STUN 服务器必须响应 STUN 请求。你需要公共服务器或必须配置自己的;coturn 是一个流行的开源实现。
即使有了 STUN,约 8-10% 的点对点连接可能因其网络策略的特性而失败。例如,网络管理员可以为网络上的所有用户完全阻止 UDP;请参阅实践中的 STUN 和 TURN。因此,为提供可靠体验,应用可能还需要 TURN 服务器来在对等端之间中继数据。
- 中继点对点连接是次优的:有额外的网络跳,每个流以 1+ Mbps 流式传输,很容易使任何服务的容量饱和。因此,TURN 始终用作最后手段,需要应用进行仔细的容量规划。
多方服务可能需要集中式基础设施来帮助优化许多流的交付并提供 RTC 体验的其他服务。在某些方面,多方网关与 TURN 扮演相同的角色,但原因不同。话虽如此,与充当简单包代理的 TURN 服务器不同,“智能代理”可能需要更多的 CPU 和 GPU 资源来处理每个单独的流,然后再将最终输出转发给每个连接方。
数据效率与压缩
WebRTC 音频和视频引擎将动态调整媒体流的比特率以匹配对等端之间的网络链路条件。应用可以设置和更新媒体约束(例如,视频分辨率、帧率等),引擎完成其余工作——这部分很简单。
不幸的是,DataChannel 不能这样说,它旨在传输任意应用数据。与 WebSocket 类似,DataChannel API 将接受二进制和 UTF-8 编码的应用数据,但它不应用任何进一步处理来减小传输数据的大小:优化二进制负载和压缩 UTF-8 内容是 WebRTC 应用的责任。
此外,与在可靠有序传输之上运行的 WebSocket 不同,WebRTC 应用必须考虑 UDP、DTLS 和 SCTP 协议产生的额外开销,以及通过部分可靠传输交付数据的特性;请参阅部分可靠交付与消息大小。
WebSocket 提供提供传输数据自动压缩的协议扩展。唉,WebRTC 没有等效功能;所有消息都按应用提供的原样传输。
性能检查清单
点对点架构为应用带来了其独特的性能挑战集。直接的一对一通信相对简单,但当涉及两方以上时,至少在性能方面,事情变得复杂得多。需要列入议程的简要标准列表:
信令服务
- 使用低延迟传输。
- 配置足够的容量。
- 考虑在连接建立后使用 DataChannel 进行信令。
防火墙和 NAT 穿越
- 在启动 RTCPeerConnection 时提供 STUN 服务器。
- 尽可能使用 Trickle ICE——更多信令,但更快设置。
- 为失败的点对点连接提供 TURN 服务器。
- 预测并为 TURN 中继配置足够的容量。
数据分发
- 考虑对大型多方通信使用超级节点或专用中介。
- 考虑在将接收的数据转发给其他对等端之前在中介上进行优化。
数据效率
- 为语音和视频流指定适当的媒体约束。
- 优化通过 DataChannel 发送的二进制负载。
- 考虑压缩通过 DataChannel 发送的 UTF-8 内容。
- 监控 DataChannel 上的缓冲数据量,并适应网络链路条件的变化。
交付与可靠性
- 使用乱序交付以避免队头阻塞。
- 如果使用按序交付,最小化消息大小以减少队头阻塞的影响。
- 发送小消息(< 1,150 字节)以最小化丢包对分段应用消息的影响。
- 为部分可靠交付设置适当的重传次数和超时。“正确”的设置取决于消息大小、应用数据类型和对等端之间的延迟。