原文出处:WebRTC Native码导读(十二):P2P连接过程完全解析

一年前我初步分析了 WebRTC 的 P2P 连接过程,并总结为了安卓 P2P 连接过程和 DataChannel使用一文,那会儿我刚接触WebRTC C++ 的代码,看起来着实头大,而且安卓的代码要调试、测试也很麻烦,所以很多细节就没有展开,今天就让我们在 iOS 的工程里,对 P2P 连接的过程进行一个彻底的剖析。

概览

首先我们从宏观上了解一下 P2P 连接的过程,以及一些关键类之间的关系,这样在看代码时就不至于迷失在细节里。此外,没看过安卓 P2P 连接过程和DataChannel 使用的朋友,也建议先看一下。

注:除非你对这个话题有很大兴趣,否则很可能无法读完,那我建议尽早放弃;如果确实需要研究这块内容,那我建议打开源码,反复阅读此文,应当会有些收获

宏观流程

P2P 关键类

注:列在同一点里的类,是继承关系,左侧是子类,右侧是基类,下同

各种 transport 类的关系

关键类的数量关系

一个 PeerConnection - 一个 JsepTransportController - 一个 JsepTransport(启用了 bundle)- 一个 DtlsSrtpTransport - 一个 DtlsTransport - 一个 P2PTransportChannel。

一个 JsepTransportController - 一个 BasicPortAllocator - 多个 BasicPortAllocatorSession,但一次分配过程只会有一个 session。

一个 BasicPortAllocatorSession - 多个 AllocationSequence。

一个 AllocationSequence - 多个 port。

一个 P2PTransportChannel - 多个 Connection,但最终会选出一个 Connection 使用。

接下来我们就对宏观过程的代码细节进行展开。

再次预警,如果此时你已经有些倦意,那我建议立刻关闭这个页面

收集本地 candidate

设置 local sdp,开始收集 candidate:

PeerConnection::SetLocalDescription
                
JsepTransportController::MaybeStartGathering
                
P2PTransportChannel::MaybeStartGathering
                
BasicPortAllocatorSession::StartGettingPorts
                
BasicPortAllocatorSession::DoAllocate

DoAllocate

DoAllocate 里会遍历所有网络设备(Network 对象),创建 AllocationSequence 对象,调用其 Init Start 函数,分配 port。

BasicPortAllocatorSession::DoAllocate
              
AllocationSequence::Start
               message
AllocationSequence::OnMessage

AllocationSequence 分配 port 分为三个 phase:UDP, RELAY, TCP。每个 phase 之间间隔一个 step delay。一年前我在分析安卓 P2P 连接过程和 DataChannel 使用时还有一个 SslTcp phase,现在已经删掉了

UDP phase

UDP phase 会收集两种类型的 candidate:host 和 srflx。

host candidate

一旦创建了 AsyncPacketSocket 对象,有了本地 IP 和端口,host 类型的 candidate 也就已经就绪了,而AsyncPacketSocket 对象在 AllocationSequence::Init 里就已经创建好了,所以可以直接发出 host candidate。

AllocationSequence::OnMessage
              
AllocationSequence::CreateUDPPorts
              
BasicPortAllocatorSession::AddAllocatedPort
              
UDPPort::PrepareAddress
              
UDPPort::OnLocalAddressReady
              
      Port::AddAddress
               sig slot (SignalCandidateReady)
BasicPortAllocatorSession::OnCandidateReady
srflx candidate

收集 srflx candidate 的原理是,向 STUN server 发送一个 UDP 包(叫 STUN Binding request),server 会把这个包里的源 IP 地址、UDP 端口返回给客户端(叫 STUN Binding response),这个 IP 和端口将来可能可以用来和其他客户端建立 P2P 连接。关于 STUN 协议的具体内容,可以查阅 RFC Session Traversal Utilities for NAT (STUN)

收集 srflx candicate 时可以复用收集 host candidate 时创建的 socket 对象,这一逻辑通过PORTALLOCATOR_ENABLE_SHARED_SOCKET flag 控制,默认是开启的。

