架构概念探索:以开发纸牌游戏为例

简介: 新冠疫情令我错失了与朋友们见面、讨论和玩纸牌游戏的机会。

新冠疫情令我错失了与朋友们见面、讨论和玩纸牌游戏的机会。

Zoom 可以解决一些燃眉之急,但怎么玩纸牌游戏呢?怎么玩我们的Scopone呢?

于是,我决定开发一款可以与朋友们一起玩的 Scopone 游戏,同时在代码中测试一些我着迷已久的架构概念。

游戏的所有源代码都可以找到在这个代码库里找到。

我想要哪些答案

自由部署服务器

一个支持多个玩家的交互式纸牌游戏是由客户端和服务器端组成的。服务器部署在云端,但是在端的什么地方呢?是作为运行在专用服务器上的组件?还是作为 Kubernetes 托管集群中的 Docker 镜像?或者是作为一个无服务器函数?

我不知道哪一个才是最好的选择,但我关心的是游戏的核心逻辑的维护是否能够独立于部署模型。

独立于 UI 框架或库

“Angular 是最好的”。“不,React 更好也更快。”这样的争论无处不在。但这真的有关系吗?难道我们不应该将大部分前端逻辑作为纯粹的 Javascript 或 Typescript 代码,完全独立于 UI 框架或库吗?我觉得是可以的,但还是想真正地去试一试。

自动测试多用户交互场景的可能性

纸牌游戏与当今其他交互式应用程序一样,都有多个用户通过中央服务器进行实时交互。例如,当玩家打出一张牌时,其他人都需要实时看到这张牌。一开始,我不清楚如何测试这类应用程序。是否有可能使用简单的 JavaScript 测试库(如 Mocha)和标准测试实践自动测试它?

Scopone 游戏可以回答我的问题

Scopone 游戏为我提供了一个很好的机会,让我可以以一种具体的方式回答我自己提出的问题。所以,我决定尝试实现它,看看我能从中学到什么。

整体思路

Scopone 游戏的规则

Scopone 是一种传统的意大利纸牌游戏,4 名玩家分成 2 组,每组 2 人,有 40 张牌。

在游戏开始时,每个玩家都拿到 10 张牌,第一个玩家打出第一张牌,这张牌面朝上放在桌子上。然后第二个玩家出牌。如果这张牌的等级与桌上的牌相同,第二个玩家就从桌上“拿走”这张牌。如果桌上没有牌,拿走牌的玩家就获得“Scopa”的得分。然后第三个玩家出牌,并以此类推,直到所有牌都出完。

规则说完了,这里的关键点是,在玩家玩纸牌时,他们会改变游戏的状态,例如“哪些纸是正面朝上的”或“哪些玩家可以出下一张牌”。

应用程序的结构和技术栈

Scopone 游戏需要一个服务器实例和四个客户端实例,四个玩家在他们的设备上启动客户端。

image.png

如果我们注意一下游戏中各种元素之间的互动,就可以知道:

  • 玩家执行动作,例如玩家出牌;
  • 作为玩家执行动作的结果,所有玩家都需要更新游戏的状态。

这意味着客户端和服务器需要一个双向通信协议,因为客户端必须向服务器发送命令,而服务器需要向客户端推送更新后的状态。WebSocket 是一种适合用在此处的协议,各种编程语言都支持它。

服务器端是用 Go 语言实现的,因为它对 WebSocket 有很好的支持,也支持不同的部署模型,换句话说,它可以部署成专用的服务器、Docker 镜像或 Lambda。

客户端是一个基于浏览器的应用程序,以两种不同的方式实现:一种是 Angular,另一种是 React。这两个版本都使用了 TypeScript 和 RxJs,以实现响应式设计。

下图是游戏的总体架构。

image.png

命令和事件

简而言之,这个游戏的过程是这样的:

  • 客户端通过消息向服务器发送命令;
  • 服务器更新游戏状态;
  • 服务器通过一条消息将游戏的最新状态推送给客户端;
  • 当客户端接收到来自服务器的消息时,将其视为触发客户端状态更新的事件。

这个循环会一直重复,直到游戏结束。

自由部署服务器端

服务器接收客户端发送的命令消息,并根据这些命令更新游戏的状态,然后将更新后的状态发送给客户端。

客户端通过 WebSocket 通道发送命令消息,它将被转换成对服务器特定 API 的调用。

API 调用会生成响应,它将被转换成一组消息,这些消息通过 WebSocket 通道发送给每个客户端。

因此,在服务器端有两个不同的层,它们有不同的职责:游戏逻辑层和 WebSocket 机制层。

