文章目录
前言
WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。
一、会话描述
为了开启一个WebRTC会话,以下事件需要依次发生:
- 呼叫者和接受者各创建一个RTCPeerConnection对象,用来表示其WebRTC会话端。
- 呼叫者和接受者通过信令服务器交换信令。
- 呼叫者通过createDataChannel创建消息通道,并发送数据。
- 接受者通过监听datachannel事件,获取接受者的 channel的RTCDataChannel 的指向。
- 此时即可通过发送者的send()对象和接受者的onmessage事件即可开始数据通信。
二、信令
信令是在两个设备之间发送控制信息以确定通信协议、信道、媒体编解码器和格式以及数据传输方法以及任何所需的路由信息的过程。
为了交换信令信息,我们可以选择通过WebSocket连接来回发送JSON对象,或者使用通过HTTPS使XMLHttpRequest进行轮询或者其他任何可以想出来的技术组合。你甚至可以使用电子邮件作为信号通道。
还比较特殊的是,我们交换信令甚至不需要通过网络。 一个Peer可以输出一个数据对象,这个数据对象可以被打印出来,然后物理携带直到进入另一个设备,然后由该设备输出响应,并以同种方式返回, 直到WebRTC对等连接打开。 这将带来非常高的延迟,但也是可以做到的。
信令服务器
首先我们需要做的是,构建我们的信令服务器,此处我们使用的是websocket构建的服务器。
当有一个新连接进来时,我们为这个连接添加一个id,然后存到一个数组里面去,然后把这个id发送回连接的客户端去,当客户端接收到这个消息过后,客户端把当前连接的username发送到信令服务器,然后此时我们把这个新连接的相关信息发往所有客户端。
function sendToAllListUser() {
var userlist = connectionArr.map(m => {
return { clientID: m.clientID, username: m.username };
});
connectionArr.forEach(con => {
con.sendUTF(JSON.stringify({
type: "userlist",
users: userlist
}));
});
}
为了让服务器支持信令和ICE协商,我们需要升级代码,我们需要直接发送聊天系统到指定的用户而不是发送给所有人,并且保证服务器在不需要理解数据内容的情况下传递未被认可的任何消息类型。这让我们可以使用一台服务器来传递信令和消息而不是多台。我们所要做的就是来回传递信息。内容对信令服务器一点都不重要。
let con = connectionArr.find(f => f.clientID == obj.anserId);
if (con) {
con.sendUTF(message.utf8Data);
}
此时我们的信令服务器就构建好了。
设计信令协议
现在我们要构建一套信息交换规则,我们需要一套协议来定义消息格式。当然方法有很多种,这里只是其中一种,并不是唯一。
1.交换会话描述信息
开始处理信号的时候,用户的初始化操作会创建一个请求(offer) ,根据 SDP 协议其中会包含一个session描述符,并且需要把这个发送到我们称之为接收者(callee)那里, 接受者需要返回一个包含描述符的应答(answer)信息。我们的服务器使用 WebSocket来传递 “data-offer” “data-answer” 两种类型的消息数据。这些消息包含以下属性:
type
消息类型; “data-offer” 或 “data-answer”
offerId
发送者ID
anserId
接受者的ID
sdp
描述连接本地端SDP(Session Description Protocol)协议字符串(从接收者的角度来看,它描述远程端)
到此为止双方都知道使用什么样的代码和参数进行通信了。尽管如此他们仍然不知道自己该如何传递数据。 Interactive Connectivity Establishment (ICE)协议该上场了。
2.交换 ICE 候选
两个节点需要交换ICE候选来协商他们自己具体如何连接。每一个ICE候选描述一个发送者使用的通信方法,每个节点按照他们被发现的顺序发送候选并且保持发送直到退出,即使媒体数据流已经开始传递也要如此。
使用 pc.setLocalDescription(offer) 添加本地描述符后一个 icecandidate 事件将被发送到 RTCPeerConnection
一旦两端同意了一个互相兼容的候选,该候选的SDP就被用来创建并打开一个连接,通过该连接媒体流就开始运转。如果之后他们同意了一个更好(通常更高效)的候选,流亦会按需变更格式。
每个 ICE候选通过信令服务器发送一个 “new-ice-candidate” 类型的JSON信息来送给远程的另一端。每个候选信息包括以下字段:
type
消息类型: “new-ice-candidate”.
offerId
发送者ID
anserId
接受者的ID
candidate
SDP候选字符串,描述了计划的连接方法。通常不需要查看此字符串的内容。你需要做的所有代码都是使用信令服务器将其路由到远程对等机。
每个ICE消息都建议提供一个通信协议(TCP或UDP)、IP地址、端口号、连接类型(例如,指定的IP是对等机本身还是中继服务器),以及将两台计算机连接在一起所需的其他信息。这包括NAT或其他网络问题。
三、客户端应用
HTML
<label for="username">用户名:
<input type="text" name="username" id="username" placeholder="请输入用户名">
</label>
<button id="connectButton" name="connectButton">
连接
</button>
<button id="disconnectButton" name="disconnectButton" disabled>
断开
</button>
<div class="chatbox">
<ul class="left-item"></ul>
<div id="receiveBox" class="right-item"></div>
</div>
<div class="messagebox">
<label for="message">输入一个消息:
<input type="text" name="message" id="message" placeholder="请输入消息" inputmode="latin" size=60 maxlength=120
disabled>
</label>
<button id="sendButton" name="sendButton" class="buttonright" disabled>
发送
</button>
</div>
此页面结构主要分为三块,
- 用于设置当前用户名和打开和关闭远程连接;
- 主要分为左右两块,左侧显示用户列表,右侧显示消息列表;
- 主要用于设置待发送的消息和一个发送按钮。
Javascript
我们将把这段代码划分为多个功能区,以便更容易地描述它是如何工作的,接下来,我们一步一步完成我们客户端的代码。
1.创建用户
首先我们需要为自己创建一个用户名,以便我们可以直接通过该用户名建立WebRTC连接。
//创建当前账户
function confirmUsername() {
var _username = document.getElementById('username').value;
if (!_username) {
alert("用户名不能为空!");
}
localname = _username;
connectButton.disabled = true;
disconnectButton.disabled = false;
connectWebSocket();
}
2.建立WebSocket连接
//连接socket服务器
function connectWebSocket() {
// 打开一个 web socket
connection = new WebSocket("ws://" + myHostname + ":8080", 'json');
connection.onopen = function () {
// Web Socket 已连接上,使用 send() 方法发送数据
if (connection.readyState === connection.OPEN) {
console.log("已连接上...");
}
};
connection.onmessage = function (evt) {
var msg = JSON.parse(evt.data);
switch (msg.type) {
case "id":
clientID = msg.id;
sendToServer({
type: "username",
clientID: clientID,
username: localname
});
break;
case "userlist":
creatUserlistMsg(msg);
break;
case "data-offer": // Invitation and offer to chat
console.log("-----------------data-offer");
handleDataOfferMsg(msg);
break;
case "data-answer": // Callee has answered our offer
console.log("-----------------data-answer");
handleDataAnswerMsg(msg);
break;
case "new-ice-candidate": // A new ICE candidate has been received
console.log("-------------new-ice-candidate");
handleNewICECandidateMsg(msg);
break;
}
};
connection.onclose = function () {
console.log("链接已关闭...");
};
}
当连接建立好之后,信令服务器会马上发送一条消息,显示当前连接的id,我们为本地clientID赋值id,然后马上发送一个消息到信令服务器,携带我们创建的用户名。
3.创建用户列表
function creatUserlistMsg(msg) {
var listElem = document.querySelector(".left-item");
//删除已有的列表
while (listElem.firstChild) {
listElem.removeChild(listElem.firstChild);
}
// 添加所有用户
msg.users.forEach(function (node) {
var item = document.createElement("li");
item.setAttribute("clientID", node.clientID);
item.appendChild(document.createTextNode(node.username));
item.addEventListener("click", invite, false);
listElem.appendChild(item);
});
}
我们为HTML部分的第二块的左侧插入用户数据,并为每个用户添加一个点击事件,这个点击事件就是我们的WebRTC连接的相关代码了。
function invite(event) {
otherUsername = event.target.textContent;
otherClientID = event.target.getAttribute("clientID");
if (!connectButton.disabled) {
alert("未连接服务器");
} else if (localConnection) {
alert("你暂时不能连接,因为你已经有一个连接了!");
} else if (otherClientID == clientID) {
alert("不能向自己发消息");
}
else {
createPeerConnection();
}
}
4.创建RTCPeerConnection连接
//创建RTCPeerConnection
function createPeerConnection() {
console.log("Setting up a connection...");
localConnection = new RTCPeerConnection({
iceServers: [
{
urls: "turn:" + myHostname, // 一个TURN服务器
username: "webrtc",
credential: "turnserver"
}
]
});
localConnection.onicecandidate = handleICECandidateEvent;
localConnection.onnegotiationneeded = handleNegotiationNeededEvent;
sendChannel = localConnection.createDataChannel("sendChannel");
sendChannel.onopen = event => {
console.log("--------send---onopen")
messageInputBox.disabled = false;
messageInputBox.focus();
sendButton.disabled = false;
disconnectButton.disabled = false;
connectButton.disabled = true;
};
sendChannel.onclose = event => {
console.log("--------send---onclose")
disconnectPeers();
};
sendChannel.onerror = err => console.log(err);
localConnection.ondatachannel = event => {
receiveChannel = event.channel;
receiveChannel.onmessage = event => {
var el = document.createElement("p");
var txtNode = document.createTextNode(event.data);
el.appendChild(txtNode);
receiveBox.appendChild(el);
};
receiveChannel.onopen = event => console.log("*** receive:", receiveChannel.readyState);
receiveChannel.onclose = event => {
console.log("*** receive:", receiveChannel.readyState);
disconnectPeers();
};
receiveChannel.onerror = err => console.log(err);
};
}
在这个方法中,我们创建了一个RTCPeerConnection对象,并在该对象上添加了icecandidate和negotiationneeded及datachannel事件处理程序,并通过createDataChannel()创建了一个数据连接通道,并监听了onopen事件和onclose事件以便我们在Channel状态改变时做出相应变化。
在datachannel事件处理程序中, 代表了remote节点的 channel的RTCDataChannel 的指向, 它保存了我们用以在该channel上对我们希望处理的事件建立的事件监听。 一旦侦听建立, 每当remote节点接收到数据 onmessage事件将被调用, 每当通道的连接状态发生改变 onopen事件和onclose事件将被调用,因此通道完全打开或者关闭时我们都可以作出相应的相应。
5.呼叫初始化及呼叫回答
在第4步中,我们监听了negotiationneeded事件,在该事件中,我们创建一个offer,并把它添加到本地描述,并把这个本地描述发送到接收端。
async function handleNegotiationNeededEvent() {
if (!otherClientID && (otherClientIDCopy == otherClientID)) {
return;
}
try {
otherClientIDCopy = otherClientID;
console.log("---> 创建 offer");
const offer = await localConnection.createOffer();
console.log("---> 改变与连接相关的本地描述");
await localConnection.setLocalDescription(offer);
console.log("---> 发送这个本地描述到到远端用户");
console.log(clientID, otherClientID);
sendToServer({
type: "data-offer",
offerId: clientID,
anserId: otherClientID,
sdp: localConnection.localDescription
});
} catch (err) {
console.error(err);
};
}
当接收端接收到数据时,将调用handleDataOfferMsg方法,并为接受端创建一个RTCPeerConnection对象,并创建一个回答(anser)并设置为接受端的本地描述,然后把刚刚发送端发送过来的的发送端的描述设置为接收端的远程描述,此时接受端已经处理完成了。然后接受端把刚刚创建的本地描述通过信令服务器发送回发送端。
//呼叫回答
async function handleDataOfferMsg(msg) {
console.log("Received data chat offer from " + msg.username);
if (!localConnection) {
createPeerConnection();
}
var desc = new RTCSessionDescription(msg.sdp);
console.log(" - 设置接收端描述");
await localConnection.setRemoteDescription(desc);
console.log("---> 创建一个响应");
await localConnection.setLocalDescription(await localConnection.createAnswer());
sendToServer({
type: "data-answer",
offerId: msg.anserId,
anserId: msg.offerId,
sdp: localConnection.localDescription
});
}
此时接收端将调用
// 通信接收者已经接听了我们的通信
async function handleDataAnswerMsg(msg) {
console.log("*** 通信接收者已经接听了我们的通信");
try {
var desc = new RTCSessionDescription(msg.sdp);
await localConnection.setRemoteDescription(desc).catch(function (err) { console.log(err); });
} catch (err) {
console.error(err);
}
}
方法,为发送端设置远端描述。此时相关描述就设置好了。
6.发送ICE
在发送端创建createOffer之后,将触发icecandidate事件
function handleICECandidateEvent(event) {
if (event.candidate) {
console.log("*** Outgoing ICE candidate: " + event.candidate.candidate);
sendToServer({
type: "new-ice-candidate",
offerId: clientID,
anserId: otherClientID,
candidate: event.candidate
});
}
}
此时我们将发送ICE数据到接收端,然后接收端将调用
//接受者的 ICE 候选地址信息
async function handleNewICECandidateMsg(msg) {
var candidate = new RTCIceCandidate(msg.candidate);
console.log("*** 添加接受者的 ICE 候选地址信息: " + JSON.stringify(candidate));
try {
await localConnection.addIceCandidate(candidate)
} catch (err) {
console.error(err);
}
}
这两个方法可能调用多次。通信双方都会触发icecandidate,这样所有的连接操作就做完了。
7.发送消息
此时我们可以通过sendChannel.send(message)发送消息了。
function sendMessage() {
console.log(clientID, localname);
var message = messageInputBox.value;
sendChannel.send(message);
messageInputBox.value = "";
messageInputBox.focus();
}
8.关闭连接
最后一步,我们需要关闭连接。
function disconnectPeers() {
if (sendChannel) {
sendChannel.onopen = null;
sendChannel.onclose = null;
sendChannel.close();
sendChannel = null;
}
if (receiveChannel) {
receiveChannel.onmessage = null;
receiveChannel.onopen = null;
receiveChannel.onclose = null;
receiveChannel.close();
receiveChannel = null;
}
if (localConnection) {
localConnection.onicecandidate = null;
localConnection.onnegotiationneeded = null;
localConnection.ondatachannel = null;
localConnection.close();
localConnection = null;
}
if (connection) {
connection.close();
connection = null;
}
connectButton.disabled = false;
disconnectButton.disabled = true;
sendButton.disabled = true;
messageInputBox.value = "";
messageInputBox.disabled = true;
}
项目演示
收发端 | 演示 |
---|---|
发送端 | |
接收端 | |
项目地址
基于WebRTC使用RTCDataChannel接口实现双向数据通信
总结
RTCDataChannel 接口是WebRTC API的一个功能,可以让您在两个对等体之间打开一个通道,您可以通过该通道发送和接收任意数据。 API有意地类似于WebSocket API,因此可以为每个API使用相同的编程模型。