4 向微服务架构转变


前后端代码可以从这里下载: 微服务示例

前面的文章:
1、一个测试驱动的Spring Boot应用程序开发
2、2 使用React构造前端应用
3、3 基于Spring Boot的数据层示例

后面的文章:
5、5 转向事件驱动的架构

下面开始微服务应用程序的设计与开发,将面临新的挑战。

小型单体系统

前面完成的乘法猜数游戏应用程序,实现了该应用程序必需的功能。尽管确定了两个域:Challenges和Users,但仍然选择了单一应用程序策略。领域对象之间相互关联,但松散耦合。这是一个小型的单体系统。

为什么选择小型单体系统

与微服务相比,从单一的代码源开始,简化了开发过程,减少了部署产品的首个版本所需的时间。另外,在项目生命周期的开始阶段,构架和软件设计易于改变,在想法得到验证后,对其进行适配至关重要。
如果没有在使用微服务的组织中工作过,可能会低估在软件项目中引入微服务的技术复杂性,这并不容易。

微服务与生俱来的问题

作为一个替代方案,可以一开始就选择微服务架构,将Users和Challenges分成两个独立的应用程序。
拆分的原因可能是:有多个团队并行工作,互不干扰。一开始,就可以利用微服务映射到团队的优势。这里,虽然只有两个域,仍可假设已经确定了十个不同的有界上下文。理论上,可以利用大型组织的力量,更早地完成项目。
还可以使用分割微服务的方法来完成目标架构。这通常称为反向康威(Conway)。康威定律指出:系统设计类似于构架它的组织结构。这样,通过改变组织结构,使其与要实现的软件架构相似,再确定域,并将其分配到团队中。
能够并行工作并实现目标架构,看起来具有巨大优势。但是,过早地拆分微服务存在两个问题。

  • 一是,当使用敏捷方法开发软件时,通常无法花费数周时间来提前设计完整的系统。在确定松散耦合的域时,就会犯错。而且,等到意识到存在缺陷时,为时已晚。尤其是当组织没有足够的灵活性来应对这些变化时,很难克服多个团队同时基于错误划分的域进行开发的惯性。这种情况下,反向康威和早期的划分与目标背道而驰,将创建一个能反映初始架构但可能不符合最初想法的软件系统。
  • 二是,通常意味着没有将系统垂直分片。前面介绍的尽快交付是一个好主意,可以及时获得反馈。如果一开始就使用多个微服务,将横向扩展。微服务架构总是引入技术复杂性,更难设置、部署、协调和测试,会花费更多时间来开发一个最小可行性产品,并可能产生技术影响。最坏的情况下,可能根据错误的假设进行软件设计,在得到用户反馈后,将会被淘汰。

小型单体系统适用于小团队

如果能在一开始就让团队保持小规模,小型单体系统将是一个很好的计划。可专注于域定义与实验。在拥有了通过验证的产品创意和更清晰的软件架构后,可逐渐让更多的人进入团队,并开始考虑拆分代码库和整合新的团队。然后,根据需求,可使用微服务或选择另一种方式,如模块化系统。
有时无法避免与一个大团队一起开始一个项目,这由组织决定。很难让负责人相信这不是一个好主意。如果这样,小型单体系统很快会变成大型单体系统,而且使用意大利面条式的代码库,以后可能很难将其模块化。另外,很难在一段时间内只关注一个垂直面,因为这会导致很多人无所事事。在这种组织约束下,小团队理念的小型单体系统架构无法很好地发挥作用。
需要进行拆解。这种情况下,必须付出额外的努力,不仅要定义有界的上下文,还要定义未来这些模块之间的通信接口。每当设计跨越多个模块或微服务的功能时,都要确保让相应的团队参与以定义这些模块将产生和使用什么类型的输入/输出。协议定义得越清晰越详细,团队就越独立。在敏捷环境中,这意味着功能的交付时间可能比刚开始时预期的要晚,因为,团队不仅需要定义这些协议,还需要定义许多通用的技术基础。

拥抱重构

当组织不接受代码更改时,小型单体系统似乎会困难重重。如前所述,从一个小块开始以验证产品创意并获得反馈,然后,在某个时间点,会看到将整体拆分为微服务的必要性。这种拆分同时具有组织和技术优势。在项目的开始阶段,技术人员和项目经理应根据功能与技术需求,讨论并决定进行拆分的时机。
有时,作为技术人员认为这一刻永远不会到来:如果从一个整体开始,就永远被束缚在一起了。会担心项目的路线图永远不会暂停来计划和完成微服务所需的重构。考虑到这一点,技术人员可能从一开始就尝试推动组织使用微服务。这是一个不负责任的想法,因为,这会让那些认为这种技术复杂性毫无必要、会拖延项目进度的人感到失望。与其将推行微服务架构作为唯一的选择,不如加强与业务利益相关者和项目经理的沟通,以制定一个良好的计划。

规划未来拆分的小型单体应用程序

选择小型单体系统,可以遵循一些良好的实践,以便稍后轻松地进行拆分。

  • 在根包下,根据域上下文拆分代码。这种结构的优点是,可以防止跨域访问业务逻辑,而且如果以后需要的话,可以通过少量重构将一个完整的根包作为微服务提取出来。
  • 充分利用依赖注入。将代码基于接口,让Spring完成注入工作。便于重构。
  • 一旦确定了上下文,在应用程序中提供一个一致的名称。
  • 在设计阶段,直到边界清晰之前,不要害怕到处移动类。永远不要因为可以走捷径,就跨上下文使用业务逻辑。
  • 找到公共模式,并确定哪些可以在稍后提取为公共库。
  • 使用同行评审来确保架构设计合理,并能促进知识转移。
  • 与项目经理和/或业务代表沟通清楚,以便规划后续拆分时间。重构是必要的,没有什么不妥。

至少在第一次发布之前,尝试保留一个小型单体系统,这可以带来一些好处。

  • 在早期阶段,快速地开发可以更快获得产品反馈。
  • 可轻松更改域的边界。
  • 大家习惯了相同的技术准则后,有利于未来实现的一致性。
  • 可识别出公共的跨域功能,并将其共享为库(或准则)。
  • 团队将了解整个系统的全貌。然后,这些人可加入其他团队,并带去有用的知识。

新需求和游戏化

假设已发布了应用程序并将其连接到分析引擎。由于上一个功能可以显示历史记录,因此,每天都会吸引新用户和回头的老用户。但是,从指标中看到,一周后,用户倾向于放弃用新挑战来训练大脑的常规做法。因此,基于数据做出决策,尝试改善这些指标。这个简单过程称为数据驱动的决策(DDDM),对各种类型的项目都很重要。
基于此,项目计划将一些游戏机制引入应用程序中,已提高用户参与度。为了关注技术,将游戏化简化为积分、徽章和排行榜。

用户故事

作为用户,希望能保持每天进行挑战的动力,这样能不断地锻炼自己的大脑,随着时间的推移不断进步。

游戏化:积分、徽章和排行榜

游戏化是一个设计过程,在这个过程中,可以将游戏中使用的技术应用到另一个游戏领域。这样做,是希望从游戏中获得一些众所周知的好处,例如激发游戏玩家的积极性,与进程、应用或要游戏化的任何东西进行交互。
把一个应用做成游戏的一个基本思路是引入积分:每当完成一个操作,且做得很好时,就会得到一些积分。赢得积分让玩家感觉自己在进步,并得到反馈。
排行榜上的积分对于所有人都可见,通过激发玩家的竞争意识来激励玩家。
徽章象征所获得的虚拟地位。徽章不仅代表分数,还可表达不同的意义。
有些游戏很好地运用了这些元素,已鼓励人们积极参与游戏。
现在,需要为用户提交的每一个正确答案分配积分,简单起见,只有在玩家发送的答案正确时,才获得积分,每次分配10分。
页面上还会显示一个最高积分排行榜,玩家可在排行榜中找到自己,并参与竞争。
还会创建一些基本徽章:铜牌(答对10次)、银牌(答对25次)和金牌(答对50次)。第一次答对也需要一个好的反馈信息,将推出一个“第一次正确”徽章。另外,引入惊喜元素,将推出一个玩家只有在解出以数字42为乘数因子的乘法运算后才能获得徽章。
有了这些措施,相信玩家会受到激励,会回到应用程序中,与其他玩家竞争。

转向微服务

在新需求中,可以在小型单体系统中实现所有功能,而且也可以很方便完成这个项目。这里,可以为新功能创建一个名为Gamification的域,以便在同一个部署单元中实现新的扩展。
现在转换一些场景,想象一下,有其他可以帮助实现业务目标的新功能,可能是:

  • 基于用户的统计数据调整挑战的复杂度。
  • 增加提示。
  • 支持用户登录以替代用户别名。
  • 询问一些用户的个人信息,以收集更恰当的数据。

这些改进会影响现有的Challenges和Users域。此外,由于第一个版本运行得非常好,而且获得了一些资本投资,团队获得成长,应用程序开发不再需要按顺序进行,除了这些域之外,还可以利用其他功能改进已有的域。
假设投资人也提出了一些条件,希望每月活跃用户增长至100 000个。这就需要设计架构来应对这个问题,很快就会意识到,计划构建的新游戏化组件并不像主要功能那样重要。如果游戏化的功能在短时间内不可使用,只要用户仍然可以解决挑战,就没有问题。
根据分析,可以得出结论:

  • Users和Challenges域在应用程序中至关重要。应该保证它们的高可用性。水平可扩展性非常适合以下场景:部署一个应用程序的多个实例,并使用负载均衡;如果其中一个实例发生异常,则转移流量;此外,多个服务副本也可以提供更多的容量支持更多的并发用户。
  • 新的Gamification域在可用性方面有不同的要求。不需要以相同的速度扩大系统规模,可以接受其执行速度比其他域慢,甚至可接受其停工一段时间。
  • 由于团队不断壮大,可以从拥有独立部署的单元中受益。如果保持Gamification模块松散耦合并单独发布,则可以在有多个团队的组织中工作,且干扰最小。

考虑到这些非功能性需求(如可伸缩性、可用性和可扩展性),使用微服务是一个好的选择。

独立的工作流程

