使用NATS构建可伸缩的WebSocket服务器

2020-07-22 22:38:33

我的新应用程序(仍在内部开发)Woogles.io旨在成为人们可以在线玩实时文字游戏的地方。特别是在这场大流行期间,任何时候的面对面游戏似乎都已经完全停止了,我们棋盘游戏玩家需要一些东西来保持我们的理智。除了参加即将到来的智力运动奥林匹克竞赛中的6个不同的游戏之外,我试图抚慰这种瘙痒的主要方式是召集一个团队,编写一种保持纵横字谜棋盘游戏活力的方法。

大约5年前,我第一次看到了苔藓,我觉得这是有史以来最令人惊叹的事情。如果我没有受到这款应用的启发,那我就是在撒谎,虽然到目前为止,我们的设计非常独特,但我通过查看它的代码库,了解了很多关于Lichess架构的知识。

具体地说,他们提到有一个单独的WebSocket服务器(或多个服务器),以及Redis来处理WebSocket服务器和API服务器之间的通信。套接字服务器代码有相当多的责任,其中似乎有一堆与国际象棋相关的代码。我非常肯定这是因为代码是最近从无许可证的整体中分离出来的,所以其中可能存在一些技术债务。我希望从头开始意味着从一开始就把它设计得尽可能简单。

据我所知,无许可证套接字体系结构的基本要点如下:套接字服务器直接从用户的Web浏览器处理WebSocket连接。然后,它使用Redis Pub/Sub将消息直接发布到单独的API服务器。API服务器执行所有与国际象棋相关的工作,然后在一个或多个Redis频道上重新发布。最后,订阅这些通道的套接字服务器确定要响应哪个客户端的WebSocket,并写回信息。

我想尝试类似的功能,但很快就意识到,只有当您有一台API服务器监听Redis订阅时才行得通,就像lichess似乎所做的那样(尽管它是一个非常大、功能强大的服务器)。否则,每个API服务器都会收到消息,并且每一步棋都将以X-plicate方式执行,依此类推。解决方案是NATS和请求-应答模式!NAT是一个简单但功能非常强大的消息代理,它允许发布/订阅和其他类似模式。特别是,请求-回复使得您可以从不同的API服务器中形成一个“队列组”,并且只将消息传递到其中一个服务器,并且有一个可选的超时。然后,选定的服务器将在特定的NATS通道上响应该消息,阻塞该响应的套接字服务器(当然是在Goroutine中)然后可以将该响应转发到正确的客户端。

NAT还允许我们拥有大量的临时通道,而不必预先声明它们(与RabbitMQ之类的东西相反)。它使得构建某些类型的消息可以传递的“域”变得非常容易和快速。下面是我们代码中的一些示例:

这个频道是特定游戏ID的“电视模式”--基本上是一种观察者模式,可以看到游戏的两面。

User.{userID}:此通道接受特定于用户的消息。在我们典型的纵横字谜棋盘游戏中,我们绝对不想将关于对手机架的信息发回给两个用户。虽然可以在前端对信息进行清理,但任何精明的用户都可以通过查看WebSocket消息轻松地看到对手的两个机架。例如,ISC允许这样做(还允许您修改机架,因为所有绘图都是在客户端完成的!)。此通道上可能存在的其他消息可能是来自其他用户的私人消息或特定于用户的通知。

游戏.{gameID}:该频道用于游戏相关的消息。例如,“挑战”事件的结果可以在这里显示,或者在游戏期间的任何特定聊天,等等。这与游戏电视的不同之处在于,游戏频道有点私密-只有玩家之间的聊天才会进入这个频道。我们计划让观察者有他们自己的渠道,不允许任何意外(或故意的!)。“杀戮”

大堂:大堂将显示正在进行的游戏、开放的游戏请求、大堂聊天等。将这些消息保留在此频道中有利于带宽;当前正在玩游戏的玩家不需要知道谁在寻找新游戏或谁在玩谁。

在我们的套接字服务器中,我们还必须有一些类似的“领域”。当玩家进入游戏id(如/Game/ABCDEF)时,我们的套接字服务器会相应地设置用户的领域。如果用户在/Main大厅,他们的领域被设置为LOBLOG;如果用户在游戏中,他们的领域被设置为Game-gameid或gametv-gameid,这取决于他们是游戏的玩家之一,还是仅仅是一个观察者。我们用破折号-而不是。快速直观地区分我们自己的每套接字服务器“领域”结构和NATS通道。User.userid通道不像其他通道那样映射到传统的“领域”;用户应该“总是”获得该通道上的所有消息。

请注意,这种领域映射使我们不会为每个用户套接字连接创建一个NATS通道。这似乎有点过分,可能会变得复杂。相反,套接字服务器通过使用我们的领域映射来管理哪些套接字对应于哪些NATS通道。

对于套接字服务器,我们大量使用了Gorilla/websocket聊天示例,并根据我们的目的对其进行了修改。例如,我们的集线器结构如下所示:

