一文看懂WebRTC建连过程

1,006 阅读17分钟

WebRTC(Web Real-Time Communications)是Google公司开源的一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流、音频流或者其他任意数据的传输。

在开启音视频传输之前,WebRTC会使用ICE/STUN/TURN等技术进行网络连接,本文就将从宏观的建连过程和底层工作原理,介绍WebRTC的建连过程。

一、前置

WebRTC的基础API与流程可以通过下面这个项目来熟悉

github.com/shushushv/w…

名词解释:

  • ICE:一种NAT穿越的技术,使用了STUN和TURN(rfc 8445);

  • STUN:为NAT穿越设计一种应用层的网络协议 (rfc 8489),在实际应用中主要有3种用途:1. 探测本地外网地址;2. ICE连接连通性检查;3. ICE连接保活;

  • STUN服务器:架设在公网上,可以探测Client的外网地址(对应STUN协议的第1个用途);

  • TURN:使用中继方式进行NAT穿越的技术(rfc 8656);

  • TURN服务器:一般包括STUN服务器功能与中继功能;

  • SDP:WebRTC所使用的的会话描述协议,主要用于媒体能力协商匹配,类型为offer/answer;

  • Client/Peer/Agent:都是WebRTC的一个端不同的叫法;

二、连接类型

连接类型上可以分为P2P类型与SFU/MCU(客户端-服务端)类型。

P2P类型因连接双端的NAT环境不同又大致可以分为:

  • 双端都在同一内网下:不需要经过NAT穿越,可以直接连接;

  • 双端在不同的NAT下:需要经过STUN服务器协助打洞,进行NAT穿越后连接;

  • 一端的对称型NAT无法进行穿越,需要借助TURN服务器进行中转;

WebRTC是为了实现P2P设计的架构,但真实网络中存在无法穿越的对称型NAT,在主流的RTC厂商一般使用SFU/MCU架构,即使用客户直接与媒体服务器相连接,媒体服务器架设在公网上,所以连接成功率非常高。

image.png

三、连接过程

连接过程中有个特别重要的概念candidate,可以简单理解为本地为进行ICE连接收集到的内网/外网地址,连接双方收集完成并交换后,双方即可进行ICE连接(连接过程后面会详细介绍)。

P2P

  1. Client A/Client B分别连接信令服务器;

  2. Client A(发起方)开始执行,创建RTCPeerConnection实例,开启音视频采集,将音视频流挂载到peerConnection实例,生成offer sdp;

  3. 通过信令服务器中转将offer发送到Client B;

  4. Client B(接收方)开始执行,创建RTCPeerConnection实例,将对端的offer设置进去,再生成answer;

  5. 通过信令服务器中转将answer发送回Client A;

  6. Client A设置本地offer后,通过onicecandidate回调收集的candidate发送给Client B,Client B也通过设置本地answer后,将收集的candidate发送回Client A,(Client A设置本地offer与发送offer可以同时进行);

  7. Client A/Client B拿到对方的candiate后,通过addIceCandidate添加到本端,如此就可以建立p2p连接;

  8. 最后Client B在连接之上拿到Client A的音视频数据;

SFU/MCU

SFU/MCU一端为公网服务器,不需要再收集外网地址,连接过程会简单一些:

  1. Client与SFU建立信令连接;

  2. Client A(发起方)开始执行,创建RTCPeerConnection实例,开启音视频采集,将音视频流挂载到peerConnection实例,生成offer sdp;

  3. SFU设置offer后,生成answer,将candidate信息拼接到answer sdp中;

  4. Client拿到SFU的candiate后,就可以建立p2p连接(Client也会收集host类型的candidate,与SFU的candidate凑成candidate-pair才能建立p2p连接);

四、连接工作原理(ICE、STUN、TURN)

  1. STUN协议

要了解ICE的工作流程,需要熟悉下STUN协议。

rfc8489 datatracker.ietf.org/doc/html/rf…

