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

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

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

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/

目录
相关文章
|
7天前
|
API 持续交付 开发者
后端开发中的微服务架构实践与挑战
在数字化时代,后端服务的构建和管理变得日益复杂。本文将深入探讨微服务架构在后端开发中的应用,分析其在提高系统可扩展性、灵活性和可维护性方面的优势,同时讨论实施微服务时面临的挑战,如服务拆分、数据一致性和部署复杂性等。通过实际案例分析,本文旨在为开发者提供微服务架构的实用见解和解决策略。
|
20天前
|
Java 持续交付 微服务
后端开发中的微服务架构实践与挑战####
本文深入探讨了微服务架构在现代后端开发中的应用,通过具体案例分析,揭示了其如何助力企业应对业务复杂性、提升系统可维护性和可扩展性。文章首先概述了微服务的核心概念及其优势,随后详细阐述了实施微服务过程中的关键技术选型、服务拆分策略、容错机制以及持续集成/持续部署(CI/CD)的最佳实践。最后,通过一个真实世界的应用实例,展示了微服务架构在实际项目中的成功应用及其带来的显著成效。 ####
|
1月前
|
存储 分布式计算 大数据
大数据-169 Elasticsearch 索引使用 与 架构概念 增删改查
大数据-169 Elasticsearch 索引使用 与 架构概念 增删改查
54 3
|
29天前
|
缓存 Java 数据库
后端技术探索:从基础架构到高效开发的实践之路
【10月更文挑战第7天】 在现代软件开发中,后端技术是支撑应用运行的核心。本文将探讨如何从后端的基础架构出发,通过一系列高效的开发实践,提升系统的性能与可靠性。我们将深入分析后端框架的选择、数据库设计、接口开发等关键领域,并提供实用的代码示例和优化策略,帮助开发者构建更稳定、高效的后端系统。通过这篇文章,读者将获得关于后端开发的全面理解和实践指导,从而更好地应对复杂项目需求。
67 0
|
30天前
|
设计模式 API 开发者
探索现代后端开发:微服务架构与API设计
【10月更文挑战第6天】探索现代后端开发:微服务架构与API设计
|
1天前
|
监控 API 持续交付
后端开发中的微服务架构实践与挑战####
本文深入探讨了微服务架构在后端开发中的应用,分析了其优势、面临的挑战以及最佳实践策略。不同于传统的单体应用,微服务通过细粒度的服务划分促进了系统的可维护性、可扩展性和敏捷性。文章首先概述了微服务的核心概念及其与传统架构的区别,随后详细阐述了构建微服务时需考虑的关键技术要素,如服务发现、API网关、容器化部署及持续集成/持续部署(CI/CD)流程。此外,还讨论了微服务实施过程中常见的问题,如服务间通信复杂度增加、数据一致性保障等,并提供了相应的解决方案和优化建议。总之,本文旨在为开发者提供一份关于如何在现代后端系统中有效采用和优化微服务架构的实用指南。 ####
|
3天前
|
消息中间件 设计模式 运维
后端开发中的微服务架构实践与挑战####
本文深入探讨了微服务架构在现代后端开发中的应用,通过实际案例分析,揭示了其在提升系统灵活性、可扩展性及促进技术创新方面的显著优势。同时,文章也未回避微服务实施过程中面临的挑战,如服务间通信复杂性、数据一致性保障及部署运维难度增加等问题,并基于实践经验提出了一系列应对策略,为开发者在构建高效、稳定的微服务平台时提供有价值的参考。 ####
|
4天前
|
消息中间件 监控 数据管理
后端开发中的微服务架构实践与挑战####
【10月更文挑战第29天】 在当今快速发展的软件开发领域,微服务架构已成为构建高效、可扩展和易于维护应用程序的首选方案。本文探讨了微服务架构的核心概念、实施策略以及面临的主要挑战,旨在为开发者提供一份实用的指南,帮助他们在项目中成功应用微服务架构。通过具体案例分析,我们将深入了解如何克服服务划分、数据管理、通信机制等关键问题,以实现系统的高可用性和高性能。 --- ###
23 2
|
13天前
|
缓存 运维 监控
后端开发中的微服务架构实践与挑战#### 一、
【10月更文挑战第22天】 本文探讨了微服务架构在后端开发中的应用实践,深入剖析了其核心优势、常见挑战及应对策略。传统后端架构难以满足快速迭代与高可用性需求,而微服务通过服务拆分与独立部署,显著提升了系统的灵活性和可维护性。文章指出,实施微服务需关注服务划分的合理性、通信机制的选择及数据一致性等问题。以电商系统为例,详细阐述了微服务改造过程,包括用户、订单、商品等服务的拆分与交互。最终强调,微服务虽优势明显,但落地需谨慎规划,持续优化。 #### 二、
|
13天前
|
监控 安全 Serverless
"揭秘D2终端大会热点技术:Serverless架构最佳实践全解析,让你的开发效率翻倍,迈向技术新高峰!"
【10月更文挑战第23天】D2终端大会汇聚了众多前沿技术,其中Serverless架构备受瞩目。它让开发者无需关注服务器管理,专注于业务逻辑,提高开发效率。本文介绍了选择合适平台、设计合理函数架构、优化性能及安全监控的最佳实践,助力开发者充分挖掘Serverless潜力,推动技术发展。
34 1
下一篇
无影云桌面