TT Lab
开始
学习 学习路径 课程

实时通信 — WebSocket、gRPC 流式调用与 WebRTC

从信令到数据通道,连通两个 WebRTC 对端

在 TT Lab 中继续学习

目标

构建 WebSocket 信令服务器和两个 aiortc 对等端,交换提议与应答,通过选定了可靠性和顺序的数据通道收发消息,然后测量 ICE 候选从哪里产生,以及 STUN 被封后什么会变慢。

为什么重要

WebRTC 是在浏览器与手机之间以最短延迟传送语音的标准。但是规范没有规定如何建立连接,所以信令、ICE 服务器配置和候选收集由服务自己负责。“只有在公司网络里才连不上”“连接要花 5 秒”这类故障,大多出在这一部分。同一个 Pod 内没有 NAT,但正因如此,才能不受干扰地看清候选和 STUN 的作用。

步骤

  1. 构建信令服务器——在 /root/rt/webrtc/signaling.py 中创建 run(host, port) 协程。用 websockets 服务器接收 /room/<名称> 路径,在连到同一个房间的两个连接之间,原样传递文本消息。对方还没来时收到的消息要先攒起来,等对方连上的瞬间按顺序交过去。第三个连上的连接用关闭码 4001、原因 "room full" 关闭,当一方离开时,向留下的一方发送 {"type": "bye"}。
  2. 生成提议 SDP——在 /root/rt/webrtc/peer.py 中创建 make_offer() 协程。用清空了 ICE 服务器列表的 RTCConfiguration(iceServers=[]) 创建 RTCPeerConnection,创建 "chat" 数据通道之后,经过 createOffer 和 setLocalDescription,返回 localDescription.sdp 字符串。返回之前要关闭连接。
  3. 读取 SDP——在 /root/rt/webrtc/sdp.py 中创建 summarize_sdp(sdp)。读取 SDP 字符串并返回 dict。键共有五个:ufrag(第一个 a=ice-ufrag 的值)、fingerprint(第一个 a=fingerprint 的哈希名称,小写,例如 sha-256)、setup(第一个 a=setup 的值)、media(按出现顺序记录 m= 行的媒体类型的 list)、candidates({"host": n, "srflx": n, "relay": n}——a=candidate 行按 typ 统计的个数,不存在的类型也记为 0)。行尾可能是 \r\n,也可能是 \n。
  4. 应答对等端——在 /root/rt/webrtc/peer.py 中添加 answer_peer(signal_url, room, out_path) 协程。连接到 signal_url/room/,等待 {"type": "offer", "sdp": ...},收到后依次执行 setRemoteDescription → createAnswer → setLocalDescription,然后发送 {"type": "answer", "sdp": ...}。对于对方打开的数据通道中的消息,在前面加上 "echo:" 后返回,并把通道的 label、ordered、maxRetransmits 以 JSON 形式写入 out_path。收到 {"type": "bye"} 时,关闭连接并结束。
  5. 提议对等端——在 /root/rt/webrtc/peer.py 中添加 offer_peer(signal_url, room, count) 协程。创建 "chat" 数据通道并发送提议,收到 answer 后执行 setRemoteDescription,通道打开后,从 "m0" 开始逐个发送 count 条,每收到一次 echo 就测量往返时间。返回 {"sent": count, "echoed": 收到的个数, "rtt_ms": 往返毫秒的 list}。
  6. 丢弃迟到数据的通道——把 offer_peer 的数据通道设置为 ordered=False、maxRetransmits=0。评分器的基准应答方会记录所收到通道的属性来确认。
  7. 查看候选来自哪里——在 /root/rt/webrtc/peer.py 中添加 gather(stun=None) 协程。如果给出了 stun,就用一个 RTCIceServer(stun) 作为 ICE 服务器,否则使用空列表,创建一个数据通道,并测量 createOffer、setLocalDescription 所花的毫秒数。返回 {"elapsed_ms": 毫秒, "candidates": [(typ, 地址, 端口), ...]}。评分器会分别在没有 STUN、使用同一个 Pod 内的 STUN 服务器、使用无法到达的 STUN 地址的情况下,调用三次。

参考

构建信令服务器

在 /root/rt/webrtc/signaling.py 中创建 run(host, port) 协程。用 websockets 服务器接收 /room/<名称> 路径,在连到同一个房间的两个连接之间,原样传递文本消息。对方还没来时收到的消息要先攒起来,等对方连上的瞬间按顺序交过去。第三个连上的连接用关闭码 4001、原因 "room full" 关闭,当一方离开时,向留下的一方发送 {"type": "bye"}。