前面已经看到遵循DDD原理来完成模块化架构的过程,可以将最终产生的有界上下文拆分成不同的代码存储库,这样多个团队可以独立扩展工作。但是,如果这些模块是同一个部署单元的一部分,团队之间就存在一些依赖关系,需要将所有模块集成在一起,确保其能互相配合,并将整个组件部署到生产环境中。如果还有其他基础结构元素在模块之间共享,其依赖关系会更大。
微服务将模块化提升到一个新水平,可以独立部署。各团队不仅可以有不同的存储库,而且可以拥有不同的工作流。
在这个系统中,可在单独的存储库中开发一些Spring Boot应用程序。每个应用程序都有自己的嵌入式Web服务器,可以分别部署。这消除了发布单体系统时产生的问题:测试、打包、相互依赖的数据库更新等。
在维护和支持层面,微服务有助于构建DevOps文化,每个应用程序都可能拥有其相应的基础架构元素:Web服务器、数据库、指标、日志等。使用像Spring Boot这里框架时,可将系统看作一组相互交互的小型应用程序。如果其中一个出现问题,就由负责的团队解决问题,这对于一个单体系统来说,通常很难明确区分。

水平可伸缩性

相应扩展一个单体应用程序时,可选择垂直地使用一个更大的服务器/容器,或水平地使用更多实例和负载均衡器。水平可伸缩性通常是首选,因为多台小型计算机比一台功能强大的大型计算机便宜。另外,可通过增加或减少实例能更好地应对不同的工作负载。
借助微服务,可选择更灵活的策略来实现可伸缩性。示例中,挑战猜测是系统的关键部分,必须处理大量的并发请求,可部署两个微服务实例,而只部署一个尚未开发的Gamification微服务实例。如果是单体应用,通常很难区分。如图所示:

水平可伸缩性:单体应用程序
小型单体应用程序:实例1
Challenge/Users功能(要求:2个CPU 800MB RAM)
Gamification功能(要求:1个CPU 400MB RAM)
负载均衡器
小型单体应用程序:实例2
Challenge/Users功能(要求:2个CPU 800MB RAM)
Gamification功能(要求:1个CPU 400MB RAM)
总计:6个CPU 2400MB RAM
水平可伸缩性:微服务
微服务1实例:Challenge/Users功能(要求:2个CPU 800MB RAM)
负载均衡器
微服务1实例:Challenge/Users功能(要求:2个CPU 800MB RAM)
微服务实例:Gamification功能(要求:1个CPU 400MB RAM)
总计:5个CPU 2000MB RAM

细粒度的非功能需求

水平可伸缩性的优势可应用于其他非功能性需求。前面说过,如果Gamification在短时间内不可用,情况也没那么糟。如果运行的是单体系统,整个软件可能因为Gamification模块的意外情况而发生崩溃。使用微服务,可选择暂时关闭该部件。
这同样适用于安全性,可能需要对管理用户个人数据的微服务进行更严格的访问,但不需要在Gamification域中处理这种安全性开销。
作为独立的应用程序,微服务带来了更大的灵活性。

其他优势

微服务架构还有其他好处:

  • 多种技术。可能会使用Java或Golang构架微服务,还可能使用不同的数据库引擎等。
  • 与组织结构保持一致。可能尝试使用康威定律,按照组织结构来设计微服务。
  • 更换系统部件的努力。如果微服务给软件架构带来更多的隔离,那么更换就会很容易,而不会造成太大的影响。当然,好的模块化系统实现也具有良好的可替换性。

劣势

微服务架构也有许多缺点:

  • 需要更多时间来交付第一个版本。由于微服务架构的复杂性,使得其比单体应用程序需要更多时间。
  • 跨域移动功能变得更困难。一旦进行了拆分,跨域服务合并代码或移动功能需要的工时更多。
  • 隐式引入了新范式。微服务架构使得系统更分散,将面临异步处理、分布式事务和最终一致性等新挑战。
  • 需要学习新范式以使用它。当构建一个分布式系统时,需要了解路由、服务发现、分布式跟踪和日志记录等范式,这并不容易。
  • 可能需要新工具。Spring Cloud、Docker、Message Brokers、Kubernetes等框架和工具有助于实现微服务架构。
  • 需要更多资源以运行系统。项目开始阶段,当系统流量还不高时,维护基于微服务的系统可能比单体系统要昂贵得多。
  • 可能偏离标准和常规做法。转向微服务可以使团队之间实现更大的独立性。但是,如果每个人都开始创建自己的解决方案来解决相同的问题,而不是重用范式,可能产生负面影响,造成时间浪费,产生更难理解的结果。
  • 架构更复杂。意味着新人需要更多时间来了解整个系统的工作方式。
  • 可能会被非必要的新技术分散注意力。使用微服务,这种情况发生的频率更高。

架构概述

下面要采取行动,来满足系统的游戏化需求。这里有两个应用:原来的Multiplication和新的Gaminfication,下图显示了系统组件之间的关系:

获取静态内容
发送尝试/获取状态/获取用户
检索排行榜
发送尝试
浏览器
UI服务器
Multiplication微服务
Gamification微服务

这里,增加了一些新功能:

  • 增加了一个新的微服务:Gamification。
  • 微服务Multiplication将向微服务Gamification发送每一次尝试以处理新积分、徽章和更新排行榜。
  • React UI中将添加一个新组件,用于显示带有分数和徽章的排行榜。

需要注意:

  • 可通过嵌入式Web服务器部署UI,最好将UI服务器作为不同的部署单元。
  • UI需要调用两个服务,看起来比较奇怪。可使用网关模式,使客户端感知不到后端的软件架构。
  • 从微服务Multiplication到微服务Gamification的同步调用并不是最佳的,应该引起注意。

设计和实现新服务

采用与前面的Multiplication类似的方法实现Gamification。

接口

使用模块化系统时,尤其要注意模块之间的契约。对于微服务,这尤其重要。
这里,Gamification需要公开一个接口来接收新的ChallengeAttempt,该服务要这些数据来计算用户的统计信息。接口应该是REST API的,传递的JSON对象可以简单地包含与微服务Multiplication存储的ChallengeAttempt相同的字段:ChallengeAttempt和User的数据。在Gamification端,将只使用需要的数据。
另外,UI需要收集排行榜的详细信息,也要在微服务Gamification中创建REST接口来访问这些数据。

Gamification的Spring Boot框架

使用Spring Initializr创建新的应用程序,包含依赖:Lombok、Spring Web、Validation、Spring Data JPA和H2数据库。如图所示:
项目依赖
打开项目,可以看到如下目录结构:
项目结构

领域建模

现在对Gamification域进行建模,尽量尊重上下文边界,与现有功能保持最小耦合。

  • 创建一个得分卡(ScoreCard)对象,保存用户挑战尝试获得的分数。
  • 创建一个徽章卡(BadgeCard)对象,表示用户在给定时间赢得的特定类型的徽章。不需要与得分卡捆绑,当超过特定的分数即可赢得徽章。
  • 创建一个排行榜位置(LeaderBoardPosition),以向用户展示排名。

这个模型中对象之间的关系如下:

1
1
N
1
N
1
N
1
N
1
1
1
LeaderBoardPosition
User
BadgeCard
ScoreCard
ChallengeAttempt
Challenge

现在仍然保持域之间的松散耦合:

  • Users域依旧保持完全隔离,不保留对任何对象的引用。
  • Challenges域只需要知道Users,不需要连接游戏化概念。
  • Gamification域需要引用User和ChallengeAttempt,可以发送一个Attempt后获取这些数据,然后,在本地存储一些引用。

域对象可以轻松地映射到Java类上。下面就来看看这些类。
ScoreCard类是实体类,代码如下:

package cn.zhangjuli.gamification.game.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ScoreCard {
    public static final int DEFAULT_SCORE = 10;
    @Id
    @GeneratedValue
    private Long scoreId;
    private Long userId;
    private Long attemptId;
    // 在生成equals何hashCode方法时,剔除该字段,因为,不需要时间戳来判断两个ScoreCard是否相同。
    @EqualsAndHashCode.Exclude
    private long scoreTimestamp;
    private int score;

    public ScoreCard(final Long userId, final Long attemptId) {
        this(null, userId, attemptId, System.currentTimeMillis(), DEFAULT_SCORE);
    }
}

BadgeType枚举了徽章类型,代码如下:

package cn.zhangjuli.gamification.game.domain;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@RequiredArgsConstructor
@Getter
public enum BadgeType {
    // 各种徽章,依赖分数
    BRONZE("铜牌"),
    SLIVER("银牌"),
    GOLD("金牌"),

    // 满足不同条件的徽章
    FIRST_WON("第一次获胜"),
    LUCKY_NUMBER("幸运数字");
    
    // 描述信息
    public final String description;
}

BadgeCard类中使用BadgeType,BadgeCard类是实体类,代码如下:

package cn.zhangjuli.gamification.game.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BadgeCard {
    @Id
    @GeneratedValue
    private Long badgeId;
    private Long userId;
    @EqualsAndHashCode.Exclude
    private long badgeTimestamp;
    private BadgeType badgeType;

    public BadgeCard(final Long userId, final BadgeType badgeType) {
        this(null, userId, System.currentTimeMillis(), badgeType);
    }
}

对排行榜位置进行建模,建立LeaderBoardPosition类,不需要在数据库中存储该对象,通过聚合用户的分数和徽章来动态使用,代码如下:

package cn.zhangjuli.gamification.game.domain;

import lombok.AllArgsConstructor;
import lombok.Value;
import lombok.With;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Value
@AllArgsConstructor
public class LeaderBoardPosition {
    Long userId;
    Long totalScore;
    // 生成一个方法来克隆一个对象,并向副本中添加一个新的字段值(这里是withBadges)。
    @With
    List<String> badges;

    public LeaderBoardPosition(final Long userId, final Long totalScore) {
        this.userId = userId;
        this.totalScore = totalScore;
        this.badges = List.of();
    }
}

服务实现

业务逻辑将分为两个部分:

  • 游戏逻辑,负责处理ChallengeAttempt并生成结果得分和徽章。
  • 排行榜逻辑,汇总数据并根据得分构建排名。

游戏逻辑实现GameService接口,很简单,基于ChallengeAttempt,计算分数和徽章并存储起来。微服务Multiplication可通过名为GameController的控制器访问该业务逻辑,该控制器将公开一个POST端点来接收ChallengeAttempt。在持久层,业务逻辑将要求使用ScoreRepository来保存ScoreCard,还需要一个BadgeRepository来保存BadgeCard。下面是其类图:

*
1
1
1
1
1
1
1
BadgeProcessor
GameServiceImpl
ScoreRepository
BadgeRepository
GameService
GameController

GameService接口定义如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolveDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import lombok.Value;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface GameService {
    /**
     * 为给定用户处理新的尝试
     * @param challenge 挑战数据,包含用户信息、乘法因子等。
     * @return 一个 {@link GameResult} 对象,包含分数和徽章信息。
     */
    GameResult newAttemptForUser(ChallengeSolveDTO challenge);

    /**
     * GameResult对象,用来将尝试中获得的分数和徽章组合在一起。
     */
    // 表明该类是不可变类
    @Value
    class GameResult {
        int score;
        List<BadgeType> badges;
    }
}

需要的ChallengeSolveDTO 类如下:

package cn.zhangjuli.gamification.challenge;

import lombok.Value;

/**
 * 定义了微服务Multiplication和微服务Gamification之间的契约,为保持独立性,在两个项目中都需要创建。
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
// 表明该类是不可变类
@Value
public class ChallengeSolveDTO {
    long attemptId;
    boolean correct;
    int factorA;
    int factorB;
    long userId;
    String userAlias;
}

有了基本的框架,就可以使用TDD并使用空接口实现和DTO类为业务逻辑创建一些测试用例。下面我GameService创建两个测试用例:一个正确的尝试和一个错误的尝试。GameServiceTest代码如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeCard;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.assertj.core.api.BDDAssertions.then;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
public class GameServiceImplTest {
    private GameService gameService;

    @BeforeEach
    public void setUp() {
        gameService = new GameServiceImpl();
    }

    @Test
    public void processCorrectAttemptTest() {
        // given
        long userId = 1L, attemptId = 10L;
        ChallengeSolvedDTO attempt = new ChallengeSolvedDTO(attemptId, true, 50, 60, userId,
                "john_doe");

        // when
        GameService.GameResult gameResult = gameService.newAttemptForUser(attempt);

        // then
        then(gameResult).isEqualTo(
                new GameService.GameResult(10, List.of(BadgeType.LUCKY_NUMBER))
        );
    }

    @Test
    void processWrongAttemptTest() {
        // when
        GameService.GameResult gameResult = gameService.newAttemptForUser(
                new ChallengeSolvedDTO(10L, false, 10, 10, 1L, "john")
        );

        // then
        then(gameResult).isEqualTo(new GameService.GameResult(0, List.of()));
    }
}

很显然,测试不能通过,下面就完成GameServiceImpl类,实现相应的方法,代码如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeCard;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Service
@Slf4j
public class GameServiceImpl implements GameService{
    @Override
    public GameResult newAttemptForUser(ChallengeSolvedDTO challenge) {
        if (challenge.isCorrect()) {
            ScoreCard scoreCard = new ScoreCard(challenge.getUserId(), challenge.getAttemptId());
            log.info("用户 {} 的尝试 {} 得分 {}",
                    challenge.getUserAlias(), challenge.getAttemptId(), scoreCard.getScore());
            List<BadgeCard> badgeCards = processForBadges(challenge);
            return new GameResult(scoreCard.getScore(),
                    badgeCards.stream().map(BadgeCard::getBadgeType).collect(Collectors.toList()));
        } else {
            log.info("尝试 {} 不正确。用户 {} 没有得分。",
                    challenge.getAttemptId(), challenge.getUserAlias());
            return new GameResult(0, List.of());
        }
    }

    private List<BadgeCard> processForBadges(final ChallengeSolvedDTO challengeSolved) {
        return List.of(new BadgeCard(1L, BadgeType.LUCKY_NUMBER));
    }
}

下面来处理徽章,建立一个BadgeProcessor接口,接收相关数据和已解决的尝试,然后,决定是否分配给定类型的徽章。BadgeProcessor接口如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface BadgeProcessor {
    /**
     * 根据参数处理用户获得的徽章
     * @param currentScore 当前得分
     * @param scoreCardList 得分卡列表
     * @param solvedDTO 已解决的尝试
     * @return 徽章类型,使用Optional封装
     */
    Optional<BadgeType> processForOptionalBadge(
            int currentScore,
            List<ScoreCard> scoreCardList,
            ChallengeSolvedDTO solvedDTO
    );

    /**
     * 得到处理器正在处理的徽章类型,用于根据需要过滤处理器。
     * @return 徽章类型
     */
    BadgeType badgeType();
}

GameServiceImpl类使用BadgeProcessor列表对象来处理各种徽章,只需要实现各种对应的BadgeProcessor即可处理各种类型的徽章,使用@Component注解,便于Spring加载到上下文中。下面实现BronzeBadgeProcessor类,代码如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Component
public class BronzeBadgeProcessor implements BadgeProcessor {

    @Override
    public Optional<BadgeType> processForOptionalBadge(int currentScore, List<ScoreCard> scoreCardList, ChallengeSolvedDTO solvedDTO) {
        return currentScore > 50 ?
                Optional.of(BadgeType.BRONZE) :
                Optional.empty();
    }

    @Override
    public BadgeType badgeType() {
        return BadgeType.BRONZE;
    }
}

BronzeBadgeProcessor 类的测试如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.game.domain.BadgeType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public class BronzeBadgeProcessorTest {
    private BronzeBadgeProcessor badgeProcessor;

    @BeforeEach
    public void setUp() {
        badgeProcessor = new BronzeBadgeProcessor();
    }

    @Test
    public void shouldGiveBadgeIfScoreOverThreshold() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(60, List.of(), null);
        assertThat(badgeType).contains(BadgeType.BRONZE);
    }

    @Test
    public void shouldNotGiveBadgeIfScoreUnderThreshold() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(40, List.of(), null);
        assertThat(badgeType).isEmpty();
    }
}

类似地,实现FirstWonBadgeProcessor类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Component
public class FirstWonBadgeProcessor implements BadgeProcessor {
    @Override
    public Optional<BadgeType> processForOptionalBadge(int currentScore, List<ScoreCard> scoreCardList, ChallengeSolvedDTO solvedDTO) {
        return scoreCardList.size() == 1 ?
                Optional.of(BadgeType.FIRST_WON) :
                Optional.empty();
    }

    @Override
    public BadgeType badgeType() {
        return BadgeType.FIRST_WON;
    }
}

同样地,完成FirstWonBadgeProcessor 类的测试如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public class FirstWonBadgeProcessorTest {
    private FirstWonBadgeProcessor badgeProcessor;

    @BeforeEach
    public void setUp() {
        badgeProcessor = new FirstWonBadgeProcessor();
    }

    @Test
    public void shouldGiveBadgeIfFirstTime() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(10,
                List.of(new ScoreCard(1L, 1L)),
                null);
        assertThat(badgeType).contains(BadgeType.FIRST_WON);
    }

    @Test
    public void shouldNotGiveBadgeIfNotFirstTime() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(0,
                List.of(new ScoreCard(1L, 1L),new ScoreCard(1L, 2L)),
                null);
        assertThat(badgeType).isEmpty();
    }
}

SilverBadgeProcessor类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Component
public class SilverBadgeProcessor implements BadgeProcessor {

    @Override
    public Optional<BadgeType> processForOptionalBadge(int currentScore, List<ScoreCard> scoreCardList, ChallengeSolvedDTO solvedDTO) {
        return currentScore > 150 ?
                Optional.of(BadgeType.SLIVER) :
                Optional.empty();
    }

    @Override
    public BadgeType badgeType() {
        return BadgeType.SLIVER;
    }
}

SilverBadgeProcessorTest 类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.game.domain.BadgeType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public class SilverBadgeProcessorTest {
    private BadgeProcessor badgeProcessor;

    @BeforeEach
    public void setUp() {
        badgeProcessor = new SilverBadgeProcessor();
    }

    @Test
    public void shouldGiveBadgeIfScoreOverThreshold() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(160, List.of(),
                null);
        assertThat(badgeType).contains(BadgeType.SLIVER);
    }

    @Test
    public void shouldNotGiveBadgeIfScoreUnderThreshold() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(140, List.of(),
                null);
        assertThat(badgeType).isEmpty();
    }
}

GoldBadgeProcessor类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Component
public class GoldBadgeProcessor implements BadgeProcessor {

    @Override
    public Optional<BadgeType> processForOptionalBadge(int currentScore, List<ScoreCard> scoreCardList, ChallengeSolvedDTO solvedDTO) {
        return currentScore > 400 ?
                Optional.of(BadgeType.GOLD) :
                Optional.empty();
    }

    @Override
    public BadgeType badgeType() {
        return BadgeType.GOLD;
    }
}

GoldBadgeProcessorTest类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.game.domain.BadgeType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public class GoldBadgeProcessorTest {
    private BadgeProcessor badgeProcessor;

    @BeforeEach
    public void setUp() {
        badgeProcessor = new GoldBadgeProcessor();
    }

    @Test
    public void shouldGiveBadgeIfScoreOverThreshold() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(450, List.of(),
                null);
        assertThat(badgeType).contains(BadgeType.GOLD);
    }

    @Test
    public void shouldNotGiveBadgeIfScoreUnderThreshold() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(340, List.of(),
                null);
        assertThat(badgeType).isEmpty();
    }
}

LuckyNumberBadgeProcessor类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Component
public class LuckyNumberBadgeProcessor implements BadgeProcessor {

    private static final int LUCKY_FACTOR = 42;

    @Override
    public Optional<BadgeType> processForOptionalBadge(int currentScore, List<ScoreCard> scoreCardList, ChallengeSolvedDTO solvedDTO) {
        return solvedDTO.getFactorA() == LUCKY_FACTOR ||
                solvedDTO.getFactorB() == LUCKY_FACTOR ?
                Optional.of(BadgeType.LUCKY_NUMBER) :
                Optional.empty();
    }

    @Override
    public BadgeType badgeType() {
        return BadgeType.LUCKY_NUMBER;
    }
}

LuckyNumberBadgeProcessorTest类如下:

