转载:https://blog.brkho.com/2017/03/15/dive-into-client-server-web-games-webrtc/
多人游戏很有趣。对于他们在单人沉浸感方面所缺乏的东西,在线游戏弥补了与朋友一起探索、在线结识陌生人以及与有能力的同龄人正面交锋的独特奖励体验。人们只需要看看英雄联盟、炉石传说和守望先锋的巨头,就可以意识到对多人游戏的大众需求。1虽然这些特许经营权是成功的,但是,它们在进入其千兆字节的游戏客户端方面存在重大障碍。当然,安装不会阻止铁杆游戏玩家,但对于许多休闲玩家来说,额外的步骤是无法开始的。
出于这个原因,网页游戏在大型多人游戏体验方面具有巨大的潜力。虽然下载和安装客户端对某些人来说可能太多了,但通过简单地访问网页来玩游戏是低摩擦的,有利于病毒式传播。我目前正在构建这样一个游戏,在这篇博文中,我想分享我在浏览器和游戏服务器之间建立原始连接的经验。
dr:如果您已经熟悉这些概念,可以查看完整的示例代码以开始使用。
TCP 与 UDP
开发任何多人游戏的第一步是确定传输层协议,到目前为止最受欢迎的两个是TCP和UDP。许多资源已经广泛地涵盖了差异2,3,所以我只对这个主题进行简要处理。简而言之,UDP 是一个简单的无连接协议,允许源将单个数据包发送到目标。由于网络的不可靠性质,某些数据包可能会在不同的时间被丢弃或到达目的地,UDP 不提供任何保护措施。另一方面,TCP是基于连接的,并保证数据包按源发送的顺序传递和接收。当然,这是以牺牲速度为代价的,因为在发送下一个数据包之前,源需要确认实际已收到数据包。
虽然TCP已被用于许多成功的游戏(最著名的是魔兽世界),但大多数现代在线游戏都喜欢UDP,因为在丢弃数据包及其相关延迟的情况下重新传输数据包不仅是不必要的,而且在快节奏的游戏过程中也是不可接受的。UDP 使用起来肯定有点复杂,但通过一些努力4,您可以利用其灵活性来发挥自己的优势并避免使 ping 膨胀。
浏览器上的 UDP
“这听起来不错,”你说,“但有什么收获呢?通常,只要您注意防止传输故障和网络拥塞,就不会有问题。不幸的是,在网页游戏方面有一个非常大的问题——出于安全原因,没有跨平台的方式通过浏览器中的UDP发送或接收数据包5。大多数像agar.io这样的在线网页游戏都依赖于WebSockets进行网络,这为与服务器的TCP连接公开了一个干净的接口。然而,正如我之前提到的,TCP在需要亚秒级反应的地方分解,所以这是否意味着我们被困在将射击游戏和MOBA作为本地客户端分发?
由WebRTC保存
当然不是!无论解决方案多么复杂,网络总能找到方法。进入WebRTC,这是一个浏览器API,可以为点对点连接实现实时通信6。虽然WebRTC的大部分是为媒体传输量身定制的(例如Discord的Web应用程序中的语音聊天或Web Messenger中的视频通话),但它包含一个经常被忽视的小规范,称为数据通道,它允许在两个对等浏览器之间发送任意消息。
我之前提到过TCP和UDP是最流行的传输层协议,但它们远非唯一的协议。WebRTC数据通道使用流控制传输协议(SCTP),该协议像TCP一样面向连接,但在可靠性和数据包传递方面允许可配置性。换句话说,SCTP可以配置为像TCP一样,保证数据包的传递和排序,或者我们可以关闭这些功能以最终获得类似于UDP的功能。
所以这很棒;使用WebRTC数据通道,我们可以通过配置为像UDP一样运行的SCTP发送消息,完美地解决了我们的问题。然而,事实证明,WebRTC是一头咆哮的野兽,当你试图设置它时,它会以一千个季风的力量震动地球。事实上,最近几次在游戏环境中在黑客新闻上提到 WebRTC,许多评论者指出他们要么无法让它工作,要么被它的复杂性吓倒了,甚至不敢尝试7,8。此外,WebRTC用于点对点连接,而当今大多数竞争激烈的网络游戏都需要客户端 - 服务器模型来防止作弊9。当然,我们别无选择,只能将服务器视为另一个邻居“对等体”,这提供了额外的箍来跳过以建立连接。
点对点网络的挑战
继续以不便为主题,现代网络上的点对点通信本身就提出了另一个挑战。在理想情况下,每个客户端都有自己的固定 IP 地址,其他客户端可以使用该地址建立直接连接。然而,IPv4地址空间实际上仅限于大约30亿个唯一地址,几乎不足以让世界上其他人拥有一台连接互联网的计算机,更不用说额外的平板电脑,笔记本电脑和物联网沉浸式炊具了。作为IPv6之前平静期的临时修复,大多数家庭和企业网络都采用称为网络地址转换(NAT)的过程。
无需赘述,NAT设备(如家用路由器)管理其网络中所有计算机的连接。例如,您公寓中的所有互联网连接设备很可能位于具有面向公众的IP的单个路由器后面,例如。为了节省 IPv4 地址空间,您的消费者设备都共享 NAT 设备的公共 IP,同时为每个设备分配自己的本地 IP,该 IP 仅在本地网络(例如)中是唯一的。当然,更广泛的互联网上的计算机无法使用其本地地址联系或唯一标识您的家用计算机;全球数千台(如果不是数百万台)设备都具有相同的本地 IP。50.50.50.50
192.168.0.10
这就是网络地址转换发挥作用的地方。虽然外部设备无法直接联系您的计算机,但它们可以通过联系计算机后面的 NAT 设备的不同公共 IP 来非常接近。然后,路由器可以使用查找表将传入的请求转换为本地地址,然后将请求转发到您的家用计算机上。
更具体地说,您的计算机将通过首先将其请求发送到路由器来联系服务器,路由器又将该计算机的本地 IP 与 NAT 设备上的空闲端口相关联。然后,它将发送方地址替换为 NAT 设备的 IP 以及刚刚分配给您家庭计算机的端口,从而将请求发送到预期目标。例如,NAT 设备可能会将请求转发到看似源自的目标服务器。50.50.50.50:20000
但是,服务器并不关心请求地址是否来自 NAT;准备就绪后,服务器将简单地将其响应发送回发件人字段中提供的任何地址。这会导致服务器沿与您的家庭计算机唯一关联的端口将响应发送回 NAT 设备。然后,NAT 设备将接收服务器的响应,并使用查找表将其路由到正确的计算机。因此,IPv4 地址空间是保守的,这样做所需的所有间接寻址都从客户端和服务器中抽象出来。有了 NAT,每个人都很开心!
好吧,除了我们。在前面的示例中,我们假设家用计算机已经知道不在NAT后面的服务器的公共IP。 另一方面,WebRTC是为点对点连接而设计的,其中双方都可能位于NAT设备后面,并且两个地址都不知道。因此,WebRTC要求执行一个称为NAT遍历的中间发现步骤,即使在我们的客户端-服务器用例中,服务器的地址实际上是事先知道的,我们也必须实现该步骤。
此步骤最轻量级的协议称为 STUN,其中对等方 ping 称为 STUN 服务器的专用服务器以发现其公共 IP 地址和端口组合(例如)。两个对等方都从 STUN 服务器请求其地址,STUN 服务器发回接收请求的公共 IP 和端口。两个对等体现在都有效地从STUN服务器的响应中知道自己的“公共”IP,他们可以用来开始建立WebRTC连接。50.50.50.50:20000
不幸的是,作为最后的复杂性,企业网络经常使用特殊类型的NAT,例如对称NAT,STUN对其无效,原因我们将在本博客文章的末尾讨论。在这些罕见的情况下,我们被迫使用其他协议(例如TURN)来建立连接。为了管理可能的NAT遍历协议的字母汤,WebRTC使用另一种称为ICE的协议来统治它们。ICE在网络上执行检查,如果可用,则使用STUN,如果没有,则回退到更复杂的协议,如TURN。我们将继续假设我们使用的是支持STUN的传统家庭网络。
WebRTC对等连接和数据通道
有了所有的背景信息,我现在将介绍WebRTC数据通道创建过程的高级概述,然后跳转到设置自己的客户端和服务器所需的实际代码。
WebRTC提供了一个接口,作为创建任何类型的连接,数据通道或其他方式的起点。客户端可以初始化对象并开始查找要连接的其他对等客户端并开始交换数据。当然,在这一点上,客户无法直接知道其他客户在哪里。在WebRTC术语中,我们通过一个称为信令的特定应用程序过程来解决这个问题,其中两个对等方通过已知的服务器交换握手,并使用ICE和STUN了解彼此的“公共”IP。举一个真实的例子,Messenger上的两个朋友只有在通过Facebook的中央服务器交换可公开访问的地址后才能发起点对点通话。RTCPeerConnection
RTCPeerConnection
在信令过程之后,两个客户端都知道如何直接联系对方,并拥有发送任意数据包所需的所有信息。然而,正如我们前面提到的,WebRTC面向媒体传输,并且还要求客户端在任何类型的连接完成之前交换有关其媒体功能的数据。即使我们没有使用媒体API的任何部分,WebRTC仍然要求我们在打开数据通道之前进行完整的媒体握手。此握手称为会话描述协议 (SDP),如下所示:
- 客户端 1 和客户端 2 都连接到某个预定义的服务器,称为信令服务器。
- 他们通过信令服务器了解彼此的存在,并决定启动连接。
- 客户端 1 创建一个“产品/服务”,该产品/服务随后包含有关客户端 1 的媒体功能的信息(例如,如果它具有网络摄像头或可以播放音频)。
RTCPeerConnection.createOffer
- 客户端 1 通过信令服务器代理将产品/服务发送到客户端 2。
- 客户端 2 从信令服务器接收报价并将其传递给 以使用客户端 2 自己的媒体功能创建“答案”。
RTCPeerConnection.createAnswer
- 客户端 2 通过信令服务器将应答发送回客户端 1。
- 客户端 1 接收并验证答案。然后,它启动ICE协议,在我们的示例中,该协议与STUN服务器联系以发现其公共IP。当 STUN 服务器响应时,它会通过信令服务器将此信息(称为“ICE 候选”)发送到客户端 2。
- 客户端 2 接收客户端 1 的 ICE 候选项,通过相同的机制查找自己的 ICE 候选项,并通过信令服务器将它们发送到客户端 1。
- 每个客户端现在都知道另一个客户端的媒体功能和可公开访问的 IP。它们在没有信令服务器帮助的情况下交换直接ping,并建立连接。两个客户端现在可以通过 API 愉快地相互发送消息。
RTCDataChannel
客户端-服务器模型中的WebRTC
归根结底,我们可以将游戏客户端视为“客户端 1”,将游戏服务器视为“客户端 2”,并遵循复杂但定义明确的 WebRTC 协议来建立客户端-服务器连接。在客户端上实现WebRTC连接很简单;WebRTC首先是一个浏览器API,因此我们可以调用大多数现代浏览器提供的正确函数。
虽然WebRTC有相当不错的浏览器支持,但在服务器上使用WebRTC API是完全不同的故事。出于个人风格,我最初是用 JavaScript 和 Node.js 编写我的游戏服务器。我开始使用node-webrtc,它是Chromium WebRTC库的JavaScript包装器。然而,我很快发现这取决于非常旧的WebRTC二进制文件,这些二进制文件使用与现代Chrome10不兼容的过时的SDP握手。然后,我转向了electron-webrtc,它只是在后台运行一个无头电子客户端,通过进程间通信提供WebRTC功能。我能够毫不费力地获得基本连接,但我担心它的可扩展性,这是由于在主进程和成熟的电子应用程序之间打乱数据的额外开销造成的。node-webrtc
electron-webrtc
在一天结束时,我意识到我对 JavaScript 的性能推理并不那么舒服,我的游戏服务器需要一个具有强大多线程支持的平台。我决定削减所有多余的东西,并采取传统的路线,在C++中构建我的游戏服务器。对于WebRTC功能,我可以链接Chromium的WebRTC库,该库也是用本机代码编写的。
所以现在我们的客户端在浏览器中运行JavaScript,我们的服务器运行在C++,但我们仍然有一块拼图——连接两个对等方的信令服务器。幸运的是,我们可以在这里偷工减料,因为游戏服务器是一个特殊的对等体,我们实际上事先知道它的直接地址。我们可以简单地在游戏服务器的后台运行一个轻量级的 WebSockets 库,并通过客户端的 TCP 轻松连接到它。然后,客户端可以通过WebSocket发送WebRTC报价,服务器可以在本地处理数据,而不必像在传统的信令服务器中那样转发数据。
实现
我们已经涵盖了很多信息,现在让我们最终将它们放在客户端-服务器WebRTC连接的最小示例中。为了保持一致性,我的客户端在OS X和Chrome 56上运行,我的服务器在EC2实例上的Ubuntu 16.04上运行(对于开发服务器来说矫枉过正,但是嘿,我的积分即将到期)。我正在编译我的服务器。客户端和服务器的完整源代码都可以在我的GitHub上找到,这应该可以帮助您跟进。c4.xlarge
gcc 5.4
我们需要做的第一件事是设置服务器依赖项。如果你不太习惯C++构建工具,你可以克隆我上面链接的全功能存储库,并将其用作起点。我们将使用 WebSocket++,这是一个用于伪信令服务器的仅标头C++WebSockets 实现。WebSocket++本身依赖于Boost.Asio进行异步编程,我们可以轻松安装。由于 WebSocket++ 是一个仅标头库,我们可以简单地克隆存储库并将子目录复制到我们的包含路径中。apt-get install libboost-all-dev
websocketpp
我们还需要一种在客户端和服务器之间发送结构化消息的格式。在生产中,我会使用紧凑且高性能的序列化解决方案,例如Protocol Buffers,但出于本演示的目的,我们将只使用JSON,因为它在JavaScript中提供了一流的支持。在服务器端,我将使用rapidjson来解析和序列化数据。与 WebSocket++ 一样,这是一个仅标头库,因此您只需克隆存储库并将子目录复制到包含路径中即可。include/rapidjson
接下来,我们必须构建并安装Chromium的WebRTC库。这是Chrome中用于WebRTC功能的库,因此可以保证它是正确和高效的。我最初从头开始构建它,但这很痛苦,因为您需要克隆存储库,使用 Chromium 特定的构建工具进行构建,并将输出放入共享库文件夹中。我最近发现了一个很好的脚本集合,可以为您完成繁重的工作,我强烈建议您使用它们来保持自己的理智。
即使有了这个方便的实用程序,当 Chromium master 上的最新提交无法在我的机器上构建时,我仍然遇到了问题。在找到绿色构建之前,我不得不进行几次提交。我选择了提交,所以如果你在构建WebRTC时遇到问题,我建议从相同的提交哈希开始。如果你使用的是上面链接的aisouard脚本,不幸的是,自从我第一次开始使用它以来,指定WebRTC提交构建的方式已经发生了变化。因此,我已经锁定了我的服务器设置过程以使用提交脚本,因此如果您想遵循,请进行修订。综上所述,您只需几个命令即可安装WebRTC:3dda246b69
libwebrtc
83814ef6f3
libwebrtc
checkout
apt-get install build-essential libglib2.0-dev libgtk2.0-dev libxtst-dev \
libxss-dev libpci-dev libdbus-1-dev libgconf2-dev \
libgnome-keyring-dev libnss3-dev libasound2-dev libpulse-dev \
libudev-dev cmake
git clone https://github.com/aisouard/libwebrtc.git
cd libwebrtc
<OPTIONAL> git checkout 83814ef6f3
<OPTIONAL> vim CMakeModules/Version.cmake
<OPTIONAL> change the LIBWEBRTC_WEBRTC_REVISION hash to 3dda246b69df7ff489660e0aee0378210104240b
git submodule init
git submodule update
mkdir out
cd out
cmake ..
make
make install
我们现在有了所有的服务器依赖项,所以让我们开始一个基本的 WebSockets 连接。这是让它起步的完整代码:
[main.cpp]
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>
#include <iostream>
using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
using websocketpp::lib::bind;
typedef websocketpp::server<websocketpp::config::asio> WebSocketServer;
typedef WebSocketServer::message_ptr message_ptr;
// The WebSocket server being used to handshake with the clients.
WebSocketServer server;
// Callback for when the WebSocket server receives a message from the client.
void OnWebSocketMessage(WebSocketServer* s, websocketpp::connection_hdl hdl, message_ptr msg) {
std::cout << msg->get_payload() << std::endl;
}
int main() {
// In a real game server, you would run the WebSocket server as a separate thread so your main process can handle the game loop.
server.set_message_handler(bind(OnWebSocketMessage, &server, ::_1, ::_2));
server.init_asio();
server.set_reuse_addr(true);
server.listen(8080);
server.start_accept();
// I don't do it here, but you should gracefully handle closing the connection.
server.run();
}
这段代码不应该看起来太复杂;我们只是创建一个由 asio 支持的 WebSocketServer 对象,设置一个消息处理程序,并调用一些配置方法。如注释中所述,这将导致您的主服务器运行 WebSocket 侦听循环,阻止它执行任何其他操作。在实际项目中,应将 WebSocket 服务器作为单独的线程运行。您可以通过从个人计算机调用来验证 WebSocket 服务器是否实际正在运行。telnet <server IP> 8080
与服务器上的 WebSocket 通信的相应客户端代码同样简单。
[example-client.js]
// URL to the server with the port we are using for WebSockets.
const webSocketUrl = 'ws://<replace with server address>:8080';
// The WebSocket object used to manage a connection.
let webSocketConnection = null;
// Callback for when the WebSocket is successfully opened.
function onWebSocketOpen() {
console.log('Opened!');
webSocketConnection.send('Hello, world!');
}
// Callback for when we receive a message from the server via the WebSocket.
function onWebSocketMessage(event) {
console.log(event.data);
}
// Connects by creating a new WebSocket connection and associating some callbacks.
function connect() {
webSocketConnection = new WebSocket(webSocketUrl);
webSocketConnection.onopen = onWebSocketOpen;
webSocketConnection.onmessage = onWebSocketMessage;
}
虽然简单,但这演示了我们需要的所有功能:在客户端上创建新的 WebSocket、分配一些回调和发送消息。如果您调用,您应该在浏览器控制台上看到“已打开!”打印,在服务器的标准输出上看到“Hello,world!”打印。connect
我们现在可以实例化 anand an,它们是浏览器的一部分,API.is 然后用于创建 SDP 的报价,该报价通过我们的 WebSockets 连接发送到服务器。
RTCPeerConnectionRTCDataChannelRTCPeerConnection
[example-client.js]
function onWebSocketOpen() {
const config = { iceServers: [{ url: 'stun:stun.l.google.com:19302' }] };
rtcPeerConnection = new RTCPeerConnection(config);
const dataChannelConfig = { ordered: false, maxRetransmits: 0 };
dataChannel = rtcPeerConnection.createDataChannel('dc', dataChannelConfig);
dataChannel.onmessage = onDataChannelMessage;
dataChannel.onopen = onDataChannelOpen;
const sdpConstraints = {
mandatory: {
OfferToReceiveAudio: false,
OfferToReceiveVideo: false,
},
};
rtcPeerConnection.onicecandidate = onIceCandidate;
rtcPeerConnection.createOffer(onOfferCreated, () => {}, sdpConstraints);
}
我们创建了一个指向STUN的URL,server.is 由Google维护的公共STUN服务器以供开发使用,因此建议设置自己的STUN服务器以用于生产。接下来,我们创建一个与 and 关联的数据通道,指定在配置对象中使用无序、不可靠的 SCTP。我们绑定了一些稍后会返回的回调,并尝试创建 SDP 产品/服务。第一个参数是创建成功的回调,第二个参数是创建失败的回调,最后一个参数是不言自明的配置对象。实际产品/服务将传递到成功回调。
RTCPeerConnectionstun:stun.l.google.com:19302RTCPeerConnectioncreateOffer
[example-client.js]
function onOfferCreated(description) {
rtcPeerConnection.setLocalDescription(description);
webSocketConnection.send(JSON.stringify({type: 'offer', payload: description}));
}
在报价回调中,我们通过调用存储客户端自己的媒体功能,然后通过 WebSocket 将我们的报价作为字符串化 JSON 发送。在服务器端,我们可以通过解析 JSON 来处理此请求。setLocalDescription
[main.cpp]
#include <rapidjson/document.h>
OnWebSocketMessage(WebSocketServer* s, websocketpp::connection_hdl hdl, message_ptr msg) {
rapidjson::Document message_object;
message_object.Parse(msg->get_payload().c_str());
// Probably should do some error checking on the JSON object.
std::string type = message_object["type"].GetString();
if (type == "offer") {
std::string sdp = message_object["payload"]["sdp"].GetString();
// Do some some stuff with the offer.
} else {
std::cout << "Unrecognized WebSocket message type." << std::endl;
}
}
此时,我们希望在服务器上创建anand anon,以便我们可以处理客户的报价并生成答案。不幸的是,随着C++的出现,需要相当数量的样板代码来完成需要 15 行 JavaScript 的相同任务。主要区别在于WebRTC库使用观察者模式来处理WebRTC事件,而不是方便的JS回调。为了运行对等连接,我们必须通过覆盖抽象类系列来实现所有 19 个可能的事件。RTCPeerConnection
RTCDataChannel
onmessage
onOfferCreated
webrtc::*Observer
webrtc::PeerConnectionObserver
用于对等连接事件,例如接收 ICE 候选项。webrtc::CreateSessionDescriptionObserver
用于创建报价或答案。webrtc::SetSessionDescriptionObserver
用于确认和存储报价或答案。webrtc::DataChannelObserver
用于接收 SCTP 消息等数据通道事件。
我提供了observers.h,它为大多数这些事件方法实现了无操作,以简化您的开发。实际上,我们只关心其中的几个事件。对于我们确实需要操作的事件,我们提供了稍后将在其中定义的回调函数。main.cpp
[main.cpp]
#include "observers.h"
void OnDataChannelCreated(webrtc::DataChannelInterface* channel);
void OnIceCandidate(const webrtc::IceCandidateInterface* candidate);
void OnDataChannelMessage(const webrtc::DataBuffer& buffer);
void OnAnswerCreated(webrtc::SessionDescriptionInterface* desc);
PeerConnectionObserver peer_connection_observer(OnDataChannelCreated, OnIceCandidate);
DataChannelObserver data_channel_observer(OnDataChannelMessage);
CreateSessionDescriptionObserver create_session_description_observer(OnAnswerCreated);
SetSessionDescriptionObserver set_session_description_observer;
我们现在需要了解一下WebRTC的线程模型。简而言之,WebRTC需要两个线程来运行它 - 信令线程和工作线程。信令线程处理大量的WebRTC计算;它创建所有基本组件并触发我们可以通过调用 中定义的观察者方法来使用的事件。另一方面,工作线程被委派资源密集型任务(如媒体流),以确保信令线程不会被阻塞。如果我们使用 a,WebRTC 将自动为我们创建两个线程。observers.h
PeerConnectionFactory
[main.cpp]
#include <webrtc/api/peerconnectioninterface.h>
#include <webrtc/base/physicalsocketserver.h>
#include <webrtc/base/ssladapter.h>
#include <webrtc/base/thread.h>
#include <thread>
rtc::scoped_refptr<webrtc::PeerConnectionFactoryInterface> peer_connection_factory;
rtc::PhysicalSocketServer socket_server;
std::thread webrtc_thread;
void SignalThreadEntry() {
// Create the PeerConnectionFactory.
rtc::InitializeSSL();
peer_connection_factory = webrtc::CreatePeerConnectionFactory();
rtc::Thread* signaling_thread = rtc::Thread::Current();
signaling_thread->set_socketserver(&socket_server);
signaling_thread->Run();
signaling_thread->set_socketserver(nullptr);
}
int main() {
webrtc_thread = std::thread(SignalThreadEntry);
// ... set up the WebSocket server.
}
CreatePeerConnectionFactory
将当前线程设置为信令线程,并在后台创建一些工作线程。由于我们使用主线程进行 WebSocket 侦听循环,我们需要创建一个 new,以便 WebRTC 和 WebSocket 可以共存。webrtc_thread
在WebRTC线程入口函数中,我们实例化一个,它将该线程指定为信令线程。在执行了一些设置(例如提供套接字与工作线程通信)之后,我们终于可以使用工厂生成 an并响应 SDP。PeerConnectionFactory
RTCPeerConnection
[main.cpp]
rtc::scoped_refptr<webrtc::PeerConnectionInterface> peer_connection;
rtc::scoped_refptr<webrtc::DataChannelInterface> data_channel;
void OnWebSocketMessage(...) {
// ... parse the JSON.
if (type == "offer") {
std::string sdp = message_object["payload"]["sdp"].GetString();
webrtc::PeerConnectionInterface::RTCConfiguration configuration;
webrtc::PeerConnectionInterface::IceServer ice_server;
ice_server.uri = "stun:stun.l.google.com:19302";
configuration.servers.push_back(ice_server);
// Create the RTCPeerConnection with an observer.
peer_connection = peer_connection_factory->CreatePeerConnection(configuration, nullptr, nullptr, &peer_connection_observer);
webrtc::DataChannelInit data_channel_config;
data_channel_config.ordered = false;
data_channel_config.maxRetransmits = 0;
// Create the RTCDataChannel with an observer.
data_channel = peer_connection->CreateDataChannel("dc", &data_channel_config);
data_channel->RegisterObserver(&data_channel_observer);
webrtc::SdpParseError error;
webrtc::SessionDescriptionInterface* session_description(webrtc::CreateSessionDescription("offer", sdp, &error));
// Store the client's SDP offer.
peer_connection->SetRemoteDescription(&set_session_description_observer, session_description);
// Creates an answer to send back.
peer_connection->CreateAnswer(&create_session_description_observer, nullptr);
}
// ... handle other cases.
}
虽然这看起来很复杂,但它本质上与我们为客户编写的 JavaScript 代码相同。首先,我们创建一个谷歌开发的STUN服务器,并使用它创建一个通过无序,不可靠的SCTP传输的数据通道。最后,我们使用存储客户的报价并创建一个答案以发送回客户端。这将反过来调用我们的回调,我们可以向其添加代码以将答案发送到客户端。RTCPeerConnection
SetRemoteDescription
CreateAnswer
OnSuccess
CreateSessionDescriptionObserver
OnAnswerCreated
[main.cpp]
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
void OnAnswerCreated(webrtc::SessionDescriptionInterface* desc) {
peer_connection->SetLocalDescription(&set_session_description_observer, desc);
std::string offer_string;
desc->ToString(&offer_string);
rapidjson::Document message_object;
message_object.SetObject();
message_object.AddMember("type", "answer", message_object.GetAllocator());
rapidjson::Value sdp_value;
sdp_value.SetString(rapidjson::StringRef(offer_string.c_str()));
rapidjson::Value message_payload;
message_payload.SetObject();
message_payload.AddMember("type", "answer", message_object.GetAllocator());
message_payload.AddMember("sdp", sdp_value, message_object.GetAllocator());
message_object.AddMember("payload", message_payload, message_object.GetAllocator());
rapidjson::StringBuffer strbuf;
rapidjson::Writer<rapidjson::StringBuffer> writer(strbuf);
message_object.Accept(writer);
std::string payload = strbuf.GetString();
ws_server.send(websocket_connection_handler, payload, websocketpp::frame::opcode::value::text);
}
我们使用存储服务器自己的答案(作为参数传入)。在这里,我们遇到了极差的代码人体工程学,但希望很明显,我们所做的只是逐个字段构建一个简单的 JSON blob。一旦我们构建了消息,我们就会将其字符串化并将答案发送回客户端。SetLocalDescription
rapidjson's
[example-client.js]
function onWebSocketMessage(event) {
const messageObject = JSON.parse(event.data);
if (messageObject.type === 'answer') {
rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(messageObject.payload));
} else {
console.log('Unrecognized WebSocket message type.');
}
}
我们通过解析消息来获取其类型和有效负载来处理消息。客户端继续通过使用消息有效负载进行调用来存储服务器的 SDP 答案。setRemoteDescription
现在客户端和服务器已经按照WebRTC的要求交换了它们的媒体功能,剩下的就是以ICE候选的形式交换它们可公开访问的地址。在客户方面,为我们管理大部分;它使用提供的 STUN 服务器执行 ICE 协议,并将所有找到的 ICE 候选者传递给回调。然后,我们所要做的就是将 ICE 候选项发送到我们之前分配的功能中的服务器。RTCPeerConnection
rtcPeerConnection.onicecandidate
onicecandidate
[example-client.js]
function onIceCandidate(event) {
if (event && event.candidate) {
webSocketConnection.send(JSON.stringify({type: 'candidate', payload: event.candidate}));
}
}
我们可以在服务器上处理此消息。OnWebSocketMessage
[main.cpp]
void OnWebSocketMessage(...) {
// ... Parse JSON and handle an offer message.
} else if (type == "candidate") {
std::string candidate = message_object["payload"]["candidate"].GetString();
int sdp_mline_index = message_object["payload"]["sdpMLineIndex"].GetInt();
std::string sdp_mid = message_object["payload"]["sdpMid"].GetString();
webrtc::SdpParseError error;
auto candidate_object = webrtc::CreateIceCandidate(sdp_mid, sdp_mline_index, candidate, &error);
peer_connection->AddIceCandidate(candidate_object);
} else {
// ... Handle unrecognized type.
}
服务器将JSON blob的字段解析为适当的WebRTC ICE候选对象,然后通过该对象进行保存。AddIceCandidate
服务器自己的 ICE 候选项类似地由对等连接生成,但这次它们是通过 传递的。我们为此函数提供了自己的回调,我们可以在其中将候选人转发给客户端。OnIceCandidate
PeerConnectionObserver
[main.cpp]
void OnIceCandidate(const webrtc::IceCandidateInterface* candidate) {
std::string candidate_str;
candidate->ToString(&candidate_str);
rapidjson::Document message_object;
message_object.SetObject();
message_object.AddMember("type", "candidate", message_object.GetAllocator());
rapidjson::Value candidate_value;
candidate_value.SetString(rapidjson::StringRef(candidate_str.c_str()));
rapidjson::Value sdp_mid_value;
sdp_mid_value.SetString(rapidjson::StringRef(candidate->sdp_mid().c_str()));
rapidjson::Value message_payload;
message_payload.SetObject();
message_payload.AddMember("candidate", candidate_value, message_object.GetAllocator());
message_payload.AddMember("sdpMid", sdp_mid_value, message_object.GetAllocator());
message_payload.AddMember("sdpMLineIndex", candidate->sdp_mline_index(),
message_object.GetAllocator());
message_object.AddMember("payload", message_payload, message_object.GetAllocator());
rapidjson::StringBuffer strbuf;
rapidjson::Writer<rapidjson::StringBuffer> writer(strbuf);
message_object.Accept(writer);
std::string payload = strbuf.GetString();
ws_server.send(websocket_connection_handler, payload, websocketpp::frame::opcode::value::text);
}
同样,代码过于冗长,因为它对客户端自己的回调是直接和模拟的。服务器获取提供的 ICE 候选项,将其字段解析为 JSON 对象,并通过 WebSocket 发送。rapidjson
onIceCandidate
客户端从服务器接收 ICE 候选项,它也在其中调用。onWebSocketMessage
addIceCandidate
[example-client.js]
function onWebSocketMessage(event) {
// ... Parse string and handle answer.
} else if (messageObject.type === 'candidate') {
rtcPeerConnection.addIceCandidate(new RTCIceCandidate(messageObject.payload));
} else {
// ... Handle unrecognized type.
}
如果您正确执行了所有操作,则调用客户端现在应该启动并(希望)完成与服务器的握手。我们可以通过使用我们之前分配的回调来验证。connect
onDataChannelOpen
dataChannel.onopen
[example-client.js]
function onDataChannelOpen() {
console.log('Data channel opened!');
}
如果握手成功,应该被解雇,并向控制台输出祝贺消息!然后,我们可以使用此新打开的数据通道来ping服务器。onDataChennelOpen
[example-client.js]
function ping() {
dataChannel.send('ping');
}
当数据通道成功打开时,服务器同样会收到一个事件。这是通过回调触发的。但是,与客户端不同的是,服务器还有一个额外的步骤要做。在打开原始数据通道时,WebRTC库会创建一个包含更新字段的新数据通道,该通道作为参数传递给回调。此步骤在客户端代码中抽象出来,但重新分配新的数据通道并在服务器上重新绑定通道并不是非常困难。OnDataChannelCreated
PeerConnectionObserver
OnDataChannelCreated
DataChannelObserver
[main.cpp]
void OnDataChannelCreated(webrtc::DataChannelInterface* channel) {
data_channel = channel;
data_channel->RegisterObserver(&data_channel_observer);
}
由于现在重新绑定到正确的数据通道,服务器现在可以开始通过其回调接收消息。DataChannelObserver
OnDataChannelMessage
[main.cpp]
void OnDataChannelMessage(const webrtc::DataBuffer& buffer) {
std::string data(buffer.data.data<char>(), buffer.data.size());
std::cout << data << std::endl;
std::string str = "pong";
webrtc::DataBuffer resp(rtc::CopyOnWriteBuffer(str.c_str(), str.length()), false /* binary array */);
data_channel->Send(resp);
}
这会将接收到的ping(由WebRTC管理)打印到标准输出,并用pong响应。客户可以通过我们分配到的乒乓球处理乒乓球。
DataBufferonDataChannelMessagedataChannel.onmessage
[example-client.js]
function onDataChannelMessage(event) {
console.log(event.data);
}
最后,我们完成了!如果实施正确,我们通过调用这将向服务器发送“ping”消息来收获我们的劳动成果。服务器处理客户端的消息,将“ping”打印到标准输出并发回“pong”消息。收到服务器的消息后,客户端将“pong”输出到浏览器控制台。ping
基准
呵呵,这是很多概念和代码,只是为了建立一个简单的连接。使用 WebSocket 初始化类似的连接只需要大约 10 行客户端代码和 20 行服务器代码。鉴于前期成本的这种差异,WebRTC及其相关的样板文件是否值得?我运行了一些基准测试来找出答案。
在第一次测试中,我每秒从客户端向服务器发送 20 次 ping 20 次,并测量往返时间。我在“完美连接”上做到了这一点,WebRTC数据通道(SCTP)和简单的WebSockets连接(TCP)都没有丢包。
正如预期的那样,在没有数据包丢失的情况下,WebRTC和WebSocket的性能都可以接受,WebRTC RTT集群约为40-50ms,WebSocket平均约为80-90ms。TCP协议中肯定有一定的开销,但对于大多数游戏来说,额外的50毫秒左右不会成就或破坏玩家体验。
在第二个测试中,我在相同的持续时间内以相同的速率发送 ping,但我还使用流量整形器丢弃了 5% 的传出数据包和 5% 的传入数据包。同样,我在WebRTC和WebSockets上进行了测试。
诚然,5%的下降率有点夸张,但无论如何,结果都是惊人的。由于我们在不可靠的 SCTP 中传输 WebRTC,因此 RTT 的分发完全不受影响。我们丢弃了大约 40 个数据包,但在服务器每秒发送状态 20 次的游戏环境中,这不是问题。另一方面,WebSocket 实现有一个长尾巴,有些数据包在超过 900 毫秒内没有到达。更糟糕的是,很大一部分数据包的 RTT 超过 250 毫秒,这将导致任何游戏玩家都可以证明的极其烦人的体验。
结论
尽管需要大量的坚持,但我们最终还是能够将WebRTC硬塞进客户端-服务器架构中。我们实现了一个数据通道连接的示例,该连接的性能比 WebSockets 好得多,适用于完美连接和数据包丢失的网络。但是,示例代码在很大程度上是说明性的,并且包含大量次优模式。除了代码中散落的全局变量之外,服务器在回调中立即处理数据通道消息时还包含明显的低效率。在我们的示例中,这样做的成本可以忽略不计,但在实际的游戏服务器中,消息处理程序将是一个必须与状态交互的开销更大的函数。然后,消息处理函数将阻止信令线程在执行期间处理线路上的任何其他消息。为了避免这种情况,我建议将所有消息推送到线程安全的消息队列中,在下一个游戏周期中,在不同线程中运行的主游戏循环可以批量处理网络消息。为此,我在自己的游戏服务器中使用了Facebook的无锁队列。有关如何围绕WebRTC更好地组织游戏的想法,请随时查看我的服务器代码和客户端代码。OnDataChannelMessage
关于WebRTC,还有一些值得一提的注意事项。首先,WebRTC甚至还没有在每个主流浏览器中都得到支持11。虽然Firefox和Chrome长期以来一直在支持浏览器的列表中,但Safari和Edge明显缺席。我很乐意在自己的游戏中只支持现代 Firefox 和 Chrome,但根据您的目标受众,只分发原生客户端可能更有意义。
此外,我之前提到过对称NAT设备背后的企业网络不能使用STUN。这是因为对称 NAT 不仅通过将本地 IP 与端口相关联,而且还通过将本地 IP 与目标相关联来提供额外的安全性。然后,NAT 设备将仅接受来自原始目标服务器的关联端口上的连接。这意味着,虽然 STUN 服务器仍然可以发现客户端的 NAT IP,但该地址对其他对等方毫无用处,因为只有 STUN 服务器可以沿它进行响应。
为了解决这个问题,我们可以使用一种称为TURN的不同协议,该协议仅充当中继服务器,在两个对等体之间转发数据包。然而,这种方法是次优的,因为它由于间接性而增加了对等方之间的往返时间。我认为尚未探索的一种有趣方法是将 TURN 服务器与游戏服务器相结合,但运行自定义 TURN 实现,将收到的数据包直接推送到游戏循环的消息队列。这解决了对称 NAT 问题,甚至比这篇博文中描述的方法更有效。我很可能会在我进一步充实我的游戏之后进行实验。敬请期待!
尽管有这些挫折,WebRTC数据通道仍然是一个强大的工具,可以用来提高许多网页游戏的响应能力。我对WebRTC采用的未来感到兴奋,并希望它将迎来下一代大型多人游戏浏览器体验。
引用
- https://www.superdataresearch.com/us-digital-games-market/
- http://gafferongames.com/networking-for-game-programmers/udp-vs-tcp/
- http://gamedev.stackexchange.com/questions/431/is-the-tcp-protocol-good-enough-for-real-time-multiplayer-games
- http://gafferongames.com/networking-for-game-programmers/
- http://new.gafferongames.com/post/why_cant_i_send_udp_packets_from_a_browser/
- https://www.html5rocks.com/en/tutorials/webrtc/basics/
- https://news.ycombinator.com/item?id=13741155
- https://news.ycombinator.com/item?id=13264952
- http://gamedev.stackexchange.com/questions/67738/limitations-of-p2p-multiplayer-games-vs-client-server
- https://github.com/js-platform/node-webrtc/issues/257
- http://caniuse.com/#feat=rtcpeerconnection