复用 socket 的情况下,AllocationSequence::CreateStunPorts 函数会直接返回,因为早在AllocationSequence::CreateUDPPorts 函数的执行过程中,就已经执行了 STUN Binding request的发送逻辑。

发送 STUN Binding request:

UDPPort::OnLocalAddressReady
            
UDPPort::MaybePrepareStunCandidate
            
UDPPort::SendStunBindingRequest
            
StunRequestManager::SendDelayed
             message
StunRequest::OnMessage
             sig slot (SignalSendPacket)
UDPPort::OnSendPacket
            
AsyncUDPSocket::SendTo
            
PhysicalSocket::SendTo
            
系统 socket sendto

收到 STUN Binding response:

PhysicalSocketServer::WaitSelect
                
SocketDispatcher::OnEvent
                 sig slot (SignalReadEvent)
AsyncUDPSocket::OnReadEvent
                 sig slot (SignalReadPacket)
AllocationSequence::OnReadPacket
                
UDPPort::HandleIncomingPacket
                
StunRequestManager::CheckResponse
                
StunBindingRequest::OnResponse
                
UDPPort::OnStunBindingRequestSucceeded
                
        Port::AddAddress
                 sig slot (SignalCandidateReady)
BasicPortAllocatorSession::OnCandidateReady

RELAY phase

WebRTC 目前支持两种中继协议:GTURN 和 TURN。现在基本都是使用标准的 TURN 协议。TURN 协议是 STUN 协议的一个扩展,它利用一个中继服务器,使得无法建立 P2P 连接的客户端(NAT 严格限制导致)也能实现通讯。关于 NAT 类型与 P2P 连接的可行性,可参考附录一:NAT 类型与 P2P 连接的可行性

TURN 协议的工作流程如下:

客户端发送 Allocate request 到 server,server 返回 401 未授权错误(带有 realm 和 nonce),客户端再发送带上认证信息的 Allocate request,server 返回成功分配的 relay address。分配成功后,客户端需要通过发送机制(Send Mechanism)或信道机制(Channels)在 server 上配置和其他 peer 的转发信息。此外 allocation 和 channel 都需要保活。

WebRTC 使用的是信道机制,因为这一机制的数据开销更低。

收集 TURN relay candidate 时也可以复用收集 host candidate 时创建的 socket 对象,这一逻辑通过PORTALLOCATOR_ENABLE_SHARED_SOCKET flag 控制,前面我们就已经知道,默认情况下它是开启的。

由于 TURN 协议是 STUN 协议的扩展,所以基本的发送请求、接收响应的代码是复用的,下面只描述 TURN 协议独特的部分:

AllocationSequence::OnMessage
              
AllocationSequence::CreateRelayPorts
              
BasicPortAllocatorSession::AddAllocatedPort
              
TurnPort::PrepareAddress
              
TurnPort::SendRequest
               message
StunRequest::OnMessage
               发送请求接收响应
StunRequestManager::CheckResponse
              
TurnAllocateRequest::OnErrorResponse
              
TurnAllocateRequest::OnAuthChallenge
              
TurnPort::SendRequest
               发送请求接收响应
StunRequestManager::CheckResponse
              
TurnAllocateRequest::OnResponse
              
TurnPort::OnAllocateSuccess
              
        Port::AddAddress
               sig slot (SignalCandidateReady)
BasicPortAllocatorSession::OnCandidateReady

StunRequest 和 StunRequestManager

上面提到的 STUN Binding, TURN Allocate 的 request response 处理,以及后面要遇到的 STUN ping request response 处理,都由 StunRequest 和 StunRequestManager 类负责。

StunRequest 类是对 STUN request 的定义和封装,基类里实现了 request 超时管理、重发的逻辑,各种特定类型的逻辑由子类实现,例如 StunBindingRequest 和 TurnAllocateRequest。

StunRequestManager 则实现了 response 和 request 匹配的逻辑:manager 按 transaction id => request 的 hash 保存了所有的 request,收到 response 后,根据 transaction id 即可找到对应的request,进而可以执行 request 对象的回调。