协议头

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|0 0|     STUN Message Type     |         Message Length        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Magic Cookie                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
|                     Transaction ID (96 bits)                  |
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • STUN Message Type(14 bits):
0                 1
2  3  4 5 6 7 8 9 0 1 2 3 4 5
+--+--+-+-+-+-+-+-+-+-+-+-+-+-+
|M |M |M|M|M|C|M|M|M|C|M|M|M|M|
|11|10|9|8|7|1|6|5|4|0|3|2|1|0|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
C1C00b000b010b100b11
含义requestindicationsuccess responseerror response
  • Message Length:消息长度必须包含不包括20字节STUN头的消息的大小(以字节为单位)。由于所有增加的属性都填充为4字节的倍数,因此该字段的最后2位始终为零。这提供了另一种区分STUN数据包和其他协议数据包的方法。

  • Magic Cookie:固定值**0x2112A442**,可以和其他协议包区分,在探测外网地址时和IP做XOR使用

  • Transaction ID (96 bits) :事务ID,关联reqeust与response,探测外网地址为ipv6时也参与XOR

STUN Attributes

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Type                  |            Length             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Value (variable)                ....
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

STUN的Attributes是经典的Type-Length-Value(TLV)结构,即:

  • Type: 16 bits,在其他各个RFC里定义了Type的值

  • Length: 16 bits 指示的Value的字节数,不包括Type和Length

  • Value: 长度不限

  • Padding: 使用Value 32 bits对齐 (一般填充0,不过填什么都无所谓,反正不会读)

重要的attributes

  • MAPPED-ADDRESS/XOR-MAPPED-ADDRESS:外网的映射地址,ipv4地址会使用Magic Cookie做异或,ipv6地址会使用Magic Cookie+Transaction ID进行异或;

    •   rfc3489只定义了MAPPED-ADDRESS,rfc5389补充了XOR-MAPPED-ADDRESS并说到:有些NAT发现数据里包括NAT外网IP时,会“帮忙”转成内网地址,本身是好意,但会导致STUN协议失效;
  • USERNAME:是连通性检查后就会一直携带

  • PRIORITY:candidate的优先级

  • ICE-CONTROLLED/ICE-CONTROLLING:ice连接中的角色

  • USE-CANDIDATE:ICE-CONTROLLING提名candidate-pair使用

其他像MESSAGE-INTEGRITY等其他重要的attributes和共对应的rfc链接:Session Traversal Utilities for NAT (STUN) Parameters

  1. candidate简介

candidate的本质就是一个网络地址

candidate格式如下,最重要的有以下几部分:协议,IP,PORT,优先级

              协议 优先级     IP              PORT   类型
candidate:0 1 udp 2130706431 101.133.204.181 80 typ host generation 0
candidate:foundation 1 tcp 100 61.49.23.204 80 typ srflx raddr 61.49.23.204 rport 80 generation 0 

优先级计算:

priority = (2^24)*(type preference) +
              (2^8)*(local preference) +
              (2^0)*(256 - component ID)

Rfc 8445推荐 type preference:

  • 126 for host candidates,

  • 110 for peer-reflexive candidates

  • 100 for server-reflexive candidate

  • 0 for relayed candidates

local preference是代表本地的网络类型,component ID是代表rtp是否与rtcp共用一个端口(rtp & 共用为1;rtcp为2)

  1. candidate收集

candidate分类有4种,每种都会有不同的收集方式:

地址类型获取途径
host candidate本机获取的地址,一般就是网卡的地址
server-reflexive candidates使用STUN协议,由STUN server返回的公网地址
relayed candidates使用STUN协议,TURN server返回的是中继公网地址
peer-reflexive candidate连通性检查时,由STUN协议返回的外网地址
                     To Internet
                          |
                          |
                          |  /------------  Relayed
                      Y:y | /               Address
                      +--------+
                      |        |
                      |  TURN  |
                      | Server |
                      |        |
                      +--------+
                          |
                          |
                          | /------------  Server
                   X1':x1'|/               Reflexive
                    +------------+         Address
                    |    NAT     |
                    +------------+
                          |
                          | /------------  Local
                      X:x |/               Address
                      +--------+
                      |        |
                      | Agent  |
                      |        |
                      +--------+

