vlambda博客
学习文章列表

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

PART I

Getting Started with Microservice Development Using Spring Boot

在这一部分中,您将学习如何使用 Spring Boot 的一些最重要的特性来开发微服务。

本部分包括以下章节:

  • 第 1 章微服务简介
  • 第二章Spring Boot简介
  • 第 3 章创建一组协作微服务
  • 第 4 章使用 Docker 部署我们的微服务
  • 第 5 章使用 OpenAPI 添加 API 描述
  • 第 6 章添加持久性
  • 第 7 章开发响应式微服务

Adding Persistence

在本章中,我们将学习如何持久化微服务正在使用的数据。正如在第2章中已经提到的,Spring Boot简介,我们将使用Spring Data项目将数据持久化到MongoDB和MySQL数据库。

productrecommendation 微服务将使用 Spring Data MongoDB 和 review 微服务将为 JPA 使用 Spring Data(Java Persistence API) 来访问 MySQL 数据库。我们将向 RESTful API 添加操作,以便能够在数据库中创建和删除数据。用于读取数据的现有 API 将被更新以访问数据库。我们将数据库作为 Docker 容器运行,由 Docker Compose 管理,也就是说,与我们运行微服务的方式相同。

本章将涵盖以下主题:

  • 向核心微服务添加持久层
  • 编写专注于持久性的自动化测试
  • 在服务层使用持久层
  • 扩展复合服务 API
  • 将数据库添加到 Docker Compose 环境中
  • 手动测试新的 API 和持久层
  • 更新微服务环境的自动化测试

技术要求

有关如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第 21 章适用于 macOS
  • 第 22 章 适用于 Windows

要手动访问数据库,我们将使用 Docker 映像中提供的 CLI 工具来运行数据库。我们还将公开 Docker Compose 中每个数据库使用的标准端口,3306 用于 MySQL 和 27017 用于 MongoDB。这将使我们能够使用我们最喜欢的数据库工具来访问数据库,就像它们在我们的计算机上本地运行一样。

本章代码示例均来自$BOOK_HOME/Chapter06中的源码。

如果您想查看本章源代码的更改,即查看使用 Spring Data 为微服务添加持久性所采取的措施,您可以将其与 的源代码进行比较第 5 章使用 OpenAPI 添加 API 描述。您可以使用您最喜欢的 diff 工具并比较两个文件夹 $ BOOK_HOME/Chapter05$BOOK_HOME/Chapter06

在详细介绍之前,让我们看看我们的前进方向。

章节目标

在本章结束时,我们将在微服务中拥有如下所示的层:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.1:我们的目标是微服务环境

协议层处理协议特定的逻辑。它很薄,只包含 api 中的 RestController 注解 项目和util 中常见的GlobalControllerExceptionHandler代码>项目。每个微服务的主要功能位于 服务层。 product-composite 服务包含一个集成层,用于处理与三个核心的通信微服务。核心微服务都有一个持久层,用于与其数据库进行通信。

我们将能够使用如下命令访问存储在 MongoDB 中的数据:

docker-compose exec mongodb mongo product-db --quiet --eval "db.products.find()"

该命令的结果应如下所示:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.2:访问存储在 MongoDB 中的数据

关于存储在 MySQL 中的数据,我们将能够使用如下命令访问它:

docker-compose exec mysql mysql -uuser -p review-db -e "select * from reviews"

该命令的结果应如下所示:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.3:访问存储在 MySQL 中的数据

mongomysql 命令的输出是缩短以提高可读性。

让我们看看如何实现这一点。我们将从向我们的核心微服务添加持久性功能开始!

向核心微服务添加持久层

让我们开始为核心微服务添加一个持久层。除了使用 Spring Data,我们还将使用 Java bean 映射工具 MapStruct,它可以轻松地在 Spring Data 实体对象和 API 模型类之间进行转换。如需更多详情,请参阅http://mapstruct .org/

首先,我们需要为我们打算使用的数据库添加 MapStruct、Spring Data 和 JDBC 驱动程序的依赖项。之后,我们可以定义我们的 Spring Data 实体类和存储库。 Spring Data 实体类和存储库将放置在它们自己的 Java 包中,persistence。例如,对于产品微服务,它们将被放置在 Java 包 se.magnus.microservices.core.product.persistence 中。

添加依赖项

我们将使用 MapStruct v1.3.1,因此我们首先在构建文件中为每个核心微服务定义一个保存版本信息的变量,build.gradle:

ext {
  mapstructVersion = "1.3.1"
}

接下来,我们声明对 MapStruct 的依赖:

implementation "org.mapstruct:mapstruct:${mapstructVersion}"

由于 MapStruct 在编译时通过处理 MapStruct 注解生成 bean 映射的实现,我们需要添加一个 annotationProcessor 和一个 testAnnotationProcessor 依赖:

annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
testAnnotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"

为了使编译时生成在 IntelliJ IDEA 等流行 IDE 中工作,我们还需要添加以下依赖项:

compileOnly "org.mapstruct:mapstruct-processor:${mapstructVersion}"

如果您使用的是 IntelliJ IDEA,还需要确保启用了对注释处理的支持。打开 Preferences 并导航到 Build、Execute、部署 |编译器 |注释处理器。验证是否选中了名为 启用注释处理 的复选框!

对于 productrecommendation 微服务,我们声明以下依赖于 Spring Data for MongoDB:

implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

对于 review 微服务,我们声明了一个 依赖于 Spring Data for JPA 和一个 JDBC 驱动程序对于这样的 MySQL:

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'mysql:mysql-connector-java'

为了在运行自动化集成测试时启用 MongoDB 和 MySQL,我们将使用 Testcontainers 及其对 JUnit 5、MongoDB 和 MySQL 的支持。对于 productrecommendation 微服务,我们声明以下测试依赖项:

implementation platform('org.testcontainers:testcontainers-bom:1.15.2')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mongodb'

对于 review 微服务,我们声明如下测试依赖:

implementation platform('org.testcontainers:testcontainers-bom:1.15.2')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'

有关如何在集成测试中使用 Testcontainers 的更多信息,请参阅稍后的编写专注于持久性的自动化测试部分。

使用实体类存储数据

实体类在包含哪些字段方面与对应的API模型类相似;请参阅 api 中的 Java 包 se.magnus.api.core 项目。我们将添加两个字段,idversion,在实体类中与 API 模型类相比。

id 字段用于保存每个存储实体的数据库标识,对应使用关系数据库时的主键。我们会将生成标识字段的唯一值的责任委托给 Spring Data。根据使用的数据库,Spring Data 可以将此责任委托给数据库引擎或自行处理。在任何一种情况下,应用程序代码都不需要考虑如何设置唯一的数据库 id 值。 id 字段未在 API 中公开,作为从安全角度来看的最佳实践。模型类中标识实体的字段将在对应的实体类中分配一个唯一索引,从业务角度确保数据库的一致性。

