引言
WebRTC (Web Real-Time Communication)是一个开放源代码项目,允许 Web 应用程序进行实时的点对点通信,无需任何中间服务器或插件。它最初由 Google 开发,后来逐渐被 W3C 和 IETF 等标准化组织所采纳。
由于WebRTC能够实现浏览器之间的直接通信,避免了中间服务器的性能瓶颈,因此它非常适合需要实时互动的场景,包括但不限于:
- 视频会议:实现浏览器之间的多方视频会议。
- 在线云游戏:让游戏玩家进行实时互动和协作。
- 远程协作:让远程用户进行实时协作和屏幕共享。
入门时看了一些文档和博客,拉了别人的Demo跑了一下,便以为学懂了😂。最近想进阶学习下,做个Demo方便调试,才发现自己基础都没打牢,整个建立连接的时序理解的并不够清楚,各个步骤顺序稍有错乱就会达不到预期,有的甚至还没报错。纸上得来终觉浅...以此博客来记录梳理一下吧~
核心概念
WebRTC建立连接的过程中主要涉及三大通信主体(客户端A、客户端B、信令服务器)、两大核心流程(交换SDP、交换ICE)。这里先单独讲讲信令服务器、SDP、ICE这三个核心概念,后续理解建连流程。
Signal信令服务器
WebRTC在建立起P2P的连接前,需要交换双方的一些信息,这时候就需要有一个中间方来在建立连接过程中实现数据交换,我们将这个中间方称为信令服务器。WebRTC的方案里并没有提供信令服务器的实现,需要开发者自行实现这样的服务器,完成以下几个功能:
- 向对端发起通话
- 交换双方的SDP信息
- 交换双方的ICE信息
- .......
由于信令服务器不仅需要接收客户端的消息,还要将对端信息推送给客户端,因此通常使用WebSocket与客户端通信。有不少教程直接使用socket.io库(支持WebSocket与HTTP长轮询方式),但它要求客户端与服务端必须要统一使用socket.io,因此下文Demo直接使用原生WebSocket实现。
SDP媒体协商
SDP(Session Description Protocol,会话描述协议)是一个应用层协议,它通常用于视频会议、语音通话等实时多媒体通信场景中描述会话信息,以确保双方可以正确地建立和维护多媒体通信会话。SDP媒体协商就是通信双方生成自身SDP,并通过Signal服务与对方交换SDP的过程。
🌰 SDP会话信息包含哪些内容呢?我们用一个实际生成的SDP例子来看下
o=- 1308340942883445354 2 IN IP4 127.0.0.1 #会话的唯一标识符和版本号,以及会话主机的网络类型和地址
s=- #会话名称字段,这里没有指定会话名称
t=0 0 #时间描述,表示会话何时开始和结束。
a=group:BUNDLE 0 1
a=extmap-allow-mixed
a=msid-semantic: WMS 194aa7fa-55a1-4135-9298-5c836e012123 #媒体流标识符
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126 #媒体描述,包括媒体类型(audio音频/video视频)、端口号(9)、传输协议(UDP/TLS/RTP/SAVPF)和使用的编码格式。
c=IN IP4 0.0.0.0 #网络信息,包括网络类型(IN互联网)、地址类型(IP4)、连接地址(0.0.0.0表示媒体流可以发送到任何接口)
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:76eU #ICE候选人用户名
a=ice-pwd:n8KzDzhJgJ+v3l4Df1L/OPYA #ICE候选人密码
a=ice-options:trickle
a=fingerprint:sha-256 CC:7D:6D:9D:CD:AF:6B:8A:FC:E3:D9:35:
CE:B1:14:D9:82:4A:52:26:43:94:4E:98:B2:63:5B:97:66:53:DB:75 #DTLS连接使用的加密算法和签名,用于保障UDP传输的安全性
a=setup:actpass #DTLS连接角色(actpass支持主动和被动、active主动、passive被动)
a=mid:0 #媒体流ID,即m=audio的媒体流标识符为0
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=sendrecv
a=msid:194aa7fa-55a1-4135-9298-5c836e012123 d3dd0e0e-2cf0-4115-b981-e8420b7215a9
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=rtcp-fb:111 transport-cc
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:63 red/48000/2
a=fmtp:63 111/111
a=rtpmap:9 G722/8000
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:13 CN/8000
a=rtpmap:110 telephone-event/48000
a=rtpmap:126 telephone-event/8000
a=ssrc:1810336017 cname:mQtYC6tfVDv6BlTM
a=ssrc:1810336017 msid:194aa7fa-55a1-4135-9298-5c836e012123 d3dd0e0e-2cf0-4115-b981-e8420b7215a9
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 105 106 107 108 109 127 125 39 40 45 46 98 99 100 101 112 113 116 117 118
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:76eU
a=ice-pwd:n8KzDzhJgJ+v3lSDfOL/OPYA
a=ice-options:trickle
a=fingerprint:sha-256 CC:5D:6D:9D:CD:AF:6B:8A:FC:E3:D9:35:AE:B1:14:D6:82:4A:52:26:43:94:4E:98:B2:63:5B:97:66:53:DB:75
a=setup:actpass
a=mid:1
......
这里总结一下,SDP中主要包含这些类型的数据:
1.会话元数据
- 会话名称和会话信息
- 会话开始和结束时间
2.媒体属性
- 媒体类型(音频、视频、文本等)
- 编解码格式
3.网络信息
- 传输协议(RTP、UDP、TCP等)
- 连接信息(地址、端口)
4.加密和安全信息
- 加密算法
- 密钥信息
5.ICE信息
- ICE用户名和密码
- ICE地址收集方式(trickle增量式收集,即边收集边建立ICE连接)
ICE交互式建连
当我们希望实现一个去中心化的点对点通信时,需要让通信双方知晓对方的地址,这样才能知道如何将数据层层转发给对方。然鹅在实际的P2P场景中,由于存在NAT协议转换和防火墙,实现P2P通信时,我们常常难以直接获知对方的真实网络IP。ICE(Interactive Connectivity Establishment,交互式连接建立)这个技术就是用来解决上述问题的。ICE交互式建连的大致流程如下:
- 建立连接时,通信双方需要先向STUN或者TURN服务器请求自身的“地址”(即icecandidate,包括本地IP地址、端口号和传输协议等)。
- STUN或者TURN服务器收到请求后,会对客户端进行一系列的连通性测试,确定可用的路径和最佳连接方式后,生成icecandidate返回。
- 客户端收到自身icecandidate后,再通过信令服务器将icecandidate告知对方。
🌰以下是一个由onicecandidate回调返回的ice,包含以下信息:
{
// 【标准的 ICE 候选信息】
// `2122260223`: priority 值,用于排序候选的优先级,值越大优先级越高。
// `10.73.57.34`: 候选的 IP 地址,是一个内网地址。
// `63459`: 候选的端口号。
// `typ host`: 表示这个候选是一个本地主机地址。
// `generation 0`: 候选的生成序号,表示这是第 0 代候选。
// `network-id 1`: 网络接口的 ID。
// `network-cost 10`: 网络接口的代价,值越小代表网络质量越好。
"candidate": "candidate:2485506769 1 udp 2122260223 10.73.57.34 63459 typ host generation 0 ufrag l02z network-id 1 network-cost 10",
// 【这个ICE候选对应的媒体流ID】
"sdpMid": "1",
// 【这个ICE候选对应的SDP描述中的行号】
"sdpMLineIndex": 1,
}
🤔如何理解sdpMid和sdpMLineIndex这两个字段呢?
举个例子,假设 SDP 描述中有3个媒体流:
m=audio 49170 RTP/AVP 0
a=mid:0 #媒体流ID
m=video 51372 RTP/AVP 31
a=mid:1
m=application 54400 DTLS/SCTP 5000
a=mid:2
对于音频流,sdpMid=0, sdpMLineIndex=0
对于视频流,sdpMid=1, sdpMLineIndex=1
对于数据通道,sdpMid=2, sdpMLineIndex=2
💡思考:ICE与SDP的关系
虽然ICE负责沟通通信地址、SDP负责沟通媒体流格式,但可以从上面的实际数据结构可以看到,二者的关系并不是完全割裂开来的。
- SDP内包含了ICE的用户名和密码,接收端可以使用用户名和密码来验证收到的ICE信息的来源和完整性,防止未经授权的对等端或攻击者发送伪造的ICE消息,从而保护ICE连接过程的安全性。
- ICE的
sdpMid和sdpLineIndex与SDP媒体流ID关联,用来标识每个ICE候选者所属的媒体流。如果某个媒体流的 ICE 协商失败,也不会影响其他媒体流的连接。
很多博客里说ICE和SDP交换的流程是同时发生的,但实际上手调试后,发现其实不然。我一开始也是这么理解了,便没有去仔细思考时序问题,导致一直拿不到ICE信息💔
由于ICE内需要标识SDP中媒体流ID,因此必须先使用createOffer/createAnwser创建SDP后,才会触发ICE收集流程。另外,由于ICE需要和SDP内的媒体描述对应的,因此我们需要保证创建的SDP中包含m=video...这样的媒体描述,才能从onicecandidate中拿到ICE。
参考:stackoverflow.com/questions/2…
核心对象
RTCPeerConnection 端连接对象
RTCPeerConnection是浏览器为webRTC提供的端连接对象。
【核心API及其作用】
- 发现本端ICE通道 (onicecandidate)
- 记录对端的ICE(addIceCandidate)
- 创建SDP offer/answer(createOffer/createAnswer)
- 记录对端的SDP(setRemoteDescription)
- 记录本端的SDP(setLocalDescription)
- 接收数据流(ontrack)
- 发送数据流(addTrack)
DataChannel数据通道
RTCPeerConnection对象能够使用createDataChannel创建一个与远程对等连接的新通道,该通道可以传输任何类型的数据,主要用于传输非媒体流的数据。由于它的通讯不需要经过服务器中转,灵活性很高。
const pc = new RTCPeerConnection();
const channel = pc.createDataChannel("chat");
channel.onopen = (event) => {
channel.send('Hi you!');
}
channel.onmessage = (event) => {
console.log(event.data);
}
MediaStreamTrack媒体轨道
媒体流的轨道,一个MediaStreamTrack包含一种媒体源(媒体设备或录制内容)返回的单一类型的媒体(如音频,视频)。 WebRTC并不能直接控制媒体流,对流的一切控制都可以通过MediaTrackConstraints。
// 获取本地录制的音频与视频
localStreamRef.current = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
pcRef.current = new RTCPeerConnection();
// 将自己的本地视频传给远端
localStreamRef.current.getTracks().forEach((track: MediaStreamTrack, index) => {
// getTracks返回两条轨道:音频、视频
pcRef.current!.addTrack(track, localStreamRef.current!);
});
// 播放本地录制的视频
localVideoRef.current!.srcObject = localStreamRef.current;
MediaStream媒体流
MediaStream是MediaStreamTrack的合集,可以包含 >=0 个 MediaStreamTrack。MediaStream能够确保它所包含的所有轨道都是是同时播放的。
pcRef.current = new RTCPeerConnection();
// 接收远端视频由video播放
// e.streams 就是一个包含若干个MediaStream的Array
// https://developer.mozilla.org/en-US/docs/Web/API/RTCTrackEvent/streams
pcRef.current.ontrack = (e) => (remoteVideoRef.current!.srcObject = e.streams[0]);
P2P视频通话Demo
接着我们通过实现一个简单的P2P视频通话Demo,来进一步熟悉WebRTC的建联流程。 Demo包含了客户端与服务端,客户端即需要通信的主体,服务端则主要负责信令转发服务。
Demo源码指路
下面就着这个时序图讲讲核心流程~
通信时序图
sequenceDiagram
rect rgba(236, 239, 252, 0.3)
Alice->>Alice: getUserMedia
Alice->>Signal: calling Bob
Signal->>Bob: calling Bob
Bob->>Bob: getUserMedia
Bob->>Signal: acceptCall
Signal->>Alice: acceptCall
end
rect rgba(251, 239, 252, 0.3)
Alice->>Alice: createPeerConnection
Alice->>Alice: peerConnection.addTrack
Alice->>Alice: peerConnection.createOffer
Alice->>Alice: peerConnection.setLocalDescription(offer)
Alice->>STUN/TURN: request ice candidate
Alice->>Signal: send sdp offer
Signal->>Bob: send sdp offer
Bob->>Bob: createPeerConnection
Bob->>Bob: peerConnection.addTrack
Bob->>Bob: peerConnection.setRemoteDescription(offer)
Bob->>Bob: peerConnection.createAnswer
Bob->>Bob: peerConnection.setLocalDescription(answer)
Bob->>STUN/TURN: request ice candidate
Bob->>Signal: send sdp answer
Signal->>Alice: send sdp answer
Alice->>Alice: peerConnection.setRemoteDescription(answer)
end
rect rgba(236, 239, 252, 0.3)
STUN/TURN-->>Alice: onicecandidate
Alice->>Signal: send candidate
Signal->>Bob: send candidate
Bob->>Bob: peerConnection.addIceCandidate(candidate)
STUN/TURN-->>Bob: onicecandidate
Bob->>Signal: send candidate
Signal->>Alice: send candidate
Alice->>Alice: peerConnection.addIceCandidate(candidate)
end
Alice->>Bob: send mediastream
Bob->>Alice: send mediastream
1. 与信令服务器建立连接
开始建立WebRTC前,客户端需要先与Signal信令服务器建立连接,方便后续接收Signal推送过来的发起通话、sdp、ice信息。
客户端
客户端websocket的连接非常简单,实际代码中我们也可以封装成一个单独的类来使用。
// 构建ws url
//__USER_IDENTITY__相当于用户ID,信令服务器可以用这个ID标识不同的ws连接
const SIGNALING_SERVER = "ws://localhost:3322";
const wsUrl = `${SIGNALING_SERVER}?user=${__USER_IDENTITY__}`;
// 建立ws连接
const connection = new WebSocket(wsUrl);
//接收ws消息
connection.onmessage = (data)=>{};
// 发送ws消息(参数为string)
connection.send(JSON.stringify(data));
服务端
对于服务端而言,ws连接是多对一的,因此这里引入一个socketPool连接池保存对于不同客户端的连接,方便找到消息接收方。
通过代码可以看到,其实Signal在中间就是做了一个无脑转发的功能,大家也可以根据自己需要去掉switch case直接实现简单转发即可。这里只是为了清晰一些展示消息类型,所以单独罗列了出来。
const socketPool = {}; // websocket连接池
const initWebSocket = (server) => {
const wss = new WebSocket.Server({ server });
// 与客户端每次建立websocket连接都会触发connection事件
// wss.clients.size 当前连接数
wss.on("connection", (ws, req) => {
// 提取url中的user参数,作为ws连接的key
const parsedUrl = url.parse(req.url, true);
const userId = parsedUrl.query["user"];
socketPool[userId] = ws;
ws.on("message", onMessage);
});
};
function onMessage(message) {
const { cmd, payload = {} } = JSON.parse(message);
console.log(`ws Received`, cmd, payload, Object.keys(socketPool));
const recieveWs = socketPool[payload.to];
switch (cmd) {
// 转发呼叫信息
case SOCKET_CMD_RECIVE.calling:
recieveWs?.send(
JSON.stringify({
cmd: SOCKET_CMD_SEND.calling,
payload: {
from: payload.from,
to: payload.to,
},
})
);
break;
// 转发呼叫应答信息
case SOCKET_CMD_RECIVE.acceptCall:
recieveWs?.send(
JSON.stringify({
cmd: SOCKET_CMD_SEND.acceptCall,
payload: {
from: payload.from,
to: payload.to,
},
})
);
break;
// 转发sdp-offer
case SOCKET_CMD_RECIVE.offer:
recieveWs?.send(
JSON.stringify({
cmd: SOCKET_CMD_SEND.offer,
payload: {
from: payload.from,
to: payload.to,
offer: payload.offer,
},
})
);
break;
// 转发sdp-answer
case SOCKET_CMD_RECIVE.answer:
recieveWs?.send(
JSON.stringify({
cmd: SOCKET_CMD_SEND.answer,
payload: {
from: payload.from,
to: payload.to,
answer: payload.answer,
},
})
);
break;
// 转发ice
case SOCKET_CMD_RECIVE.candidate:
recieveWs?.send(
JSON.stringify({
cmd: SOCKET_CMD_SEND.candidate,
payload: {
from: payload.from,
to: payload.to,
candidate: payload.candidate,
},
})
);
break;
// 通知对端挂断通话
case SOCKET_CMD_RECIVE.hangUp:
recieveWs?.send(
JSON.stringify({
cmd: SOCKET_CMD_SEND.hangUp,
payload: {
from: payload.from,
to: payload.to,
},
})
);
break;
default:
break;
}
}
// http和ws用同一个端口
const app = new Koa();
const server = http.createServer(app.callback());
initWebSocket(server);
2. 获取本地媒体流
在正式通话前,我们需要拿到用于通话的媒体流,使用getUserMedia可以唤起浏览器摄像头,getDisplayMedia可以唤起浏览器录屏。
拿到本地媒体流后就可以通过Siganl向对端发起通话了,对端收到通话邀请后,同样需要获取本地媒体流权限。
const streamRef = useRef<MediaStream | null>(null);
// getUserMedia 本地摄像头
// getDisplayMedia 浏览器录屏
streamRef.current = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
3. 创建peerConnection
创建RTCPeerConnection对象,负责点对点的数据传输。使用peerConnection.addTrack()提供媒体流,为后续创建SDP做准备。
// 提供stun/turn服务器地址
const ICE_CONFIG = {
iceServers: [
{ urls: ["stun:stun.ekiga.net:3478", "stun:stun.ekiga.net:3578"] },
{ urls: ["turn:turnserver.com"], username: "user", credential: "pass" },
]
};
// 创建peerConnection
createPeerConnection() {
const peerConnection = new RTCPeerConnection(ICE_CONFIG);
// 必须在createOffer前提供媒体流,否则创建的sdp将缺少media信息,收集ice的流程也不会开启
// https://stackoverflow.com/questions/27489881/webrtc-never-fires-onicecandidate/27758788#27758788
streamRef.current.getTracks().forEach((track) => {
peerConnection.addTrack(track, this.localStream);
});
}
注意,如果某端希只接收不发送媒体流,那么peerConnection.addTrack并不是必要的。
但是peerConnection.addTrack执行后,后续生成的SDP才会有m=audio/video这样的媒体描述,因此我们会把获取媒体流放在createOffer的前一步。
SDP中的媒体描述是必要的,如果你不希望向对端传送媒体流,也可以跳过getUserMedia使用以下方法,让后续createOffer创建一个只收不发的SDP媒体描述。
【方法一】
createOffer时设置offerToReceiveAudio: true,在创建的 SDP Offer 中,会包含一个 m=aideo 媒体行,并指定 a=recvonly 属性。true表示该 RTCPeerConnection 实例希望接收来自对端的视频流。默认情况下offerToReceiveAudio值为false,媒体属性为a=sendonly。
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
【方法二】
offerToReceiveAudio / offerToReceiveVideo 是一个旧属性,MDN上更推荐使用用 RTCRtpTransceiver 去控制是否接受传入的音频。
peerConnection.addTransceiver('audio', { direction: 'recvonly' });
peerConnection.addTransceiver('video', { direction: 'recvonly' });
const offer = await this.peerConnection.createOffer();
4. SDP协商
SDP协商的过程简单描述来说就是:A创建SDP offer,通过Signal转发给B,B收到后记录下来。然后B创建SDP answer,通过Signal回复给A,A收到后同样记录下来。
另外需要注意的是,在创建SDP后,还要使用peerConnection.setLocalDescription记录下自身的SDP,只有setLocalDescription后,WebRTC才会将onicecandidate与stun/turn请求绑定,我们才能通过onicecandidate回调拿到ice信息。
// A创建offer并发送给 B
async sendOffer() {
const offer = await this.peerConnection.createOffer();
// 使用ws发送给信令服务器
this.signal.send({
cmd: E_SOCKET_CMD_SEND.offer,
payload: { from: this.localID, to: this.remoteID, offer },
});
// setLocalDescription 后才会触发收集ice流程
this.peerConnection.setLocalDescription(offer);
}
// B收到offer后,创建peerConnection,记录下来
this.createPeerConnection();
this.peerConnection.setRemoteDescription(offer);
this.sendAnswer();
// B创建answer并发送给A
async sendAnswer() {
const answer = await this.peerConnection.createAnswer();
this.signal.send({
cmd: E_SOCKET_CMD_SEND.answer,
payload: { from: this.localID, to: this.remoteID, answer },
});
await this.peerConnection.setLocalDescription(answer);
}
// A收到answer后,记录下来
async setAnswer(answer: RTCSessionDescriptionInit) {
if (!answer) return;
this.peerConnection.setRemoteDescription(answer);
}
5. ICE协商
由stun/turn服务器发送的candidate通过onicecandidate回调拿到。拿到后,我们通过Signal传送给对端,收到对端ICE后,使用addIceCandidate记录下来。WebRTC会优先选择最高优先级、连通性最好的candidate作为后续的数据传输路径。
// 获取本机ice,并发送给信令服务器
peerConnection.onicecandidate = (e) => {
if (e.candidate) {
this.signal.send({
cmd: E_SOCKET_CMD_SEND.candidate,
payload: {
from: this.localID,
to: this.remoteID,
// 根据addIceCandidate需要的参数构造candidate
// // https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/addIceCandidate
candidate: {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
},
},
});
} else {
console.log("All ICE candidates have been sent");
}
}
// 监听ice收集阶段
peerConnection.onicegatheringstatechange = (e) => {
console.log("ice state change", e);
};
// 记录收到的candidate
peerConnection.addIceCandidate(candidate)
6. 接收并播放媒体流
const videoRef = useRef<HTMLVideoElement | null>(null);
<video ref={videoRef} autoPlay></video>
// 获取远端视频流并使用video播放
peerConnection.ontrack = (e) => {
videoRef.current.srcObject = e.streams[0];
};
终于讲完了~以上就是大致的WebRTC建立连接流程🎉
其中很多细节由于篇幅和时间原因并没有再详细展开了,强烈建立在理解的基础上,自己动手做一做,把坑都踩了,就学废了~
如果本文有任何说法有误或理解不到位之处,也欢迎各位大佬指教交流!!!