天天看点

WebRTC 之通信—PeerConnections 与信令服务

作者:音视频开发老舅

本文为 RTC 系列第三篇,上一篇介绍了 RTCPeerConnection 在本地的连接过程,如果是和远程用户连接,SDP 和 ICE 的交换是需要经过网络传输的,在 RTC 中,负责信息交换的服务器称为信令服务器,它所做的事情就是在连接前传输信息。由于 SDP 和 ICE 都是简单的字符串数据,因此信令服务只要能够传递字符串数据即可,任何满足要求的服务器都可以作为信令服务器,我们可以根据需要搭建自己的信令服务,可以使用 websocket,也可以使用 http、sip 等其他协议。

C++音视频开发学习资料:点击莬费领取→音视频开发(资料文档+视频教程+面试题)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)

server 实现:

因为使用 socket.io 处理双向通信比较容易,因此本文以 socket.io 为例展示一个简单的 P2P 信令服务的实现,使用其他协议也同理。

用户想和其他用户进行通话首先需要知道对方是谁,因此信令服务器需要有一套类似房间的机制,具体形式取决于业务场景,可能是聊天室,可能是广场,总之需要把网络上的用户连起来,我们这里实现一个简单的 user-list 系统:

io.on("connection", async (socket) => {
  const userList = [...(await io.allSockets())];
  io.emit("user-list", userList);
});           

当用户 A 向 B 发起连接时,A 只要把之前发给 B 的内容发给服务器,并告诉服务器发送目标是 B 即可,同样 B 发消息也一样,只需要把消息发给服务器,指定目标给 A。对于服务器而言只需要将内容转发出去即可,完全不需要理解内容:

socket.on("message", (msg) => {
  const target = io.sockets.sockets.get(msg.target);
  target?.emit("message", { from: socket.id, data: msg.data, type: `on-${msg.type}` });
});           

这样就实现了一个 server,下面来看 client 端实现。

client 实现:

与之前的本地 demo 相比,客户端最大的改变就是发送端和接收端是分离的,因此我们需要把两分逻辑拆分开,现在再来看 SDP 交互流程:

  1. A createOffer offerA
  2. A setLocalDescription offerA
  3. A 发送 offerA 给 B
  4. B setRemoteDescription offerA
  5. B createAnswer AnswerB
  6. B setLocalDescription AnswerB
  7. B 发送 AnswerB 给 A
  8. A setRemoteDescription AnswerB

这里 A 与 B 在两个不同的页面上,它们不会直接通信,中间加入信令服务器,交互的流程就变成了这样:

  • 发送方:
    • createOffer
    • setLocalDescription
    • 发送 offer 给 server
    • 接收 server 的 answer
    • setRemoteDescription
  • 接收方:
    • 接收 server 的 offer
    • setRemoteDescription
    • createAnswer
    • setLocalDescription
    • 发送 answer 给 server

我们先实现一个发送客户端,由于发送端是主动发起方,因此需要知道发送目标,这里使用一个简单的 user-list 机制来作为演示,当点击用户列表中的某个用户时向其进行发送:

【免费】FFmpeg/WebRTC/RTMP/NDK/Android音视频流媒体高级开发-学习视频教程-腾讯课堂

C++音视频开发学习资料:点击莬费领取→音视频开发(资料文档+视频教程+面试题)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)

userWrapper.addEventListener('click', async (e) => {
	targetId = e.target.innerText;
	if (targetId !== socket.id) {
		if (targetId) {
			const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
			localVideo.srcObject = localStream;
			createPeerConnection();
			localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
		}
	}
});           

createPeerConnection 中创建一个 RTCPeerConnection,可以在 negotiationneeded 事件回调中创建 offer,在监听到 icecandidate 时发送出去:

function createPeerConnection() {
    pc = new RTCPeerConnection(pcConfig);
    pc.addEventListener('negotiationneeded', async () => {
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    socket.emit('message', {
        type: 'offer',
        target: targetId,
        data: { offer }
    });
  });
}
           

之后就是等待回复了,这里监听 server 的数据:

case 'on-answer':
	await pc.setRemoteDescription(data.answer);
	break;
           

之后再看接收端的逻辑,接收端的数据源来自信令服务器,这里从监听 server 开始:

case 'on-offer':
	targetId = from;
	createPeerConnection();
	await pc.setRemoteDescription(data.offer);
	const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
	localVideo.srcObject = localStream;
	localStream.getTracks().forEach(track => {
		pc.addTrack(track, localStream);
	});
	const answer = await pc.createAnswer();
	await pc.setLocalDescription(answer);
	socket.emit('message', {
		type: 'answer',
		target: targetId,
		data: { answer },
	});
	break;           

其中 createPeerConnection 逻辑与发送端是一致的,这里不会触发 negotiationneeded,不需要创建 offer。之后数据转发回发送端正常处理,SDP 交换的流程就结束了。

SDP 之后进行 ICE 交换,ICE 逻辑很简单,不需要数据握手,只要在收到时发出去,收到时设置到 PeerConnection 上即可:

pc.addEventListener('icecandidate', e => {
    if (e.candidate) {
        socket.emit('message', {
            type: 'candidate',
            target: targetId,
            data: { candidate: e.candidate }
        });
    }
});           
case 'on-candidate':
	await pc.addIceCandidate(data.candidate);
	break;           

这样就建立好连接了,想要获取远端流直接监听 track 就可以:

pc.addEventListener('track', e => {
	if (remoteVideo.srcObject !== e.streams[0]) {
		remoteVideo.srcObject = e.streams[0];
	}
});           

以上就是一个简单的 WebRTC P2P 系统的实现,整个过程中,只有连接时需要信令服务器的参与,连接成功后,音视频的发送是通过 addTrack 完成的,这个过程不需要信令服务器参与。

但是上面的 P2P demo 有一个问题,它只能在同一内网环境工作,这就涉及到 P2P 中另一个知识点:打洞和穿越。

STUN/TURN

由于 NAT 的特性,外网主机无法和内网主机直接建立连接。这时需要在我自己的 NAT 设备上先打一个洞,外网主机穿越这个洞来与内网用户建立连接。

我们可以使用 STUN/TURN 服务来实现内网穿透的能力,在创建 RTCPeerConnection 时可以通过参数传入 STUN/TURN 信息:

const config = {
	'iceServers': [
		{ 'urls': 'stun:stun.l.google.com:19302' },
		{ "urls": "turn:numb.viagenie.ca", "username": "[email protected]", "credential": "muazkh" }
	]
};
new RTCPeerConnection(config);           

STUN/TURN 服务器的搭建过程在此不展开了,感兴趣可以阅读 coturn 项目。

多人场景架构

上面的是一对一场景下的实现方案,在实际应用中还有多人音视频的需求,处理多人场景有以下几种方案:

  • P2P:P2P(Point To Point)即点对点,在一对一会话场景我们已经见过了,多人会话和单人会话一样,每两个人都要建立 P2P 连接。
    • 客户端:n 人的会话,推流 n - 1 拉流 n - 1
    • 优点:不需要服务器感知视频流
    • 缺点:人数越多客户端压力越大,无法处理太多人的场景
  • MCU:MCU(Multi-point Control Unit)中文为多点控制单元,有一个中心服务器,用户的所有流都直接推到这个服务器上,由服务器将多路数据流合成一路,客户端拉这一路流即可。
    • 客户端:n 人的会话,推流 1 拉流 1
    • 优点:节省客户端资源,适用于广播业务场景
    • 缺点:流在服务端已经确定,不能定制,无法满足灵活定制的业务场景
  • SFU:SFU(Selective Forwarding Unit) 中文为选择性转发单元,中心服务器只负责流的转发,由客户端根据需要选择拉流。
    • 客户端:n 人的会话,推流 1 拉流 n
    • 优点:可以满足复杂多遍的需求场景
    • 缺点:拉流过多时客户端性能有影响,需要根据实际业务情况进行平衡

多人音视频会话有不同的业务形态,实际应用中需要根据具体的场景选择合适的架构方案。

继续阅读