TCP phase

TCP phase 不是主流,这里就不展开了。

OnCandidateReady

OnCandidateReady 里会调用两个重要的函数(通过 sig slot):P2PTransportChannel::OnPortReadyP2PTransportChannel::OnCandidatesReady

在 OnPortReady 里,P2PTransportChannel 会把 port 存入 ports_ 数组,供后续收到 remote candidate 后建立 Connection 用。此外,这里也会立即尝试用这个 port 和每个远端 candidate 建立 Connection。

在 OnCandidatesReady 里,P2PTransportChannel 会把 candidate 一路回调给 APP 层:

P2PTransportChannel::OnCandidatesReady
                  
JsepTransportController::OnTransportCandidateGathered_n
                  
PeerConnection::OnTransportControllerCandidatesGathered
                  
PeerConnection::OnIceCandidate
                  
PeerConnectionObserver::OnIceCandidate

这里我们看到,port 和 candidate 都送到了 P2PTransportChannel 这里,因为它就是对 ICE 逻辑的封装,接下来我们很快会看到,remote candidate 也是交给了 P2PTransportChannel。

设置远端 candidate

收到远端的 candidate 后,我们调用 PeerConnection::AddIceCandidate 接口进行设置,其内部调用栈为:

PeerConnection::AddIceCandidate
              
JsepTransportController::AddRemoteCandidates
              
JsepTransport::AddRemoteCandidates
              
P2PTransportChannel::AddRemoteCandidate
              
P2PTransportChannel::CreateConnections

CreateConnections 会遍历本地所有的 port(在 OnCandidateReady 中保存),尝试与这个远端 candicate 建立连接。本文中我们只分析了 UDP 和 TURN 两种 port,所以会调用到 UDPPort::CreateConnectionTurnPort::CreateConnection 创建 Connection。

创建了 Connection 之后,怎么做连通性检查呢?这就是 ICE 协议定义的内容了。

ICE 连通性检查

关于 ICE 连通性检查的介绍,可以阅读「安卓 P2P 连接过程和 DataChannel 使用」的「连通性检查」部分,当然,最好是看看 ICE 协议的 RFC了:Interactive Connectivity Establishment (ICE): A Protocol for Network Address Translator (NAT) Traversal for Offer/AnswerProtocols

前面我们提到,在 OnCandidateReady 里我们会用刚分配好的 Port 与每个 remote candidate 建立 Connection,此外,如果收到了对方的 STUN ping request,那就会立即创建一个 Connection,再加上添加 remote candidate 的情况,这三种情况下,创建完 Connection 之后,P2PTransportChannel 都会立即执行 SortConnectionsAndUpdateState 函数,其中首先会对 Connection 进行排序(见下文),此外也会尝试开始 ping Connection。

STUN ping request 其实就是 STUN binding request,所以它的发送、response 的接收,前面都已经分析过了,这里只展示不同的部分:

P2PTransportChannel::AddRemoteCandidate
                  
P2PTransportChannel::SortConnectionsAndUpdateState
                  
P2PTransportChannel::MaybeStartPinging
                  
P2PTransportChannel::PingConnection
                  
StunRequestManager::SendDelayed
                   发送请求接收响应
Connection::OnConnectionRequestResponse
                  
Connection::set_write_state
                   sig slot (SignalStateChange)
P2PTransportChannel::OnConnectionStateChange
                  
P2PTransportChannel::RequestSortAndStateUpdate
                   message
P2PTransportChannel::SortConnectionsAndUpdateState

创建 Connection 会触发 ping,ping 成功后会触发 Connection 的状态切换(见下文)。排序后,我们最终会选出一个合适的 Connection,通知上层可以进行数据通讯了。

P2PTransportChannel::SwitchSelectedConnection
                     sig slot (SignalReadyToSend)
DtlsTransport::OnReadyToSend
                     sig slot (SignalReadyToSend)
RtpTransport::OnReadyToSend

在这个过程中,Connection 的状态如何变迁?ICE 状态如何变迁?Connection 如何排序?如何选择?接下来我们就仔细展开分析。

