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

2022-06-11 13:28:47 浏览数 (1)

作者 | Enrico Piccinin

译者 | 明知山

策划 | 丁晓昀

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

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

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

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

1 我想要哪些答案

自由部署服务器

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

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

独立于 UI 框架或库

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

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

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

Scopone 游戏可以回答我的问题

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

2 整体思路

Scopone 游戏的规则

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

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

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

应用程序的结构和技术栈

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

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

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

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

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

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

下图是游戏的总体架构。

命令和事件

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

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

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

3 自由部署服务器端

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

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

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

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

游戏逻辑层

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

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

WebSocket 机制层

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

层之间的依赖关系

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

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

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

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

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

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

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

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

4 独立于 UI 框架或库

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

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

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

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

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

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

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

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

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

我们假设 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(下一个玩家) 的视图层让客户端打出一张牌,而其他玩家的客户端就不会有这个动作。

视图层与服务层的交互

轻组件和重服务

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

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

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

这样做的好处

这么做的好处是什么?

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

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

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

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

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

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

Scopone 是一个四人游戏。

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

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

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

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

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

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

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

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

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

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

6 如何模拟多个客户端

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

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

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

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

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

如何为测试创建上下文

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

为测试创建上下文

最后,如何执行测试

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

运行测试

合在一起

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

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

这就是服务层 API 的 BDD

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

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

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

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

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

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

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

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

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

7 结论

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

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

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

8 附录:视图层机制

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

  • 处理 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 组件实现这个特定功能的相关代码。

代码语言:javascript复制
export const Hand: FC = () => {
 const server = useContext(ServerContext);
  . . .
 const [handReactState, setHandReactState] = useState<HandReactState>({
   . . .
   enablePlay: false,
 });
  . . .
 useEffect(() => {
  . . .
  . . .
   const enablePlay$ = server.enablePlay$.pipe(
     tap((enablePlay) => {
       setHandReactState((prevState) => ({ ...prevState, enablePlay }));
     })
   );


   const subscription = merge(
       . . .
     handClosed$
   ).subscribe();


   return () => {
     console.log("Unsubscribe Hand subscription");
     subscription.unsubscribe();
   };
 }, [server]);
  . . .


 return (
   <>
       . . .
       <Cards
           . . .
         enabled={handReactState.enablePlay}
       ></Cards>  
       . . .
   </>
 );
};

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

作者简介:

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

原文链接:

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

0 人点赞