WebRTC 规范没有规定如何传递 SDP(JSEP,即 RFC 9429,也把这一部分交给应用程序)。所以几乎所有服务都会另设这样一个小型中继服务器。4000–4999 是 RFC 6455 留给应用程序的关闭码。

生成提议 SDP

在 /root/rt/webrtc/peer.py 中创建 make_offer() 协程。用清空了 ICE 服务器列表的 RTCConfiguration(iceServers=[]) 创建 RTCPeerConnection,创建 "chat" 数据通道之后,经过 createOffer 和 setLocalDescription,返回 localDescription.sdp 字符串。返回之前要关闭连接。

要让 SDP 中出现 m= 行,在提议之前必须先确定要发送什么(数据通道或媒体轨道)。如果不指定 ICE 服务器,aiortc 会使用公共 STUN 服务器,而在对外 UDP 被封的地方,候选收集会因等待其响应而停顿几秒。

读取 SDP

在 /root/rt/webrtc/sdp.py 中创建 summarize_sdp(sdp)。读取 SDP 字符串并返回 dict。键共有五个:ufrag(第一个 a=ice-ufrag 的值)、fingerprint(第一个 a=fingerprint 的哈希名称,小写,例如 sha-256)、setup(第一个 a=setup 的值)、media(按出现顺序记录 m= 行的媒体类型的 list)、candidates({"host": n, "srflx": n, "relay": n}——a=candidate 行按 typ 统计的个数,不存在的类型也记为 0)。行尾可能是 \r\n,也可能是 \n。

host 是自己接口的地址,srflx(server reflexive)是 STUN 服务器看到的外部地址,relay 是 TURN 服务器借出的中继地址。fingerprint 是 DTLS 证书的哈希,只有通过信令传来的这个值与握手时收到的证书一致,才能建立连接。

应答对等端

在 /root/rt/webrtc/peer.py 中添加 answer_peer(signal_url, room, out_path) 协程。连接到 signal_url/room/,等待 {"type": "offer", "sdp": ...},收到后依次执行 setRemoteDescription → createAnswer → setLocalDescription,然后发送 {"type": "answer", "sdp": ...}。对于对方打开的数据通道中的消息,在前面加上 "echo:" 后返回,并把通道的 label、ordered、maxRetransmits 以 JSON 形式写入 out_path。收到 {"type": "bye"} 时,关闭连接并结束。

数据通道由提议方创建,应答方通过 datachannel 事件接收。应答对等端的 ICE 服务器列表也要保持为空。评分器的基准提议方会经由你的信令服务器连上来。

提议对等端

在 /root/rt/webrtc/peer.py 中添加 offer_peer(signal_url, room, count) 协程。创建 "chat" 数据通道并发送提议,收到 answer 后执行 setRemoteDescription,通道打开后,从 "m0" 开始逐个发送 count 条,每收到一次 echo 就测量往返时间。返回 {"sent": count, "echoed": 收到的个数, "rtt_ms": 往返毫秒的 list}。

如果在通道的 open 事件之前发送,消息就会消失。ICE 检查 → DTLS 握手 → SCTP 连接全部完成,才算 open。到这里所花的时间,就是 WebRTC 的连接准备成本。

丢弃迟到数据的通道

把 offer_peer 的数据通道设置为 ordered=False、maxRetransmits=0。评分器的基准应答方会记录所收到通道的属性来确认。

对于语音帧或光标位置这类过了 20ms 就没用的数据,与其为了重发一个丢失的片段而拖住后面的内容,不如丢弃。SCTP 可以为每个通道分别确定顺序和重传,这是 TCP 之上的 WebSocket 无法模拟的。

查看候选来自哪里

在 /root/rt/webrtc/peer.py 中添加 gather(stun=None) 协程。如果给出了 stun,就用一个 RTCIceServer(stun) 作为 ICE 服务器,否则使用空列表,创建一个数据通道,并测量 createOffer、setLocalDescription 所花的毫秒数。返回 {"elapsed_ms": 毫秒, "candidates": [(typ, 地址, 端口), ...]}。评分器会分别在没有 STUN、使用同一个 Pod 内的 STUN 服务器、使用无法到达的 STUN 地址的情况下,调用三次。

按空格拆分 a=candidate 行,第五个是地址,第六个是端口,typ 之后是类型。同一个 Pod 内没有 NAT,所以请先预测一下 srflx 的地址会与什么相同。