image.png

游戏逻辑层

这个层负责实现游戏逻辑,即根据接收到的命令更新游戏状态,并返回最新的状态,发送给每个客户端。

因此,这个层可以使用内部状态和一组实现命令逻辑的 API 来实现。API 将向客户端返回最新的状态。

WebSocket 机制层

这个层负责将从 WebSocket 通道接收到的消息转换为相应的 API 调用。此外,它也需要将更新后的状态(调用 API 生成的响应)转换为推送给相应的客户端的消息。

层之间的依赖关系

基于前面的讨论,游戏逻辑层独立于 WebSocket,只是一组返回状态的 API。

WebSocket 机制层实现了 WebSocket 特性,这一层将依赖所选择的部署模型。

例如,如果我们决定将服务器端作为一个专用的服务器进行部署,那就需要选择实现了 WebSocket 协议的包(在这里我们选择了Gorilla),而如果我们决定作为 AWS Lambda 函数进行部署,那就需要依靠 WebSocket 协议的 Lambda 实现。

如果我们要保持游戏逻辑层与 WebSocket 机制层严格分离,就是在后者中导入前者(单向的),那么游戏逻辑层就不管担心所选择的具体部署模型是哪个。

image.png

基于这种策略,我们可以只开发单个版本的游戏逻辑,并自由地在各个地方部署服务器。

这有几个好处。例如,在开发客户端时,我们可以在本地运行 Gorilla WebSocket 实现,这样会非常方便,甚至可以在 VSCode 中启用调试模式。这样就可以在服务器代码中设置断点,通过客户端发送的各种命令来调试游戏逻辑。

在将游戏部署到生产环境的服务器时(这样就可以与我的朋友们实时游戏),可以直接将相同的游戏逻辑部署到云端,例如谷歌应用程序引擎(GAE)。

此外,当我发现不管我们有没有在玩游戏,谷歌都会收取最低的费用(GAE 总是保持至少一个服务器打开),我可以在不改变游戏逻辑代码的情况下将服务器迁移到 AWS Lambda 的“按需”收费模型。

独立于 UI 框架或库

现在的大问题是:选择 Angular 还是 React?

我也问了自己另一个问题:是否有可能用 TypeScipt 开发大部分的客户端逻辑,独立于用来管理视图的前端框架或库?

结果证明,至少在这个案例中,它是可能的,只是有一些有趣的副作用。

应用前端的设计:视图层和服务层

应用程序前端部分的设计有三个简单的想法:

  • 客户端分为两层:
  • 视图层是可组合的组件(Angular 和 React 都可以将 UI 作为组件的组合),可以实现纯表示逻辑。
  • 服务层,用 TypeScript 实现,不任何 Angular 或 React 的状态管理,自己处理调用远程服务器的命令和解释来自服务器端的状态变更响应。
  • 服务层为视图层提供了两种类型的 API:
  • 公共方法——通过调用这些方法来调用远程服务器上的命令,或者说是更改客户端的状态。
  • 公共事件流——实现为 RxJs Observable,可以被任何想要得到状态变化通知的 UI 组件订阅。
  • 视图层只有两个简单的职责:
  • 拦截 UI 事件并将其转换为对服务层公共 API 方法的调用。
  • 订阅公共 API Observable,并对接收到的通知做出相应的表示更改。

image.png

一个视图-服务-服务器交互示例

image.png

玩家可以通过点击牌面打出一张牌

更具体一点,我们来看一下怎样打出一张牌。

我们假设 Player_X 将要打下一张牌。Player_X 点击“红桃 A”牌面,这个 UI 事件会触发“Player_X 打出红桃 A”这个动作。

以下是应用程序将会经历的步骤:

  • 视图层拦截用户生成的事件,并调用服务层的 playCard 方法,参数为“红桃 A”。
  • 服务层向远程服务器发送消息“Player_X 打出红桃 A”。
  • 远程服务器更新游戏的状态,并通知所有客户端状态发生了变化。例如,它告诉所有客户端 Player_X 打了哪张牌以及谁是下一个可以出牌的玩家。
  • 每个客户端的服务层都接收到由远程服务器发送的状态更新消息,并通过 Observable 流转化为特定事件的通知。例如,Player_X 的客户端服务层接收到的 isMyTurnToPlay$为 false,因为 Player_X 肯定不是下一个玩家。如果另一个玩家是 Player_Y, Player_Y 客户端的务层接收到的 isMyTurnToPlay$将是 true。
  • 每个客户端的视图层都订阅了由服务层发布的事件流,并对事件通知作出反应,按需更新 UI。例如,Player_Y(下一个玩家)的视图层让客户端打出一张牌,而其他玩家的客户端就不会有这个动作。

