9、建立P2P连接:完整连接流程、候选者交换、连接建立调试、常见连接问题
好,咱们今天来聊聊P2P连接建立这件事。说实话,这是整个WebRTC里最让人头疼,也最有意思的部分。我刚开始做的时候,光是让两个浏览器连上就折腾了两天。后来发现,其实只要把流程理清楚,问题就好解决了。
9.1 完整连接流程:从Offer到连接
一个完整的P2P连接,说白了就是三步走:
- 发起方创建Offer — 告诉对方“我想跟你连”
- 应答方创建Answer — 对方说“好,我准备好了”
- 双方交换候选者 — 互相告诉对方“我在哪,怎么找到我”
嗯,这里要注意,Offer和Answer里装的是什么呢?是SDP(Session Description Protocol)。它描述了你的媒体能力——支持什么编码、什么分辨率、网络参数等等。我见过不少新手以为SDP就是连接信息,其实它只是“我能做什么”,不是“我在哪”。
核心流程:
- Peer A 创建 RTCPeerConnection
- Peer A 调用 createOffer() 生成 SDP
- Peer A 调用 setLocalDescription() 设置本地SDP
- Peer A 通过信令服务器把SDP发给 Peer B
- Peer B 调用 setRemoteDescription() 设置远端SDP
- Peer B 调用 createAnswer() 生成应答SDP
- Peer B 调用 setLocalDescription() 设置本地SDP
- Peer B 把应答SDP发回给 Peer A
- Peer A 调用 setRemoteDescription() 设置远端SDP
代码写起来大概是这样:
// Peer A - 发起方
const pc = new RTCPeerConnection(config);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令发送 offer 给 Peer B
// Peer B - 应答方
const pc = new RTCPeerConnection(config);
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 通过信令发送 answer 给 Peer A
// Peer A 收到 answer
await pc.setRemoteDescription(answer);
我个人习惯在createOffer之前先把媒体流加上,这样生成的SDP会更完整。有一次我忘了加流,结果SDP里没有媒体信息,对面直接报错,排查了半天。
9.2 候选者交换:ICE的“找路”过程
Offer和Answer交换完了,接下来就是候选者交换。候选者(ICE Candidate)说白了就是“我在网络上的位置”。
ICE(Interactive Connectivity Establishment)会收集三种候选者:
| 类型 | 来源 | 优先级 | 说明 |
|---|---|---|---|
| host | 本机网卡 | 最高 | 局域网内直接通信 |
| srflx | STUN服务器 | 中等 | 公网IP,但可能被NAT限制 |
| relay | TURN服务器 | 最低 | 中继转发,兜底方案 |
为什么会收集这么多候选者?因为网络环境太复杂了。你在公司内网、家里WiFi、手机4G,每个场景下的候选者都不一样。ICE会尝试所有可能的组合,找到一条能通的路。
候选者交换的流程是这样的:
- 每个Peer收集自己的候选者
- 通过onicecandidate事件获取候选者
- 通过信令服务器发送给对方
- 对方调用addIceCandidate()添加
// 收集并发送候选者
pc.onicecandidate = (event) => {
if (event.candidate) {
// 通过信令发送 event.candidate 给对方
signaling.send({
type: 'candidate',
candidate: event.candidate
});
}
};
// 接收并添加候选者
pc.addIceCandidate(candidate);
我曾经遇到过一个坑:候选者发送得太快,对方还没设置远端SDP就收到了候选者。这时候addIceCandidate会失败。解决办法很简单——把候选者缓存起来,等setRemoteDescription完成后再批量添加。
小技巧:可以用一个数组暂存候选者,在setRemoteDescription的回调里统一添加。或者用trickle ICE的方式,边收集边发,但要做好顺序控制。
9.3 连接建立调试:怎么看懂ICE状态
连接建立过程中,ICE状态会经历几个阶段。我建议你打开浏览器控制台,把日志级别调到最高,这样能看到ICE的详细过程。
ICE状态变化:
- new — 刚创建,还没开始
- checking — 正在检查候选者对
- connected — 找到了可用连接
- completed — 所有候选者检查完毕
- failed — 没找到可用连接
- disconnected — 连接中断
- closed — 连接关闭
调试时我最常用的方法是监听这几个事件:
pc.oniceconnectionstatechange = () => {
console.log('ICE状态:', pc.iceConnectionState);
};
pc.onicegatheringstatechange = () => {
console.log('收集状态:', pc.iceGatheringState);
};
pc.onicecandidateerror = (event) => {
console.error('候选者错误:', event.errorCode, event.errorText);
};
嗯,这里有个经验:如果ICE状态卡在checking不动,多半是候选者没交换成功。检查一下信令通道是不是正常,候选者有没有被正确序列化。
注意:onicecandidateerror这个事件很多人会忽略。它告诉你STUN或TURN服务器连不上,或者返回了错误。我曾经因为STUN服务器地址写错了,排查了整整一个下午。
9.4 常见连接问题:避坑指南
做WebRTC开发,连接问题是最常见的。我整理了几个高频问题,都是我在项目中踩过的坑。
问题1:连接建立失败,ICE状态为failed
原因通常是:
- 没有配置STUN/TURN服务器
- 防火墙阻止了UDP流量
- 候选者没有正确交换
解决办法:检查ICE服务器配置,确保STUN地址可访问。如果网络环境严格,必须配置TURN服务器作为兜底。
问题2:连接建立成功,但媒体流不通
这个我遇到过好几次。原因往往是SDP里没有媒体信息,或者编解码器不匹配。检查一下createOffer时是否已经添加了媒体流,以及双方的SDP里是否有相同的编解码器。
问题3:候选者交换顺序问题
前面提到过,候选者来得太早会失败。我曾经写过一个demo,本地跑得好好的,部署到服务器就不行。后来发现是信令延迟导致候选者先于SDP到达。解决方案是等setRemoteDescription完成后再处理候选者。
问题4:NAT穿透失败
有些对称NAT环境下,STUN也搞不定。这时候只能靠TURN中继。我建议在生产环境中始终配置TURN服务器,别指望STUN能解决所有问题。
避坑指南:
- 始终配置至少一个STUN服务器和一个TURN服务器
- 候选者交换要等SDP设置完成后再进行
- 监听onicecandidateerror,及时发现问题
- 生产环境不要用Google的公共STUN服务器,不稳定
- ICE状态变化要记录日志,方便排查
9.5 知识体系图
下面这张图总结了P2P连接建立的完整流程和关键节点:
这张图把整个流程串起来了。你看,左边是Peer A,右边是Peer B,中间是信令服务器。Offer和Answer走信令通道,候选者也是通过信令交换的。等双方都拿到了对方的SDP和候选者,ICE就开始检查,找到一条可用的路径,连接就建立了。
调试建议:在chrome://webrtc-internals里可以看到所有ICE候选者、状态变化和SDP信息。我每次调试必开这个页面,比console.log好用十倍。
好了,P2P连接建立这部分就讲到这里。记住,流程本身不复杂,复杂的是各种网络环境下的兼容性。多测试、多打日志、多看webrtc-internals,慢慢就有感觉了。