WebRTC 近来因其在语音和视频通信领域的惊人 应用而备受关注。但你是否知道,WebRTC 还支持点对点数据传输呢?接下来我会介绍数据通道的“是什么”和“怎么做”,然后向你展示如何在BananaBread中使用数据通道实现点对点多人游戏。
在 Mozilla 多伦多办公室,一个周五下午,使用 WebRTC 进行 5 人 BananaBread 游戏的实况录像。
浏览器注意事项
如果你想使用 WebRTC 数据通道,你需要使用最新的Firefox Nightly或Chrome Canary。BananaBread 多人游戏需要 WebRTC 数据通道的二进制消息支持,而 Chrome 目前还没有实现该功能。
什么是数据通道?
WebRTC 数据通道允许你在活动对等连接上发送文本或二进制数据。数据通道有两种类型。可靠通道可以保证你发送的消息已经到达另一方。如果你发送多个可靠消息,它们将按顺序到达。你也可以发送不可靠消息,这些消息无法保证按顺序到达,甚至可能根本无法到达。它们类似于 TCP 和 UDP。
创建和使用数据通道
不幸的是,建立对等连接和创建数据通道需要相当复杂的工作量。使用库来处理这些工作,并抽象化 Firefox 和 Chrome 之间的一些实现细节是十分合理的。
这里使用的库是p2p。它提供了一个简单的 API 用于建立对等连接,并设置流和数据通道。它还提供了一个代理服务器组件和一个托管代理,你可以使用它,而不是自己设置一个。
使用 p2p
你可以从一个简单的网页开始。类似这样的代码就足够了
在一个简单的配置中,一个对等体充当主机,监听其他想要连接的对等体。
/* This is the address of a p2p broker server, possibly the example one at http://mdsw.ch:8080 */
var broker = "http://";
/* We'll use this to store any active connections so we can get to them later. */
var connections = {};
/* This creates an object that will handle let us listen for incoming connections. */
var peer = new Peer(broker, {video: false, audio: false});
/* This is invoked whenever a new connection is established. */
peer.onconnection = function(connection) {
connections[connection.id] = connection;
connection.ondisconnect = function() {
delete connections[connection.id];
};
connection.onmessage = function(label, msg) {
console.log(msg.data);
};
};
/* This is called when your peer has received a routing address from the broker.
The route is what lets other peers send messages through the broker that are used to
establish the peer-to-peer connection. */
peer.onroute = function(route) {
console.log(route);
}
/* This tells the broker that this peer is interested in hosting. */
peer.listen();
传递到 onconnection
的连接对象包含两个数据通道,分别标记为 reliable 和 unreliable。当连接接收到消息时,标签以及数据会一起传递到 onmessage
函数中。
如果你的对等体是主机,那么捕获由代理分配的路由地址是很有用的。另一个对等体需要代理 URL 和路由地址才能连接到你的对等体。
最后,连接对象还公开了本地和远程流,如果你想发送视频或音频,你可以使用它们。
connection.streams['local'];
connection.streams['remote'];
如果你的对等体正在连接到另一个对等体,代码与上面相同,只是你需要调用 connect
而不是 listen
/* Call this with the routing address that the host received from the broker. */
peer.connect();
Emscripten 的套接字
如果你不熟悉Emscripten,你需要了解它可以将 C++ 库和程序编译成 JavaScript,从而使它们可以在浏览器中运行。我们正是用它将Sauerbraten变成了BananaBread。
Sauerbraten 内置了多人游戏支持,它依赖于 POSIX 套接字,而 POSIX 套接字的工作方式与 WebRTC 对等连接截然不同。使用套接字的 C++ 程序期望通过向套接字 API 提供 IP 地址和端口号以及包含一些任意数据的缓冲区来与远程主机通信。BananaBread 特别只调用四种 API 函数:socket、bind、recvmsg 和 sendmsg。
每次调用 socket 时,我们都会创建一个新的 JS 对象来保存地址、端口和消息队列。队列很重要,因为我们需要保存从数据通道接收到的消息,以便稍后由应用程序处理,应用程序会调用 recvmsg。这里还有一些空间用于构建一个小标题,我们将用于发送消息。
由于我们使用的是相同的 p2p 库,所以创建新对等体的代码与之前相同,只是事件处理程序不同。以下是 onconnection 的代码
peer.onconnection = function(connection) {
var addr;
// Assign 10.0.0.1 to the host
if(route && route === connection['route']) {
addr = 0x0100000a; // 10.0.0.1
} else {
addr = Sockets.addrPool.shift();
}
connection['addr'] = addr;
Sockets.connections[addr] = connection;
connection.ondisconnect = function() {
// Don't return the host address (10.0.0.1) to the pool
if(!(route && route === Sockets.connections[addr]['route']))
Sockets.addrPool.push(addr);
delete Sockets.connections[addr];
};
connection.onmessage = function(label, message) {
var header = new Uint16Array(message, 0, 2);
if(Sockets.portmap[header[1]]) {
/* The queued message is retrived later when the program calls recvmsg. */
Sockets.portmap[header[1]].inQueue.push([addr, message]);
}
}
Sockets.addrPool
是一个包含可用 IP 地址的列表,我们可以将其分配给新的连接。地址用于在 C++ 程序想要发送或接收数据时找到正确的活动连接。
socket: function(family, type, protocol) {
var fd = Sockets.nextFd ++;
Sockets.fds[fd] = {
addr: undefined,
port: undefined,
inQueue: [],
header: new Uint16Array(2),
};
return fd;
};
当程序想要监听某个端口时,会直接调用 bind,而当使用未绑定套接字(以便可以在套接字上调用 recvmsg 并且远程主机可以发送回复)时,会间接调用 bind。在后一种情况下,我们可以给套接字分配任何未使用的端口。我们不需要担心 IP 地址。
bind: function(fd, addr, addrlen) {
var info = Sockets.fds[fd];
if (!info) return -1;
if(addr) {
/* The addr argument is actually a C++ pointer, so we need to read the value from the Emscripten heap. */
info.port = _ntohs(getValue(addr + Sockets.sockaddr_in_layout.sin_port, 'i16'));
} else {
/* Finds and returns an unused port. */
info.port = _mkport();
}
/* We might need to pass the local address to C++ code so we should give it a meaningful value. */
info.addr = 0xfe00000a; // 10.0.0.254
/* This is used to find the socket associated with a port so we can deliver incoming messages. */
Sockets.portmap[info.port] = info;
};
对于 sendmsg,我们需要找到与给定 IP 地址关联的套接字。我们还需要在消息缓冲区之前添加一个小标题,其中包含目标端口(以便远程主机可以传递消息)和源端口(以便远程主机可以将回复发送给消息)。Recvmsg 与 sendmsg 非常类似。
(请注意,读取和写入 msg 参数数据的代码被省略了,因为它比较复杂,
并且没有提供更多信息。)
sendmsg: function(fd, msg, flags) {
var info = Sockets.fds[fd];
if (!info) return -1;
/* Here's where we bind to an unused port if necessary. */
if(!info.port) {
bind(fd);
}
/* The next three lines retrieve the destination address and port from the msg argument. */
var name = {{{ makeGetValue('msg', 'Sockets.msghdr_layout.msg_name', '*') }}};
var port = _ntohs(getValue(name + Sockets.sockaddr_in_layout.sin_port, 'i16'));
var addr = getValue(name + Sockets.sockaddr_in_layout.sin_addr, 'i32');
var connection = Sockets.connections[addr];
if (!(connection && connection.connected)) {
/* Emscripten requires that all socket operations are non-blocking. */
___setErrNo(ERRNO_CODES.EWOULDBLOCK);
return -1;
}
/* Copy the message data into a buffer so we can send it over the data channel. */
var bytes = new Uint8Array();
info.header[0] = info.port; // Source port
info.header[1] = port; // Destination port
/* Create a new array buffer that's big enough to hold the message bytes and the header. */
var data = new Uint8Array(info.header.byteLength + bytes.byteLength);
/* Copy the header and the bytes into the new buffer. */
buffer.set(new Uint8Array(info.header.buffer));
buffer.set(bytes, info.header.byteLength);
connection.send('unreliable', buffer.buffer);
};
recvmsg: function(fd, msg, flags) {
var info = Sockets.fds[fd];
if (!info) return -1;
/* There's no way to deliver messages to this socket if it doesn't have a port. */
if (!info.port) {
assert(false, 'cannot receive on unbound socket');
}
/* Similar to sendmsg, if there are no messages waiting we return instead of blocking. */
if (info.inQueue.length() == 0) {
___setErrNo(ERRNO_CODES.EWOULDBLOCK);
return -1;
}
var entry = info.inQueue.shift();
var addr = entry[0];
var message = entry[1];
var header = new Uint16Array(message, 0, info.header.length);
var bytes = new Uint8Array(message, info.header.byteLength);
/* Copy the address, port and bytes into the msg argument. */
return bytes.byteLength;
};
下一步
p2p 库和 Emscripten 的套接字都是为支持 BananaBread 多人游戏而创建的,因此它们都缺少一些功能,这些功能对于构建其他应用程序很有用。具体来说,
- 在 p2p 库中添加对基于对等体的代理的支持(以便连接的对等体可以为彼此代理新的连接)
- 在 Emscripten 中添加对基于 WebRTC 的面向连接和可靠套接字的支持
如果你正在使用它构建一些很酷的东西,或者你想构建一些东西,但有疑问,请随时在Twitter上或在irc.mozilla.org的 #games 频道中问我。
关于 ack
我的名字是 Alan。我是一名计算机科学家和黑客。我在 Mozilla 工作,负责各种平台项目。目前我专注于改进 Web 上的高性能实时应用(游戏!)。我所做的很多事情都围绕着软件、硬件和设计的开源展开。
关于 Robert Nyman [名誉编辑]
Mozilla Hacks 的技术布道师和编辑。发表有关 HTML5、JavaScript 和开放 Web 的演讲和博客文章。Robert 是 HTML5 和开放 Web 的坚定支持者,自 1999 年以来一直在从事 Web 前端开发工作,曾在瑞典和纽约市工作。他还定期在http://robertnyman.com上发布博客文章,喜欢旅行和结识新朋友。
5 条评论