package cn.zhangjuli.gamification.game.badgeprocessors;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public class LuckyNumberBadgeProcessorTest {
    private BadgeProcessor badgeProcessor;

    @BeforeEach
    public void setUp() {
        badgeProcessor = new LuckyNumberBadgeProcessor();
    }

    @Test
    public void shouldGiveBadgeIfLuckyFactor() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(10,
                List.of(new ScoreCard(1L, 1L)),
                new ChallengeSolvedDTO(1L, true, 42, 10, 1L, "john"));
        assertThat(badgeType).contains(BadgeType.LUCKY_NUMBER);
    }

    @Test
    public void shouldNotGiveBadgeIfNotLuckyFactor() {
        Optional<BadgeType> badgeType = badgeProcessor.processForOptionalBadge(40,
                List.of(new ScoreCard(1L, 1L)),
                new ChallengeSolvedDTO(1L, true, 43, 10, 1L, "john"));
        assertThat(badgeType).isEmpty();
    }
}

现在,实现GameServiceImpl类如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.badgeprocessors.BadgeProcessor;
import cn.zhangjuli.gamification.game.domain.BadgeCard;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class GameServiceImpl implements GameService {
    private final ScoreRepository scoreRepository;
    private final BadgeRepository badgeRepository;
    private final List<BadgeProcessor> badgeProcessorList;

    @Override
    public GameResult newAttemptForUser(ChallengeSolvedDTO challenge) {
        if (challenge.isCorrect()) {
            ScoreCard scoreCard = new ScoreCard(challenge.getUserId(), challenge.getAttemptId());
            scoreRepository.save(scoreCard);
            log.info("用户 {} 的尝试 {} 得分 {}",
                    challenge.getUserAlias(), challenge.getAttemptId(), scoreCard.getScore());
            List<BadgeCard> badgeCards = processForBadges(challenge);
            return new GameResult(scoreCard.getScore(),
                    badgeCards.stream().map(BadgeCard::getBadgeType).collect(Collectors.toList()));
        } else {
            log.info("尝试 {} 不正确。用户 {} 没有得分。",
                    challenge.getAttemptId(), challenge.getUserAlias());
            return new GameResult(0, List.of());
        }
    }

    /**
     * 检查总分和不同的得分来得到徽章
     * @param challengeSolved 新的尝试
     * @return 徽章的列表
     */
    private List<BadgeCard> processForBadges(final ChallengeSolvedDTO challengeSolved) {
        Optional<Integer> optionalTotalScore =
                scoreRepository.getTotalScoreForUser(challengeSolved.getUserId());
        if (optionalTotalScore.isEmpty()) {
            return Collections.emptyList();
        }
        int totalScore = optionalTotalScore.get();

        // 得到用户的总分和徽章
        List<ScoreCard> scoreCardList =
                scoreRepository.findByUserIdOrderByScoreTimestampDesc(challengeSolved.getUserId());
        Set<BadgeType> alreadyGetBadges =
                badgeRepository.findByUserIdOrderByBadgeTimestampDesc(challengeSolved.getUserId())
                        .stream()
                        .map(BadgeCard::getBadgeType)
                        .collect(Collectors.toSet());

        // 调用徽章处理器来处理还没有得到的徽章
        List<BadgeCard> newBadgeCards = badgeProcessorList.stream()
                .filter(badgeProcessor -> !alreadyGetBadges.contains(badgeProcessor.badgeType()))
                .map(badgeProcessor -> badgeProcessor.processForOptionalBadge(totalScore,
                        scoreCardList, challengeSolved))
                .flatMap(Optional::stream)
                .map(badgeType -> new BadgeCard(challengeSolved.getUserId(), badgeType))
                .collect(Collectors.toList());

        badgeRepository.saveAll(newBadgeCards);

        return newBadgeCards;
    }
}

对应的GameServiceImplTest 类如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.badgeprocessors.BadgeProcessor;
import cn.zhangjuli.gamification.game.domain.BadgeCard;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
public class GameServiceImplTest {
    private GameService gameService;

    @Mock
    private ScoreRepository scoreRepository;
    @Mock
    private BadgeRepository badgeRepository;
    @Mock
    private BadgeProcessor badgeProcessor;

    @BeforeEach
    public void setUp() {
        gameService = new GameServiceImpl(
                scoreRepository,
                badgeRepository,
                List.of(badgeProcessor)
        );
    }

    @Test
    public void processCorrectAttemptTest() {
        // given
        long userId = 1L, attemptId = 10L;
        ChallengeSolvedDTO attempt = new ChallengeSolvedDTO(attemptId, true, 50, 60, userId,
                "john_doe");
        ScoreCard scoreCard = new ScoreCard(userId, attemptId);
        given(scoreRepository.getTotalScoreForUser(userId))
                .willReturn(Optional.of(10));
        given(scoreRepository.findByUserIdOrderByScoreTimestampDesc(userId))
                .willReturn(List.of(scoreCard));
        given(badgeRepository.findByUserIdOrderByBadgeTimestampDesc(userId))
                .willReturn(List.of(new BadgeCard(userId, BadgeType.FIRST_WON)));
        given(badgeProcessor.badgeType()).willReturn(BadgeType.LUCKY_NUMBER);
        given(badgeProcessor.processForOptionalBadge(10, List.of(scoreCard), attempt))
                .willReturn(Optional.of(BadgeType.LUCKY_NUMBER));

        // when
        GameService.GameResult gameResult = gameService.newAttemptForUser(attempt);

        // then
        then(gameResult).isEqualTo(
                new GameService.GameResult(10, List.of(BadgeType.LUCKY_NUMBER))
        );
        verify(scoreRepository).save(scoreCard);
        verify(badgeRepository).saveAll(List.of(new BadgeCard(userId, BadgeType.LUCKY_NUMBER)));
    }

    @Test
    void processWrongAttemptTest() {
        // when
        GameService.GameResult gameResult = gameService.newAttemptForUser(
                new ChallengeSolvedDTO(10L, false, 10, 10, 1L, "john")
        );

        // then
        then(gameResult).isEqualTo(new GameService.GameResult(0, List.of()));
    }
}

数据

业务逻辑层在,对ScoreRepository和BadgeRepository方法做了假设,现在需要建立存储库。
只需要对Spring Data的CrudRepository类进行扩展,就可以获得CRUD基本功能,就可以轻松保存徽章和计分卡。而其他查询,可以使用查询方法和JPQL。
BadgeRepository接口定义了一个查询方法,根据给定用户查找徽章,按日期排序,最新获得的徽章排在最前面,代码如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.BadgeCard;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface BadgeRepository extends CrudRepository<BadgeCard, Long> {
    /**
     * 根据用户查找徽章,按时间逆序
     * @param userId 用户ID
     * @return 徽章列表,按时间逆序
     */
    List<BadgeCard> findByUserIdOrderByBadgeTimestampDesc(final Long userId);
}

对于ScoreCard,还需要其他查询,例如:

  1. 计算用户的总分。
  2. 根据用户查找所有得分记录。

ScoreRepository类如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface ScoreRepository extends CrudRepository<ScoreCard, Long> {
    /**
     * 得到用户总分,即所有得分之和
     * @param userId 用户ID
     * @return 用户总分,如果用户不存在,为空
     */
    @Query("SELECT sum(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId")
    Optional<Integer> getTotalScoreForUser(@Param("userId") Long userId);

    /**
     * 根据用户ID查找得分,按时间逆序
     * @param userId 用户的ID
     * @return 得分列表,按时间逆序
     */
    List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);
}

