实时通信 — WebSocket、gRPC 流式调用与 WebRTC
从信令到数据通道,连通两个 WebRTC 对端
目标
构建 WebSocket 信令服务器和两个 aiortc 对等端,交换提议与应答,通过选定了可靠性和顺序的数据通道收发消息,然后测量 ICE 候选从哪里产生,以及 STUN 被封后什么会变慢。
为什么重要
WebRTC 是在浏览器与手机之间以最短延迟传送语音的标准。但是规范没有规定如何建立连接,所以信令、ICE 服务器配置和候选收集由服务自己负责。“只有在公司网络里才连不上”“连接要花 5 秒”这类故障,大多出在这一部分。同一个 Pod 内没有 NAT,但正因如此,才能不受干扰地看清候选和 STUN 的作用。
步骤
- 构建信令服务器——在 /root/rt/webrtc/signaling.py 中创建 run(host, port) 协程。用 websockets 服务器接收 /room/<名称> 路径,在连到同一个房间的两个连接之间,原样传递文本消息。对方还没来时收到的消息要先攒起来,等对方连上的瞬间按顺序交过去。第三个连上的连接用关闭码 4001、原因 "room full" 关闭,当一方离开时,向留下的一方发送 {"type": "bye"}。
- 生成提议 SDP——在 /root/rt/webrtc/peer.py 中创建 make_offer() 协程。用清空了 ICE 服务器列表的 RTCConfiguration(iceServers=[]) 创建 RTCPeerConnection,创建 "chat" 数据通道之后,经过 createOffer 和 setLocalDescription,返回 localDescription.sdp 字符串。返回之前要关闭连接。
- 读取 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。
- 应答对等端——在 /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"} 时,关闭连接并结束。
- 提议对等端——在 /root/rt/webrtc/peer.py 中添加 offer_peer(signal_url, room, count) 协程。创建 "chat" 数据通道并发送提议,收到 answer 后执行 setRemoteDescription,通道打开后,从 "m0" 开始逐个发送 count 条,每收到一次 echo 就测量往返时间。返回 {"sent": count, "echoed": 收到的个数, "rtt_ms": 往返毫秒的 list}。
- 丢弃迟到数据的通道——把 offer_peer 的数据通道设置为 ordered=False、maxRetransmits=0。评分器的基准应答方会记录所收到通道的属性来确认。
- 查看候选来自哪里——在 /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。请先用 mkdir -p /root/rt/webrtc 创建。
- 信令服务器用 cd /root/rt/webrtc && /opt/rt-lab/bin/python -c "import asyncio, signaling; asyncio.run(signaling.run('127.0.0.1', 8765))" 来启动。不要把文件名取成 signal.py——它会遮住标准库的 signal,让 asyncio 首先就坏掉。
- 基准对等端是 /opt/fixtures/rt/webrtc/refpeer.py,Pod 内的 STUN 服务器是 /opt/fixtures/rt/webrtc/stunserver.py。
- 有两个常见错误:在通道打开之前就 send;以及没有指定 ICE 服务器,因等待被封的公共 STUN 而使连接准备推迟几秒。
- Python 必须用 /opt/rt-lab/bin/python 运行。本实验的库只装在那个虚拟环境里,如果直接用 python3 运行,就会出现 ModuleNotFoundError。像 alias rpy=/opt/rt-lab/bin/python 这样简写一下会比较方便。
- 实验 Pod 的对外连接被封锁。所有通信都发生在同一个 Pod 内的 127.0.0.1 上,不需要安装或下载。
- 评分器会以单独的进程加载你的代码,并实际建立连接。示例文件只是函数框架,原样保留是通不过的。前面步骤中已经完成的函数不要删除。
- 实验会话结束后,/root 中的文件不会保留。需要的代码请在结束之前另行保存。
构建信令服务器
在 /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 的地址会与什么相同。