image.png

视图层与服务层的交互

轻组件和重服务

基于这些规则,我们最终构建了“轻组件”,它只管理 UI 关注点(表示和 UI 事件处理),而“重服务”则负责处理所有的逻辑。

最重要的是,“重服务”(包含大部分逻辑)完全独立于所使用的 UI 框架或库。它既不依赖 Angular 也不依赖 React。

有关 UI 层的更多细节可以在本文的附录部分找到。

这样做的好处

这么做的好处是什么?

当然不是不同的框架和库之间的可移植性。一旦选择了 Angular,就不太可能有人想要切换到 React,反之亦然,但还是有些优势的。

这种方法的一个优点是,如果实现得彻底,它将标准化我们开发前端的方式,并更易于理解。归根到底,这也只是通过定制的方式(服务层就是定制的)设计单向信息流。定制具有较低的抽象级别,也更简单,但可能需要付出一些“重新发明轮子”的代价。

不过,最大的好处在于应用程序具有更好和更容易的可测试性。

UI 测试是非常复杂的,无论你使用的是哪个框架或库。

但如果我们将大部分代码转换为纯 TypeScript 实现,测试就会变得更容易。我们可以使用标准测试框架来测试应用程序的核心逻辑(在这里我们使用了Mocha),我们还可以用一种相对简单的方式来处理复杂的测试场景,我们将在下一节讨论。

自动测试实时多用户交互场景

Scopone 是一个四人游戏。

4 个客户端必须通过 WebSocket 连接到一个中央服务器。一个客户端执行的操作,例如“打出一张牌”,会触发所有客户端的更新(也就是所谓的副作用)。

这是一种实时多用户交互场景。这意味着如果我们想要测试整个应用程序的行为,需要同时运行多个客户端和一个服务器端。

我们该如何自动测试这些场景?我们可以用标准的 JavaScript 测试库来测试它们吗?我们可以在独立的开发者工作站上测试它们吗?这些是接下来要回答的问题。事实证明,所有这些事情都是可能的,至少在很大程度上是可能的。

实时多用户交互场景测试的是什么

举一个简单的例子,假设我们想要测试游戏开始时所有玩家的纸牌分发是正确的。在新游戏开始后,所有客户端都会从服务器收到 10 张牌(Scopone 游戏有 40 张牌,每个玩家可以拿到 10 张)。

如果我们想在一台独立的机器(比如,开发者的机器)上自动测试这种行为,就需要一个本地服务器。我们可以这样做,因为服务器端可以作为一个本地的容器或 WebSocket 服务器运行。所以,我们假设有一个本地服务器运行在我们的机器上。

但是,为了运行测试,我们还需要找到一种方法来创建合适的上下文环境以及可以触发我们想测试的副作用的动作(纸牌的分发就是一个玩家开始游戏的副作用)。换句话说,我们需要找到一种方法来模拟以下的情况:

  • 4 个玩家启动应用程序并加入同一个游戏(创建正确的上下文环境);
  • 一个玩家开始游戏(触发我们想要测试的副作用)。

只有这样我们才能检查服务器是否将预期的牌发给所有玩家。

image.png

多用户场景的一个测试用例

如何模拟多个客户端

每个客户端由一个视图层和一个服务层组成。

服务层的 API(方法和 Observable 流)是在一个类中定义的(ScoponeServerService 类)。

每个客户端创建这个类的一个实例,并连接到服务器。视图层与它的服务类实例进行交互。

如果我们想要模拟 4 个客户端,就创建 4 个不同的实例,并将它们全部连接到我们的本地服务器。

image.png

创建 4 个服务类实例,代表 4 个不同的客户端

如何为测试创建上下文

现在,我们有了 4 个已经连接到服务器的客户端,我们需要为测试构建正确的上下文。我们需要 4 个玩家,并等待他们加入游戏。

image.png

为测试创建上下文

最后,如何执行测试

在创建了 4 个客户端和正确的上下文之后,我们就可以运行测试了。我们可以让一个玩家发送命令开始游戏,然后检查每个玩家是否收到了预期的纸牌数量。

image.png

运行测试

合在一起

多用户交互场景的测试如下:

  • 为每个用户创建一个服务实例;
  • 按照正确的顺序向服务发送命令,创建测试的上下文;
  • 发送触发副作用的命令(就是被测试的命令);
  • 验证每个服务的 Observable API 发出的通知,也就是命令的结果(副作用),是否包含了预期的数据。