Spring Data JPA查询不支持聚合,可以使用JPQL来实现,这样的代码尽可能与数据库无关:
SELECT sum(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId
与标准的SQL一样,GROUP BY用于按字段进行分组,可使用:param标记对参数进行定义,然后,使用@Param注解对应的方法参数,也可以使用参数占位符,如:?1

控制器

在Gamification域中,与Multiplication服务进行了约定,会将每个ChallengeAttempt发送到Gamification服务的REST API端点。下面实现GameController类如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import cn.zhangjuli.gamification.game.GameService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@RestController
@RequestMapping("/attempts")
@RequiredArgsConstructor
public class GameController {
    private final GameService gameService;

    @PostMapping
    @ResponseStatus(HttpStatus.OK)
    void postResult(@RequestBody ChallengeSolvedDTO solvedDTO) {
        gameService.newAttemptForUser(solvedDTO);
    }
}

GameController接收一个包含User和Challenge数据的JSON对象,不需要返回任何内容,使用ResponseStatus注解,配置Spring返回200 OK状态码即可。实际上,不需要添加这个注解,只是为了提高可读性,最好还是加上,因为,默认情况下,该方法正确执行时,不返回任何值。当然,如果出错,如抛出异常,Spring Boot的默认错误处理逻辑会截获它,并返回一个带有不同状态码的错误响应。
GameControllerTest 类如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.challenge.ChallengeSolvedDTO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.BDDAssertions.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
@AutoConfigureJsonTesters
@WebMvcTest(GameController.class)
public class GameControllerTest {
    @MockBean
    private GameService gameService;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JacksonTester<ChallengeSolvedDTO> jsonRequestAttempt;

    @Test
    public void postValidResult() throws Exception {
        // given
        long userId = 1L, attemptId = 10L;
        ChallengeSolvedDTO solvedDTO = new ChallengeSolvedDTO(attemptId, true, 50, 60, userId,
                "john_doe");

        // when
        MockHttpServletResponse response = mockMvc.perform(
                post("/attempts").contentType(MediaType.APPLICATION_JSON)
                        .content(jsonRequestAttempt.write(solvedDTO).getJson())
        ).andReturn().getResponse();

        // then
        then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
    }

    @Test
    public void postInvalidResult() throws Exception {
        // given
        long userId = 1L, attemptId = 10L;
        ChallengeSolvedDTO solvedDTO = new ChallengeSolvedDTO(attemptId, false, 50, 60, userId,
                "john_doe");

        // when
        MockHttpServletResponse response = mockMvc.perform(
                post("/attempts").contentType(MediaType.APPLICATION_JSON)
                        .content(jsonRequestAttempt.write(solvedDTO).getJson())
        ).andReturn().getResponse();

        // then
        then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
    }
}

对GameController的测试,意义不大,其实主要还是GameService的测试。

排行榜功能实现

下面来完成排行榜功能,排行榜功能的UML图如下所示:

1
1
1
1
1
1
LeaderBoardController
LeaderBoardService
LeaderBoardServiceImpl
BadgeRepository
ScoreRepository

首先创建LeaderBoardService接口,用于返回LeaderBoardPosition列表,代码如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface LeaderBoardService {
    /**
     * 得到排行榜,从高到低排序
     * @return 当前排行榜,从高到低排序
     */
    List<LeaderBoardPosition> getCurrentLeaderBoard();
}

如果能汇总分数,并对结果进行排序,则排行榜的实现就很简单。现在假设ScoreRepository可以获得分数的排名,然后查询BadgeRepository,来检索用户的徽章。
使用TDD开发,将LeaderBoardService接口实现为空方法,LeaderBoardServiceImpl类的代码如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * 从ScoreRepository中获取分数排名,从BadgeRepository中获取用户徽章,计算排行榜
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Service
@RequiredArgsConstructor
public class LeaderBoardServiceImpl implements LeaderBoardService {
    private final ScoreRepository scoreRepository;
    private final BadgeRepository badgeRepository;

    @Override
    public List<LeaderBoardPosition> getCurrentLeaderBoard() {
        return null;
    }
}

现在创建单元测试,LeaderBoardServiceImplTest类如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
public class LeaderBoardServiceImplTest {
    private LeaderBoardService leaderBoardService;

    @Test
    public void getLeaderBoardTest() {
        // given
        LeaderBoardPosition leaderBoardPosition = new LeaderBoardPosition(1L, 300L, List.of());

        // when
        List<LeaderBoardPosition> leaderBoard = leaderBoardService.getCurrentLeaderBoard();

        // then
        List<LeaderBoardPosition> expectedLeaderBoard = List.of(
                new LeaderBoardPosition(1L, 300L, List.of(BadgeType.LUCKY_NUMBER.getDescription()))
        );
        then(leaderBoard).isEqualTo(expectedLeaderBoard);
    }
}

现在来实现LeaderBoardServiceImpl对应的方法,代码如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

/**
 * 从ScoreRepository中获取分数排名,从BadgeRepository中获取用户徽章,计算排行榜
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Service
@RequiredArgsConstructor
public class LeaderBoardServiceImpl implements LeaderBoardService {
    private final ScoreRepository scoreRepository;
    private final BadgeRepository badgeRepository;

    @Override
    public List<LeaderBoardPosition> getCurrentLeaderBoard() {
        // 得到排行榜得分
        List<LeaderBoardPosition> leaderBoardScores = scoreRepository.findFirst10();
        // 合并徽章
        return leaderBoardScores.stream().map(
                position -> {
                    List<String> badges = badgeRepository.findByUserIdOrderByBadgeTimestampDesc(
                            position.getUserId()
                    ).stream().map(badge -> badge.getBadgeType().getDescription())
                            .collect(Collectors.toList());
                    return position.withBadges(badges);
                }
        ).collect(Collectors.toList());
    }
}

LeaderBoardServiceImplTest类修改如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.BadgeCard;
import cn.zhangjuli.gamification.game.domain.BadgeType;
import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
public class LeaderBoardServiceImplTest {
    private LeaderBoardService leaderBoardService;

    @Mock
    private ScoreRepository scoreRepository;
    @Mock
    private BadgeRepository badgeRepository;

    @BeforeEach
    public void setUp() {
        leaderBoardService = new LeaderBoardServiceImpl(scoreRepository, badgeRepository);
    }

    @Test
    public void getLeaderBoardTest() {
        // given
        LeaderBoardPosition leaderBoardPosition = new LeaderBoardPosition(1L, 300L, List.of());
        given(scoreRepository.findFirst10()).willReturn(List.of(leaderBoardPosition));
        given(badgeRepository.findByUserIdOrderByBadgeTimestampDesc(1L))
                .willReturn(List.of(new BadgeCard(1L, BadgeType.LUCKY_NUMBER)));

        // when
        List<LeaderBoardPosition> leaderBoard = leaderBoardService.getCurrentLeaderBoard();

        // then
        List<LeaderBoardPosition> expectedLeaderBoard = List.of(
                new LeaderBoardPosition(1L, 300L, List.of(BadgeType.LUCKY_NUMBER.getDescription()))
        );
        then(leaderBoard).isEqualTo(expectedLeaderBoard);
    }
}

ScoreRepository 类修改如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import cn.zhangjuli.gamification.game.domain.ScoreCard;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface ScoreRepository extends CrudRepository<ScoreCard, Long> {
    /**
     * 得到用户总分,即所有得分之和
     * @param userId 用户ID
     * @return 用户总分,如果用户不存在,为空
     */
    @Query("SELECT sum(s.score) FROM ScoreCard s WHERE s.userId = :userId GROUP BY s.userId")
    Optional<Integer> getTotalScoreForUser(@Param("userId") Long userId);

    /**
     * 根据用户ID查找得分,按时间逆序
     * @param userId 用户的ID
     * @return 得分列表,按时间逆序
     */
    List<ScoreCard> findByUserIdOrderByScoreTimestampDesc(final Long userId);

    /**
     * 从ScoreCard中,构造{@link LeaderBoardPosition}列表来表示排行榜的用户得分。
     * @return 排行榜,根据分数逆序
     */
    @Query("SELECT NEW cn.zhangjuli.gamification.game.domain.LeaderBoardPosition(s.userId, SUM(s.score)) " +
            "FROM ScoreCard s " +
            "GROUP BY s.userId ORDER BY SUM(s.score) DESC")
    List<LeaderBoardPosition> findFirst10();
}

执行测试,可通过。
实现控制器类 LeaderBoardController 如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@RestController
@RequestMapping("/leaders")
@RequiredArgsConstructor
public class LeaderBoardController {
    private final LeaderBoardService leaderBoardService;

    @GetMapping
    public List<LeaderBoardPosition> getLeaderBoard() {
        return leaderBoardService.getCurrentLeaderBoard();
    }
}

对应的测试类LeaderBoardControllerTest如下:

package cn.zhangjuli.gamification.game;

import cn.zhangjuli.gamification.game.domain.LeaderBoardPosition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
@AutoConfigureJsonTesters
@WebMvcTest(LeaderBoardController.class)
public class LeaderBoardControllerTest {
    @MockBean
    private LeaderBoardService leaderBoardService;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    public JacksonTester<List<LeaderBoardPosition>> json;

    @Test
    public void getLeaderBoard() throws Exception {
        // given
        LeaderBoardPosition leaderBoardPosition1 = new LeaderBoardPosition(1L, 500L);
        LeaderBoardPosition leaderBoardPosition2 = new LeaderBoardPosition(2L, 400L);
        List<LeaderBoardPosition> leaderBoard = new ArrayList<>();
        Collections.addAll(leaderBoard, leaderBoardPosition1, leaderBoardPosition2);
        given(leaderBoardService.getCurrentLeaderBoard()).willReturn(leaderBoard);

        // when
        MockHttpServletResponse response = mockMvc.perform(
                get("/leaders")
                        .accept(MediaType.APPLICATION_JSON)
        ).andReturn().getResponse();

        // then
        then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
        then(response.getContentAsString()).isEqualTo(json.write(leaderBoard).getJson());
    }
}

执行测试,可通过。
重新启动应用程序,使用http工具或在浏览器中查看结果如下:

> http :8081/leaders
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Thu, 30 Nov 2023 01:13:40 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

[
    {
        "badges": [
            "银牌",
            "金牌"
        ],
        "totalScore": 20,
        "userId": 1
    }
]

配置

在application.properties中配置如下:

server.port=8081
# 能访问H2数据库的web控制台
spring.h2.console.enabled=true
# 设置数据源的URL,使用指定文件作为数据源
spring.datasource.url=jdbc:h2:file:~/gamification;DB_CLOSE_ON_EXIT=FALSE
# 在创建或修改实体时创建和更新数据库表
spring.jpa.hibernate.ddl-auto=update
# 在控制台日志中显示数据库操作的SQL语句
spring.jpa.show-sql=true

这里增加了server.port属性,将在同一台机器运行,所以使用了另一个端口,而且,数据源也采用了不同的H2文件来存储。
另外,还应该启动CORS支持,以便跨域访问支持,类似于Multiplication,配置代码如下:

package cn.zhangjuli.gamification.configuration;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**").allowedOrigins("http://localhost:3000");
    }
}

同样对REST API提供JSON支持,添加JsonConfiguration类如下:

package cn.zhangjuli.gamification.configuration;

import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Configuration
public class JsonConfiguration {
    @Bean
    public Module hibernateModule() {
        return new Hibernate5Module();
    }
}

这里需要在pom.xml中添加依赖如下:

        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-hibernate5</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/javax.persistence/javax.persistence -->
        <dependency>
            <groupId>javax.persistence</groupId>
            <artifactId>javax.persistence-api</artifactId>
            <version>2.2</version>
        </dependency>

重启应用程序,使用http访问如下:

> http :8081/leaders
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Thu, 30 Nov 2023 02:27:21 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

[
    {
        "badges": [
            "银牌",
            "金牌"
        ],
        "totalScore": 20,
        "userId": 1
    }
]

与微服务Multiplication的集成

完成了Gamification的一个版本后,需要通过Multiplication进行通信,以便集成这两个微服务。

创建Gamification访问客户端

已经在Gamification端创建了一些REST API,就需要构建一个REST API客户端来使用这些API,获取相关数据。Spring Web通过了相应的工具:RestTemplate类,Spring Boot在其顶部提供了一个额外的层:RestTemplateBuilder,当使用Spring Boot Web Starter时,默认注入该构建器,可以使用其方法,通过多个配置项,很方便地创建RestTemplate对象。可添加特定的消息转换器、安全凭证(如果需要用它来访问服务器)、HTTP拦截器等。Multiplication和Gamification都使用了Spring Boot的预定义配置,可使用默认设置,这意味着由RestTemplate发送的序列化JSON对象可以在服务器端(Gamification)进行正确的反序列化。
因此,在Multiplication中,创建一个单独的Gamification的REST客户端,即GamificationServiceClient 类如下:

package cn.zhangjuli.multiplication.clients;

import cn.zhangjuli.multiplication.challenge.ChallengeAttempt;
import cn.zhangjuli.multiplication.challenge.ChallengeSolvedDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

