一、STUN和TURN
STUN(Session Traversal Utilities for NAT)
- 做什么:客户端去问 STUN 服务器:"从外面看,我的公网 IP 和端口是什么?"服务器把 反射地址(reflexive candidate) 告诉你。
- 结果:双方各自知道自己在公网上的"洞",很多情况下可以直接 P2P 收发媒体/数据,不经过第三方转发。
- 特点:流量仍是 端到端,STUN 只参与 发现地址 和少量保活类报文;带宽成本主要在用户之间,STUN 服务器负载相对小。
TURN(Traversal Using Relays around NAT)
- 做什么:当打洞失败(对称 NAT、严格防火墙、双方无法直连等)时,客户端和 TURN 服务器建立中继,媒体/数据经过 TURN 转发。
- 结果:连通性最好,但不再算纯 P2P,服务器要扛双向流量。
- 特点:要账号/密钥、占服务器带宽与机器资源,运维和成本都比 STUN 高;一般作为 最后手段和STUN一起配。
- STUN:尽量让你知道公网地址,争取直连。
- TURN:直连不行时,走中继保证能通。
实际产品里常见写法是:同一个 URL或不同条目里同时配公共STUN(如 stun:...)和 自建/租用的 TURN(turn:...,带用户名密码),由 ICE 自动选最优路径。
STUN 和 TURN 是两种用于解决 NAT(网络地址转换)穿透问题的关键协议,简单来说:STUN负责"指路",帮设备找到自己在公网上的"门牌号";TURN负责"修路",当直连不通时,充当一个"信使"来中转所有数据。
在实际应用中,STUN和TURN并非互斥,而是通过一个叫做 ICE(交互式连接建立) 的机制智能地协同工作。它的策略可以理解为"先试最优的,不行再降级":
-
尝试最优路径(P2P直连) :ICE会先收集所有可能的连接方式,其中最理想的是主机自己的内网地址,其次是STUN服务器发现的公网地址。然后,双方会尝试通过这些地址直接建立连接。
-
触发保底方案(TURN中继) :如果上述所有直接连接的尝试都失败了(例如,任何一方处于严格的对称型NAT之后),ICE就会自动切换到保底方案------使用TURN服务器进行中继。
之所以这样设计,是为了在"连接质量"和"连接成功率"之间取得最佳平衡。
-
STUN成功时 :你的视频通话会走点对点直连,享受到最低的延迟和最高的传输效率。
-
TURN介入时 :虽然成本更高、延迟也略有增加,但它确保了连接能够100%建立成功,避免了"无法通话"的尴尬。
二、对端和本端通信流程
- 信令通道先建好
设备 ws->open("ws://.../server")。
onOpen:信令 WebSocket 已连上,后面 JSON 都走这条连接
- 对端发 request("我要和你建会话")
对端 → 设备(JSON):
{ "type": "request", "id": "<peer_id>" }
//转化为XML格式
<?xml version="1.0" encoding="UTF-8"?>
<signaling>
<id>MBF672lwa7</id>
<type>request</type>
</signaling>
因为没有 SDP,sdp_chars=0
设备侧做了什么:
为该 id 建 PeerConnection,按需加 H264 视频轨、本端 DataChannel,onDataChannel;注册 onGatheringStateChange / onLocalCandidate / onStateChange;然后 setLocalDescription(),开始收集 ICE 并生成 本地 SDP(offer)。
- 设备发 candidate(本端 ICE)
设备 → 对端(可能多条):
{ "type": "candidate", "id": "<peer_id>", "candidate": "candidate:...", "mid": "..." }
//转化为XML格式
<?xml version="1.0" encoding="UTF-8"?>
<signaling>
<id>MBF672lwa7</id>
<type>candidate</type>
<candidate><![CDATA[candidate:1 1 UDP 2114977791 192.168.6.161 46924 typ host]]></candidate>
<mid>cam-video</mid>
</signaling>
TX type=candidate(no sdp field) 是因为这条消息里没有 **sdp** 字段,只有 **candidate`。
- 设备发 offer(本端 SDP)
设备 → 对端(ICE gathering complete 后):
{ "type": "offer", "id": "<peer_id>", "sdp": "v=0\r\no=..." }
<?xml version="1.0" encoding="UTF-8"?>
<signaling>
<id>MBF672lwa7</id>
<type>offer</type>
<sdp><![CDATA[v=0
o=rtc 3908217902 0 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE cam-video 0
a=group:LS cam-video
a=msid-semantic:WMS *
a=ice-options:ice2,trickle
a=fingerprint:sha-256 EE:10:86:14:35:16:EF:54:C4:BE:E1:62:16:CA:AC:88:09:36:A4:DA:12:7E:5C:E5:E1:97:F2:F1:5C:62:DE:A8
m=video 46924 UDP/TLS/RTP/SAVPF 102
c=IN IP4 192.168.6.161
a=mid:cam-video
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=sendonly
a=ssrc:1 cname:cam-video
a=ssrc:1 msid:stream0 cam-video
a=msid:stream0 cam-video
a=rtcp-mux
a=rtpmap:102 H264/90000
a=rtcp-fb:102 nack
a=rtcp-fb:102 nack pli
a=rtcp-fb:102 goog-remb
a=fmtp:102 profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1
a=setup:actpass
a=ice-ufrag:15GH
a=ice-pwd:VED2rtk1uih9nzxYabBCKr
a=candidate:1 1 UDP 2114977791 192.168.6.161 46924 typ host
a=candidate:2 1 UDP 1678769919 183.238.154.133 6313 typ srflx raddr 0.0.0.0 rport 0
a=end-of-candidates
m=application 46924 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 192.168.6.161
a=mid:0
a=sendrecv
a=sctp-port:5000
a=max-message-size:262144
a=setup:actpass
a=ice-ufrag:15GH
a=ice-pwd:VED2rtk1uih9nzxYabBCKr
]]></sdp>
</signaling>
此时 PC state 多为 Connecting(ICE/DTLS 进行中)。
- 对端发 answer
对端 → 设备:
{ "type": "answer", "id": "<peer_id>", "sdp": "..." }
<?xml version="1.0" encoding="UTF-8"?>
<signaling>
<id>MBF672lwa7</id>
<type>answer</type>
<sdp><![CDATA[v=0
o=- 454412499414640947 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE cam-video 0
a=msid-semantic: WMS
m=video 9 UDP/TLS/RTP/SAVPF 102
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=candidate:3758453149 1 udp 2113937151 e0448cdf-8171-455e-8757-f546c14dd4e1.local 63464 typ host generation 0 network-cost 999
a=ice-ufrag:iWXL
a=ice-pwd:bz+8Mw5lticb2quOdoAbg4yu
a=ice-options:trickle
a=fingerprint:sha-256 EB:ED:D9:F3:3E:86:3E:E7:16:F1:2D:F9:66:1E:61:03:27:49:74:28:EE:62:73:5A:70:54:EA:F8:22:FB:2D:56
a=setup:active
a=mid:cam-video
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=recvonly
a=rtcp-mux
a=rtpmap:102 H264/90000
a=rtcp-fb:102 goog-remb
a=rtcp-fb:102 nack
a=rtcp-fb:102 nack pli
a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:iWXL
a=ice-pwd:bz+8Mw5lticb2quOdoAbg4yu
a=ice-options:trickle
a=fingerprint:sha-256 EB:ED:D9:F3:3E:86:3E:E7:16:F1:2D:F9:66:1E:61:03:27:49:74:28:EE:62:73:5A:70:54:EA:F8:22:FB:2D:56
a=setup:active
a=mid:0
a=sctp-port:5000
a=max-message-size:262144
]]></sdp>
</signaling>
设备侧:setRemoteDescription(answer);必要时 从 SDP 同步 RTP MID(日志里的 RTP MID header ext_id=4)。
[17:14:55.049] [webrtc_ldc] answer sdp first line: v=0
[17:14:55.054] [webrtc_ldc] RTP MID header ext_id=4 mid=cam-video uri=urn:ietf:params:rtp-hdrext:sdes:mid
[17:14:55.068] [webrtc_ldc] setRemoteDescription(answer)
之后 ICE/DTLS 继续,直到 Connected。
- 对端发 candidate(可选、常与 answer 交错)
对端 → 设备:
{ "type": "candidate", "candidate": "...", "mid": "..." }
设备侧:addRemoteCandidate。
这段日志里,answer 之后没有再打 RX candidate,说明对端候选可能都编在 SDP 里,或 ICE 已够用。)
- 媒体与 DataChannel 就绪
video track open:Track 已可用,设备开始 sendFrame(并 IDR)。
PC state=2:一般为 已连接。
DataChannel open:本端创建的 app 等通道可用。
对端(浏览器) 信令服务器(WS) 设备
| | |
| WS 已连接(本端先连上) |<======= WS open ============ |
| | |
|-------- JSON: request(id) --------------------------------->|
| | 建 PeerConnection
| | addTrack(H264) / createDC("app")
| | setLocalDescription()
| | |
| | |
|<------- JSON: candidate(id,mid=cam-video) ------------------|
|<------- JSON: candidate(id,mid=cam-video) ------------------|
| (host → srflx) | onLocalCandidate
| | |
|<------- JSON: offer(id,sdp) --------------------------------|
| | gathering Complete → 发 SDP
| | |
|-------- JSON: answer(id,sdp) ------------------------------>|
| | setRemoteDescription(answer)
| | RTP MID 同步 / track open
| | PC state=2 / DC open(local,app)
| | |
|<======== DTLS-SRTP(视频) / SCTP(DataChannel),不经信令 WS ========>|
| | (ICE 选路;与 WS 无关)
一句话
对端用 request 触发;
设备用多条 candidate + 一条 offer 回应;
对端用 answer(及可选 candidate)收尾;之后在 UDP 上做 ICE,媒体走 SRTP,信令仍走原 WebSocket。