这就是服务层 API 的 BDD

我们可以将这种方法视为针对服务层 API 的行为驱动开发(BDD)测试。

按照 BDD 的规范,测试行为是这样的:

  • 假设初始情境:4 名玩家加入游戏;
  • 时间:玩家开始游戏;
  • 然后:我们希望每个玩家拿到 10 张牌。

测试函数是用一种 DSL 编写的,它由一些特别的辅助函数组成,这些函数的组合创建了上下文(playersJoinTheGame 就是辅助函数的一个例子)。

它不是端到端测试,但可以非常强大

这不是一个完整的端到端测试。我们并没有测试视图层。

但它仍然可以是一个非常强大的工具,特别是如果我们坚持“轻组件和重服务”的规则。

如果视图层由轻组件组成,并且大部分逻辑都集中在服务层,那么我们就能够覆盖应用程序行为的核心,不管是客户端的还是服务器端的,我们只需要进行相对简单的设置,使用标准的工具(我们使用了 Mocha 测试库,它绝对不是最新最闪亮的框架),并且是在开发人员的机器上进行。

这样做的好处是,开发人员可以编写出能够快速执行的测试套件,提高执行测试的频率。同时,这样的测试套件实际上测试了从客户端到服务器的整个应用程序逻辑(即使是多用户实时应用程序),提供了很高的可信度。

结论

开发纸牌游戏是一种有趣的体验。

除了在疫情期间为我带来一些乐趣之外,它还让我有机会通过代码来探索一些架构概念。

我们经常用架构概念来表达我们的观点。我发现,将这些概念付诸实践,即使是简单的概念验证,也会增加我们对它们的理解,让我们更有信心在实际项目中使用它们。

附录:视图层机制

视图层中的组件主要做了两件事情:

  • 处理 UI 事件并将它们转换为服务的命令。
  • 订阅由服务公开的流,并通过更新 UI 来响应事件。

为了更具体地说明最后一点的含义,我们可以举一个例子:如何确定谁是下一个出牌的玩家。

正如我们所说的,这个游戏的一个规则是玩家可以一张接一张地出牌。例如,如果 Player_X 是第一个玩家,Player_Y 是第二个玩家,那么在 Player_X 出了一张牌之后,只有 Player_Y 才能出下一张牌,其他玩家都不能出牌。这个信息是服务器维护的状态的一部分。

每次出了一张牌时,服务器就会向所有客户端发送一条消息,指定下一个玩家是谁。

服务层通过一个叫作 enablePlay$的 Observable 流将消息转换为通知。如果消息说玩家可以出下一张牌,服务层通过 enablePlay$通知的值为 true,否则就为 false。

让玩家出牌的组件必须订阅 enablePlay$流,并对通知的数据做出相应的反应。

在我们的 React 实现中,这是一个叫作 Hand 的功能组件。这个组件定义了一个状态变量 enablePlay,它的值代表出牌的可能性。Hand 组件订阅了 enablePlay$ Observable 流,每当它收到 enablePlay$的通知时,就通过设置 enablePlay 的值来触发 UI 重绘。

下面是使用 React 的 Hand 组件实现这个特定功能的相关代码。

image.png

image.png

Angular 版本的逻辑是一样的,并且是在 HandComponent 中实现的。唯一的区别是对 enablePlay$ Observable 流的订阅是直接在模板中通过 async 管道完成的。

作者简介:

Enrico Piccinin 对代码和 IT 组织中偶尔发生的离奇事情很感兴趣。凭借在 IT 开发领域多年的经验,他希望了解将“新 IT”应用在传统组织中会发生什么。你可以在 enricopiccinin.com 或 LinkedIn 上找到 Enrico。

原文链接:

https://www.infoq.com/articles/exploring-architecture-building-game/