3.1 andidate收集的JS代码实现

// 1. 如此设置只能收集到host类型的candidate
const pc = new RTCPeerConnection({iceServer: []});
...
pc.onicecandidate = e => console.log('candidate: ', e.candidate)
pc.icegatheringstatechange = () => console.log('icegatheringstate: ', pc.iceGatheringState)
pc.setLocalDescription();

// 2. 如此设置可以收集到host & server-reflexive类型的candidate
const pc = new RTCPeerConnection({
    iceServers:[{
        urls: ['stun:stun.l.google.com:19302'],
        username: '',
        credential: ''
    }];
});

// 3. 如此设置可以收集到host & server-reflexive & relayed类型的candidate
const pc = new RTCPeerConnection({
    iceServers: [
        {
            urls: 'turn:192.0.2.15:3478',
            username: 'v8G=',
            credential: 'xG2'
        }
    ]
});

// peer-reflexive类型并不会通过onicecandiate回调

3.2 STUN服务器工作原理

image.png STUN服务器收到客户端发送的binding request包,会从IP协议拿到外网的IP,从传输协议拿到PORT,最后将IP与PORT做异或处理放到binding response的XOR-MAPPED-ADDRESS中返回,客户端拿到后回调给上层。

3.3 TURN服务器工作原理

image.png

  1. turn client向turn server发送STUN Allocate请求(attribute: REQUESTED-TRANSPORT & LIFETIME等),turn server会为该请求分配一个端口,作为中继地址,再通过STUN response返回给turn client(attribute: XOR-RELAYED-ADDRESS);

  2. turn client将中继类型的candidate发送给其他Peer,其他Peer会将数据发送给中继地址,turn server将数据中转发送给turn client;

3.4 p2p类型与SFU连接在candidate收集方面的差异

  • p2p连续一般会完整地进行上述过程的收集;

  • 如果是SFU/MCU架构,服务器都有公网IP,不需要收集candidate;本地仅需要收集host candidate就可以与服务器进行建连过程,不需要再收集server-reflexive candidates和relayed candidates。

  1. candidate交换

candiate有2种形式交换:

  • candiate收集完成后,组装到本地生成的sdp中,将sdp发送给对方,对方通过setRemoteDescriptionAPI来完成candidate的设置,一般SFU/MCU会使用这种方式,因为服务端有公网地址,返回answer时可以直接拼接到sdp中。

v=0
o=- 827784982034516459 2 IN IP4 127.0.0.1
s=-
t=0 0
a=extmap-allow-mixed
a=msid-semantic:  WMS
a=group:BUNDLE 0c=IN IP4 0.0.0.0
a=setup:active
a=mid:0
a=ice-ufrag:UFJFQ+RL8H2+WFDAkSWm+AAB
a=ice-pwd:aZZEwPXx3f0QYRVl7BOZRZ9r
a=fingerprint:sha-256 8A:D7:B1:AE:E2:54:B8:7E:51:EB:5A:F8:46:28:89:01:64:B3:F2:98:AE:3D:16:8D:21:30:7D:EB:62:DC:BC:52
a=candidate:0 1 udp 2130706431 101.133.204.181 80 typ host generation 0
a=candidate:1 1 udp 2130706431 101.133.204.181 50000 typ host generation 0
a=candidate:2 1 tcp 2130705431 101.133.204.181 80 typ host tcptype passive generation 0
a=ice-options:renomination
a=sctpmap:5000 webrtc-datachannel 262144
a=sctp-port:5000
a=max-message-size:262144
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
  • 为了加快sdp协商的速度,candidate可与sdp分开单独发送给对方,对方通过addIceCandidateAPI来完成candidate的设置

candiate交换信道:

candidate通常与sdp转发的信道一致,一般通过业务websocket服务转发(Agora),也可以由http服务器转发;

近年出现了WHIP/WHEP协议草案规定使用HTTP请求和响应来传输信令数据。

  1. candidate的处理

