5、P2P连接建立全流程:Offer/Answer模型、SDP协议深度解析、Candidate收集与排序

各位同学,今天我们来啃一块硬骨头——P2P连接建立的全流程。说实话,这是整个WebRTC体系里最容易让人懵圈的部分。我记得刚接触时,看着Offer、Answer、SDP、ICE Candidate这些术语在信令通道里飞来飞去,脑子里全是问号。

但别怕。咱们一步步拆解。说白了,整个流程就三件事:谈条件、找路径、建连接。今天我们就围绕这三件事展开。

核心要点: P2P连接建立 = 媒体协商(SDP交换)+ 网络协商(ICE Candidate交换)

5.1 Offer/Answer模型:谈判的艺术

Offer/Answer模型,说白了就是两个浏览器在谈条件。你支持什么编码?我支持什么编码?你用什么端口?我用什么端口?谈妥了才能干活。

我习惯把这个过程比作两个人在打电话前先确认对方用什么手机、信号好不好。嗯,虽然比喻不太严谨,但意思到了。

5.1.1 谁先发起?

在WebRTC里,发起方创建Offer,应答方返回Answer。但这里有个坑:谁当发起方?

  • 呼叫场景: 主动拨号的一方是发起方
  • 房间场景: 先加入房间的是发起方
  • 特殊情况: 双方同时发起?嗯,这种情况我遇到过,需要业务层做冲突处理
我的经验: 在实际项目中,我建议让服务器决定谁先发起。比如在视频会议场景里,让主持人端先创建Offer,这样能避免很多并发问题。

5.1.2 流程详解

来看一个典型的Offer/Answer交换流程:

// 发起方(Caller)
const pc = new RTCPeerConnection(config);
const offer = await pc.createOffer();    // 创建Offer
await pc.setLocalDescription(offer);     // 设置本地SDP
// 通过信令通道发送offer给对端

// 应答方(Callee)
const pc = new RTCPeerConnection(config);
await pc.setRemoteDescription(offer);    // 设置远端SDP
const answer = await pc.createAnswer();  // 创建Answer
await pc.setLocalDescription(answer);    // 设置本地SDP
// 通过信令通道发送answer给对端

// 发起方收到Answer
await pc.setRemoteDescription(answer);   // 设置远端SDP

这里有个细节:setLocalDescriptionsetRemoteDescription的顺序不能乱。我曾经在调试一个bug时,发现连接总是建立失败,查了半天才发现是setRemoteDescription在setLocalDescription之前调用了。嗯,这种低级错误,犯过一次就记住了。

5.2 SDP协议深度解析:一张纸上的所有秘密

SDP(Session Description Protocol)虽然叫协议,但它其实就是一个文本格式的描述文件。你想想看,它要把双方的媒体能力、网络信息、安全参数全部写在一张纸上,然后互相交换。

来看一个真实的SDP片段:

v=0
o=- 1234567890 2 IN IP4 0.0.0.0
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9
c=IN IP4 0.0.0.0
a=rtpmap:111 opus/48000/2
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
a=rtpmap:9 G722/8000
a=fmtp:111 minptime=10;useinbandfec=1
a=mid:0
a=sendrecv

别被这些字段吓到。我拆开来讲:

字段 含义 我的理解
v=0 SDP版本号 基本固定,不用管
o= 会话发起者信息 包含会话ID和版本号
m=audio 媒体描述行 告诉对方我支持哪些编码
a=rtpmap 编码映射 把编码ID和名称对应起来
a=sendrecv 媒体方向 我能发也能收
注意: SDP里的IP地址和端口在初始阶段通常是占位符(比如0.0.0.0:9),真正的网络信息要通过ICE Candidate来填充。千万别以为SDP里的IP就是最终通信地址。

5.2.1 BUNDLE机制

你注意到上面SDP里的a=group:BUNDLE 0 1了吗?这是WebRTC的一个优化机制。说白了,就是把音频、视频、数据通道的多个流合并到同一个传输通道里。

为什么要这么做?因为每个媒体流都需要一个UDP端口,如果同时开音频、视频、屏幕共享,端口消耗很大。BUNDLE让它们共用一条连接,省端口、省资源。

5.3 Candidate收集与排序:找路的过程

SDP交换完了,双方知道了对方支持什么编码。但问题来了:怎么把数据传过去?

这就是ICE Candidate要做的事。Candidate,翻译过来就是“候选地址”。每个端都会收集自己可能的通信地址,然后发给对方。

5.3.1 三种Candidate类型

我习惯把Candidate分成三类,按优先级从高到低排列:

  1. host Candidate(主机候选):本机网卡IP,比如192.168.1.100。优先级最高,因为不走任何中转。
  2. srflx Candidate(反射候选):经过NAT映射后的公网IP。比如你的内网是192.168.1.100,经过路由器NAT后变成120.24.xxx.xxx。
  3. relay Candidate(中继候选):通过TURN服务器转发的地址。优先级最低,因为延迟大、带宽贵。
关键点: 排序规则是:host > srflx > relay。但实际连接时,双方会尝试所有组合,选最快的那个。

5.3.2 收集过程

Candidate收集是自动的,但我们可以监听事件来观察:

pc.onicecandidate = (event) => {
  if (event.candidate) {
    console.log('发现Candidate:', event.candidate.candidate);
    // 通过信令通道发送给对端
    signalingChannel.send({
      type: 'candidate',
      candidate: event.candidate
    });
  } else {
    console.log('所有Candidate收集完毕');
  }
};

这里有个细节:event.candidatenull时表示收集完成。我见过有人在这个回调里一直发空Candidate,导致对端解析出错。嗯,记得加个判空。

5.3.3 连接性检查与排序

双方交换完Candidate列表后,ICE就开始做连接性检查。说白了,就是尝试用各种组合去ping对方。

举个例子:

  • 你的host Candidate(192.168.1.100)去连对方的host Candidate(192.168.1.200)
  • 你的host Candidate去连对方的srflx Candidate
  • 你的srflx Candidate去连对方的host Candidate
  • ...以此类推

每对组合称为一个Candidate Pair。ICE会按优先级排序,优先检查高优先级的Pair。一旦某个Pair连通了,就建立连接,其他Pair不再检查。

避坑指南: 我曾经遇到一个场景,双方都在同一个内网,但ICE却选择了relay路径。查了半天发现是host Candidate被防火墙拦截了。解决方案是配置ICE的iceTransportPolicyrelay强制走中继,或者调整防火墙规则。

5.4 全流程总结

来,我们把整个流程串起来。我画了一张图,帮你理清思路:

P2P连接建立全流程 发起方(Offerer) 应答方(Answerer) 1. 创建Offer 2. 接收Offer Offer(SDP) 3. 创建Answer 4. 接收Answer Answer(SDP) 5. 收集Candidate 5. 收集Candidate 6. 交换Candidate 6. 交换Candidate ICE Candidate交换

整个流程走下来,你会发现:SDP交换是谈条件,Candidate交换是找路。两者缺一不可。

一句话总结: Offer/Answer搞定“能不能连”,ICE Candidate搞定“怎么连”。两个都走通了,P2P连接就建立了。

好了,这一章的内容就到这里。记住,P2P连接建立是WebRTC最核心的环节,也是问题最多的地方。多动手调试,多抓包分析,慢慢就会形成自己的经验体系。


公众号:蓝海资料掘金营,微信deep3321