目录
相关文章
|
5月前
|
SQL 前端开发 关系型数据库
如何开发一套研发项目管理系统?(附架构图+流程图+代码参考)
研发项目管理系统助力企业实现需求、缺陷与变更的全流程管理,支持看板可视化、数据化决策与成本优化。系统以MVP模式快速上线,核心功能包括需求看板、缺陷闭环、自动日报及关键指标分析,助力中小企业提升交付效率与协作质量。
|
5月前
|
NoSQL 数据可视化 安全
如何开发一套车辆管理系统?(附架构图+流程图+代码参考)
本文介绍了如何通过搭建车辆管理系统(VMS)帮助企业摆脱传统管理方式,实现流程化、可视化、合规化和自动化。内容涵盖系统架构、关键功能模块、数据模型、API设计、前后端实现及实施建议,提供可落地的技术方案,助力企业降低隐形成本、提升管理效率与透明度,实现数据驱动决策。
|
5月前
|
JSON 文字识别 BI
如何开发车辆管理系统中的加油管理板块(附架构图+流程图+代码参考)
本文针对中小企业在车辆加油管理中常见的单据混乱、油卡管理困难、对账困难等问题,提出了一套完整的系统化解决方案。内容涵盖车辆管理系统(VMS)的核心功能、加油管理模块的设计要点、数据库模型、系统架构、关键业务流程、API设计与实现示例、前端展示参考(React + Antd)、开发技巧与工程化建议等。通过构建加油管理系统,企业可实现燃油费用的透明化、自动化对账、异常检测与数据分析,从而降低运营成本、提升管理效率。适合希望通过技术手段优化车辆管理的企业技术人员与管理者参考。
|
5月前
|
消息中间件 缓存 JavaScript
如何开发ERP(离散制造-MTO)系统中的生产管理板块(附架构图+流程图+代码参考)
本文详解离散制造MTO模式下的ERP生产管理模块,涵盖核心问题、系统架构、关键流程、开发技巧及数据库设计,助力企业打通计划与执行“最后一公里”,提升交付率、降低库存与浪费。
|
4月前
|
前端开发 JavaScript BI
如何开发车辆管理系统中的车务管理板块(附架构图+流程图+代码参考)
本文介绍了中小企业如何通过车务管理模块提升车辆管理效率。许多企业在管理车辆时仍依赖人工流程,导致违章处理延误、年检过期、维修费用虚高等问题频发。将这些流程数字化,可显著降低合规风险、提升维修追溯性、优化调度与资产利用率。文章详细介绍了车务管理模块的功能清单、数据模型、系统架构、API与前端设计、开发技巧与落地建议,以及实现效果与验收标准。同时提供了数据库建表SQL、后端Node.js/TypeScript代码示例与前端React表单设计参考,帮助企业快速搭建并上线系统,实现合规与成本控制的双重优化。
|
5月前
|
消息中间件 JavaScript 前端开发
如何开发ERP(离散制造-MTO)系统中的技术管理板块(附架构图+流程图+代码参考)
本文详解ERP(离散制造-MTO)系统中的技术管理板块,涵盖产品定义、BOM、工序、工艺文件及变更控制的结构化与系统化管理。内容包括技术管理的核心目标、总体架构、关键组件、业务流程、开发技巧与最佳实践,并提供完整的参考代码,助力企业将技术数据转化为可执行的生产指令,提升制造效率与质量。
|
5月前
|
消息中间件 JavaScript 关系型数据库
如何开发一套ERP(离散制造-MTO)系统(附架构图+流程图+代码参考)
本文介绍了面向离散制造-MTO(按订单生产)模式的ERP系统设计与实现方法。内容涵盖ERP系统定义、总体架构设计、主要功能模块解析、关键业务流程(订单到交付、BOM展开、MRP逻辑、排产等)、开发技巧(DDD、微服务、事件驱动)、参考代码示例、部署上线注意事项及实施效果评估。旨在帮助企业与开发团队构建高效、灵活、可扩展的ERP系统,提升订单交付能力与客户满意度。
|
5月前
|
NoSQL 关系型数据库 BI
如何开发一套固定资产管理系统?(附架构图+流程图+代码参考)
固定资产管理涉及采购、入库、维修、盘点、报废等多个环节,是企业资产保值增值的关键。本文详解固定资产管理系统(FAMS)的核心功能、系统架构、资产全生命周期流程,并提供功能设计、开发实操技巧与关键代码示例,涵盖台账、申购、入库、报修、处置、盘点等重点模块。内容聚焦企业落地实践,帮助提升资产管理效率、降低风险、保障审计合规。
|
4月前
|
运维 监控 安全
公链开发中的高可用架构设计要点
本指南提供公链高可用架构的可复用流程与模板,涵盖目标拆解、先决条件、分步执行、故障排查及验收标准,结合跨链DApp与量化机器人案例,提升落地效率与系统稳定性。
|
4月前
|
消息中间件 运维 监控
交易所开发核心架构拆解与流程图
本文系统解析交易所架构核心要素,从接入层到清算结算,结合系统流程图拆解各模块职责与协作机制。深入剖析撮合引擎、账本设计与风控逻辑,建立性能、可用性、安全性等多维评估标准,并提供可落地的流程图绘制、压测优化与进阶学习路径,助力构建高效、安全、可扩展的交易系统。(238字)