candidate交换完成后,就会把双方的candidate进行排列组合成candidate-pair,并且双方会进入角色扮演,一个作为controlling控制方,一个作为controlled被控制方,不同角色会在后面连接的过程中的处理方式不同,角色是会扮演ICE的全过程。

上面组成的所有candidate-pair称为checklist,checklist会根据优先级排序、裁剪,连通性检查,然后controlling会提名一个candidate-pair作为最终的通讯链路。

  1. candidate-pair优先级

candidate-pair的优先级是根据local candidate和remote candidate优先级按下面公式计算而得,计算完成后会按优先级从高到低排序

pair priority = 2^32*MIN(G,D) + 2*MAX(G,D) + (G>D?1:0)
  1. 裁剪candidate-pair

裁剪有2个原则: 如果pair的local candidate base相同、remote candidate完成相同,那就会裁剪掉优先级低的pair;二是如果pair数量很多,会按优先级从低开始裁剪。

  1. 连通性检查

裁剪后就会进行连通性检查,使用的就是STUN协议的binding request和binding response,用于校验信息的的属性为USERNAME,其值为 remote.iceUfrage:local.iceUfrag,来源为双方协调的sdp

  1. candidate-pair提名与candidate-pair确定

提名由controlling发起,controlled被动接受。

提名的方式有2种:use-candidate与renomination,二者互斥,只会选用一种,在浏览器里使用renomination,需要在sdp里加上a=ice-options:renomination

其中use-candidate又分为普通提名(Regular nomination)与激进提名(Aggressive nomination),普通提名是先对checklist进行连通性检查,检查通过后,在valid list里使用STUN的USE-CANDIATE进行提名。而激进提名是在checklist第一次连通性检查时,就进行提名。

下面是抓包Chrome浏览器使用的激进式提名

renomination是checklist哪个pair先探测通就使用哪个,后面有优先级更高的pair连通时,就通过STUN的NOMINATION attribute来通知controlled来切换candidate-pair。

renomination只有一个草案,并非rfc标准,NOMINATION attribute的id也为WebRTC自行定义 0xC001

// https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/api/transport/stun.h;l=735;bpv=0;bpt=1
enum IceAttributeType {
  ...
  // The following attributes are in the comprehension-optional range
  // (0xC000-0xFFFF) and are not registered with IANA. These STUN attributes are
  // intended for ICE and should NOT be used in generic use cases of STUN
  // messages.
  //
  // Note that the value 0xC001 has already been assigned by IANA to
  // ENF-FLOW-DESCRIPTION
  // (https://www.iana.org/assignments/stun-parameters/stun-parameters.xml).
  STUN_ATTR_NOMINATION = 0xC001,  // UInt32
  // UInt32. The higher 16 bits are the network ID. The lower 16 bits are the
  // network cost.
  ...
};
  1. ICE之后

ICE连接成功之后,还会使用STUN进行保活,同时进行DTLS加密协商(浏览器不可关闭),再之后会使用SRTP进行音视频数据通讯,RTCP作为流控制协议来保证QoS,通过SCTP来完成datachannel数据传输。

STUN保活仍然使用的stun binding request/response,不使用indication,是因为indication没有ACK机制,不适合用来保活。

五、WebRTC代码实现

前端开发同学肯定在面试过程中遇到过根据Promise A+规范来实现一个Promise类,上面提到的连接工作原理被定义在各个rfc中,开发者可以根据rfc来实现自己的WebRTC引擎。Google实现的WebRTC引擎代码是开源的,并且可以在线查看:source.chromium.org/chromium/ch…

其中和连接相关的代码在 webrtc/p2p目录。因为我也不是专业搞引擎开发的,并不能写出多么深刻的理解,代码实现部分如果想深入学习需要阅读其他专业文章如WebRTC Native 源码导读(十二):P2P 连接过程完全解析 - Piasy的博客 | Piasy Blog

