WebRTC系列<四> 全面了解客户端-服务器网页游戏的WebRTC

news2024/11/15 12:17:01

转载: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.50192.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的中央服务器交换可公开访问的地址后才能发起点对点通话。RTCPeerConnectionRTCPeerConnection

在信令过程之后,两个客户端都知道如何直接联系对方,并拥有发送任意数据包所需的所有信息。然而,正如我们前面提到的,WebRTC面向媒体传输,并且还要求客户端在任何类型的连接完成之前交换有关其媒体功能的数据。即使我们没有使用媒体API的任何部分,WebRTC仍然要求我们在打开数据通道之前进行完整的媒体握手。此握手称为会话描述协议 (SDP),如下所示:

  1. 客户端 1 和客户端 2 都连接到某个预定义的服务器,称为信令服务器。
  2. 他们通过信令服务器了解彼此的存在,并决定启动连接。
  3. 客户端 1 创建一个“产品/服务”,该产品/服务随后包含有关客户端 1 的媒体功能的信息(例如,如果它具有网络摄像头或可以播放音频)。RTCPeerConnection.createOffer
  4. 客户端 1 通过信令服务器代理将产品/服务发送到客户端 2。
  5. 客户端 2 从信令服务器接收报价并将其传递给 以使用客户端 2 自己的媒体功能创建“答案”。RTCPeerConnection.createAnswer
  6. 客户端 2 通过信令服务器将应答发送回客户端 1。
  7. 客户端 1 接收并验证答案。然后,它启动ICE协议,在我们的示例中,该协议与STUN服务器联系以发现其公共IP。当 STUN 服务器响应时,它会通过信令服务器将此信息(称为“ICE 候选”)发送到客户端 2。
  8. 客户端 2 接收客户端 1 的 ICE 候选项,通过相同的机制查找自己的 ICE 候选项,并通过信令服务器将它们发送到客户端 1。
  9. 每个客户端现在都知道另一个客户端的媒体功能和可公开访问的 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-webrtcelectron-webrtc

在一天结束时,我意识到我对 JavaScript 的性能推理并不那么舒服,我的游戏服务器需要一个具有强大多线程支持的平台。我决定削减所有多余的东西,并采取传统的路线,在C++中构建我的游戏服务器。对于WebRTC功能,我可以链接Chromium的WebRTC库,该库也是用本机代码编写的。

所以现在我们的客户端在浏览器中运行JavaScript,我们的服务器运行在C++,但我们仍然有一块拼图——连接两个对等方的信令服务器。幸运的是,我们可以在这里偷工减料,因为游戏服务器是一个特殊的对等体,我们实际上事先知道它的直接地址。我们可以简单地在游戏服务器的后台运行一个轻量级的 WebSockets 库,并通过客户端的 TCP 轻松连接到它。然后,客户端可以通过WebSocket发送WebRTC报价,服务器可以在本地处理数据,而不必像在传统的信令服务器中那样转发数据。

实现

我们已经涵盖了很多信息,现在让我们最终将它们放在客户端-服务器WebRTC连接的最小示例中。为了保持一致性,我的客户端在OS X和Chrome 56上运行,我的服务器在EC2实例上的Ubuntu 16.04上运行(对于开发服务器来说矫枉过正,但是嘿,我的积分即将到期)。我正在编译我的服务器。客户端和服务器的完整源代码都可以在我的GitHub上找到,这应该可以帮助您跟进。c4.xlargegcc 5.4

我们需要做的第一件事是设置服务器依赖项。如果你不太习惯C++构建工具,你可以克隆我上面链接的全功能存储库,并将其用作起点。我们将使用 WebSocket++,这是一个用于伪信令服务器的仅标头C++WebSockets 实现。WebSocket++本身依赖于Boost.Asio进行异步编程,我们可以轻松安装。由于 WebSocket++ 是一个仅标头库,我们可以简单地克隆存储库并将子目录复制到我们的包含路径中。apt-get install libboost-all-devwebsocketpp

我们还需要一种在客户端和服务器之间发送结构化消息的格式。在生产中,我会使用紧凑且高性能的序列化解决方案,例如Protocol Buffers,但出于本演示的目的,我们将只使用JSON,因为它在JavaScript中提供了一流的支持。在服务器端,我将使用rapidjson来解析和序列化数据。与 WebSocket++ 一样,这是一个仅标头库,因此您只需克隆存储库并将子目录复制到包含路径中即可。include/rapidjson