/**
 * 向Gamification服务发送信息,由Gamification服务进行处理(存储)
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Slf4j
@Service
public class GamificationServiceClient {
    private final RestTemplate restTemplate;
    private final String gamificationHostUrl;

    public GamificationServiceClient(final RestTemplateBuilder restTemplateBuilder,
                                     @Value("${service.gamification.host}")
                                     final String gamificationHostUrl) {
        this.restTemplate = restTemplateBuilder.build();
        this.gamificationHostUrl = gamificationHostUrl;
    }

    /**
     * 根据ChallengeAttempt对象构造ChallengeSolvedDTO对象,然后发送到Gamification服务的/attempts端
     * @param attempt ChallengeAttempt对象
     * @return 布尔值,当服务OK时为真,有问题是为假
     */
    public boolean sendAttempt(final ChallengeAttempt attempt) {
        try {
            ChallengeSolvedDTO solvedDTO = new ChallengeSolvedDTO(attempt.getId(),
                    attempt.isCorrect(), attempt.getFactorA(),
                    attempt.getFactorB(), attempt.getUser().getId(),
                    attempt.getUser().getAlias());
            ResponseEntity<String> response = restTemplate.postForEntity(
                    gamificationHostUrl + "/attempts", solvedDTO, String.class
            );
            log.info("Gamification服务响应:{}", response.getStatusCode());
            return response.getStatusCode().is2xxSuccessful();
        } catch (Exception e) {
            log.error("发送挑战尝试有问题:", e);
            return false;
        }
    }
}

需要在application.properties中添加Gamification服务的访问地址,如下所示:

# Gamification服务的URL
service.gamification.host=http://localhost:8081

同样,这里需要用到Gamification中的ChallengeSolvedDTO 类,用来将ChallengeAttempt对象转换为Gamification中使用的ChallengeSolvedDTO 对象,ChallengeSolvedDTO 类如下:

package cn.zhangjuli.multiplication.challenge;

import lombok.Value;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Value
public class ChallengeSolvedDTO {
    long attemptId;
    boolean correct;
    int factorA;
    int factorB;
    long userId;
    String userAlias;
}

将GamificationServiceClient添加到ChallengeServiceImpl中,这样就可以在用户进行挑战时,将ChallengeAttempt发送给Gamification了。ChallengeServiceImpl类修改如下:

package cn.zhangjuli.multiplication.challenge;

import cn.zhangjuli.multiplication.clients.GamificationServiceClient;
import cn.zhangjuli.multiplication.user.User;
import cn.zhangjuli.multiplication.user.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class ChallengeServiceImpl implements ChallengeService {
    private final UserRepository userRepository;
    private final ChallengeAttemptRepository attemptRepository;
    private final GamificationServiceClient gamificationServiceClient;

    @Override
    public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
        // Check if the attempt is correct
        boolean isCorrect =
                attemptDTO.getGuess() == attemptDTO.getFactorA() * attemptDTO.getFactorB();

        // 检查alias用户是否存在,不存在就创建
        User user = userRepository.findByAlias(attemptDTO.getUserAlias())
                .orElseGet(() -> {
                    log.info("Creating new user with alias {}", attemptDTO.getUserAlias());
                    return userRepository.save(
                            new User(attemptDTO.getUserAlias())
                    );
                });

        // Builds the domain object. Null id for now.
        ChallengeAttempt checkedAttempt = new ChallengeAttempt(null,
                user,
                attemptDTO.getFactorA(),
                attemptDTO.getFactorB(),
                attemptDTO.getGuess(),
                isCorrect);

        // Stores the attempt
        ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt);

        // 发送尝试给Gamification
        boolean status = gamificationServiceClient.sendAttempt(storedAttempt);
        log.info("Gamification服务响应:{}", status);
        
        return storedAttempt;
    }

    @Override
    public List<ChallengeAttempt> getStatisticsForUser(final String userAlias) {
        return attemptRepository.findTop10ByUserAliasOrderByIdDesc(userAlias);
    }
}

同样,对应的测试类ChallengeServiceTest修改如下:

package cn.zhangjuli.multiplication.challenge;

import cn.zhangjuli.multiplication.clients.GamificationServiceClient;
import cn.zhangjuli.multiplication.user.User;
import cn.zhangjuli.multiplication.user.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */

@ExtendWith(MockitoExtension.class)
public class ChallengeServiceTest {
    private ChallengeService challengeService;
    // 使用Mockito进行模拟
    @Mock
    private UserRepository userRepository;
    @Mock
    private ChallengeAttemptRepository attemptRepository;
    @Mock
    private GamificationServiceClient gamificationServiceClient;

    @BeforeEach
    public void setUp() {
        challengeService = new ChallengeServiceImpl(
                userRepository,
                attemptRepository,
                gamificationServiceClient
        );
        // Keep in mind that we needed to move the
        // given(attemptRepository)... to the test cases
        // that use it to prevent the unused stubs errors.
    }

    @Test
    public void checkCorrectAttemptTest() {
        // given
        // 这里希望save方法什么都不做,只返回第一个(也是唯一一个)传递的参数,这样不必调用真实的存储库即可测试该层。
        given(attemptRepository.save(any()))
                .will(returnsFirstArg());
        ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 60, "john_doe", 3000);

        // when
        ChallengeAttempt resultAttempt = challengeService.verifyAttempt(attemptDTO);

        // then
        then(resultAttempt.isCorrect()).isTrue();
        verify(userRepository).save(new User("john_doe"));
        verify(attemptRepository).save(resultAttempt);
        verify(gamificationServiceClient).sendAttempt(resultAttempt);
    }

    @Test
    public void checkWrongAttemptTest() {
        // given
        given(attemptRepository.save(any()))
                .will(returnsFirstArg());
        ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 60, "john_doe", 5000);

        // when
        ChallengeAttempt resultAttempt = challengeService.verifyAttempt(attemptDTO);

        // then
        then(resultAttempt.isCorrect()).isFalse();
        verify(userRepository).save(new User("john_doe"));
        verify(attemptRepository).save(resultAttempt);
        verify(gamificationServiceClient).sendAttempt(resultAttempt);
    }

    @Test
    public void checkExistingUserTest() {
        // given
        given(attemptRepository.save(any()))
                .will(returnsFirstArg());
        User existingUser = new User(1L, "john_doe");
        given(userRepository.findByAlias("john_doe"))
                .willReturn(Optional.of(existingUser));
        ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 60, "john_doe", 5000);

        // when
        ChallengeAttempt resultAttempt = challengeService.verifyAttempt(attemptDTO);

        // then
        then(resultAttempt.isCorrect()).isFalse();
        then(resultAttempt.getUser()).isEqualTo(existingUser);
        verify(userRepository, never()).save(any());
        verify(attemptRepository).save(resultAttempt);
        verify(gamificationServiceClient).sendAttempt(resultAttempt);
    }

    @Test
    public void retrieveStatisticsTest() {
        // given
        User user = new User("john_doe");
        ChallengeAttempt attempt1 = new ChallengeAttempt(1L, user, 50, 60, 3010, false);
        ChallengeAttempt attempt2 = new ChallengeAttempt(2L, user, 50, 60, 3051, false);
        List<ChallengeAttempt> lastAttempts = List.of(attempt1, attempt2);
        given(attemptRepository.findTop10ByUserAliasOrderByIdDesc("john_doe"))
                .willReturn(lastAttempts);

        // when
        List<ChallengeAttempt> latestAttemptsResult = challengeService.getStatisticsForUser("john_doe");

        // then
        then(latestAttemptsResult).isEqualTo(lastAttempts);
    }
}

使用API测试工具,如postman、HTTPie等,可以得到如下结果:
HTTPie
查看后端的H2数据库,也可以发现已经成功存储了信息。

用户处理

在排行榜中需要根据用户ID来处理分数、徽章和排名,为了更好地展示,需要将每个ID映射到用户别名,以更友好的方式呈现出来。新增UserController类如下:

package cn.zhangjuli.multiplication.user;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/users")
public class UserController {
    private final UserRepository userRepository;

    @GetMapping("/{idList}")
    public List<User> getUsersByIdList(@PathVariable final List<Long> idList) {
        return userRepository.findAllByIdIn(idList);
    }
}

需要在UserRepository类中添加对应的根据用户ID列表查找用户信息的接口,代码如下:

package cn.zhangjuli.multiplication.user;

import org.springframework.data.repository.CrudRepository;

import java.util.List;
import java.util.Optional;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
public interface UserRepository extends CrudRepository<User, Long> {
    Optional<User> findByAlias(final String alias);

    List<User> findAllByIdIn(List<Long> idList);
}

现在,创建一个新的测试类,来测试UserController,测试类如下:

package cn.zhangjuli.multiplication.user;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

/**
 * @author Juli Zhang, <a href="mailto:zhjl@lut.edu.cn">Contact me</a> <br>
 */
@ExtendWith(MockitoExtension.class)
@AutoConfigureJsonTesters
@WebMvcTest(UserController.class)
public class UserControllerTest {
    @MockBean
    private UserRepository userRepository;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JacksonTester<List<User>> jsonUsers;

    @Test
    void getUsersByIdList() throws Exception {
        // given
        User user1 = new User(1L, "john");
        User user2 = new User(2L, "noise");
        given(userRepository.findAllByIdIn(List.of(1L, 2L))).willReturn(List.of(user1, user2));

        // when
        MockHttpServletResponse response = mockMvc.perform(
                get("/users/1,2")
        ).andReturn().getResponse();

        // then
        then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
        then(response.getContentAsString()).isEqualTo(
                jsonUsers.write(List.of(user1, user2)).getJson()
        );
    }
}

使用HTTPie命令行测试如下:

> http :8080/users/1,2,53
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Fri, 01 Dec 2023 02:01:19 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

[
    {
        "alias": "noise",
        "id": 1
    },
    {
        "alias": "jkj",
        "id": 2
    },
    {
        "alias": "525",
        "id": 53
    }
]

也可以使用图形化用户界面,如:
HTTPie

用户界面

后端已经完成,就要编写前端实现。需要两个新的JavaScript类:

  • 一个新的API客户端,用于从Gamification获取排行榜数据。
  • 一个React组件用于显示排行榜。

创建GameApiClient 类从Gamification服务中获取排行榜信息,类似Multiplication中的ApiClient类,代码如下:

class GameApiClient {
    static SERVER_URL = 'http://localhost:8081';
    static GET_LEADERBOARD = '/leaders';