如您所见,Hub领域只是领域ID到客户端列表的映射。每个客户端结构实质上都包含该客户端的实际WebSocket连接。

如上所述,我们的WebSocket服务器被设计得尽可能简单,因此它对正在玩的游戏一无所知,甚至对数据库也一无所知。因此,当面对订阅特定WebSocket客户端到某个域名的请求时,它会直接使用NATS向API服务器请求:

AddToRealm函数只是将客户端套接字添加到给定域,如LOBBY或GAME-GAMEID。

然后,套接字服务器有一个pubsub对象,该对象本质上是一个围绕到NATS服务器的连接的对象。它使用channel-prefix.>;格式监听我上面列出的通道。例如,。

此PubsubProcess函数在单独的goroutine上运行,并且只侦听在各种NATS通道上传入的消息。我省略了订阅每个频道的部分,但我们只是简单地创建了几个不同命名主题的订阅(gametv>;等等)。此外,上面的sendToRealm函数被省略了,但它非常简单-它只是将套接字消息发送到给定域中的每个WebSocket。

您可以在这里查看套接字服务器的Github资源库中的更多详细信息:https://github.com/domino14/liwords-socket。

当我们收到来自用户的套接字消息时,例如,如果他们刚刚在游戏中进行了移动,我们最终必须将该消息路由到API服务器。我们做一个。

实质上,其中的msg是刚传入的消息。MSG包含游戏ID和所玩的实际走法。

在API方面,我们有一个非常类似的消息总线在运行,它订阅了自己的NATS通道。类似于上面的情况,我们让它订阅名为ipc.pb.gameplayEvent.>;的频道,这样它就可以获得所有的游戏事件。然后,它从用户存储(数据库)获取用户ID,从游戏存储获取游戏ID,确保用户正在玩给定的游戏,验证移动,然后更新游戏状态,将其保存回来,并将新的游戏事件发布回Gametv.{gameID}频道和各个用户频道。

请注意,我们不一定要发布回游戏。{gameID}频道,因为每个用户事件都应该进行清理(去除其他用户的机架信息)。因此,我们针对每个用户清理事件,并将其发布回每个用户的user.{userid}频道。不过,这只是一个实现细节,在没有秘密的游戏中,如果您愿意,您可以只使用一个游戏频道。

您可能会注意到,当我们有多个API服务器时,此行不能正常工作:

这是因为所有API服务器都会收到消息,并且它们都会尝试做相同的事情-验证移动、播放、将其保存到商店等。这是我们在部署多个服务器后必须解决的问题。作为一个业余爱好应用程序,预计不会立即获得超过几千个并发用户,我们希望能够像lichess那样使用相当大的API服务器。但是,即使我们有一个相当有状态的服务器,构建高可伸缩性似乎也是一个很好的实践。这里有两个选择:

这是一个相当简单的方法,它可能适用于您的游戏(它也可能适用于我们的游戏,我们将运行一些测试)。主要问题是这需要一个完全无状态的后端API服务器。完全从数据库加载纵横字谜棋盘游戏,将其重放到当前回合(包括计算机器人游戏所需的所有右锚/交叉集-请参阅典型的求解器算法,如Steven Gordon的GADDAG)、验证走法、玩它、重新计算所有棋盘参数、分数,并将所有内容串行化到数据库,对于一个回合来说,这可能有点太密集了。老实说,我们不确定,但由于许多游戏同时进行,特别是闪电战游戏,我的感觉是这将是相当密集的处理器。不过,对于许多其他游戏来说,这可能没什么问题,API服务器中的无状态始终是个好主意!

更密集的游戏有专用的状态服务器。现在我们不是在这里构建堡垒之夜,但我们相信,如果我们将游戏缓存到内存中,会有更好的体验。我们目前的游戏商店实际上完全在内存中,运行速度很快。然而,我们需要努力增加持久性,以便玩家可以回去检查/分析/分享他们的旧游戏。

我考虑的解决方案是像平常一样将所有的动作保存到数据库中,但是从内存缓存中加载游戏。缓存已经完全实现了游戏,包括棋盘、袋子状态、随机种子、锚、交叉集、分数等等。但是很明显,问题是缓存是按服务器的。因此,每个API服务器上的NATS订户将不得不丢弃所有包含不在缓存中的游戏ID的消息。我想这应该够快的了。

这里可能会有其他问题,例如,如果服务器崩溃,或者如果由于某种原因,当前可访问的服务器无法在缓存中找到它的游戏(净拆分或类似的东西)。我们不希望前端永远挂着。部署新的API服务器也需要一些时间-我们必须告诉旧的API服务器不再接受新的游戏请求,然后等到游戏结束后再取消调试。我们必须想出一些办法来缓解这些问题。非常欢迎您的意见!

我喜欢将NATS和WebSockets结合使用,为有点复杂的在线游戏构建可伸缩的高性能消息总线。希望你能从这篇文章中得到一些用处!