接下来,我们必须构建并安装Chromium的WebRTC库。这是Chrome中用于WebRTC功能的库,因此可以保证它是正确和高效的。我最初从头开始构建它,但这很痛苦,因为您需要克隆存储库,使用 Chromium 特定的构建工具进行构建,并将输出放入共享库文件夹中。我最近发现了一个很好的脚本集合,可以为您完成繁重的工作,我强烈建议您使用它们来保持自己的理智。

即使有了这个方便的实用程序,当 Chromium master 上的最新提交无法在我的机器上构建时,我仍然遇到了问题。在找到绿色构建之前,我不得不进行几次提交。我选择了提交,所以如果你在构建WebRTC时遇到问题,我建议从相同的提交哈希开始。如果你使用的是上面链接的aisouard脚本,不幸的是,自从我第一次开始使用它以来,指定WebRTC提交构建的方式已经发生了变化。因此,我已经锁定了我的服务器设置过程以使用提交脚本,因此如果您想遵循,请进行修订。综上所述,您只需几个命令即可安装WebRTC:3dda246b69libwebrtc83814ef6f3libwebrtccheckout

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 个可能的事件。RTCPeerConnectionRTCDataChannelonmessageonOfferCreatedwebrtc::*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.hPeerConnectionFactory

[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。PeerConnectionFactoryRTCPeerConnection

[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传输的数据通道。最后,我们使用存储客户的报价并创建一个答案以发送回客户端。这将反过来调用我们的回调,我们可以向其添加代码以将答案发送到客户端。RTCPeerConnectionSetRemoteDescriptionCreateAnswerOnSuccessCreateSessionDescriptionObserverOnAnswerCreated

[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。一旦我们构建了消息,我们就会将其字符串化并将答案发送回客户端。SetLocalDescriptionrapidjson'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 候选项发送到我们之前分配的功能中的服务器。RTCPeerConnectionrtcPeerConnection.onicecandidateonicecandidate

[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 候选项类似地由对等连接生成,但这次它们是通过 传递的。我们为此函数提供了自己的回调,我们可以在其中将候选人转发给客户端。OnIceCandidatePeerConnectionObserver

[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 发送。rapidjsononIceCandidate

客户端从服务器接收 ICE 候选项,它也在其中调用。onWebSocketMessageaddIceCandidate

[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.
}

如果您正确执行了所有操作,则调用客户端现在应该启动并(希望)完成与服务器的握手。我们可以通过使用我们之前分配的回调来验证。connectonDataChannelOpendataChannel.onopen

[example-client.js]
function onDataChannelOpen() {
  console.log('Data channel opened!');
}

如果握手成功,应该被解雇,并向控制台输出祝贺消息!然后,我们可以使用此新打开的数据通道来ping服务器。onDataChennelOpen

[example-client.js]
function ping() {
  dataChannel.send('ping');
}

当数据通道成功打开时,服务器同样会收到一个事件。这是通过回调触发的。但是,与客户端不同的是,服务器还有一个额外的步骤要做。在打开原始数据通道时,WebRTC库会创建一个包含更新字段的新数据通道,该通道作为参数传递给回调。此步骤在客户端代码中抽象出来,但重新分配新的数据通道并在服务器上重新绑定通道并不是非常困难。OnDataChannelCreatedPeerConnectionObserverOnDataChannelCreatedDataChannelObserver

[main.cpp]
void OnDataChannelCreated(webrtc::DataChannelInterface* channel) {
  data_channel = channel;
  data_channel->RegisterObserver(&data_channel_observer);
}

由于现在重新绑定到正确的数据通道,服务器现在可以开始通过其回调接收消息。DataChannelObserverOnDataChannelMessage

[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采用的未来感到兴奋,并希望它将迎来下一代大型多人游戏浏览器体验。

引用

  1. https://www.superdataresearch.com/us-digital-games-market/
  2. http://gafferongames.com/networking-for-game-programmers/udp-vs-tcp/
  3. http://gamedev.stackexchange.com/questions/431/is-the-tcp-protocol-good-enough-for-real-time-multiplayer-games
  4. http://gafferongames.com/networking-for-game-programmers/
  5. http://new.gafferongames.com/post/why_cant_i_send_udp_packets_from_a_browser/
  6. https://www.html5rocks.com/en/tutorials/webrtc/basics/
  7. https://news.ycombinator.com/item?id=13741155
  8. https://news.ycombinator.com/item?id=13264952
  9. http://gamedev.stackexchange.com/questions/67738/limitations-of-p2p-multiplayer-games-vs-client-server
  10. https://github.com/js-platform/node-webrtc/issues/257
  11. http://caniuse.com/#feat=rtcpeerconnection

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/12496.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

C# Control.DoubleBuffered 属性的使用

C# Control.DoubleBuffered 属性的使用 在我们开发的过程中,经常需要对界面进行美化,而美化的过程,一般来说就是添加图片, 让界面更加清新脱俗,更加耳目一新。 有一次有一个软件发送到客户那里试用,客户对功能是非常满意的,但是对界面的布局和颜色,就大为不满。 原来…

【Hack The Box】windows练习-- Resolute

HTB 学习笔记 【Hack The Box】windows练习-- Resolute &#x1f525;系列专栏&#xff1a;Hack The Box &#x1f389;欢迎关注&#x1f50e;点赞&#x1f44d;收藏⭐️留言&#x1f4dd; &#x1f4c6;首发时间&#xff1a;&#x1f334;2022年9月7日&#x1f334; &#x1…

uniapp picker 的使用,这玩意做的真不怎么样

uniapp picker 的使用&#xff0c;这玩意做的真不怎么样 最近要做小程序&#xff0c;考虑到需要多平台都用一套东西&#xff0c;就选用了 uniapp。 在写表单的时候用到它的 picker 组件&#xff0c;看官方文档楞是没看明白怎么用&#xff0c;试了半天没试出来&#xff0c;还是…

每日一题 —— LC. 790 多米诺和托米诺

有两种形状的瓷砖&#xff1a;一种是 2 x 1 的多米诺形&#xff0c;另一种是形如 “L” 的托米诺形。两种形状都可以旋转。 给定整数 n &#xff0c;返回可以平铺 2 x n 的面板的方法的数量。返回对 10^9 7 取模 的值。 平铺指的是每个正方形都必须有瓷砖覆盖。两个平铺不同&…

数据集成平台关于【源平台调度任务生命周期】

任务调度者 调度事件生产任务调度任务池-异步
AsynDispatcher --source 实例化适配器执行 消费任务实例化集成应用 DataHub Instance
handleSourceDispatch()依赖注入集成方案适配器调度条件检查执行适配器调度方法联动其它方案调度任务 SourceEvent 适配器调度周期 初始化…

化合物应用 | 动物实验溶剂选择

在给药时为了实现药物准确运送到动物体内、减少溶剂本身的副作用和毒性等的目标&#xff0c;需要选择合适的溶剂配方。溶剂的理化性质&#xff0c;如 pH、粘稠度、渗透压等都会对给药产生影响&#xff0c;需要慎重考虑。例如粘稠度过高可能会导致注射用针头的堵塞&#xff0c;粘…

作为新人,如何快速融入新团队?用好这8个点

大家好&#xff0c;之前在公司调岗&#xff0c;转到了新团队。 从一开始的不适应、不习惯&#xff0c;到现在的逐步习惯&#xff0c;真的就是和那句老话说的一样「有压力才会有成长」&#xff0c;下面晨光会结合在新团队学到的内容进行分享。 文章分为以下几个部分&#xff1…

通过云速搭CADT实现云原生分布式数据库PolarDB-X 2.0的部署

云速搭 CADT 是一款为上云应用提供自助式云架构管理的产品&#xff0c;显著地降低应用云上管理的难度和时间成本。本产品提供丰富的预制应用架构模板&#xff0c;同时也支持自助拖拽方式定义应用云上架构&#xff1b;支持较多阿里云服务的配置和管理。用户可以方便的对云上架构…

人力资源数字化转型,是企业高质量发展的关键

情景一 中层管理者&#xff0c;每天不是在开会&#xff0c;就是在帮下属解决问题&#xff0c;时间被搞的一团乱麻&#xff1b; 为了顺利推进项目&#xff0c;总是把自己逼成卷王&#xff0c;即使如此也没能挽救业绩下滑的命运。 情景二 由于业务能力出色被提拔带团队的新晋…

计算机网络-传输层(UDP协议报文格式,伪首部,UDP校验过程)

文章目录1. UDP协议UDP报文格式UDP校验过程1. UDP协议 UDP只在IP数据报服务之上增加了很少功能&#xff0c;即复用分用和差错检测功能。 UDP的主要特点: UDP是无连接的&#xff0c;减少开销和发送数据之前的时延。 UDP使用最大努力交付&#xff0c;即不保证可靠交付。 UDP是…

webpack5 Preload / Prefetch解决按需求加载速度

代码分离 | webpack 中文文档webpack 是一个模块打包器。它的主要目标是将 JavaScript 文件打包在一起&#xff0c;打包后的文件用于在浏览器中使用&#xff0c;但它也能够胜任转换&#xff08;transform&#xff09;、打包&#xff08;bundle&#xff09;或包裹&#xff08;pa…

vue_mixin混入

目录官网基本概念什么是Mixin混入和组件的区别混入和vuex的区别mixin的优点mixin的缺点使用mixin语法mixin局部混入-mixins全局混入-Vue.mixin方法(不推荐)mixin与组件合并逻辑[1]data数据总结举例说明[2]methods方法总结举例说明[3]生命周期函数总结举例说明问题&#xff1a;一…

Linux 进程信号

目录 一.信号 1.介绍 2.信号概念 3.查看系统定义的信号列表 4.信号处理的方式 二.信号产生前 1.用户层产生信号的方式 三.产生信号 1.通过按键产生信号 2.调用系统函数向进程发信号 &#xff08;1&#xff09;kill &#xff08;2&#xff09;raise &#xff08;…

大学生WEB前端静态网页——旅游介绍35页 响应式,

⛵ 源码获取 文末联系 ✈ Web前端开发技术 描述 网页设计题材&#xff0c;DIVCSS 布局制作,HTMLCSS网页设计期末课程大作业 | 游景点介绍 | 旅游风景区 | 家乡介绍 | 等网站的设计与制作| HTML期末大学生网页设计作业&#xff0c;Web大学生网页 HTML&#xff1a;结构 CSS&…

Android 高通 Launcher3 添加桌面快捷方式

1、最近接到产测一个需求&#xff0c;需要在首页WorkSpace添加产测apk快捷方式&#xff0c;于是乎我去查看了一下Android12的源码&#xff0c;包名/build3/zm/Em_TK1080/EM_TK1080_prj/EM_TK1080_prj/qssi12/packages/apps/Launcher3/res/xml 主要修改文件 default_workspace…

TCP程序设计基础

TCP网络程序设计是指用Socket类编写通信程序。利用TCP协议进行通信的两个应用程序是有主次之分的&#xff0c;一个称为服务器程序&#xff0c;另一个称为客户机程序。两者的功能和编写方法不大一样。服务器端与客户端的交互过程如图所示&#xff1a; 1. InetAddress类 java.…

用 Kafka + DolphinDB 实时计算K线

Kafka 是一个高吞吐量的分布式消息中间件&#xff0c;可用于海量消息的发布和订阅。 当面对大量的数据写入时&#xff0c;以消息中间件接收数据&#xff0c;然后再批量写入到时序数据库中&#xff0c;这样可以将消息中间件的高并发能力和时序数据库的高吞吐量联合起来&#xf…

关于报表打印

1 分页策略 分页与打印时密切相关的&#xff0c;皕杰报表提供了四种分页策略&#xff0c;即按纸张大小分页、按数据行数分页、按数据列数分页、用户自定义分页和不分页。分页由2个因素来控制&#xff0c;一个每个页面的大小&#xff0c;另外一个是分页顺序&#xff08;打印顺序…

如何将抓取下来的unicode字符串转换为中文

如果抓取的数据是json数据&#xff0c;那么直接将抓取的数据用json格式输出出来就行了。如下: response requests.get(url, headersheaders).json()那么大家遇到如下的unicode字符串的是如何解决的呢&#xff1f;如下图所示&#xff1a; 相信大家遇到这种一定会抓狂吧&#…

一种改进Harris算子的角点特征检测研究-含Matlab代码

⭕⭕⭕⭕ 目 录 ⭕⭕⭕⭕✅ 一、引言✅二、Harris角点检测算法✅三、角点检测实验验证✅四、参考文献✅五、Matlab代码获取✅ 一、引言 将图像中灰度变化剧烈或者在图像边界上曲率变化较大的点称为角点。角点检测对于工件图像的特征点定位有着重要作用&#xff0c;在进行工件的…