    static leaderBoard(): Promise<Response> {
        return fetch(GameApiClient.SERVER_URL + GameApiClient.GET_LEADERBOARD);
    }
}
export default GameApiClient;

为了可读性,将ApiClient类改名为ChallengesApiClient(使用IDEA的支持,相关改变会关联修改,否则,需要修改相关的引用代码。),其中调用GameApiClient获取用户信息,以集成其功能,修改如下:

class ChallengesApiClient {
    static SERVER_URL = 'http://localhost:8080';
    static GET_CHALLENGE = '/challenges/random';
    static POST_RESULT = '/attempts';
    static GET_ATTEMPTS_BY_ALIAS = '/attempts?alias=';
    static GET_USERS_BY_IDS = '/users';

    static challenge(): Promise<Response> {
        return fetch(ChallengesApiClient.SERVER_URL + ChallengesApiClient.GET_CHALLENGE);
    }

    static sendGuess(user: string,
                     a: number,
                     b: number,
                     guess: number): Promise<Response> {
        return fetch(ChallengesApiClient.SERVER_URL + ChallengesApiClient.POST_RESULT, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                userAlias: user,
                factorA: a,
                factorB: b,
                guess: guess
            })
        });
    }

    static getAttempts(userAlias: string): Promise<Response> {
        console.log('Get attempts for ' + userAlias)
        return fetch(ChallengesApiClient.SERVER_URL + ChallengesApiClient.GET_ATTEMPTS_BY_ALIAS + userAlias);
    }

    static getUsers(userIds: number[]): Promise<Response> {
        return fetch(ChallengesApiClient.SERVER_URL +
            ChallengesApiClient.GET_USERS_BY_IDS +
            '/' + userIds.join(','));
    }
}
export default ChallengesApiClient;

然后,创建LeaderBoardComponent类,用于显示排行榜,代码如下:

import * as React from "react";
import GameApiClient from "../services/GameApiClient";
import ChallengesApiClient from "../services/ChallengesApiClient";

class LeaderBoardComponent extends React.Component {

    constructor(props) {
        super(props);
        this.state = {
            leaderBoard: [],
            serverError: false
        }
    }

    componentDidMount() {
        this.refreshLeaderBoard();
        // 设置定时器,每5秒刷新一次排行榜
        setInterval(this.refreshLeaderBoard.bind(this), 5000);
    }

    getLeaderBoardData(): Promise {
        return GameApiClient.leaderBoard().then(
            res => {
                if (res.ok) {
                    return res.json();
                } else {
                    return Promise.reject("Gamification: 错误响应");
                }
            }
        );
    }

    getUserAliasData(userIds: number[]): Promise {
        return ChallengesApiClient.getUsers(userIds).then(
            res => {
                if (res.ok) {
                    return res.json();
                } else {
                    return Promise.reject("Multiplication: 错误响应");
                }
            }
        );
    }

    updateLeaderBoarder(lb) {
        this.setState({
            leaderBoard: lb,
            // 重置标志
            serverError: false
        })
    }

    /**
     * 从Gamification获取排行榜,如果失败,则将serverError标志设为true,
     * 将显示一条错误信息而不是表格;
     * 如果成功,将调用Multiplication,如果得到正确响应,将用户id映射为别名,
     * 并向排行榜数据中添加alias字段,如果调用失败,排行榜数据不变。
     */
    refreshLeaderBoard() {
        this.getLeaderBoardData().then(
            leaderBoardData => {
                let userIds = leaderBoardData.map(row => row.userId);
                if (userIds.length > 0) {
                    this.getUserAliasData(userIds).then(data => {
                        // 建立id到alias的Map
                        let userMap = new Map();
                        data.forEach(idAlias => {
                            userMap.set(idAlias.id, idAlias.alias);
                        });
                        // 向leaderBoardData中添加属性
                        leaderBoardData.forEach(row =>
                            row['alias'] = userMap.get(row.userId)
                        );
                        this.updateLeaderBoarder(leaderBoardData);
                    }).catch(reason => {
                        console.log('用户id映射错误:', reason);
                        this.updateLeaderBoarder(leaderBoardData);
                    });
                }
            }
        ).catch(reason => {
            this.setState({serverError: true});
            console.log("Gamification 服务错误:", reason);
        });
    }

    /**
     * 根据不同情况显示内容。如果错误,则显示一条信息;否则将显示一张表格。
     * @returns {Element} 显示内容
     */
    render() {
        if (this.state.serverError) {
            return (
                <div>很抱歉,现在不能显示统计信息。</div>
            );
        }
        return (
           <div>
               <h3>排行榜</h3>
               <table>
                   <thead>
                   <tr>
                       <th>用户</th>
                       <th>分数</th>
                       <th>徽章</th>
                   </tr>
                   </thead>
                   <tbody>
                   {
                       this.state.leaderBoard.map(row => <tr key={row.userId}>
                           <td>{row.alias ? row.alias : row.userId}</td>
                           <td>{row.totalScore}</td>
                           <td>{row.badges.map(
                               b => <span className="badge" key={b}>{b}</span>
                           )}</td>
                       </tr> )
                   }
                   </tbody>
               </table>
           </div>
        );
    }
}

export default LeaderBoardComponent;

这里用到了badge样式,在App.css中进行定义,代码如下:

.badge {
  font-size: x-small;
  border: 2px solid dodgerblue;
  border-radius: 4px;
  padding: 0.2em;
  margin: 0.1em;
}

接着,还需要将LeaderBoardComponent展示出来,将其加入ChallengeComponent类中,代码如下:

import ChallengesApiClient from "../services/ChallengesApiClient";
import * as React from "react";
import LastAttemptsComponent from "./LastAttemptsComponent";
import LeaderBoardComponent from "./LeaderBoardComponent";

// 类从React.Component继承,这就是React创建组件的方式。
// 唯一要实现的方法是render(),该方法必须返回DOM元素才能在浏览器中显示。
class ChallengeComponent extends React.Component {
    // 构造函数,初始化属性及组件的state(如果需要的话),
    // 这里创建一个state来保持检索到的挑战,以及用户为解决尝试而输入的数据。
    constructor(props) {
        super(props);
        this.state = {
            a: '',
            b: '',
            user: '',
            message: '',
            guess: '',
            lastAttempts: []
        };
        // 两个绑定方法。如果想要在事件处理程序中使用,这是必要的,需要实现这些方法来处理用户输入的数据。
        this.handleSubmitResult = this.handleSubmitResult.bind(this);
        this.handleChange = this.handleChange.bind(this);
    }

    // 这是一个生命周期方法,用于首次渲染组件后立即执行逻辑。
    componentDidMount(): void {
        this.refreshChallenge();
    }

    handleChange(event) {
        const name = event.target.name;
        this.setState({
            [name]: event.target.value
        });
    }

    handleSubmitResult(event) {
        event.preventDefault();
        ChallengesApiClient.sendGuess(this.state.user,
            this.state.a,
            this.state.b,
            this.state.guess)
            .then(res => {
                if (res.ok) {
                    res.json().then(json => {
                        if (json.correct) {
                            this.updateMessage("Congratulations! Your guess is correct");
                        } else {
                            this.updateMessage("Oops! Your guess " + json.reaultAttempt + " is" +
                                " wrong, but keep playing!");
                        }
                        this.updateLastAttempts(this.state.user);
                        this.refreshChallenge();
                    });
                } else {
                    this.updateMessage("Error: server error or not available");
                }
            });
    }

    updateMessage(m: string) {
        this.setState({
            message: m
        });
    }

    render() {
        return (
            <div className="display-column">
                <div>
                    <h3>Your new challenge is</h3>
                    <div className="challenge">
                        {this.state.a} x {this.state.b}
                    </div>
                </div>
                <form onSubmit={this.handleSubmitResult}>
                    <label>
                        Your alias:
                        <input type="text" maxLength="12" name="user"
                               value={this.state.user} onChange={this.handleChange}/>
                    </label>
                    <br/>
                    <label>
                        Your guess:
                        <input type="number" min="0" name="guess"
                               value={this.state.guess} onChange={this.handleChange}/>
                    </label>
                    <br/>
                    <input type="submit" value="Submit"/>
                </form>
                <h4>{this.state.message}</h4>
                {this.state.lastAttempts.length > 0 &&
                    <LastAttemptsComponent lastAttempts={this.state.lastAttempts}/>
                }
                <LeaderBoardComponent/>
            </div>
        );
    }

    updateLastAttempts(userAlias: string) {
        ChallengesApiClient.getAttempts(userAlias).then(res => {
            if (res.ok) {
                let attempts: Attempt[] = [];
                res.json().then(data => {
                    data.forEach(item => {
                        attempts.push(item);
                    });
                    this.setState({
                        lastAttempts: attempts
                    });
                })
            }
        })
    }

    refreshChallenge() {
        ChallengesApiClient.challenge().then(res => {
            if (res.ok) {
                res.json().then(json => {
                    this.setState({
                        a: json.factorA,
                        b: json.factorB,
                    });
                });
            } else {
                this.updateMessage("Can't reach the server");
            }
        });
    }
}

export default ChallengeComponent;

现在,启动后端(Multiplication和Gamification)和前端,就可以在浏览器中看到运行结果:
结果
在浏览器开发者环境中,可以看到每5秒就会刷新一次,获取一次排行榜的数据,在Gamification控制台和Multiplication控制台可以看到对应的输出信息。

容错能力

在整个系统中,可以发现Gamification的功能并不重要,可以接受其在一部分时间宕机。暂停Gamification,然后就可以发现,界面将给出提示信息。如下:
错误提示
即使不能显示排行榜,但用户仍然能够进行挑战,核心功能仍然能够使用。

请注意,这种情况下,将丢失数据,这段时间的用户正确的尝试不会得到分数。

作为替代方案,可以使用重试逻辑。实现一个不断尝试发布Attempt的循环:该循环在Gamification获得OK响应后终止,或经过一段时间后终止。但是,系统的复杂性将增加。

面临的挑战

现在已经将单体系统向微服务架构转变,即使在Gamification发生故障的情况下,应用程序仍然能够运行。如图所示:

获取静态内容
发送尝试/获取状态/获取用户
获取排行榜
发送尝试
浏览器
UI服务器
Multiplication微服务
Gamification微服务

这里包含两个后端逻辑,分布在两个Spring Boot应用程序中。

紧耦合

