WebRTC 是一种功能强大的技术,可用于创建网络及其他领域的实时通信应用程序。从设计上讲,它是一种点对点技术,可在两个对等方之间建立连接。然而,WebRTC 没有为连接提供信令机制,尽管这是一个关键步骤。这就是你可以发挥创造力的地方。
在本文中,我想用不同的方法来完成信令部分。主要是因为我经常说 “是的,你可以用……来做信号传递部分”;但并没有真正实施这项技术。
由于我们并不经常谈论信令部分,因此这是一个揭开 WebRTC 信令层神秘面纱的机会。信令层经常被保留给一些开源库,我们乐于使用这些库,而不用太在意它们是如何工作的。在这里,我将从头开始实现它。
什么是信令层?
信令层是一种允许两个对等节点交换信息以建立连接的机制。它是指定的,但必须由开发人员来实现,因为它是一种外部机制,需要你自己来实现。
说它是一种外部机制,并不意味着你可以随心所欲地实现它。它是一种说法,即浏览器不会为你做这件事:它会为你提供必要的信息,但会让你去实现这个机制。
信令层经常与协商过程混淆。然而,它们是两码事:
- 信令层是两个对等方交换信息的机制的通用术语。在这里,我们讨论的是所使用的信道、对等方会面的方式(♡)以及与服务器交换的协议和信息。
- 协商过程是两个对等点就连接参数达成一致的过程的专用术语。在这里,我们讨论的是 JavaScript 会话建立协议 SDP 报价/应答交换,它使用信令层来交换这些信息。这是 WebRTC 规范中定义的部分。
因此,协商过程只是信令层的一部分。
注意:即使 WebRTC 是一种点对点技术,它并不总是两个对等点之间的连接。它可以是对等点和服务器之间的连接。但对于信令层来说,这是一样的,服务器充当“对等点”来交换信息并建立连接。
信令层的四大支柱
信令层由 4 个部分组成:
- 信令通道定义了用于连接对等设备和交换信息的传输机制。它可以是 WebSocket、HTTP、MQTT…
- 信令协议:定义两个对等设备之间交换信息的格式。可以是 SIP、XMPP…
- 会话建立协议,定义协商过程。它是 SDP offer/answer 和 ICE 候选协议。
- 应用信令,它是可选的,但通常用于交换与通信相关的特定应用信息。它可用于交换用户名、房间名等基本信息,也可通过覆盖或扩展会话协议或信令协议交换更复杂的信息。
大多数情况下,该信令层会集成到应用程序的现有信令层中。这里只有会话建立协议与 WebRTC 相关联。其他部分则不然。
但信令层背后还有一个隐藏的支柱:信令服务器。这是管理信令层的服务器。
因为是的,你需要一个服务器来处理信令层: 两个对等方需要一个服务器来连接。这可能是一台专用服务器,也可能是您的应用程序已经使用过的服务器。
该服务器会连接对等设备,并传输它们之间需要交换的信息。
注:要测试 WebRTC,可以直接连接本地加载的两个对等设备,而无需使用服务器。但在现实生活中,需要服务器来处理信令层。
如何创建信令层?
如您所见,要进行 WebRTC 通话,您需要一个信令层。如何创建?
正如我们在上一节中看到的,你需要实现几件事并将它们组合在一起:
(1) 首先,需要选择信令通道和信令协议。各种技术都可以用来实现信道和协议部分。因此,它可以很容易地集成到现有的应用程序中。
(2) 接下来,需要实现会话建立协议(ICE/SDP 交换)。这是最复杂的部分。
(3) 最后,如果需要,还需要实现应用信令。
由于它需要服务器来管理信令层,因此不能立即执行。要使用 WebRTC,可以省略服务器,直接执行信令。但在开发过程中,您将需要这个服务器。最起码可行的服务器是能够从一个对等点接收信息并将其发送到另一个对等点的服务器。就像开发聊天应用程序一样。
因此,服务器端并不存在真正的复杂性。复杂性在于客户端,因为 JSEP 规范定义了需要以规定方式交换的几条信息。在内部,有一个状态机;你必须按照正确的顺序在正确的时间处理信息。
因此,您必须主要依赖异步请求和事件: 在任何时刻,你都可能收到来自对方的消息。你需要处理它。
正如你所了解的,这通常需要在客户端和服务器之间使用持久连接,或者使用一种随时联系任何用户的方法(如使用推送通知)。
构建自己的信令层
在本部分中,我们将了解如何构建自己的信令层。
我们将依赖以下堆栈:
- 服务器: Node.js 处理不同的通道(WebSocket、SSE、Stream API)。
- 客户端: 当然是 JavaScript!无依赖性或开源库。Pur Vanilla!我们将实现三种不同的通道: WebSockets、SSE 和 Stream API。信令协议将使用 JSON,会话建立协议将使用 JSEP。
注意:由于这是一个教程,因此没有应用程序信令,也没有身份验证或安全机制。
因此,让我们从使用 WebSocket 的第一个实现开始。
构建信令服务器
服务器部分是最基本的。它是一个 WebSocket 服务器,将处理信令通道和信令协议。
它接受客户端连接并在它们之间转发消息。
代码如下:
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws) => {
// Handle incoming messages from clients.
ws.on('message', (message) => {
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
});
// Start the server.
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`);
});
注意:为简单起见,服务器不处理房间。因此不要尝试连接两个以上的客户端。
使用 WebSocket 传输构建客户端
主要原则
由于客户端代码较为复杂,我们的目标是将每个部分划分为一个单独的代码块,赋予每个部分唯一(单一)的职责。这将提高代码的可读性和可维护性。
在这里,由于这是一个非常简单的示例,所以没有必要这样做。但您可以将此代码作为您自己应用程序的骨架。
建议将不同的概念分开:
1) 我们需要最基本可行的 HTML 页面来连接服务器并开始通话。在本示例中,我们将只连接数据通道,但您也可以将其调整为连接音频/视频流。
2)我们需要处理不同的逻辑块:传输(连接服务器)、协商器(协商过程)和信令(在传输和协商器之间进行粘合的聚合器)。
3)我们需要决定将 PeerConnections 放在哪里。为简单起见,这里将由信令模块来处理。但通常情况下,最好在信令区块之外进行管理,以保持媒体区块的独立性(使用注入)。
让我们先看看构建模块,然后再看看 HTML 页面。
传输模块
传输模块负责连接服务器并收发信息。
接口非常简单: 我们只需要一些函数来连接、发送信息和断开连接。
为了使其不受限制,传输块接口提供了一种设置回调的方法: 每次收到信息时,都会以信息为参数调用回调。通过这种方式,传输块就能将接收到的消息传回信令块。
下面是一个使用 WebSocket 创建传输块的 JavaScript 函数:
const buildWebSocketTransport = (name) => {
let socket = null;
let callback = null;
let from = name;
return {
addListener: (cb) => {
callback = cb;
},
connect: (to) => {
socket = new WebSocket(to);
// Connection opened
socket.addEventListener('open', (event) => {
console.log(`${name} 'socket' connected`);
});
socket.addEventListener('message', async (event) => {
if (event.data instanceof Blob) {
const reader = new FileReader();
reader.onload = async () => {
const message = JSON.parse(reader.result);
// Send the message back to the signaling layer
if (callback) {
callback.call(this, message.data, message.from);
}
};
reader.readAsText(event.data);
}
});
},
send:(message, to) => {
if (socket) {
socket.send(JSON.stringify({data: message, to, from}));
}
},
disconnect:() => {
if (socket) {
socket.close();
socket = null;
}
console.log(`${name} disconnected`);
},
name: name
}
}
如示例所示,收到的每条信息都会通过 addListener
函数中定义的回调发送回信号块。
注:本示例使用普通 WebSocket 交换文本信息。但您也可以使用诸如 Socket.io 等库来增强这部分功能。
协商模块
协商块负责处理协商过程。在这里,我们需要实现 SDP 要约/应答交换和 ICE 候选者交换。
但实际上,它只是一个状态机: 它接收信息,执行协商步骤,并在需要时返回信息。
因此,这里有一个创建协商块的 JavaScript 函数:
const buildNegotiationProcess = () => {
let callback = null;
const tmpIces = [];
return {
addListener: (cb) => {
callback = cb;
},
process: async (pc, message) => {
const {type} = message;
switch (type) {
case "negotiationneeded":
await pc.setLocalDescription(await pc.createOffer());
callback.call(this, pc.localDescription.toJSON());
break;
case "offer":
await pc.setRemoteDescription(message);
await pc.setLocalDescription(await pc.createAnswer());
callback.call(this, pc.localDescription.toJSON());
break;
case "answer":
await pc.setRemoteDescription(message);
break;
case "candidate":
if (message.candidate) {
callback.call(this, message.candidate);
} else {
callback.call(this, {type: "endofcandidates"})
}
break;
case "endofcandidates":
for (const ice of tmpIces) {
await pc.addIceCandidate(ice);
}
break;
case "connectionstatechange":
if (message.state === "failed") {
await pc.restartIce();
}
break;
default:
// candidate
if (message.candidate) {
if(pc.remoteDescription ) {
await pc.addIceCandidate(message);
} else {
tmpIces.push(message);
}
}
break;
}
}
}
}
下面是一些解释:
- 当
peerConnection
触发negotiationneeded
事件时,协商过程就开始了。这并不是一个真正的消息,但我定义了一个,以便有一个同质的状态机。 - 当协商过程需要向另一个对等节点发送消息时,它会调用回调并发送消息。
- 你可能会注意到两个 “额外 “消息,它们分别是
candidate
和endofcandidates
。它们是确保同步所必需的。例如,如果由于某种原因在远程描述之前收到了 ICE 候选消息,就不能将其添加到对等连接中。您需要将其存储起来,稍后再添加。 - 最后,如果连接失败,connectionstatechange 允许应用程序重新启动 ICE 进程。
通过这种方式,无论发生什么情况,信令块都会调用协商器。协商块负责整个协商过程。通过这种方式,我们遵循了单一责任原则。
信令模块
信令模块是传输模块和协商模块之间的纽带: 它负责从一方接收信息并发送给另一方。反之亦然。
它还包含三个功能:
- 前两个功能是创建和释放对等连接。如上所述,对等连接可以在信令模块之外进行管理。但在这里,为了简单起见,我们将在信令模块内部对其进行管理。
- 最后一项允许应用程序向对等连接添加数据通道。在这里,我们将使用数据通道来交换信息。但您也可以很容易地将其调整为连接音频/视频流。
因此,这里有一个创建信令块的 JavaScript 函数:
const buildSignalingLayer = (transport, negotiator) => {
let pc = null;
let dt = null;
let tp = transport;
let np = negotiator;
let to = null;
// Handle incoming messages from the transport layer and send them to the negotiator (for the local peer)
tp.addListener((message, from) => {
if(!to) {
to = from;
}
console.log("TP <-- message:", message, from);
np.process(pc, message);
});
// Handle messages from the negotiator and send them to the transport layer (for the remote peer)
np.addListener((message) => {
console.log("TP --> message:", message);
tp.send(message, to);
});
return {
createPeerConnection: (config) => {
pc = new RTCPeerConnection(config || {});
pc.onnegotiationneeded = (event) => {
np.process(pc, {type: "negotiationneeded"});
}
pc.onicecandidate = (event) => {
np.process(pc, {type: "candidate", candidate: event.candidate});
}
pc.onconnectionstatechange = (event) => {
console.log(`${tp.name} 'onconnectionstatechange' to ${pc.connectionState}`);
np.process(pc, {type: "connectionstatechange", state: pc.connectionState});
}
pc.ondatachannel = (event) => {
console.log(`${tp.name} 'datachannel' opened`);
const dt = event.channel;
dt.onclose = (event) => {
console.log(`${tp.name} 'datachannel' closed`);
}
}
},
close: () => {
if(dt) {
dt.close();
dt = null;
}
if(pc) {
pc.close();
pc = null;
}
// remove references transport and negotiator
np.addListener(null); // remove listener
np = null;
tp.addListener(null); // remove listener
tp = null;
},
addDataChannel: (label, options, recipient) => {
to = recipient;
dt = pc.createDataChannel(label, options);
dt.onopen = (event) => {
console.log(`${tp.name} 'datachannel' opened`);
}
dt.onclose = (event) => {
console.log(`${tp.name} 'datachannel' closed`);
}
}
}
}
需要注意的几点:
- 传输和协商器被注入到信令模块中。这使得信令区块不可知。
- 信令使用传输程序和协商程序定义的处理程序在两个实体间交换信息。
- 当信令管理 peerConnection 时,它会监听这些事件并将其转发给协商器。
- 一旦数据通道被添加到 PeerConnection,
negotiationneeded
事件就会被触发。这样,协商过程就开始了。 - 如果连接失败,协商器将调用
restartIce
函数。这是一种通过触发negotiationneeded
事件重启 ICE 进程的方法。
HTML 页面
HTML 页面非常简单:一些按钮用于使用传输连接、断开用户与服务器的连接。其他按钮用于创建和释放对等连接和数据通道。
这是 HTML 部分:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebRTC Signaling</title>
</head>
<body>
<h1>WebRTC Signaling</h1>
<div>
<button id="connectBobBtn">Connect Bob</button>
<button id="connectAliceBtn">Connect Alice</button>
<button id="disconnectBtn">Disconnect both</button>
<button id="startBtn">Bob call Alice</button>
<button id="endBtn">End call</button>
</div>
<script src="signaling.js"></script>
JavaScript 部分:
// signaling.js
// todo: copy/paste previous code here
'use strict';
import {createTransport, getURLFromTransport} from "./transports/transport.js";
import {buildSignalingLayer} from "./signaling/signaling.js";
import {buildNegotiationProcess} from "./negotiation/negotiation.js";
// Main function called once the page is loaded
const ready = () => {
// variables
let transport1, transport2 = null;
let signaling1, signaling2 = null;
// buttons listener
const connectBobBtn = document.getElementById("connectBobBtn");
const connectAliceBtn = document.getElementById("connectAliceBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
const startBtn = document.getElementById("startBtn");
const endBtn = document.getElementById("endBtn");
const inputSelect = document.getElementById('signaling');
let value = inputSelect.value;
console.log("default signaling to use", value);
inputSelect.onchange = async (e) => {
value = e.target.value;
console.log("new signaling to use", value);
}
connectBobBtn.addEventListener("click", () => {
// Connect Bob to the server
transport1 = buildWebSocketTransport("bob");
bobTransport.connect(getURLFromTransport(value, "bob"));
});
connectAliceBtn.addEventListener("click", () => {
// Connect Alice to the server
transport2 = buildWebSocketTransport("alice");
aliceTransport.connect(getURLFromTransport(value, "alice"));
});
disconnectBtn.addEventListener("click", () => {
// Disconnect both
if (transport1) {
transport1.disconnect();
transport1 = null;
}
if (transport2) {
transport2.disconnect();
transport2 = null;
}
});
startBtn.addEventListener("click", () => {
// create signaling for bob
signaling1 = buildSignalingLayer(transport1, buildNegotiationProcess());
signaling1.createPeerConnection();
// create signaling for alice
signaling2 = buildSignalingLayer(transport2, buildNegotiationProcess());
signaling2.createPeerConnection();
signaling1.addDataChannel("aChannel", {}, "alice");
});
endBtn.addEventListener("click", () => {
if (signaling1) {
signaling1.close();
signaling1 = null;
}
if (signaling2) {
signaling2.close();
signaling2 = null;
}
});
}
document.addEventListener("DOMContentLoaded", ready);
替代工具:使用 SSE
在前面的示例中,我们使用 WebSocket 作为传输方式。但你也可以使用任何其他传输方式。一种选择是使用(SSE)。
SSE 是一种允许服务器向客户端发送事件的技术。它是一种单向通信通道。它通常用于向客户端推送通知。
因此,在这里,服务器必须实现一个 REST API 来接收来自客户端的消息,并处理 SSE 连接以向客户端发送异步消息。
与 WebSocket 相比,SSE 是一种更简单的技术。事件通过 HTTP 发送。不需要特定的协议。但它不是双向的。
在这里,Bob 可以使用以下 REST API 向 Alice 发送信息:
// POST /signaling
{
"to": "alice",
"data": {
"type": "offer",
"sdp": "..."
}
}
服务器将使用 SSE 将事件转发给 Alice
// SSE to alice
event: signaling
data: {"type": "offer", "sdp": "..."}
注:尽管 SSE 提供了重新连接、事件 Ids 等不错的功能,但它使用的是纯文本信息。因此,它主要用于向客户端发送简短信息。但您也可以用它来发送 SDP 提议/答复和 ICE 候选者。
具体来说,主要区别在于服务器端,您需要在服务器端设置两个接口: SSE 处理程序和 REST API。与之相对应的是,在 REST API 的基础上添加更多功能(如身份验证、授权、度量、开放性)会更容易。
替代传输:使用 Stream API
如果 SSE 不能满足您的需求,另一个解决方案是使用Stream API。
Stream API 是一种允许服务器向客户端发送数据流的技术。与 SSE 一样,它也是一种单向通信通道。它通常用于向客户端发送大量数据,并在需要时间回复客户端时使用。
该过程与 SSE 相同:服务器必须实现 REST API 来接收来自客户端的消息并处理流连接以向客户端发送异步消息。
主要区别在于客户端:客户端需要处理流。它比 SSE 稍微复杂一些。
这是处理流的代码部分:
// As the response from the server is a stream, we need a reader
reader = response.body.getReader();
// Then, we need to decode the messages
const decoder = new TextDecoder();
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
const decodedChunk = decoder.decode(value);
// Split the chunks into messages
const messages = decodedChunk.split("\n");
for (const message of messages) {
if (message.length > 0) {
// Send the message back to the signaling layer
if (callback) {
callback.call(this, JSON.parse(message));
}
}
}
}
正如您所看到的,这里有一个额外的步骤,因为服务器可以在同一数据块中发送多个信令消息😱…
其他传输方式
还有另一种选择: 您可以使用 MQTT 来处理信令层。它是一种轻量级协议,常用于物联网应用。它是一种发布/订阅协议。
使用代理作为信令服务器,可以轻松连接两个 MQTT 客户端。
我还没有将其添加到示例中,但也许这将成为另一篇文章的主题,并丰富本示例。
当然,还有许多其他可能性。以上概述绝非详尽无遗。
替代协议
在前面的示例中,我们使用 JSON 作为信号协议。但您也可以使用任何其他协议。
主要的流行协议有:
- SIP 是用于 VoIP 的协议,因此主要用于电话应用以及将 WebRTC 与 SBC 或 IP-PBX 等设备连接起来。
- XMPP 是用于即时消息的协议,主要用于聊天应用。但也有处理 WebRTC 的扩展协议,如 Jingle。
如你所见,这里也有大量的可能性: 这只是对信息进行编码/解码的问题。
几年前,我自己创建了一个信令服务器 JSONgel Server 和一个信令客户端 JSONgel,试图将信道层(任何层)与协议层分离开来(在 JSON 中重温 Jingle 的简化版本)。我用它来处理远程医疗应用中的多个 P2P 呼叫。
结论
在本文中,我们探讨了如何从零开始构建信号令。归根结底,这并不复杂。
现在的问题是:是构建自己的信令层还是使用开源库?
好消息是,如果您想使用开源 WebRTC 服务器,它通常会附带 SDK 或处理信令层的库。因此,您无需自行构建。
如果您想使用 CPaaS(通信平台即服务),我供职的平台或任何其他提供视频会议或流媒体服务的平台,情况也是如此。信令层隐藏在所使用的 SDK 和 API 背后。
但如果您想自己动手,可以将这篇文章作为起点。但您会发现,随着处理新的情况(保持/检索、静音/取消静音、多方通话),它将逐渐变得更加复杂。这是因为,您必须向信令层发送新信息,以便与媒体部分发生的情况同步。
作者:Olivier Anguenot
编译自:https://www.webrtc-developers.com/signaling-in-webrtc/
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/39917.html