Connection 状态变迁

因为 Connection 的排序用到了状态,所以我们就先搞清楚 Connection 的状态及其变迁:

ICE 状态变迁

ICE 状态 IceConnectionState 定义在 api/peerconnectioninterface.h 中,状态定义如下:

JsepTransportController 的 IceConnectionState 的计算状态的逻辑在JsepTransportController::UpdateAggregateStates_n 函数中:

DtlsTransport 的 writable 状态:

P2PTransportChannel 的 IceTransportState:

candidate 收集状态:

Connection 排序

Connection 的排序采用的是 stable sort,即原本排在前面的,如果两者比较不分伯仲,就保留原有顺序,这是为了避免对顺序做不必要的扰动。

而比较逻辑主要实现在 P2PTransportChannel::CompareConnections 函数中:

如果 CompareConnections 的结果表明不相伯仲,那就 rtt 大的 Connection 排在后面。

Connection 选择和淘汰

对 Connection 排完序之后,会在 P2PTransportChannel::MaybeSwitchSelectedConnection 决定是否选中排在最前面的 Connection。

搞清楚了选中 Connection 的逻辑,那选中一个 Connection 意味着什么呢?还记得上文提到的 transport writable 状态吗,transport writable 最基本的前提就是选出了 Connection。

其实除了选中 Connection 用到了排序的结果,STUN ping 的过程也用到了排序结果,此外,一个 Connection 要想被选中,那就必须变成 writable 状态,为此也就需要进行 STUN ping。

那如何确定该 ping 哪个 connection 呢?其逻辑实现在 P2PTransportChannel::FindNextPingableConnection 中:

收集 local candidate 或者添加 remote candidate 时,每个 port 都会和 remote candidate 创建 Connection,因此很可能每个 Network 创建多个 Connection,那什么时候销毁 Connection 呢?收到无法处理的 STUN error,或者超过一定时间未收到任何数据,那就会销毁 connection。

此外,也不是每个 Connection 都需要尝试进行连通性检查,比如同一个 Network 的多个 Connection 里,如果 best Connection 已经处于非 weak 状态了,那其他不如 best 的 Connection 就都不必继续尝试,可以提前剪枝(prune)了,被剪枝的 Connection 由于不会被继续使用,因此在一段时间后就会被超时机制销毁。剪枝逻辑实现在 P2PTransportChannel::PruneConnections 函数中。

连接使用

好了,连接终于建立成功,恭喜你 :)

连接建立成功后,就可以收发应用层的数据了,数据的收发将会通过选出来的 Connection 对象完成,Connection 则是调用 Port,Port 则是调用 AsyncPacketSocket,而这里用到的 Port 和 AsyncPacketSocket 对象,都是在收集本地 candidate过程中创建的,并不会重新创建。

P2P 连接使用的细节分析,限于篇幅就留在下一篇里进行分析了,敬请期待 :)

附录一:NAT 类型与 P2P 连接的可行性

终端所处网络类型(UDP 可用性)

利用有两个固定公网 ip 的 STUN server,客户端可以通过数次测试,探测自己所处的网络类型:

+--------+
                        |  Test  |
                        |   I    |
                        +--------+
                             |
                             |
                             V
                            /\              /\
                         N /  \ Y          /  \ Y             +--------+
          UDP     <-------/Resp\--------->/ IP \------------->|  Test  |
          Blocked         \ ?  /          \Same/              |   II   |
                           \  /            \? /               +--------+
                            \/              \/                    |
                                             | N                  |
                                             |                    V
                                             V                    /\
                                         +--------+  Sym.      N /  \
                                         |  Test  |  UDP    <---/Resp\
                                         |   II   |  Firewall   \ ?  /
                                         +--------+              \  /
                                             |                    \/
                                             V                     |Y
                  /\                         /\                    |
   Symmetric  N  /  \       +--------+   N  /  \                   V
      NAT  <--- / IP \<-----|  Test  |<--- /Resp\               Open
                \Same/      |   I    |     \ ?  /               Internet
                 \? /       +--------+      \  /
                  \/                         \/
                  |                           |Y
                  |                           |
                  |                           V
                  |                           Full
                  |                           Cone
                  V              /\
              +--------+        /  \ Y
              |  Test  |------>/Resp\---->Restricted
              |   III  |       \ ?  /
              +--------+        \  /
                                 \/
                                  |N
                                  |       Port
                                  +------>Restricted

                 Figure 2: Flow for type discovery process
  1. STUN 客户端从向 STUN 服务器发送请求,要求得到自身经 NAT 映射后的地址:
    1. 收不到服务器回复,则认为 UDP 被防火墙阻断,不能通信,网络类型:Blocked;
    2. 收到服务器回复,对比本地地址,如果相同,则认为无 NAT 设备,进入第 2 步,否则认为有 NAT 设备,进入第 3 步;
  2. (已确认无 NAT 设备)STUN 客户端向 STUN 服务器发送请求,要求服务器从其他 ip 和 port 向客户端回复包:
    1. 收不到服务器从其他 ip 地址的回复,认为包被前置防火墙阻断,网络类型:Symmetric UDP Firewall;
    2. 收到则认为客户端处在一个开放的网络上,网络类型:Opened;
  3. (已确认有 NAT 设备)STUN 客户端向 STUN 服务器发送请求,要求服务器从其他 ip 和 port 向客户端回复包:
    1. 收不到服务器从其他 ip 地址的回复,认为包被前置 NAT 设备阻断,进入第 4 步;
    2. 收到服务器回复,则网络类型:Full Cone NAT;
  4. STUN 客户端向 STUN 服务器的另外一个 ip 地址发送请求(本地端口不变),要求得到自身经 NAT 映射后的地址,并和第 1 步得到的地址对比:
    1. 地址不相同,则网络类型:Symmetric NAT;
    2. 地址相同,则认为是 Restricted NAT,进入第 5 步,进一步确认类型;
  5. (已确认 Restricted NAT 设备)STUN 客户端向 STUN 服务器发送请求,要求服务器从相同 ip 的其他 port 向客户端回复包:
    1. 收不到服务器从其他 port 地址的回复,认为包被前置 NAT 设备阻断,网络类型:Port Restricted Cone NAT;
    2. 收到则认为网络类型:Restricted Cone NAT;

NAT 针对 TCP 的实现基本是一致的,并不存在太大差异,所以也就没有类型一说,这是因为 TCP 协议本身便是面向连接的,因此无需考虑网络连接无状态所带来的复杂性。

NAT traversal / hole punching

记 STUN binding 结果为:A private_port_a public_ip_a:public_port_a,B private_port_b public_ip_b:public_port_b

附录二:收集 candidate 过程日志分析

DoAllocate 开始:

[000:367] [3335] (basicportallocator.cc:753): Allocate ports on 4 networks

某个 AllocationSequence 的 UDP host 分配过程:

[000:374] [3335] (basicportallocator.cc:1446): Net[en0:192.168.50.0/24:Wifi:id=1]: Allocation Phase=Udp
[000:377] [3335] (port.cc:319): Port[0x1128ac600::1:0:local:Net[en0:192.168.50.0/24:Wifi:id=1]]: Port created with network cost 10
[000:378] [3335] (basicportallocator.cc:1517): AllocationSequence: UDPPort will be handling the STUN candidate generation.
[000:378] [3335] (basicportallocator.cc:873): Adding allocated port for 0
[000:378] [3335] (basicportallocator.cc:892): Port[0x1128ac600:0:1:0:local:Net[en0:192.168.50.0/24:Wifi:id=1]]: Added port to allocator
[000:379] [3335] (basicportallocator.cc:909): Port[0x1128ac600:0:1:0:local:Net[en0:192.168.50.0/24:Wifi:id=1]]: Gathered candidate: Cand[:2047277109:1:udp:2122260223:192.168.50.49:65400:local::0:0qbZ:dcSLq5Bs44BYu8yWN8mtlU48:1:10:0]
[000:380] [3335] (basicportallocator.cc:956): Port[0x1128ac600:0:1:0:local:Net[en0:192.168.50.0/24:Wifi:id=1]]: Port ready.
[000:383] [771] (RTCLogging.mm:31): (ARDAppEngineClient.m:94 -[ARDAppEngineClient sendMessage:forRoomId:clientId:completionHandler:]): C->RS POST: {
  "label" : 0,
  "id" : "0",
  "candidate" : "candidate:2047277109 1 udp 2122260223 192.168.50.49 65400 typ host generation 0 ufrag 0qbZ network-id 1 network-cost 10",
  "type" : "candidate"
}

