WebRTC(Web Real-Time Communications)是Google公司开源的一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流、音频流或者其他任意数据的传输。
在开启音视频传输之前,WebRTC会使用ICE/STUN/TURN等技术进行网络连接,本文就将从宏观的建连过程和底层工作原理,介绍WebRTC的建连过程。
一、前置
WebRTC的基础API与流程可以通过下面这个项目来熟悉
名词解释:
-
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架构,即使用客户直接与媒体服务器相连接,媒体服务器架设在公网上,所以连接成功率非常高。
三、连接过程
连接过程中有个特别重要的概念candidate,可以简单理解为本地为进行ICE连接收集到的内网/外网地址,连接双方收集完成并交换后,双方即可进行ICE连接(连接过程后面会详细介绍)。
P2P
-
Client A/Client B分别连接信令服务器;
-
Client A(发起方)开始执行,创建RTCPeerConnection实例,开启音视频采集,将音视频流挂载到peerConnection实例,生成offer sdp;
-
通过信令服务器中转将offer发送到Client B;
-
Client B(接收方)开始执行,创建RTCPeerConnection实例,将对端的offer设置进去,再生成answer;
-
通过信令服务器中转将answer发送回Client A;
-
Client A设置本地offer后,通过onicecandidate回调收集的candidate发送给Client B,Client B也通过设置本地answer后,将收集的candidate发送回Client A,(Client A设置本地offer与发送offer可以同时进行);
-
Client A/Client B拿到对方的candiate后,通过addIceCandidate添加到本端,如此就可以建立p2p连接;
-
最后Client B在连接之上拿到Client A的音视频数据;
SFU/MCU
SFU/MCU一端为公网服务器,不需要再收集外网地址,连接过程会简单一些:
-
Client与SFU建立信令连接;
-
Client A(发起方)开始执行,创建RTCPeerConnection实例,开启音视频采集,将音视频流挂载到peerConnection实例,生成offer sdp;
-
SFU设置offer后,生成answer,将candidate信息拼接到answer sdp中;
-
Client拿到SFU的candiate后,就可以建立p2p连接(Client也会收集host类型的candidate,与SFU的candidate凑成candidate-pair才能建立p2p连接);
四、连接工作原理(ICE、STUN、TURN)
STUN协议
要了解ICE的工作流程,需要熟悉下STUN协议。
rfc8489 datatracker.ietf.org/doc/html/rf...
协议头
diff
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):
diff
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|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
C1C0 | 0b00 | 0b01 | 0b10 | 0b11 |
---|---|---|---|---|
含义 | request | indication | success response | error response |
-
Message Length:消息长度必须包含不包括20字节STUN头的消息的大小(以字节为单位)。由于所有增加的属性都填充为4字节的倍数,因此该字段的最后2位始终为零。这提供了另一种区分STUN数据包和其他协议数据包的方法。
-
Magic Cookie:固定值**
0x2112A442
**,可以和其他协议包区分,在探测外网地址时和IP做XOR使用 -
Transaction ID (96 bits) :事务ID,关联reqeust与response,探测外网地址为ipv6时也参与XOR
STUN Attributes
diff
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
candidate简介
candidate的本质就是一个网络地址
candidate格式如下,最重要的有以下几部分:协议,IP,PORT,优先级
makefile
协议 优先级 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
优先级计算:
scss
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)
candidate收集
candidate分类有4种,每种都会有不同的收集方式:
地址类型 | 获取途径 |
---|---|
host candidate | 本机获取的地址,一般就是网卡的地址 |
server-reflexive candidates | 使用STUN协议,由STUN server返回的公网地址 |
relayed candidates | 使用STUN协议,TURN server返回的是中继公网地址 |
peer-reflexive candidate | 连通性检查时,由STUN协议返回的外网地址 |
lua
To Internet
|
|
| /------------ Relayed
Y:y | / Address
+--------+
| |
| TURN |
| Server |
| |
+--------+
|
|
| /------------ Server
X1':x1'|/ Reflexive
+------------+ Address
| NAT |
+------------+
|
| /------------ Local
X:x |/ Address
+--------+
| |
| Agent |
| |
+--------+
3.1 andidate收集的JS代码实现
javascript
// 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服务器工作原理
暂时无法在飞书文档外展示此内容
STUN服务器收到客户端发送的binding request包,会从IP协议拿到外网的IP,从传输协议拿到PORT,最后将IP与PORT做异或处理放到binding response的XOR-MAPPED-ADDRESS中返回,客户端拿到后回调给上层。
3.3 TURN服务器工作原理
暂时无法在飞书文档外展示此内容
-
turn client向turn server发送STUN Allocate请求(attribute: REQUESTED-TRANSPORT & LIFETIME等),turn server会为该请求分配一个端口,作为中继地址,再通过STUN response返回给turn client(attribute: XOR-RELAYED-ADDRESS);
-
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。
candidate交换
candiate有2种形式交换:
- candiate收集完成后,组装到本地生成的sdp中,将sdp发送给对方,对方通过
setRemoteDescription
API来完成candidate的设置,一般SFU/MCU会使用这种方式,因为服务端有公网地址,返回answer时可以直接拼接到sdp中。
ini
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分开单独发送给对方,对方通过
addIceCandidate
API来完成candidate的设置
candiate交换信道:
candidate通常与sdp转发的信道一致,一般通过业务websocket服务转发(Agora),也可以由http服务器转发;
近年出现了WHIP/WHEP协议草案规定使用HTTP请求和响应来传输信令数据。
candidate的处理
candidate交换完成后,就会把双方的candidate进行排列组合成candidate-pair,并且双方会进入角色扮演,一个作为controlling控制方,一个作为controlled被控制方,不同角色会在后面连接的过程中的处理方式不同,角色是会扮演ICE的全过程。
上面组成的所有candidate-pair称为checklist,checklist会根据优先级排序、裁剪,连通性检查,然后controlling会提名一个candidate-pair作为最终的通讯链路。
candidate-pair优先级
candidate-pair的优先级是根据local candidate和remote candidate优先级按下面公式计算而得,计算完成后会按优先级从高到低排序
scss
pair priority = 2^32*MIN(G,D) + 2*MAX(G,D) + (G>D?1:0)
裁剪candidate-pair
裁剪有2个原则: 如果pair的local candidate base相同、remote candidate完成相同,那就会裁剪掉优先级低的pair;二是如果pair数量很多,会按优先级从低开始裁剪。
连通性检查
裁剪后就会进行连通性检查,使用的就是STUN协议的binding request和binding response,用于校验信息的的属性为USERNAME,其值为 remote.iceUfrage:local.iceUfrag
,来源为双方协调的sdp
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
ruby
// 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.
...
};
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仅使用了优先级排序吗?
ini
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;
}
- 在source.chromium.org上点击 Connection::priority在下方可以看到调用的地方,点击其中一个在右下角显示具体代码,可以看到是在CompareConnectionCandiates里补充调用的,后面追踪的方式相同,不再单独截图了
-
看下CompareConnectionCandiates里的比较逻辑:
- 先是通过CompareCandidatePairNetworks方法比较了网络开销(network cost)
- 如果上步相等则比较优先级(这里至少看到了,WebRTC对Connection排序并不只看优先级)
- 如果上步仍相等则比较candidate-pair的generation
- 如果上步仍相等则看Connection是否被标记为裁剪(pruned)
-
继续点击CompareConnectionCandiates,看看它何时被调用,可以看到在同文件里被调用了3次,那根据抓住主要矛盾规则,继续分析下CompareConnections做了什么
scss
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的方式:
- 先是通过CompareConnectionStates比较Connection的状态,关于Connection状态可以看上面推荐文章里的[Connection 状态变迁]
- 如果上步相等则看ice role如为controlled,看看这个Connection是不是被提名了,看看哪个最近接收到数据
- 如果上面仍相等则看第2步分析的CompareConnectionCandiates(注意这里仍然有可能比较不出来)
- 继续点击CompareConnections,可以看到被同文件的SortAndSwitchConnection调用,其逻辑正如其函数名,先排序,排完后用排第1的connection去看下是否要切换
arduino
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);
}
- 代码倒着追到这里,其实就已经知道排序的逻辑了,可以作为一个阶段性小结了,当然也可以继续向上看调用栈,如果不看代码根据ICE的工作原理也可以想象到新组成candidate-pair(Connection)后肯定要排下序,我们可以从reason参数中窥探其上游哪些场景调用,果然有一些NEW_CONNECTION的情况
kotlin
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,
};
- 刚才是看代码从后向前推导,使用debug方式的话,可以直接打到最开始的位置,这里看到reason是REMOTE_CANDIDATE_GENERATION_CHANGE,如果想这条链路可在调用栈上继续向前追踪
六、抓包
只熟悉理论很快就会忘记内容,如果想深入看下协议内容,可以使用Wireshark抓包(本机p2p使用wireshark是抓不到的),下载地址:Wireshark · Download