version 字段用于实现乐观锁定,允许 Spring Data 验证数据库中实体的更新不会覆盖并发更新。如果数据库中存储的version字段的值高于version 字段,表示更新是针对陈旧数据执行的——要更新的信息从数据库中读取后已经被其他人更新。 Spring Data 将阻止基于陈旧数据执行更新的尝试。在编写持久性测试的部分中,我们将看到验证 Spring Data 中的乐观锁定机制是否可以防止对陈旧数据执行更新的测试。由于我们只为创建、读取和删除操作实现 API,因此我们不会在 API 中公开 version 字段。

产品实体类中最有趣的部分,用于在 MongoDB 中存储实体,如下所示:

@Document(collection="products")
public class ProductEntity {

 @Id
 private String id;

 @Version
 private Integer version;

 @Indexed(unique = true)
 private int productId;

 private String name;
 private int weight;

以下是上述代码中的一些观察结果:

  • @Document(collection = "products") 注解用于将该类标记为用于MongoDB的实体类,即映射到MongoDB 中名为 products 的集合。
  • @Id@Version 注解用于标记 idversion 字段供Spring Data,如前所述。
  • @Indexed(unique = true) 注解用于获取为业务键创建的唯一索引,productId

Recommendation 实体类中最有趣的部分,也用于在 MongoDB 中存储实体,如下所示:

@Document(collection="recommendations")
@CompoundIndex(name = "prod-rec-id", unique = true, def = "{'productId': 1, 'recommendationId' : 1}")
public class RecommendationEntity {

    @Id
    private String id;

    @Version
    private Integer version;

    private int productId;
    private int recommendationId;
    private String author;
    private int rating;
    private String content;

添加到前面产品实体的说明中,我们可以看到如何使用复合业务键的 @CompoundIndex 注释创建唯一复合索引基于 productIdrecommendationId 字段。

最后是 Review 实体类中最有趣的部分,用于在 SQL 数据库中存储实体 像 MySQL,看起来像这样:

@Entity
@Table(name = "reviews", indexes = { @Index(name = "reviews_unique_idx", unique = true, columnList = "productId,reviewId") })
public class ReviewEntity {

    @Id @GeneratedValue
    private int id;

    @Version
    private int version;

    private int productId;
    private int reviewId;
    private String author;
    private String subject;
    private String content;

上述代码的注释:

  • @Entity@Table 注解用于将该类标记为用于 JPA 的实体类 — 映射到 SQL 数据库中名为 reviews 的表。
  • @Table 注解还用于指定将基于 productIdreviewId 字段。
  • @Id@Version 注解用于标记 idversion 字段供Spring Data 如前所述。引导 Spring Data for JPA 为 自动生成唯一的 id 值id 字段,我们使用 @GeneratedValue 注释。

实体类的完整源代码见各个核心微服务项目中的persistence包。

在 Spring Data 中定义存储库

Spring Data 带有一组用于定义存储库的基类。我们将使用基类 CrudRepositoryPagingAndSortingRepository

  • CrudRepository 基类提供标准方法,用于对存储在数据库中的数据执行基本的创建、读取、更新和删除操作。
  • PagingAndSortingRepository 基类为 CrudRepository< 添加了对分页和排序的支持/code> 基类。

我们将使用 CrudRepository 类作为 Recommendation< 的基类/code> 和 Review 存储库和 PagingAndSortingRepository 类作为 Product 存储库的基类。

我们还将向我们的存储库添加一些额外的查询方法,用于使用业务密钥 productId 查找实体。

Spring Data 支持根据方法签名的命名约定定义额外的查询方法。例如,findByProductId(int productId) 方法签名可用于指导 Spring Data 自动创建从底层集合返回实体的查询或表。在这种情况下,它将返回将 productId 字段设置为 productId 参数。有关如何声明额外查询的更多详细信息,请参阅 https://docs.spring.io/spring-data/data-commons/docs/current/reference/html/#repositories.query-methods。查询创建

Product 存储库类如下所示:

public interface ProductRepository extends PagingAndSortingRepository <ProductEntity, String> {
    Optional<ProductEntity> findByProductId(int productId);
}

由于 findByProductId 方法可能返回零个或一个产品实体,因此通过将返回值包装在 可选 对象。

Recommendation 存储库类如下所示:

public interface RecommendationRepository extends CrudRepository <RecommendationEntity, String> {
    List<RecommendationEntity> findByProductId(int productId);
}

在这种情况下,findByProductId方法会对很多推荐实体返回零,所以返回值定义为一个列表。

最后,Review 存储库类如下所示:

public interface ReviewRepository extends CrudRepository<ReviewEntity, Integer> {
    @Transactional(readOnly = true)
    List<ReviewEntity> findByProductId(int productId);
}

由于 SQL 数据库是事务性的,因此我们必须为查询方法 findByProductId() 指定默认事务类型(在我们的例子中为只读) .

就是这样——这就是为我们的核心微服务建立持久层所需的全部内容。

有关存储库类的完整源代码,请参阅每个核心微服务项目中的 persistence 包。

让我们通过编写一些测试来验证它们是否按预期工作来开始使用持久性类。

编写专注于持久性的自动化测试

在编写持久性测试时,我们希望在测试开始时启动数据库,并在测试完成时将其拆除。但是,我们不希望测试等待其他资源启动,例如 Netty 等 Web 服务器(运行时需要)。

Spring Boot 附带了两个针对此特定要求量身定制的类级别注释:

  • @DataMongoTest:这个注解会在测试开始时启动一个MongoDB数据库。
  • @DataJpaTest:这个注解会在测试开始时启动一个 SQL 数据库。
    • 默认情况下,Spring Boot 将测试配置为将更新回滚到 SQL 数据库,以最大程度地降低对其他测试产生负面影响的风险。在我们的例子中,这种行为会导致一些测试失败。因此,使用类级别注释 @Transactional(propagation = NOT_SUPPORTED) 禁用自动回滚。

为了在集成测试执行期间处理数据库的启动和关闭,我们将使用Testcontainers。在研究如何编写持久性测试之前,让我们先了解一下如何使用 Testcontainers。

使用测试容器

Testcontainers (https://www.testcontainers .org) 是一个库,通过运行资源管理器(如数据库或消息代理作为 Docker 容器。可以将 Testcontainers 配置为在 JUnit 测试启动时自动启动 Docker 容器,并在测试完成时拆除容器。

要在现有测试类中为 Spring Boot 应用程序(如本书中的微服务)启用 Testcontainers,我们可以在测试中添加 @Testcontainers 注释班级。使用 @Container 注释,我们可以例如声明 Review 微服务的集成测试将使用运行 MySQL 的 Docker 容器。代码如下所示:

@SpringBootTest
@Testcontainers
class SampleTests {
  @Container
  private static MySQLContainer database =
    new MySQLContainer("mysql:5.7.32");

为 MySQL 指定的版本 5.7.32 是从 Docker Compose 文件中复制的,以确保使用相同的版本。

这种方法的一个缺点是每个测试类都将使用自己的 Docker 容器。在 Docker 容器中启动 MySQL 需要几秒钟,在我的 Mac 上通常需要 10 秒钟。运行使用相同类型测试容器的多个测试类将为每个测试类添加此延迟。为了避免这种额外的延迟,我们可以使用 单容器模式(参见 https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers)。按照这种模式,基类用于为 MySQL 启动单个 Docker 容器。 Review中使用的基类MySqlTestBase微服务看起来像这样:

public abstract class MySqlTestBase {

  private static MySQLContainer database =
    new MySQLContainer("mysql:5.7.32");

  static {
    database.start();
  }

  @DynamicPropertySource
  static void databaseProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", database::getJdbcUrl);
    registry.add("spring.datasource.username", database::getUsername);
    registry.add("spring.datasource.password", database::getPassword);
  }
}

前面的源码说明:

  • database 容器的声明方式与前面的示例相同。
  • static 块用于在调用任何 JUnit 代码之前启动数据库容器。
  • 数据库容器将在启动时获得一些定义的属性,例如使用哪个端口。为了在应用程序上下文中注册这些动态创建的属性,定义了一个静态方法 databaseProperties()。该方法使用 @DynamicPropertySource 注释以覆盖应用程序上下文中的数据库配置,例如来自 application.yml 文件。

测试类使用基类如下:

class PersistenceTests extends MySqlTestBase {
class ReviewServiceApplicationTests extends MySqlTestBase {

对于使用 MongoDB 的 productreview 微服务,添加了相应的基类MongoDbTestBase

默认情况下,Testcontainers 的日志输出相当广泛。 Logback 配置文件可以放在 src/test/resource 文件夹中,以限制日志输出量。 Logback 是一个日志框架(http:// /logback.qos.ch),并使用 spring-boot-starter-webflux< 包含在微服务中/code> 依赖。有关详细信息,请参阅 https://www.testcontainers.org/supported_docker_environment/ logging_config/。本章使用的配置文件名为 src/test/resources/logback-test.xml,如下所示:

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

    <root level="INFO">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

上面的 XML 文件中的一些注释

  • 配置文件包括 Spring Boot 提供的两个配置文件,用于获取定义的默认值,并配置了一个日志附加器,可以将日志事件写入控制台。
  • 配置文件将日志输出限制为 INFO 日志级别,丢弃 DEBUG TRACE 日志记录由 Testcontainers 库发出。

有关 Spring Boot 支持日志记录和使用 Logback 的详细信息,请参阅 https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-configure-logback-用于日志记录

最后,当使用 @DataMongoTest@DataJpaTest注释而不是 @SpringBootTest 注释仅在集成测试期间启动 MongoDB 和 SQL 数据库,还有一件事要考虑。 @DataMongoTest@DataJpaTest 注释旨在默认启动嵌入式数据库。由于我们要使用容器化数据库,因此我们必须禁用此功能。对于 @DataJpaTest 注释,这可以通过使用 @ 来完成AutoConfigureTestDatabase 注释如下:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class PersistenceTests extends MySqlTestBase {

对于 @DataMongoTest 注解,这个 可以通过使用 excludeAutoConfiguration 参数并指定将排除类 EmbeddedMongoAutoConfiguration。代码如下所示:

@DataMongoTest(
  excludeAutoConfiguration = EmbeddedMongoAutoConfiguration.class)
class PersistenceTests extends MongoDbTestBase {

随着 Testcontainers 的引入,我们准备好了解如何编写持久性测试。

编写持久性测试

三个核心微服务的持久性测试是相似的,所以我们只对产品微服务。

测试类 PersistenceTests 声明了一个方法 setupDb()< /code>,用@BeforeEach注解,在每个测试方法之前执行。 setup 方法从数据库中以前的测试中删除任何实体,并插入一个实体,测试方法可以将其用作测试的基础:

@DataMongoTest
class PersistenceTests {

    @Autowired
    private ProductRepository repository;
    private ProductEntity savedEntity;

    @BeforeEach
    void setupDb() {
        repository.deleteAll();
        ProductEntity entity = new ProductEntity(1, "n", 1);
        savedEntity = repository.save(entity);
        assertEqualsProduct(entity, savedEntity);
    }

接下来是各种测试方法。首先是 create 测试:

@Test
void create() {
    ProductEntity newEntity = new ProductEntity(2, "n", 2);
    repository.save(newEntity);

    ProductEntity foundEntity =
    repository.findById(newEntity.getId()).get();
    assertEqualsProduct(newEntity, foundEntity);

    assertEquals(2, repository.count());
}

这个测试创建一个新的实体,验证它可以使用findById()找到方法,最后断言数据库中存储了两个实体,一个由 setup 方法创建,一个由测试自己。

update 测试如下所示:

@Test
void update() {
    savedEntity.setName("n2");
    repository.save(savedEntity);

    ProductEntity foundEntity =
    repository.findById(savedEntity.getId()).get();
    assertEquals(1, (long)foundEntity.getVersion());
    assertEquals("n2", foundEntity.getName());
}

此测试更新由 setup 方法创建的实体,使用标准 findById() 方法,并断言它包含一些字段的预期值。请注意,创建实体时,其 version 字段设置为 0 由 Spring Data 提供,因此我们希望它在更新后为 1

delete 测试如下所示:

@Test
void delete() {
    repository.delete(savedEntity);
    assertFalse(repository.existsById(savedEntity.getId()));
}

此测试删除由 setup 方法创建的实体,并验证它不再存在于数据库中。

read 测试如下所示:

@Test
void getByProductId() {
    Optional<ProductEntity> entity =
    repository.findByProductId(savedEntity.getProductId());
    assertTrue(entity.isPresent());
    assertEqualsProduct(savedEntity, entity.get());
}

本次测试使用findByProductId()方法获取setup 方法,验证是否找到 ,然后使用本地帮助方法, assertEqualsProduct(),验证 findByProductId() 返回的实体是否与 设置 方法。

接下来是两种验证替代流程的测试方法——错误条件的处理。首先是验证是否正确处理重复项的测试:

@Test
void duplicateError() {
  assertThrows(DuplicateKeyException.class, () -> {
    ProductEntity entity = new ProductEntity(savedEntity.getProductId(), "n", 1);
    repository.save(entity);
  });
}

该测试尝试使用与 setup 方法创建的实体所使用的相同业务密钥来存储实体。如果保存操作成功,或者保存失败并出现预期的 DuplicateKeyException 以外的异常,则测试将失败。

在我看来,另一个负面测试是测试班中最有趣的测试。这是一个在更新陈旧数据的情况下验证正确错误处理的测试——它验证乐观锁定机制是否有效。它看起来像这样:

@Test
void optimisticLockError() {

    // Store the saved entity in two separate entity objects
    ProductEntity entity1 =
    repository.findById(savedEntity.getId()).get();
    ProductEntity entity2 =
    repository.findById(savedEntity.getId()).get();

    // Update the entity using the first entity object
    entity1.setName("n1");
    repository.save(entity1);

    //  Update the entity using the second entity object.
    // This should fail since the second entity now holds an old version
    // number, that is, an Optimistic Lock Error
    assertThrows(OptimisticLockingFailureException.class, () -> {
      entity2.setName("n2");
      repository.save(entity2);
    });

    // Get the updated entity from the database and verify its new state
    ProductEntity updatedEntity =
    repository.findById(savedEntity.getId()).get();
    assertEquals(1, (int)updatedEntity.getVersion());
    assertEquals("n1", updatedEntity.getName());
}

从代码中观察到 如下:

  1. 首先,测试读取同一个实体两次并将其存储在两个不同的变量中,entity1实体 2
  2. 接下来,它使用变量之一 entity1 来更新实体。数据库中实体的更新会导致实体的version字段被Spring Data自动增加。另一个变量 entity2 现在包含陈旧的数据,通过它的 version 字段,该字段的值低于数据库中的相应值。
  3. 当测试尝试使用包含陈旧数据的变量 entity2 更新实体时,预计会因抛出 OptimisticLockingFailureException 异常。
  4. 测试结束时断言数据库中的实体反映了第一次更新,即包含名称 "n1",并且version 字段的值为 1;仅对数据库中的实体执行了一次更新。

最后,product 服务包含一个测试,该测试演示了 Spring Data 中对排序和分页的内置支持的用法:

@Test
void paging() {
    repository.deleteAll();
    List<ProductEntity> newProducts = rangeClosed(1001, 1010)
        .mapToObj(i -> new ProductEntity(i, "name " + i, i))
        .collect(Collectors.toList());
    repository.saveAll(newProducts);

    Pageable nextPage = PageRequest.of(0, 4, ASC, "productId");
    nextPage = testNextPage(nextPage, "[1001, 1002, 1003, 1004]",
    true);
    nextPage = testNextPage(nextPage, "[1005, 1006, 1007, 1008]",
    true);
    nextPage = testNextPage(nextPage, "[1009, 1010]", false);
}

对上述代码的解释

  1. 测试从删除任何现有数据开始,然后插入 10 个实体,其 productId 字段范围从 10011010
  2. 接下来,它创建 PageRequest,请求页面计数为 4 个实体和基于 ProductId 升序排列的排序顺序。
  3. 最后,它使用辅助方法 testNextPage 来读取预期的三个页面,验证每个页面中预期的产品 ID 并验证 Spring Data正确报告是否存在更多页面。

辅助方法 testNextPage 如下所示:

private Pageable testNextPage(Pageable nextPage, String expectedProductIds, boolean expectsNextPage) {
    Page<ProductEntity> productPage = repository.findAll(nextPage);
    assertEquals(expectedProductIds, productPage.getContent()
    .stream().map(p -> p.getProductId()).collect(Collectors.
    toList()).toString());
    assertEquals(expectsNextPage, productPage.hasNext());
    return productPage.nextPageable();
}

辅助方法使用页面请求对象 nextPage 从存储库方法 findAll()。根据结果​​,它从返回的实体中提取产品 ID 到一个字符串中,并将其与预期的产品 ID 列表进行比较。最后,它返回下一页。

持久化测试的完整源代码请参见每个核心微服务项目中的测试类PersistenceTests

product 微服务中的持久性测试可以使用 Gradle 执行,命令如下:

cd $BOOK_HOME/Chapter06
./gradlew microservices:product-service:test --tests PersistenceTests

运行测试后,它应该响应以下内容:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.4:构建成功的响应

有了持久层,我们可以更新核心微服务中的服务层以使用持久层。

在服务层使用持久层

在本节中,我们将学习如何使用服务层中的持久层来存储和检索数据库中的数据。我们将通过以下步骤:

  1. 记录数据库连接 URL
  2. 添加新的 API
  3. 从服务层调用持久层
  4. 声明一个 Java bean 映射器
  5. 更新服务测试

记录数据库连接 URL

当扩展 每个微服务连接到自己的数据库的微服务数量时,很难跟踪每个微服务实际使用的数据库。为了避免这种混淆,一个好的做法是在微服务启动后直接添加一条日志语句,用于记录用于连接数据库的连接信息。

例如,product 服务的启动代码如下所示:

public class ProductServiceApplication {
  private static final Logger LOG =
  LoggerFactory.getLogger(ProductServiceApplication.class);

  public static void main(String[] args) {
    ConfigurableApplicationContext ctx =
    SpringApplication.run(ProductServiceApplication.class, args);
    String mongodDbHost =
    ctx.getEnvironment().getProperty("spring.data.mongodb.host");
    String mongodDbPort =
    ctx.getEnvironment().getProperty("spring.data.mongodb.port");
    LOG.info("Connected to MongoDb: " + mongodDbHost + ":" +
    mongodDbPort);
  }
}

LOG.info 方法的调用将在日志中写入如下内容:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.5:预期的日志输出

完整源码见各个核心微服务项目中的主要应用类,例如ProductServiceApplication Code-In-Text--PACKT-">产品-服务 项目。

添加新的 API

在我们可以使用持久层在数据库中创建和删除信息之前,我们需要在我们的核心服务API中创建相应的API操作。

用于创建和删除产品实体的 API 操作如下所示:

@PostMapping(
    value    = "/product",
    consumes = "application/json",
    produces = "application/json")
Product createProduct(@RequestBody Product body);

@DeleteMapping(value = "/product/{productId}")
void deleteProduct(@PathVariable int productId);

删除操作的实现将是幂等;也就是说,如果多次调用,它将返回相同的结果。这是故障场景中的一个有价值的特征。例如,如果客户端在调用删除操作期间遇到网络超时,它可以简单地再次调用删除操作而不必担心响应不同,例如,第一次响应时 OK (200) 和 Not Found (404)响应连续呼叫或任何意外的副作用。这意味着即使实体不再存在于数据库中,该操作也应返回状态代码 OK (200)。

recommendationreview 实体的 API 操作看起来相似的;但是,请注意,当涉及到 recommendationreview 实体,它将删除所有 recommendations对指定的 productId 进行评论

完整源代码见接口声明(ProductService, RecommendationService ,以及中核心微服务的ReviewService) api 项目。

从服务层调用持久层

服务层中用于使用持久层的源代码的结构对于所有核心微服务都是相同的。因此,我们将只浏览 product 微服务的源代码。

首先,我们需要将持久层的存储库类和一个 Java bean 映射器类注入到构造函数中:

private final ServiceUtil serviceUtil;
private final ProductRepository repository;
private final ProductMapper mapper;

@Autowired
public ProductServiceImpl(ProductRepository repository, ProductMapper mapper, ServiceUtil serviceUtil) {
    this.repository = repository;
    this.mapper = mapper;
    this.serviceUtil = serviceUtil;
}

下一节中,我们将看到Java映射器类是如何定义的。

接下来,createProduct方法实现如下:

public Product createProduct(Product body) {
    try {
        ProductEntity entity = mapper.apiToEntity(body);
        ProductEntity newEntity = repository.save(entity);
        return mapper.entityToApi(newEntity);
    } catch (DuplicateKeyException dke) {
        throw new InvalidInputException("Duplicate key, Product Id: " +
        body.getProductId());
    }
}

createProduct 方法使用了存储库中的 save 方法存储一个新实体。应该注意映射器类是如何使用两个映射器方法在 API 模型类和实体类之间转换 Java bean 的,apiToEntity() entityToApi()。我们为 create 方法处理的唯一错误是 DuplicateKeyException 异常,我们将其转换为 InvalidInputException 异常。

getProduct 方法如下所示:

public Product getProduct(int productId) {
    if (productId < 1) throw new InvalidInputException("Invalid
    productId: " + productId);
    ProductEntity entity = repository.findByProductId(productId)
        .orElseThrow(() -> new NotFoundException("No product found for
         productId: " + productId));
    Product response = mapper.entityToApi(entity);
    response.setServiceAddress(serviceUtil.getServiceAddress());
    return response;
}

经过一些基本的输入验证(即确保 productId 不是负数),findByProductId() 方法用于查找产品实体。由于存储库 方法返回一个 可选 产品,我们 可以使用可选的orElseThrow()方法 类,以便在未找到产品实体时方便地抛出 NotFoundException 异常。在返回产品信息之前,使用serviceUtil对象填写微服务当前使用的地址。

最后,我们来看看 deleteProduct 方法:

public void deleteProduct(int productId) {
    repository.findByProductId(productId).ifPresent(e ->
    repository.delete(e));
}

delete 方法也使用 findByProductId() 方法在存储库中并使用 Optional< 中的 ifPresent() 方法/code> 类,以便仅在实体存在时方便地删除实体。请注意,实现是幂等的;如果没有找到实体,它不会报告任何失败。

完整源码见各个核心微服务项目中的服务实现类,例如ProductServiceImpl Code-In-Text--PACKT-">产品-服务 项目。

声明一个 Java bean 映射器

那么,神奇的 Java bean 映射器呢?

如前所述,MapStruct 用于声明我们的映射器类。 MapStruct 的使用在所有三个核心微服务中都是相似的,因此我们将只遍历 product 微服务中的 mapper 对象的源代码。

product 服务的映射器类如下所示:

@Mapper(componentModel = "spring")
public interface ProductMapper {

    @Mappings({
        @Mapping(target = "serviceAddress", ignore = true)
    })
    Product entityToApi(ProductEntity entity);

    @Mappings({
        @Mapping(target = "id", ignore = true),
        @Mapping(target = "version", ignore = true)
    })
    ProductEntity apiToEntity(Product api);
}

从代码中可以看出以下几点:

  • entityToApi() 方法将实体对象映射到 API 模型对象。由于实体类没有serviceAddress的字段,entityToApi() 方法被注释为忽略 API 模型中的 serviceAddress 字段目的。
  • apiToEntity() 方法将 API 模型对象映射到实体对象。同理,apiToEntity()方法注解忽略idversion 字段。

MapStruct 不仅支持按名称映射字段,还可以定向映射不同名称的字段。在 recommendation 服务的映射器类中,rating 实体字段映射到 API 模型字段,rate,使用以下注解:

    @Mapping(target = "rate", source="entity.rating"),
    Recommendation entityToApi(RecommendationEntity entity);

    @Mapping(target = "rating", source="api.rate"),
    RecommendationEntity apiToEntity(Recommendation api);

成功构建 Gradle 后,可以在每个项目的 build/classes 文件夹中找到生成的映射实现。例如 product-serviceProductMapperImpl.java > 项目。

完整源码见各个核心微服务项目中的mapper类,例如ProductMapper -In-Text--PACKT-">产品-服务 项目。

更新服务测试

核心微服务公开的 API 测试自上一章以来已经更新,测试涵盖了创建和删除 API 操作。

添加的测试在所有三个核心微服务中都是相似的,因此我们将只浏览 product 微服务中的服务测试的源代码。

为了确保每个测试的状态已知,设置方法 setupDb() 被声明并使用 @BeforeEach,所以每次测试前都会执行。 setup 方法删除任何以前创建的实体:

@Autowired
private ProductRepository repository;

@BeforeEach
void setupDb() {
   repository.deleteAll();
}

create API 的测试方法验证一个产品实体在创建后是否可以被检索,以及创建另一个具有相同 productId 的产品实体在对 API 请求的响应中导致预期的错误 UNPROCESSABLE_ENTITY

@Test
void duplicateError() {
   int productId = 1;
   postAndVerifyProduct(productId, OK);
   assertTrue(repository.findByProductId(productId).isPresent());

   postAndVerifyProduct(productId, UNPROCESSABLE_ENTITY)
      .jsonPath("$.path").isEqualTo("/product")
      .jsonPath("$.message").isEqualTo("Duplicate key, Product Id: " +
       productId);
}

删除 API 的测试方法验证产品实体可以被删除,并且第二个删除请求是幂等的——它也返回状态码 OK,即使该实体不再存在于数据库中:

@Test
void deleteProduct() {
   int productId = 1;
   postAndVerifyProduct(productId, OK);
   assertTrue(repository.findByProductId(productId).isPresent());

   deleteAndVerifyProduct(productId, OK);
   assertFalse(repository.findByProductId(productId).isPresent());

   deleteAndVerifyProduct(productId, OK);
}

为了简化向 API 发送 创建、读取和删除请求并验证响应状态,我们创建了三个辅助方法:

  • postAndVerifyProduct()
  • getAndVerifyProduct()
  • deleteAndVerifyProduct()

postAndVerifyProduct() 方法如下所示:

private WebTestClient.BodyContentSpec postAndVerifyProduct(int productId, HttpStatus expectedStatus) {
   Product product = new Product(productId, "Name " + productId,
   productId, "SA");
   return client.post()
      .uri("/product")
      .body(just(product), Product.class)
      .accept(APPLICATION_JSON)
      .exchange()
      .expectStatus().isEqualTo(expectedStatus)
      .expectHeader().contentType(APPLICATION_JSON)
      .expectBody();
}

helper 方法执行实际的 HTTP 请求并验证响应体的响应代码和内容类型。除此之外,如果需要,辅助方法还返回响应的主体以供调用者进一步调查。其他两个用于读取和删除请求的辅助方法是类似的。

三个服务测试类的源码可以在每个核心微服务项目中找到,例如product-service 项目中的>ProductServiceApplicationTests。

现在,让我们继续看看我们如何扩展复合服务 API。

扩展复合服务 API

在本节中,我们将看到如何使用创建和删除复合实体的操作来扩展复合 API。我们将通过以下步骤:

  1. 在复合服务 API 中添加新操作
  2. 在集成层中添加方法
  3. 实现新的复合 API 操作
  4. 更新复合服务测试

在复合服务 API 中添加新操作

创建和删除实体和处理聚合实体的复合版本类似于核心服务API中的创建和删除操作。主要区别在于它们为基于 OpenAPI 的文档添加了注释。有关 OpenAPI 注释 @Operation@ 用法的说明ApiResponse,参考Chapter 5Adding an API Description Using OpenAPI,特别是将特定于 API 的文档添加到 ProductCompositeService 接口 部分。

用于创建复合产品实体的 API 操作声明如下:

@Operation(
  summary = "${api.product-composite.create-composite-product.description}",
  description = "${api.product-composite.create-composite-product.notes}")
@ApiResponses(value = {
  @ApiResponse(responseCode = "400", description = "${api.responseCodes.badRequest.description}"),
  @ApiResponse(responseCode = "422", description = "${api.responseCodes.unprocessableEntity.description}")
  })
@PostMapping(
  value    = "/product-composite",
  consumes = "application/json")
void createProduct(@RequestBody ProductAggregate body);

删除复合产品实体的API操作声明如下:

@Operation(
  summary = "${api.product-composite.delete-composite-product.description}",
  description = "${api.product-composite.delete-composite-product.notes}")
@ApiResponses(value = {
  @ApiResponse(responseCode = "400", description = "${api.responseCodes.badRequest.description}"),
  @ApiResponse(responseCode = "422", description = "${api.responseCodes.unprocessableEntity.description}")
})
@DeleteMapping(value = "/product-composite/{productId}")
void deleteProduct(@PathVariable int productId);

完整的源代码见Java接口 api 项目中的 ProductCompositeService

我们还需要像以前一样将 API 文档的描述性文本添加到 application.yml ="Code-In-Text--PACKT-">产品复合 项目:

create-composite-product:
  description: Creates a composite product
  notes: |
    # Normal response
    The composite product information posted to the API will be
    split up and stored as separate product-info, recommendation
    and review entities.

    # Expected error responses
    1. If a product with the same productId as specified in the
    posted information already exists, an **422 - Unprocessable
    Entity** error with a "duplicate key" error message will be
    Returned

delete-composite-product:
  description: Deletes a product composite
  notes: |
    # Normal response
    Entities for product information, recommendations and reviews
    related to the specified productId will be deleted.
    The implementation of the delete method is idempotent, that is,
    it can be called several times with the same response.
    This means that a delete request of a non-existing product will
    return **200 Ok**.

使用 Swagger UI 查看器,更新后的 OpenAPI 文档将如下所示:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.6:更新的 OpenAPI 文档

本章稍后,我们将使用 Swagger UI 查看器来尝试新的复合 API 操作。

在集成层中添加方法

在我们在复合服务中实现新的创建和删除API之前,我们需要扩展集成层,以便它可以调用核心API中的底层创建和删除操作微服务。

三个核心微服务中调用create和delete操作的集成层方法比较简单,相互类似,这里只看一下调用产品 微服务。

createProduct() 方法如下所示:

@Override
public Product createProduct(Product body) {
    try {
        return restTemplate.postForObject(
                   productServiceUrl, body, Product.class);
    } catch (HttpClientErrorException ex) {
        throw handleHttpClientException(ex);
    }
}

它只是将发送 HTTP 请求的责任委托给 RestTemplate 对象,并将错误处理委托给辅助方法 handleHttpClientException

deleteProduct() 方法如下所示:

@Override
public void deleteProduct(int productId) {
    try {
        restTemplate.delete(productServiceUrl + "/" + productId);
    } catch (HttpClientErrorException ex) {
        throw handleHttpClientException(ex);
    }
}

它的实现方式与 create 方法相同,但执行的是 HTTP 删除请求。

集成层的完整源代码可以在 ProductCompositeIntegration 类中找到-">产品复合项目。

实现新的复合 API 操作

现在,我们可以实现复合创建和删除方法!

组合的 create 方法会将聚合产品对象拆分为离散对象,用于 productrecommendation, review 并调用集成层对应的create方法:

@Override
public void createProduct(ProductAggregate body) {
    try {
        Product product = new Product(body.getProductId(),
        body.getName(), body.getWeight(), null);
        integration.createProduct(product);
        if (body.getRecommendations() != null) {
            body.getRecommendations().forEach(r -> {
                Recommendation recommendation = new
                Recommendation(body.getProductId(),
                r.getRecommendationId(), r.getAuthor(), r.getRate(),
                r.getContent(), null);
                integration.createRecommendation(recommendation);
            });
        }

        if (body.getReviews() != null) {
            body.getReviews().forEach(r -> {
                Review review = new Review(body.getProductId(),
                r.getReviewId(), r.getAuthor(), r.getSubject(),
                r.getContent(), null);
                integration.createReview(review);
            });
        }
    } catch (RuntimeException re) {
        LOG.warn("createCompositeProduct failed", re);
        throw re;
    }
}

组合的delete方法只是调用集成层中的三个delete方法来删除底层数据库中对应的实体:

@Override
public void deleteProduct(int productId) {
    integration.deleteProduct(productId);
    integration.deleteRecommendations(productId);
    integration.deleteReviews(productId);
}

服务实现的完整源代码可以在 ProductCompositeServiceImpl 类中找到-">产品复合项目。

对于快乐的一天场景,这个实现可以正常工作,但是如果我们考虑各种错误场景,我们会发现这个实现会带来麻烦!

例如,如果某个底层核心微服务因内部、网络或数据库问题而暂时不可用怎么办?

这可能会导致部分创建或删除复合产品。对于删除操作,如果请求者简单地调用组合的删除方法直到它成功,则可以修复此问题。但是,如果潜在问题仍然存在一段时间,请求者可能会放弃,从而导致复合产品的状态不一致——在大多数情况下是不可接受的!

在下一章,第 7 章开发响应式微服务,我们将看到如何使用同步 API 解决这些类型的缺点作为 RESTful API。

现在,让我们继续考虑这个脆弱的设计。

更新复合服务测试

测试复合服务,如第3章中所述,创建一组协作微服务< /em>(请参阅单独添加自动化微服务测试部分),仅限于使用简单的模拟组件而不是实际的核心服务。这限制了我们测试更复杂的场景,例如,尝试在底层数据库中创建重复项时的错误处理。因此,复合创建和删除 API 操作的测试相对简单:

@Test
void createCompositeProduct1() {
   ProductAggregate compositeProduct = new ProductAggregate(1, "name",
   1, null, null, null);
   postAndVerifyProduct(compositeProduct, OK);
}

@Test
void createCompositeProduct2() {
    ProductAggregate compositeProduct = new ProductAggregate(1, "name",
        1, singletonList(new RecommendationSummary(1, "a", 1, "c")),
        singletonList(new ReviewSummary(1, "a", "s", "c")), null);
    postAndVerifyProduct(compositeProduct, OK);
}

@Test
void deleteCompositeProduct() {
    ProductAggregate compositeProduct = new ProductAggregate(1, "name",
        1,singletonList(new RecommendationSummary(1, "a", 1, "c")),
        singletonList(new ReviewSummary(1, "a", "s", "c")), null);
    postAndVerifyProduct(compositeProduct, OK);
    deleteAndVerifyProduct(compositeProduct.getProductId(), OK);
    deleteAndVerifyProduct(compositeProduct.getProductId(), OK);
}

服务测试的完整源代码可以在ProductCompositeServiceApplicationTests类中找到< code class="Code-In-Text--PACKT-">product-composite 项目。

这些是源代码中所需的所有更改。在我们可以一起测试微服务之前,我们必须学习如何将数据库添加到由 Docker Compose 管理的系统环境中。

将数据库添加到 Docker Compose 环境中

现在,我们拥有所有源代码。在我们可以启动微服务环境并尝试新的API和新的持久层之前,我们必须启动一些数据库。

我们将把 MongoDB 和 MySQL 带入由 Docker Compose 控制的系统环境中,并为我们的微服务添加配置,以便它们在运行时可以找到它们的数据库。

Docker Compose 配置

MongoDB和MySQL在Docker Compose配置文件中声明如下,docker-compose.yml:

  mongodb:
    image: mongo:4.4.2
    mem_limit: 512m
    ports:
      - "27017:27017"
    command: mongod
    healthcheck:
      test: "mongo --eval 'db.stats().ok'"
      interval: 5s
      timeout: 2s
      retries: 60

  mysql:
    image: mysql:5.7.32
    mem_limit: 512m
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=rootpwd
      - MYSQL_DATABASE=review-db
      - MYSQL_USER=user
      - MYSQL_PASSWORD=pwd
    healthcheck:
      test: "/usr/bin/mysql --user=user --password=pwd --execute \"SHOW DATABASES;\""
      interval: 5s
      timeout: 2s
      retries: 60

上述代码的注释:

  1. 我们将使用 MongoDB v4.4.2 和 MySQL 5.7.32 的官方 Docker 镜像,并转发它们的默认端口 270173306 到 Docker 主机,在使用 Docker Desktop 时也可以在 localhost 上使用苹果电脑。
  2. 对于 MySQL,我们还声明了一些环境变量,定义如下:
    • root密码
    • 容器启动时创建的数据库名称
    • 容器启动时为数据库设置的用户的用户名和密码
  3. 我们还声明了 Docker 将运行的健康检查以确定 MongoDB 和 MySQL 数据库的状态。

为了避免在数据库启动和运行之前尝试连接到其数据库的微服务出现问题,productrecommendation服务声明依赖于MongoDB数据库,如下:

depends_on:
  mongodb:
    condition: service_healthy

出于同样的原因,review服务被声明为依赖于mysql 数据库:

depends_on:
  mysql:
    condition: service_healthy

这意味着 Docker Compose 不会启动微服务容器,直到数据库容器启动并通过其健康检查报告健康。

数据库连接配置

有了 数据库,我们现在需要为核心微服务设置配置,以便它们知道如何连接到他们的数据库。这是在每个核心微服务的配置文件 application.yml 中设置的,在 产品服务推荐服务review-service 项目。

productrecommendation 服务的配置类似,所以我们只研究 product 服务的配置。配置的以下部分很有趣:

spring.data.mongodb:
  host: localhost
  port: 27017
  database: product-db

logging:
 level:
 org.springframework.data.mongodb.core.MongoTemplate: DEBUG

---

spring.config.activate.on-profile: docker

spring.data.mongodb.host: mongodb

上述代码的重要部分:

  1. 使用默认 Spring 配置文件在没有 Docker 的情况下运行时,预计可以在 localhost:27017 上访问数据库。
  2. MongoTemplate 的日志级别设置为 DEBUG 将允许我们来查看日志中执行了哪些 MongoDB 语句。
  3. 当使用 Spring 配置文件在 Docker 中运行时,docker,预计可以在 mongodb:27017

review 服务的 配置会影响它连接到其 SQL 数据库的方式,看起来如下所示:

spring.jpa.hibernate.ddl-auto: update

spring.datasource:
  url: jdbc:mysql://localhost/review-db
  username: user
  password: pwd

spring.datasource.hikari.initializationFailTimeout: 60000

logging:
 level:
 org.hibernate.SQL: DEBUG
 org.hibernate.type.descriptor.sql.BasicBinder: TRACE

---
spring.config.activate.on-profile: docker

spring.datasource:
 url: jdbc:mysql://mysql/review-db

对前面代码的解释:

  1. 默认情况下,Spring Data JPA 将使用 Hibernate 作为 JPA 实体管理器。
  2. The spring.jpa.hibernate.ddl-auto property is used to tell Spring Data JPA to create new or update existing SQL tables during startup.

    注意:强烈建议设置spring.jpa.hibernate.ddl-auto在生产环境中将属性设置为 nonevalidate —这可以防止 Spring Data JPA 操纵 SQL 表的结构。有关详细信息,请参阅 https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-database-initialization

  3. 在没有 Docker 的情况下运行时,使用默认的 Spring 配置文件,预计可以使用默认端口在 localhost 上访问数据库 3306
  4. 默认情况下,HikariCP 被 Spring Data JPA 使用 作为 JDBC 连接池。为了尽量减少硬件资源有限的计算机上的启动问题,initializationFailTimeout 参数设置为 60 秒。这意味着 Spring Boot 应用程序将在启动过程中等待长达 60 秒以建立数据库连接。
  5. Hibernate 的日志级别设置将导致 Hibernate 打印使用的 SQL 语句和使用的实际值。请注意,在生产环境中使用时,出于隐私原因,应避免将实际值写入日志。
  6. 当使用 Spring 配置文件在 Docker 中运行时,docker,预计数据库可以在 mysql 主机名使用默认端口 3306

有了这个配置,我们就可以启动系统环境了。但在我们这样做之前,让我们学习如何运行数据库 CLI 工具。

MongoDB 和 MySQL CLI 工具

一旦我们 开始使用微服务运行一些测试,看看 数据实际上存储在微服务中将会很有趣' 数据库。每个数据库 Docker 容器都带有基于 CLI 的工具,可用于查询数据库表和集合。为了能够运行数据库 CLI 工具,可以使用 Docker Compose exec 命令。

当我们在下一节中进行手动测试时,将使用本节中描述的命令。现在不要尝试运行它们;它们将失败,因为我们还没有启动和运行数据库!

要启动 MongoDB CLI 工具,mongo,在 mongodb 容器,运行以下命令:

docker-compose exec mongodb mongo ––quiet
>

输入 exit 离开 mongo CLI。

要启动 MySQL CLI 工具,mysql,在 mysql 容器并使用 review-db "> 启动时创建的用户,运行以下命令:

docker-compose exec mysql mysql -uuser -p review-db
mysql>

mysql CLI 工具会提示您输入密码;您可以在 docker-compose.yml 文件中找到它。查找环境变量 MYSQL_PASSWORD 的值。

输入 exit 离开 mysql CLI。

我们将在下一节中看到这些工具的用法。

如果您更喜欢图形数据库工具,您也可以在本地运行它们,因为 MongoDB 和 MySQL 容器都在 localhost 上公开了它们的标准端口。

新 API 和持久层的手动测试

现在,我们已准备好一起测试微服务。我们将构建新的 Docker 镜像,并使用基于 Docker 镜像的 Docker Compose 启动系统环境。接下来,我们将使用 Swagger UI 查看器来运行一些手动测试。最后,我们将使用数据库 CLI 工具来查看将哪些数据插入到数据库中。

使用以下命令构建并启动系统环境:

cd $BOOK_HOME/Chapter06
./gradlew build && docker-compose build && docker-compose up

在 Web 浏览器中打开 Swagger UI,http://localhost:8080/openapi/swagger-ui.html,然后执行以下步骤网页:

  1. 点击 ProductComposite 服务和 POST 方法来展开它们
  2. 单击 试用 按钮并进入正文字段
  3. 替换 productId 的默认值 0 123456 的字段
  4. 向下滚动到 Execute 按钮并点击它
  5. 验证返回的响应码是200

下面是示例截图点击执行按钮:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.7:测试服务器响应

docker-compose up 命令的日志输出中,我们应该能够看到如下输出(为了提高可读性而缩写):

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.8:来自 docker-compose up 的日志输出

我们还可以使用数据库 CLI 工具查看不同数据库中的实际内容。

product服务中查找内容,即products 集合 在 MongoDB 中,使用以下 命令:

docker-compose exec mongodb mongo product-db --quiet --eval "db.products.find()"

期待这样的回应:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.9:查找产品

recommendation服务中查找内容,即recommendations< MongoDB 中的 /code> 集合,使用以下命令:

docker-compose exec mongodb mongo recommendation-db --quiet --eval "db.recommendations.find()"

期待这样的回应:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.10:查找推荐

review服务中查找内容,即reviews< MySQL 中的 /code> 表,使用以下命令:

docker-compose exec mysql mysql -uuser -p review-db -e "select * from reviews"

mysql CLI 工具会提示您输入密码;您可以在 docker-compose.yml 文件中找到它。查找环境变量 MYSQL_PASSWORD 的值。期待如下响应:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.11:查找评论

通过中断 docker-compose up 命令 系统环境_idIndexMarker391">与 Ctrl + C,接着是 命令 docker-compose down。在此之后,让我们看看如何在微服务环境中更新自动化测试。

更新微服务环境的自动化测试

微服务环境的自动化测试,test-em-all.bash,需要进行更新,以确保每个微服务的数据库在运行测试之前具有已知状态。

该脚本扩展了一个设置函数 setupTestdata(),它使用组合的创建和删除 API 来设置测试使用的测试数据.

setupTestdata 函数如下所示:

function setupTestdata() {

    body=\
    '{"productId":1,"name":"product 1","weight":1, "recommendations":[
        {"recommendationId":1,"author":"author
         1","rate":1,"content":"content 1"},
        {"recommendationId":2,"author":"author
         2","rate":2,"content":"content 2"},
        {"recommendationId":3,"author":"author
         3","rate":3,"content":"content 3"}
    ], "reviews":[
        {"reviewId":1,"author":"author 1","subject":"subject
         1","content":"content 1"},
        {"reviewId":2,"author":"author 2","subject":"subject
         2","content":"content 2"},
        {"reviewId":3,"author":"author 3","subject":"subject
         3","content":"content 3"}
    ]}'
    recreateComposite 1 "$body"

    body=\
    '{"productId":113,"name":"product 113","weight":113, "reviews":[
    {"reviewId":1,"author":"author 1","subject":"subject
     1","content":"content 1"},
    {"reviewId":2,"author":"author 2","subject":"subject
     2","content":"content 2"},
    {"reviewId":3,"author":"author 3","subject":"subject
     3","content":"content 3"}
]}'
    recreateComposite 113 "$body"

    body=\
    '{"productId":213,"name":"product 213","weight":213,
    "recommendations":[
       {"recommendationId":1,"author":"author
         1","rate":1,"content":"content 1"},
       {"recommendationId":2,"author":"author
        2","rate":2,"content":"content 2"},
       {"recommendationId":3,"author":"author
        3","rate":3,"content":"content 3"}
]}'
    recreateComposite 213 "$body"
}

它使用辅助函数 recreateComposite() 来执行删除和创建 API 的实际请求:

function recreateComposite() {
    local productId=$1
    local composite=$2

    assertCurl 200 "curl -X DELETE http://$HOST:$PORT/product-
    composite/${productId} -s"
    curl -X POST http://$HOST:$PORT/product-composite -H "Content-Type:
    application/json" --data "$composite"
}

setupTestdata 函数是在 --PACKT-">waitForService 函数:

waitForService curl -X DELETE http://$HOST:$PORT/product-composite/13
setupTestdata

waitForService 函数的主要目的是验证所有微服务是否已启动并运行。上一章使用了组合产品服务的get API。在本章中,将使用删除 API。使用get API时,如果没有找到实体,只调用product核心微服务; recommendationreview 服务不会被调用验证它们是否已启动并运行。对删除 API 的调用还将确保 productId 13 上的 Not Found 测试将成功。在下一章中,我们将看到如何定义特定的 API 来检查微服务环境的健康状态。

使用以下命令执行更新的测试脚本:

cd $BOOK_HOME/Chapter06
./test-em-all.bash start stop

执行应通过编写如下日志消息结束:

读书笔记《第一部分 使用 Spring Boot 开始微服务开发》第6章添加数据持久层

图 6.12:测试执行结束时的日志消息

微服务领域的自动化测试更新到此结束。

概括

在本章中,我们看到了如何使用 Spring Data 为核心微服务添加持久层。我们使用 Spring Data、存储库和实体的核心概念在 MongoDB 和 MySQL 中存储数据。 MongoDB 等 NoSQL 数据库和 MySQL 等 SQL 数据库的编程模型相似,尽管它不是完全可移植的。我们还看到了 Spring Boot 的注解,@DataMongoTest@DataJpaTest ,可以方便的设置针对持久化的测试;这是在测试运行之前自动启动数据库的地方,但没有启动微服务在运行时需要的其他基础设施,例如,Netty 等 Web 服务器。为了处理数据库的启动和拆卸,我们使用了 Testcontainers,它在 Docker 容器中运行数据库。这导致持久性测试易于设置并且以最小的开销开始。

我们还看到了服务层如何使用持久层,以及我们如何添加 API 来创建和删除实体,包括核心实体和复合实体。

最后,我们了解了使用 Docker Compose 在运行时启动 MongoDB 和 MySQL 等数据库是多么方便,以及如何在运行基于微服务的系统环境的自动化测试之前使用新的创建和删除 API 设置测试数据。

但是,本章确定了一个主要问题。使用同步 API 更新(创建或删除)复合实体(其部分存储在多个微服务中的实体)可能会导致不一致,如果不是所有涉及的微服务都已成功更新。一般来说,这是不可接受的。这将我们带入下一章,我们将研究为什么以及如何构建反应式微服务,即可扩展且健壮的微服务。

问题

  1. Spring Data 是一种基于实体和存储库的通用编程模型,可用于不同类型的数据库引擎。从本章的源代码示例来看,MySQL 和 MongoDB 的持久化代码最重要的区别是什么?
  2. 使用 Spring Data 实现乐观锁定需要什么?
  3. MapStruct 有什么用途?
  4. 如果一个操作是幂等的,这意味着什么?为什么它有用?
  5. 我们如何在不使用 API 的情况下访问存储在 MySQL 和 MongoDB 数据库中的数据?