UDP srflx 分配过程:

[000:399] [3335] (basicportallocator.cc:909): Port[0x1128ac600:0:1:0:local:Net[en0:192.168.50.0/24:Wifi:id=1]]: Gathered candidate: Cand[:4216258177:1:udp:1686052607:59.109.147.213:54541:stun:192.168.50.49:65400:0qbZ:dcSLq5Bs44BYu8yWN8mtlU48:1:10:0]
[000:410] [3335] (basicportallocator.cc:1044): Port[0x1128ac600:0:1:0:local:Net[en0:192.168.50.0/24:Wifi:id=1]]: Port completed gathering candidates.
[000:412] [771] (RTCLogging.mm:31): (ARDAppEngineClient.m:94 -[ARDAppEngineClient sendMessage:forRoomId:clientId:completionHandler:]): C->RS POST: {
  "label" : 0,
  "id" : "0",
  "candidate" : "candidate:4216258177 1 udp 1686052607 59.109.147.213 54541 typ srflx raddr 192.168.50.49 rport 65400 generation 0 ufrag 0qbZ network-id 1 network-cost 10",
  "type" : "candidate"
}

RELAY 分配过程:

[000:437] [3335] (basicportallocator.cc:1446): Net[en0:192.168.50.0/24:Wifi:id=1]: Allocation Phase=Relay
[000:438] [3335] (port.cc:319): Port[0x1128b4e00::1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Port created with network cost 10
[000:439] [3335] (basicportallocator.cc:873): Adding allocated port for 0
[000:439] [3335] (basicportallocator.cc:892): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Added port to allocator
[000:439] [3335] (turnport.cc:335): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Trying to connect to TURN server via udp @ 123.56.66.149:3478
[000:443] [3335] (turnport.cc:1281): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: TURN allocate request sent, id=61426b36764d637244316e47
[000:454] [3335] (turnport.cc:1333): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Received TURN allocate error response, id=61426b36764d637244316e47, code=401, rtt=14
[000:455] [3335] (turnport.cc:1281): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: TURN allocate request sent, id=47305a4e534736355a4e6945
[000:468] [3335] (turnport.cc:1287): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: TURN allocate requested successfully, id=47305a4e534736355a4e6945, code=0, rtt=13
[000:468] [3335] (basicportallocator.cc:909): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Gathered candidate: Cand[:425266556:1:udp:41885439:172.17.7.46:61226:relay:59.109.147.213:54541:0qbZ:dcSLq5Bs44BYu8yWN8mtlU48:1:10:0]
[000:468] [3335] (basicportallocator.cc:956): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Port ready.
[000:468] [3335] (basicportallocator.cc:1044): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Port completed gathering candidates.
[000:468] [3335] (turnport.cc:1050): Port[0x1128b4e00:0:1:0:relay:Net[en0:192.168.50.0/24:Wifi:id=1]]: Scheduled refresh in 540000ms.
[000:472] [771] (RTCLogging.mm:31): (ARDAppEngineClient.m:94 -[ARDAppEngineClient sendMessage:forRoomId:clientId:completionHandler:]): C->RS POST: {
  "label" : 0,
  "id" : "0",
  "candidate" : "candidate:425266556 1 udp 41885439 172.17.7.46 61226 typ relay raddr 59.109.147.213 rport 54541 generation 0 ufrag 0qbZ network-id 1 network-cost 10",
  "type" : "candidate"
}