在领域建模时,认为它们是松耦合的,因为领域对象之间使用最少的引用。在Multiplication微服务中需要了解Gamification逻辑,Gamification调用其API来发送Attempt,并负责传递消息。在整体系统中使用命令式风格也没有那么糟,但却使得微服务之间产生了紧耦合,这可能成为一个大问题。
当前设计中,微服务Gamification由微服务Multiplication进行编排。除了编制模式(Orchestration pattern),还可以使用编排模式(Choreography pattern),有微服务Gamification决定何时触发其逻辑。

编制(orchestration)和编排(choreography)是常用于描述“合成Web服务的两种方式”的术语。虽然它们有共同之处,但还是有些区别的。Web服务编制(Web Services Orchestration,WSO)指为业务流程(business processes)而进行Web服务合成,而Web服务编排(Web Services Choreography,WSC)指为业务协作(business collaborations)而进行Web服务合成。
WSO关注于以一种说明性的(declarative)方式(而不是编程的方式)创建合成服务。WSO定义了组成编制(orchestration)的服务,以及这些服务的执行顺序(比如并行活动、条件分支逻辑等)。因此,可以将编制(orchestration)视为一种简单的流程,这种流程自身也是一个Web服务。WSO流通常包括分支控制点、并行处理选择、人类响应步骤以及各种类型的预定义步骤(例如转换、适配器、电子邮件及Web服务等)。
WSC关注于定义多方如何在一个更大的业务事务中进行协作。WSC通过“各方描述自己如何与其他Web服务进行公共消息交换”来定义业务交互,而不是像WSO中那样描述一方是如何执行某个具体业务流程的。
在用WSC来定义业务交互时,需要一个对“业务流程在交互过程中所使用的消息交换协议”的正式描述,对在“有状态的、长期运行的、涉及多方的流程”中的对等的(peer-to-peer)消息交换(同步的或异步的)进行建模。
WSO与WSC的关键区别在于:WSC是一种对等模型(peer-to-peer model),业务流程中会有很多协作方;而WSO是一种层次化的请求者/提供者模型(hierarchical requester/provider model),WSO仅定义了应调用什么服务以及应该何时调用,没有定义多方如何进行协作。

同步接口与最终一致性

在发生Attempt时,微服务Multiplication期望Gamification服务器可用,否则,相关功能就不完整。所有这些操作都发生在请求的生命周期中。当Multiplication服务器向用户界面返回响应时,分数和徽章要么都更新了,要么出问题了。如果在它们之间构建同步接口,则请求在完全完成或失败之前一直处于阻塞状态。
当有大量微服务时,即使精心设计上下文边界,仍然会产生大量数据流,就像这里的案例一样。下面来看一个更复杂的场景。当用户达到1000分时,向其发送电子邮件。不需要对域边界进行判断,假设有一个专门的微服务,在分配一个新的分数后需要更新。还要添加一个用于收集报告数据的微服务,且需要与Multiplication和Gamification服务连接。有关假设如图所示:

1、发送尝试
10、响应
2、发送尝试
7、响应尝试
3、发送电子邮件
4、电子邮件响应
5、发送分数
6、报告响应
8、发送尝试
9、响应尝试
浏览器
Multiplication微服务
Gamification微服务
Email微服务
Reports微服务

可继续使用REST API调用来构建同步接口,然后,将有一连串的调用,如图编号所示。来自浏览器的请求需要保持等待,直到所有请求都完成。链条中的服务越多,请求阻塞的时间就越长。如果一个服务很慢,整个服务链就会变慢。整个系统的性能至少与链中性能最差的微服务一样差。
如果在构建微服务时不考虑容错性,同步依赖性甚至会更糟。示例中,从Gamification微服务到Reports微服务的一个简单的更新操作失败可能使整个系统崩溃。如果使用重试机制,性能会进一步下降。如果让其轻易失败,最终可能会有很多部分未完成的操作。
因此,同步接口在微服务之间引入了强依赖性。优势是,在用户得到响应时,后端会对报表进行更新,得到总分,知道是否可以发送电子邮件,从而立即提供反馈。
在单体系统中,不会面临这类问题,因为所有模块都处于同一可部署单元中。如果只是调用其他方法,不会因网络延迟或错误而出现问题。此外,如果出现故障,将影响整个系统,因此,不需要在设计时考虑细粒度的容错性。
因此,如果同步接口都失效,那么重要的问题是:是否首先阻塞完整的请求?是否需要确认所有操作都已完成才能返回响应?现在就来试试看,修改之前的假设,如图所示:

1、发送尝试
2、响应
3、发送尝试
4、响应尝试
5、发送电子邮件
6、电子邮件响应
7、发送分数
8、报告响应
3、发送尝试
4、响应尝试
浏览器
Multiplication微服务
Gamification微服务
Email微服务
Reports微服务

新的设计将在新线程中启动一些请求,以解除主线程的阻塞:例如可以使用Java Futures,使响应能更早地发送给客户端,来解决前面描述的所有问题。作为结果,引入了最终一致性。想象一下,在API客户端中,有一个顺序线程等待发送Attempt的响应,然后,这个客户端的进程也尝试收集分数和报告。在阻塞线程的场景中,API客户端(如UI)相信,在获得Multiplication的响应后,Gamification中的分数与Attempt一致。但在异步环境中,无法保证。如果网络延迟较少,客户端可能得到更新的分数,但是这可能需要1秒钟才能完成,或者因为服务宕机需要更长的时间,并且只在重试之后才会更新,这些都无法预测。
因此,构建微服务架构时,面临的最艰巨的挑战就是实现最终一致性。可以接受在一定时间内,微服务Gamification的数据可能与微服务Multiplication的数据不一致,只会在最终得到一致。最后,通过适当的设计使系统更健壮,微服务Gamification将保持最新状态。与此同时,API客户端不能假定不同API调用之间存在一致性。这是关键所在:不仅关乎后端系统,也与API客户端有关。如果是唯一使用API的用户,可能问题不大,开发一个REST客户端,并且将最终一致性考虑进去。然而,如果将API作为服务提供方,那么也 必须告知客户端,他们必须知道预期的操作。
那么,系统最终能否保持一致?答案取决于功能和技术要求。例如,某些情况下,系统的功能描述可能暗示着很强的一致性,就可在不产生重大影响的情况下对其进行调整。实际上,如果将电子邮件作为异步操作分离出来,可将提示用户的消息上做出改变,让用户能够看到这种结果。当然,能否做出这样的改变,取决于组织的需求和能否接受最终一致性的意愿。

如果项目需求与跨域的最终一致性服务不兼容,那么模块化的单体应用程序可能更合适。

从另一方面来说,不需要到处都是异步的。某些情况下,在微服务之间进行同步调用是有意义的。这不是需要关注的问题,也不是把软件架构小题大做的理由。

现在的系统不依赖响应来刷新排行榜,可将微服务之间的同步调用改为异步调用,而且不会产生影响。与使用带有重试机制的REST API调用相比,在微服务之间实现异步通信是一种更好的方法。

事务

在单体系统中,可以使用相同的关系数据库存储用户、尝试、得分和徽章等,就可以从数据库的事务管理中受益,获得ACID保证。如果保存ScoreCard出错,可以还原事务之前的所有命令,则ChallengeAttempt也不会被存储。这称为回滚,可以避免部分更新,可保证数据完整性。
在微服务之间,无法得到ACID保证,因为不能在微服务架构中实现真正的事务。它们是独立部署的,存在于不同的进程中,数据库也应该解耦,此外,为了避免依赖,应该接受最终一致性。
原子性确保所有相关数据都已存储或都不存储,这在微服务之间很难实现。示例中,Multiplication请求存储ChallengeAttempt,然后调用微服务Gamification来完成部分工作,即使保持请求同步,如果没有收到响应,也不知道分数和徽章是否被存储。要怎么办呢?回滚吗?无法Gamification发生了什么,是否总是保存ChallengeAttempt?
实际上,有多种解决方案:

  • 两阶段提交(2PC):可以先将ChallengeAttempt从Multiplication发送到Gamification,但双方都不存储数据,一旦获得准备存储数据的响应,便发送第二个请求作为信号,在Gamification在存储分数和徽章,在Multiplication中存储ChallengeAttempt。通过两个阶段(准备和提交),最大限度地缩短了出问题的时间。但并未消除这种可能性,因为第二阶段仍然可能失败。这是很不好的方法,因为需要使用同步接口,系统复杂性会呈指数级增长。
  • Sagas:使用异步通信,在两个微服务之间建立一个异步接口,如果Gamification端出问题,该微服务能够触及微服务Multiplication,让其了解情况,那么,Multiplication会删除刚刚保存的ChallengeAttempt。这样就补偿了事务。就复杂性而言,也付出了高昂的代价。

毫无意外,最好的解决方案是将使用数据库事务的功能流程保留在同一个微服务中。如果由于事务非常重要,而不能拆分,就应该放在同一个域中。对其他流程,可以尝试分割事务边界,并接受最终一致性。
示例中,不使用分布式事务,不需要在尝试和得分之间保持即时一致性。但是,仍然有一个缺陷:微服务Multiplication忽略了来自微服务Gamification的错误,可能成功解决了挑战,但没有得到相应的分数和徽章。

开放API

在微服务Gamification在为创建了一个为微服务Multiplication提供服务的REST API,而且,前端也需要访问这个API,实际上任何人都可以访问。如果有人使用HTTP客户端,就可以向Gamification发送虚假数据,这会破坏数据的完整性,让系统处于一个糟糕的境地,因为有人可以在不使用Multiplication端的情况下,获得分数和徽章。
解决的方法有很多。可以为端点添加安全层,确保内部API仅对后端服务可用;还可以使用反向代理,以确保只公开服务端点。

小结

文章介绍了如何从单体应用向微服务架构转变的过程,分析了单体系统的利弊,给出了构建产品的流程,先使用单体系统快速完成产品的一个可用版本,便于获取用户反馈,再根据需要决定是否向微服务架构迁移。如果决定采用微服务架构,可以按照文章中介绍的思路开展工作。当然,引入微服务架构将带来新的挑战。

前面的文章:
1、一个测试驱动的Spring Boot应用程序开发
2、2 使用React构造前端应用
3、3 基于Spring Boot的数据层示例

后面的文章:
5、5 转向事件驱动的架构

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值