但是我可以分享下我的学习方法:有目的性看源码和debug的方式来学习。WebRTC函数调用栈都非常长并且分支很多,如上来就要通读代码,那就是一拳打在棉花上,无处发力(没有抓手?),因此可以看一个点,然后看其调用链路。debug的话虽然WebRTC自带了一个Demo(c++实现),但前端学习的话建议直接调试浏览器,调试浏览器可以从其他路径学习到,过程也是比较复杂,这里也可以参考其他人的文章。

下面尝试解读下WebRTC对candiadte-pair的排序(candidate-pair对应的代码是webrtc/p2p/base/connection.cc),从中可以看到熟悉的优先级计算,我们知道WebRTC肯定会对Connection的优先级进行排序,那么WebRTC对Connection的排序过程,以及WebRTC仅使用了优先级排序吗?

uint64_t Connection::priority() const {
  RTC_DCHECK(port_) << ToDebugId() << ": port_ null in priority()";
  if (!port_)
    return 0;

  uint64_t priority = 0;
  // RFC 5245 - 5.7.2.  Computing Pair Priority and Ordering Pairs
  // Let G be the priority for the candidate provided by the controlling
  // agent.  Let D be the priority for the candidate provided by the
  // controlled agent.
  // pair priority = 2^32*MIN(G,D) + 2*MAX(G,D) + (G>D?1:0)
  IceRole role = port_->GetIceRole();
  if (role != ICEROLE_UNKNOWN) {
    uint32_t g = 0;
    uint32_t d = 0;
    if (role == ICEROLE_CONTROLLING) {
      g = local_candidate().priority();
      d = remote_candidate_.priority();
    } else {
      g = remote_candidate_.priority();
      d = local_candidate().priority();
    }
    priority = std::min(g, d);
    priority = priority << 32;
    priority += 2 * std::max(g, d) + (g > d ? 1 : 0);
  }
  return priority;
}
  1. 在source.chromium.org上点击 Connection::priority在下方可以看到调用的地方,点击其中一个在右下角显示具体代码,可以看到是在CompareConnectionCandiates里补充调用的,后面追踪的方式相同,不再单独截图了

  1. 看下CompareConnectionCandiates里的比较逻辑:

    1. 先是通过CompareCandidatePairNetworks方法比较了网络开销(network cost)
    2. 如果上步相等则比较优先级(这里至少看到了,WebRTC对Connection排序并不只看优先级)
    3. 如果上步仍相等则比较candidate-pair的generation
    4. 如果上步仍相等则看Connection是否被标记为裁剪(pruned)
  2. 继续点击CompareConnectionCandiates,看看它何时被调用,可以看到在同文件里被调用了3次,那根据抓住主要矛盾规则,继续分析下CompareConnections做了什么

int BasicIceController::CompareConnections(
    const Connection* a,
    const Connection* b,
    absl::optional<int64_t> receiving_unchanged_threshold,
    bool* missed_receiving_unchanged_threshold) const {
  RTC_CHECK(a != nullptr);
  RTC_CHECK(b != nullptr);

  // We prefer to switch to a writable and receiving connection over a
  // non-writable or non-receiving connection, even if the latter has
  // been nominated by the controlling side.
  int state_cmp = CompareConnectionStates(a, b, receiving_unchanged_threshold,
                                          missed_receiving_unchanged_threshold);
  if (state_cmp != 0) {
    return state_cmp;
  }

  if (ice_role_func_() == ICEROLE_CONTROLLED) {
    // Compare the connections based on the nomination states and the last data
    // received time if this is on the controlled side.
    if (a->remote_nomination() > b->remote_nomination()) {
      return a_is_better;
    }
    if (a->remote_nomination() < b->remote_nomination()) {
      return b_is_better;
    }

    if (a->last_data_received() > b->last_data_received()) {
      return a_is_better;
    }
    if (a->last_data_received() < b->last_data_received()) {
      return b_is_better;
    }
  }

  // Compare the network cost and priority.
  return CompareConnectionCandidates(a, b);
}

CompareConnections同样使用了多种判断哪个更better的方式:

  1. 先是通过CompareConnectionStates比较Connection的状态,关于Connection状态可以看上面推荐文章里的[Connection 状态变迁]
  2. 如果上步相等则看ice role如为controlled,看看这个Connection是不是被提名了,看看哪个最近接收到数据
  3. 如果上面仍相等则看第2步分析的CompareConnectionCandiates(注意这里仍然有可能比较不出来)
  1. 继续点击CompareConnections,可以看到被同文件的SortAndSwitchConnection调用,其逻辑正如其函数名,先排序,排完后用排第1的connection去看下是否要切换
BasicIceController::SortAndSwitchConnection(IceSwitchReason reason) {
  // Find the best alternative connection by sorting.  It is important to note
  // that amongst equal preference, writable connections, this will choose the
  // one whose estimated latency is lowest.  So it is the only one that we
  // need to consider switching to.
  // TODO(honghaiz): Don't sort;  Just use std::max_element in the right places.
  absl::c_stable_sort(
      connections_, [this](const Connection* a, const Connection* b) {
        int cmp = CompareConnections(a, b, absl::nullopt, nullptr);
        if (cmp != 0) {
          return cmp > 0;
        }
        // Otherwise, sort based on latency estimate.
        return a->rtt() < b->rtt();
      });

  RTC_LOG(LS_VERBOSE) << "Sorting " << connections_.size()
                      << " available connections due to: "
                      << IceSwitchReasonToString(reason);
  for (size_t i = 0; i < connections_.size(); ++i) {
    RTC_LOG(LS_VERBOSE) << connections_[i]->ToString();
  }

  const Connection* top_connection =
      (!connections_.empty()) ? connections_[0] : nullptr;

  return ShouldSwitchConnection(reason, top_connection);
}
  1. 代码倒着追到这里,其实就已经知道排序的逻辑了,可以作为一个阶段性小结了,当然也可以继续向上看调用栈,如果不看代码根据ICE的工作原理也可以想象到新组成candidate-pair(Connection)后肯定要排下序,我们可以从reason参数中窥探其上游哪些场景调用,果然有一些NEW_CONNECTION的情况
enum class IceSwitchReason {
  UNKNOWN,
  REMOTE_CANDIDATE_GENERATION_CHANGE,
  NETWORK_PREFERENCE_CHANGE,
  NEW_CONNECTION_FROM_LOCAL_CANDIDATE,
  NEW_CONNECTION_FROM_REMOTE_CANDIDATE,
  NEW_CONNECTION_FROM_UNKNOWN_REMOTE_ADDRESS,
  NOMINATION_ON_CONTROLLED_SIDE,
  DATA_RECEIVED,
  CONNECT_STATE_CHANGE,
  SELECTED_CONNECTION_DESTROYED,
  // The ICE_CONTROLLER_RECHECK enum value lets an IceController request
  // P2PTransportChannel to recheck a switch periodically without an event
  // taking place.
  ICE_CONTROLLER_RECHECK,
  // The webrtc application requested a connection switch.
  APPLICATION_REQUESTED,
};
  1. 刚才是看代码从后向前推导,使用debug方式的话,可以直接打到最开始的位置,这里看到reason是REMOTE_CANDIDATE_GENERATION_CHANGE,如果想这条链路可在调用栈上继续向前追踪

六、抓包

只熟悉理论很快就会忘记内容,如果想深入看下协议内容,可以使用Wireshark抓包(本机p2p使用wireshark是抓不到的),下载地址:Wireshark · Download

参考文档

  1. WebRTC candidate_candidate webrtc-CSDN博客
  2. WebRTC收集服务器反射地址候选者 源码剖析_webrtc candidate收集-CSDN博客
  3. WebRTC收集中继地址候选者 源码剖析_xor-relayed-address_椛茶的博客-CSDN博客
  4. WebRTC 中 PeerConnection 建立连接过程介绍 | 码农家园
  5. WebRTC音视频传输基础:NAT穿透
  6. WebRTC Native 源码导读(十二):P2P 连接过程完全解析 - Piasy的博客 | Piasy Blog