SpringBoot3 和 SpringCLoud 微服务(二)

《微服务大揭秘:SpringBoot与SpringCloud的魔法组合》 探索微服务架构的神秘面纱,揭秘如何利用SpringBoot和SpringCloud的魔法组合,将传统应用拆分成灵活、高效的微服务。本文将带你走进服务发现的奇妙世界,一探究竟Eureka如何成为服务间的智能导航系统,以及Zuul如何作为守护神,守卫着微服务的大门。不仅如此,我们还将一窥Spring Cloud生态如何助力微服务的蓬勃发展,以及未来微服务架构的发展趋势,让你在技术潮流中抢占先机! 阅读详情

原文:zh.annas-archive.org/md5/1722062224f06acf668c638d8a7630bb

译者:飞龙

协议:CC BY-NC-SA 4.0

第三章:创建一组协作微服务

在本章中,我们将构建我们的第一个几个微服务。我们将学习如何创建具有最小功能的协作微服务。在未来的章节中,我们将向这些微服务添加更多和更多的功能。到本章结束时,我们将通过组合微服务公开一个 RESTful API。组合微服务将调用其他三个微服务,使用它们的 RESTful API 来创建聚合响应。

本章将涵盖以下主题:

  • 介绍微服务景观

  • 生成骨架微服务

  • 添加 RESTful API

  • 添加组合微服务

  • 添加错误处理

  • 手动测试 API

  • 为微服务添加隔离的自动化测试

  • 向微服务景观添加半自动化测试

技术要求

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

  • 第二十一章,macOS 的安装说明

  • 第二十二章,使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的所有代码示例都来自$BOOK_HOME/Chapter03中的源代码。有了工具和源代码,我们可以开始学习我们将在本章中创建的微服务系统景观。

介绍微服务景观

在第一章,微服务简介中,我们简要介绍了本书将使用的基于微服务的系统景观:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_01.png

图 3.1:微服务景观

它由三个核心微服务组成,即产品、评论和推荐服务,它们都处理一种类型的资源,以及一个名为产品组合的组合微服务,该服务从三个核心服务中聚合信息。

微服务处理的信息

为了使本书中的源代码示例易于理解,它们包含的业务逻辑量最小。出于同样的原因,它们处理的企业对象的信息模型也保持最小。在本节中,我们将了解每个微服务处理的信息,包括与基础设施相关的信息。

产品服务

产品服务管理产品信息,并使用以下属性描述每个产品:

  • 产品 ID

  • 名称

  • 重量

评论服务

评论服务管理产品评论,并存储以下关于每个评论的以下信息:

  • 产品 ID

  • 评论 ID

  • 作者

  • 主题

  • 内容

推荐服务

推荐服务管理产品推荐,并存储以下关于每个推荐的以下信息:

  • 产品 ID

  • 推荐 ID

  • 作者

  • 评分

  • 内容

产品组合服务

产品组合服务从三个核心服务中聚合信息,并按以下方式展示产品信息:

  • 如产品服务所述的产品信息

  • 如审查服务所述的指定产品的产品评论列表

  • 如推荐服务所述的指定产品的产品推荐列表

基础设施相关信息

一旦我们开始以由基础设施管理的容器形式运行我们的微服务(首先是 Docker,然后是 Kubernetes),跟踪哪些容器实际上响应了我们的请求将变得很有兴趣。作为一个简单的解决方案,已向所有响应添加了一个serviceAddress属性,格式为hostname/ip-address:port。

在第十八章“使用服务网格提高可观察性和管理”和第十九章“使用 EFK 堆栈进行集中式日志记录”中,我们将了解跟踪微服务处理请求的更强大解决方案。

临时替换服务发现

由于在此阶段我们没有设置任何服务发现机制,因此我们将所有微服务运行在localhost上,并为每个微服务使用硬编码的端口号。我们将使用以下端口号:

  • 产品组合服务:7000

  • 产品服务:7001

  • 审查服务:7002

  • 推荐服务:7003

我们将在开始使用 Docker 和 Kubernetes 时删除硬编码的端口号!

在本节中,我们介绍了将要创建的微服务和它们将处理的信息。在下节中,我们将使用 Spring Initializr 为微服务创建骨架代码。

生成骨架微服务

现在是时候看看我们如何为我们的微服务创建项目了。本主题的最终结果可以在$BOOK_HOME/Chapter03/1-spring-init文件夹中找到。为了简化项目设置,我们将使用Spring Initializr为每个微服务生成一个骨架项目。骨架项目包含构建项目所需的所有文件,以及微服务的空main类和测试类。之后,我们将了解如何使用我们将使用的构建工具 Gradle 中的多项目构建命令来构建所有微服务。

使用 Spring Initializr 生成骨架代码

要开始开发我们的微服务,我们将使用一个名为 Spring Initializr 的工具为我们生成骨架代码。Spring Initializr 由 Spring 团队提供,可用于配置和生成新的 Spring Boot 应用程序。该工具帮助开发者选择应用程序将使用的附加 Spring 模块,并确保依赖项配置为使用所选模块的兼容版本。该工具支持使用 Maven 或 Gradle 作为构建系统,并可以为 Java、Kotlin 或 Groovy 生成源代码。

它可以通过 URL start.spring.io/ 或使用命令行工具 spring init 从网络浏览器中调用。为了更容易地重现微服务的创建,我们将使用命令行工具。

对于每个微服务,我们将创建一个执行以下操作的 Spring Boot 项目:

  • 使用 Gradle 作为构建工具

  • 为 Java 8 生成代码

  • 将项目打包成一个胖 JAR 文件

  • 引入 Actuator 和 WebFlux Spring 模块的依赖

  • 基于 Spring Boot v3.0.4(它依赖于 Spring Framework v6.0.6)

Spring Boot Actuator 为管理和监控提供了一些有价值的端点。我们将在稍后看到它们的作用。Spring WebFlux 将用于创建我们的 RESTful API。

为了为我们的微服务创建骨架代码,我们需要为 product-service 运行以下命令:

spring init \
--boot-version=3.0.4 \
--type=gradle-project \
--java-version=17 \
--packaging=jar \
--name=product-service \
--package-name=se.magnus.microservices.core.product \
--groupId=se.magnus.microservices.core.product \
--dependencies=actuator,webflux \
--version=1.0.0-SNAPSHOT \
product-service 

如果你想了解更多关于 spring init CLI 的信息,可以运行 spring help init 命令。要查看可以添加的依赖项,请运行 spring init --list 命令。

如果你想要自己创建四个项目而不是使用本书 GitHub 仓库中的源代码,请尝试 $BOOK_HOME/Chapter03/1-spring-init/create-projects.bash,如下所示:

mkdir some-temp-folder
cd some-temp-folder
$BOOK_HOME/Chapter03/1-spring-init/create-projects.bash 

使用 create-projects.bash 创建我们的四个项目后,我们将有以下文件结构:

microservices/
├── product-composite-service 
├── product-service
├── recommendation-service
└── review-service 

对于每个项目,我们可以列出创建的文件。让我们为 product-service 项目做这件事:

find microservices/product-service -type f 

我们将收到以下输出:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_02.png

图 3.2:列出我们为 product-service 创建的文件

Spring Initializr 为 Gradle 创建了多个文件,一个 .gitignore 文件和三个 Spring Boot 文件:

  • ProductServiceApplication.java,我们的主应用程序类

  • application.properties,一个空属性文件

  • ProductServiceApplicationTests.java,一个配置为使用 JUnit 在我们的 Spring Boot 应用程序上运行测试的测试类

根据前一章中 The magic @SpringBootApplication annotation 部分的描述,main 应用程序类 ProductServiceApplication.java 看起来应该是这样的:

package se.magnus.microservices.core.product;
@SpringBootApplication
public class ProductServiceApplication {
   public static void main(String[] args) {
      SpringApplication.run(ProductServiceApplication.class, args);
   }
} 

测试类看起来如下:

package se.magnus.microservices.core.product;
**@SpringBootTest**
class ProductServiceApplicationTests {
   @Test
   void contextLoads() {
   }
} 

@SpringBootTest 注解将以与 @SpringBootApplication 相同的方式初始化我们的应用程序;也就是说,在执行测试之前,将使用组件扫描和自动配置设置 Spring 应用程序上下文,正如前一章所述。

让我们再看看最重要的 Gradle 文件,build.gradle。此文件的内容描述了如何构建项目——例如,如何解决依赖关系和编译、测试和打包源代码。Gradle 文件首先列出要应用的插件:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.4'
    id 'io.spring.dependency-management' version '1.1.0'
} 

声明的插件如下使用:

  • java 插件将 Java 编译器添加到项目中。

  • 声明了org.springframework.boot和io.spring.dependency-management插件,这两个插件一起确保 Gradle 将构建一个胖 JAR 文件,并且我们不需要在 Spring Boot 启动器依赖项上指定任何显式的版本号。相反,它们由org.springframework.boot插件的版本隐含,即 3.0.4。

在构建文件的其余部分,我们基本上声明了项目的组名和版本、Java 版本及其依赖项:

**group** = 'se.magnus.microservices.composite.product'
**version** = '1.0.0-SNAPSHOT'
**sourceCompatibility** = '17'
repositories {
    mavenCentral()
}
**dependencies** {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
}
**tasks.named('test') {**
 **useJUnitPlatform()**
**}** 

关于使用的依赖项和最终测试声明的几点说明:

  • 依赖项,就像前面的插件一样,从中央 Maven 仓库获取。

  • 依赖项的设置与 Actuator 和 WebFlux 模块中指定的设置相同,以及一些有用的测试依赖项。

  • 最后,JUnit 被配置为在 Gradle 构建中运行我们的测试。

我们可以使用以下命令分别构建每个微服务:

cd microservices/product-composite-service; ./gradlew build; cd -; \
cd microservices/product-service;           ./gradlew build; cd -; \
cd microservices/recommendation-service;    ./gradlew build; cd -; \
cd microservices/review-service;            ./gradlew build; cd -; 

注意我们如何使用由 Spring Initializr 创建的gradlew可执行文件;也就是说,我们不需要安装 Gradle!

第一次运行带有gradlew的命令时,它会自动下载 Gradle。使用的 Gradle 版本由gradle/wrapper/gradle-wrapper.properties文件中的distributionUrl属性确定。

在 Gradle 中设置多项目构建

为了使使用一个命令构建所有微服务变得简单一些,我们可以在 Gradle 中设置一个多项目构建。步骤如下:

  1. 首先,我们创建settings.gradle文件,该文件描述了 Gradle 应该构建哪些项目:

    cat <<EOF > settings.gradle
    include ':microservices:product-service'
    include ':microservices:review-service'
    include ':microservices:recommendation-service'
    include ':microservices:product-composite-service'
    EOF 
    
  2. 接下来,我们复制从其中一个项目中生成的 Gradle 可执行文件,以便我们可以为多项目构建重用它们:

    cp -r microservices/product-service/gradle .
    cp microservices/product-service/gradlew .
    cp microservices/product-service/gradlew.bat .
    cp microservices/product-service/.gitignore . 
    
  3. 我们不再需要每个项目中生成的 Gradle 可执行文件,因此我们可以使用以下命令删除它们:

    find microservices -depth -name "gradle" -exec rm -rfv "{}" \; 
    find microservices -depth -name "gradlew*" -exec rm -fv "{}" \; 
    

    结果应该与你在文件夹$BOOK_HOME/Chapter03/1-spring-init中找到的代码类似。

  4. 现在,我们可以使用一个命令构建所有微服务:

    ./gradlew build 
    

如果你还没有运行前面的命令,你可以直接进入书籍的源代码,并从那里构建微服务:

cd $BOOK_HOME/Chapter03/1-spring-init
./gradlew build 

这应该会产生以下输出:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_03.png

图 3.3:成功构建后的输出

使用 Spring Initializr 创建的微服务骨架项目和成功使用 Gradle 构建后,我们就可以在下一节中为微服务添加一些代码了。

从 DevOps 的角度来看,多项目设置可能不是首选。相反,为了使每个微服务都有其自己的构建和发布周期,为每个微服务项目设置单独的构建管道可能更受欢迎。然而,为了本书的目的,我们将使用多项目设置,以便可以使用单个命令轻松构建和部署整个系统景观。

添加 RESTful API

现在我们已经为我们的微服务设置了项目,让我们为我们的三个核心微服务添加一些 RESTful API!

本章的最终结果以及剩余主题可以在$BOOK_HOME/Chapter03/2-basic-rest-services文件夹中找到。

首先,我们将添加两个项目(api和util),这两个项目将包含由微服务项目共享的代码,然后我们将实现 RESTful API。

添加 API 和 util 项目

要添加一个api项目,我们需要做以下几步:

  1. 首先,我们将设置一个单独的 Gradle 项目,我们可以在这个项目中放置我们的 API 定义。我们将使用 Java 接口来描述我们的 RESTful API 和模型类来描述 API 在其请求和响应中使用的数据。为了描述 API 可以返回的错误类型,还定义了一系列异常类。在 Java 接口中而不是直接在 Java 类中描述 RESTful API,对我来说,是一种将 API 定义与其实现分离的好方法。我们将在本书的后面进一步扩展这种模式,当我们向 Java 接口添加更多 API 信息以在 OpenAPI 规范中公开时。有关更多信息,请参阅第五章,使用 OpenAPI 添加 API 描述。

是否将一组微服务的 API 定义存储在公共 API 模块中,这是一个有争议的问题。这可能会在微服务之间产生不希望的依赖关系,从而导致单体特性,例如,导致更复杂和缓慢的开发过程。对我来说,对于属于同一交付组织的微服务来说,这是一个好的选择,也就是说,它们的发布由同一组织管理(将此与领域驱动设计中的边界上下文进行比较,我们的微服务被放置在单个边界上下文中)。如第一章,微服务简介中已讨论的,同一边界上下文内的微服务需要基于公共信息模型的 API 定义,因此将这些 API 定义存储在同一个 API 模块中不会添加任何不希望的依赖项。

  1. 接下来,我们将创建一个util项目,该项目可以存放一些由我们的微服务共享的辅助类,例如,用于以统一方式处理错误。

再次从 DevOps 的角度来看,最好是为每个项目构建自己的构建管道,并在微服务项目中为api和util项目设置版本控制的依赖项,也就是说,这样每个微服务都可以选择使用api和util项目的哪个版本。但为了保持本书中构建和部署步骤的简单性,我们将api和util项目作为多项目构建的一部分。

api 项目

api项目将被打包成一个库;也就是说,它不会有自己的main应用程序类。不幸的是,Spring Initializr 不支持创建库项目。相反,必须从头手动创建库项目。API 项目的源代码位于$BOOK_HOME/Chapter03/2-basic-rest-services/api。

库项目的结构与应用程序项目相同,只是我们没有main应用程序类,以及build.gradle文件中的一些细微差异。org.springframework.boot Gradle 插件被替换为implementation platform部分:

ext {
    springBootVersion = '3.0.4'
}
dependencies {
    implementation platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}") 

这允许我们在用创建一个只包含项目自身类和属性文件的正常 JAR 文件替换构建步骤中构建胖 JAR 文件的同时,保留 Spring Boot 依赖管理。

我们三个核心微服务的api项目中的 Java 文件如下:

$BOOK_HOME/Chapter03/2-basic-rest-services/api/src/main/java/se/magnus/api/core
├── product
│   ├── Product.java
│   └── ProductService.java
├── recommendation
│   ├── Recommendation.java
│   └── RecommendationService.java
└── review
    ├── Review.java
    └── ReviewService.java 

这三个核心微服务的 Java 类结构看起来非常相似,所以我们只将通过product服务的源代码进行说明。

首先,我们将查看ProductService.java Java 接口,如下所示:

package se.magnus.api.core.product;
public interface ProductService {
    **@GetMapping**(
        value    = "/product/{productId}",
        produces = "application/json")
     Product **getProduct****(****@PathVariable** int productId);
} 

Java 接口声明如下:

  • product服务仅公开一个 API 方法,getProduct()(我们将在本书的第六章,添加持久性中扩展 API)。

  • 要将方法映射到 HTTP GET请求,我们使用@GetMapping Spring 注解,其中我们指定方法将被映射到的 URL 路径(/product/{productId})以及响应的格式——在这种情况下,是 JSON。

  • 路径中的{productId}部分映射到一个名为productId的路径变量。

  • productId方法参数被@PathVariable注解,它将传入 HTTP 请求中的值映射到参数。例如,对/product/123的 HTTP GET请求将导致调用getProduct()方法,并将productId参数设置为123。

该方法返回一个Product对象,一个基于 POJO 的模型类,其成员变量对应于Product的属性,如本章开头所述。Product.java看起来如下(省略了构造函数和 getter 方法):

public class **Product** {
  private final int productId;
  private final String name;
  private final int weight;
  private final String serviceAddress;
} 

这种类型的 POJO 类也被称为数据传输对象(DTO),因为它用于在 API 实现和 API 调用者之间传输数据。当我们到达第六章,添加持久性时,我们将查看另一种类型的 POJO,它可以用来描述数据在数据库中的存储方式,也称为实体对象。

API 项目还包含异常类InvalidInputException和NotFoundException。

util 项目

util项目将以与api项目相同的方式打包为库。util项目的源代码位于$BOOK_HOME/Chapter03/2-basic-rest-services/util。该项目包含以下工具类:GlobalControllerExceptionHandler、HttpErrorInfo和ServiceUtil。

除了ServiceUtil.java中的代码外,这些类是可重用的工具类,我们可以使用它们将 Java 异常映射到适当的 HTTP 状态码,如后续章节添加错误处理中所述。ServiceUtil.java的主要目的是找出微服务使用的 hostname、IP 地址和端口号。该类公开了一个getServiceAddress()方法,微服务可以使用它来查找它们的 hostname、IP 地址和端口号,如前述章节基础设施相关信息中所述。

实现我们的 API

现在,我们可以在核心微服务中开始实现我们的 API 了!

对于三个核心微服务的实现看起来非常相似,所以我们只通过product服务的源代码来演示。其他文件可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices中找到。让我们看看我们是如何进行这一过程的:

  1. 我们需要将api和util项目作为依赖项添加到我们的build.gradle文件中,在product-service项目中:

    dependencies {
       implementation project(':api')
       implementation project(':util') 
    
  2. 为了启用 Spring Boot 的自动配置功能以检测api和util项目中的 Spring Beans,我们还需要在主应用程序类中添加一个@ComponentScan注解,该注解包括api和util项目的包:

    @SpringBootApplication
    @ComponentScan("se.magnus")
    public class ProductServiceApplication { 
    
  3. 接下来,我们创建服务实现文件,ProductServiceImpl.java,以便实现来自api项目的 Java 接口ProductService,并使用@RestController注解该类,这样 Spring 就会根据Interface类中指定的映射调用该类中的方法:

    package se.magnus.microservices.core.product.services;
    **@RestController**
    public class ProductServiceImpl implements ProductService {
    } 
    
  4. 为了能够使用来自util项目的ServiceUtil类,我们将将其注入到构造函数中,如下所示:

    private final ServiceUtil serviceUtil;
    @Autowired
    public ProductServiceImpl(ServiceUtil serviceUtil) {
        this.serviceUtil = serviceUtil;
    } 
    
  5. 现在,我们可以通过覆盖api项目中的接口的getProduct()方法来实现 API:

    @Override
    public Product getProduct(int productId) {
     return new Product(productId, "name-" + productId, 123, 
     serviceUtil.getServiceAddress());
    } 
    

    由于我们目前没有使用数据库,我们只是根据productId的输入返回一个硬编码的响应,以及由ServiceUtil类提供的服务地址。

    对于最终结果,包括日志记录和错误处理,请参阅ProductServiceImpl.java。

  6. 最后,我们还需要设置一些运行时属性——使用哪个端口以及所需的日志级别。这被添加到属性文件application.yml中:

    server.port: 7001
    logging:
      level:
        root: INFO
        se.magnus.microservices: DEBUG 
    

注意,由 Spring Initializr 生成的空application.properties文件已被 YAML 文件application.yml替换。与.properties文件相比,YAML 文件提供了更好的相关属性分组支持。如上例中的日志级别设置所示。

  1. 我们可以单独尝试product服务。使用以下命令构建并启动微服务:

    cd $BOOK_HOME/Chapter03/2-basic-rest-services
    ./gradlew build
    java -jar microservices/product-service/build/libs/*.jar & 
    

    等待以下内容在终端中打印出来:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_04.png图 3.4:启动 ProductServiceApplication

  1. 对 product 服务进行测试调用:

    curl http://localhost:7001/product/123 
    

    它应该响应如下所示的内容:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_05.png

  1. 最后,停止 product 服务:

    kill $(jobs -p) 
    

我们现在已经构建、运行并测试了我们的第一个单一微服务。在下一节中,我们将实现将使用我们迄今为止创建的三个核心微服务的复合微服务。

从 Spring Boot v2.5.0 开始,在运行 ./gradlew build 命令时将创建两个 JAR 文件:普通 JAR 文件,以及仅包含编译 Spring Boot 应用程序中的 Java 文件生成的类文件的普通 JAR 文件。由于我们不需要新的普通 JAR 文件,因此已禁用其创建,以便在运行 Spring Boot 应用程序时可以使用通配符引用普通 JAR 文件,例如:

java -jar microservices/product-service/build/libs/*.jar 

通过将以下行添加到每个微服务的 build.gradle 文件中,已禁用创建新的普通 JAR 文件:

jar {
    enabled = false
} 

对于更多详细信息,请参阅 docs.spring.io/spring-boot/docs/3.0.4/gradle-plugin/reference/htmlsingle/#packaging-executable.and-plain-archives。

添加复合微服务

现在,是时候通过添加将调用三个核心服务的复合服务来整合这些内容了!

复合服务的实现分为两部分:一个处理对核心服务的出站 HTTP 请求的集成组件,以及复合服务实现本身。这种责任划分的主要原因是为了简化自动化的单元和集成测试;我们可以通过用模拟替换集成组件来独立测试服务实现。

正如我们将在本书后面看到的那样,这种责任划分也将使引入断路器变得更加容易!

在我们查看两个组件的源代码之前,我们需要查看复合微服务将使用的 API 类,并了解如何使用运行时属性来保存核心微服务的地址信息。

集成组件和复合服务实现的完整实现可以在 Java 包 se.magnus.microservices.composite.product.services 中找到。

API 类

在本节中,我们将查看描述复合组件 API 的类。它们可以在 $BOOK_HOME/Chapter03/2-basic-rest-services/api 中找到。以下是一些 API 类:

$BOOK_HOME/Chapter03/2-basic-rest-services/api
└── src/main/java/se/magnus/api/composite
    └── product
        ├── ProductAggregate.java
        ├── ProductCompositeService.java
        ├── RecommendationSummary.java
        ├── ReviewSummary.java
        └── ServiceAddresses.java 

Java 接口类 ProductCompositeService.java 遵循核心服务使用的相同模式,如下所示:

package se.magnus.api.composite.product;
public interface ProductCompositeService {
    @GetMapping(
        value    = "/product-composite/{productId}",
        produces = "application/json")
    ProductAggregate getProduct(@PathVariable int productId);
} 

模型类ProductAggregate.java比核心模型复杂一些,因为它包含推荐和评论列表的字段:

package se.magnus.api.composite.product;
public class ProductAggregate {
    private final int productId;
    private final String name;
    private final int weight;
    private final **List<RecommendationSummary> recommendations**;
    private final **List<ReviewSummary> reviews**;
    private final ServiceAddresses serviceAddresses; 

剩余的 API 类是普通的 POJO 模型对象,结构与核心 API 的模型对象相同。

属性

为了避免将核心服务的地址信息硬编码到复合微服务的源代码中,后者使用一个属性文件来存储如何找到核心服务的信息。这个属性文件,application.yml,看起来如下所示:

server.port: 7000
app:
  product-service:
    host: localhost
    port: 7001
  recommendation-service:
    host: localhost
    port: 7002
  review-service:
    host: localhost
    port: 7003 

如前所述,此配置将在本书后面的部分被服务发现机制所取代。

集成组件

让我们看看复合微服务实现的第一部分,集成组件,ProductCompositeIntegration.java。它使用@Component注解声明为 Spring Bean,并实现了三个核心服务的 API 接口:

package se.magnus.microservices.composite.product.services;
@Component
public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService { 

集成组件使用 Spring 框架中的一个辅助类RestTemplate来执行对核心微服务的实际 HTTP 请求。在我们将其注入到集成组件之前,我们需要对其进行配置。我们在main应用程序类ProductCompositeServiceApplication.java中这样做,如下所示:

@Bean
RestTemplate restTemplate() {
   return new RestTemplate();
} 

RestTemplate对象高度可配置,但我们现在先保留其默认值。

在第二章的Spring Boot 简介部分,我们介绍了反应式 HTTP 客户端WebClient。在本章中使用WebClient代替RestTemplate将需要所有使用WebClient的源代码也必须是反应式的,包括 API 项目中 RESTful API 的声明和复合微服务的源代码。在第七章开发反应式微服务中,我们将学习如何更改微服务的实现以遵循反应式编程模型。在这个更新步骤之一,我们将用WebClient类替换RestTemplate辅助类。但在我们了解 Spring 中的反应式开发之前,我们将使用RestTemplate类。

现在我们可以注入RestTemplate,以及一个 JSONmapper,后者用于在出错时访问错误消息,以及我们在属性文件中设置的配置值。让我们看看这是如何完成的:

  1. 对象和配置值如下注入到构造函数中:

    private final RestTemplate restTemplate;
    private final ObjectMapper mapper;
    private final String productServiceUrl;
    private final String recommendationServiceUrl;
    private final String reviewServiceUrl;
    @Autowired
    public ProductCompositeIntegration(
      RestTemplate restTemplate,
      ObjectMapper mapper,
      @Value("${app.product-service.host}") 
      String productServiceHost,
    
      @Value("${app.product-service.port}")
      int productServicePort,
      @Value("${app.recommendation-service.host}")
      String recommendationServiceHost,
      @Value("${app.recommendation-service.port}")
      int recommendationServicePort,
      @Value("${app.review-service.host}")
      String reviewServiceHost,
      @Value("${app.review-service.port}")
      int reviewServicePort
    ) 
    
  2. 构造函数的主体存储注入的对象并基于注入的值构建 URL,如下所示:

    {
      this.restTemplate = restTemplate;
      this.mapper = mapper;
      productServiceUrl = "http://" + productServiceHost + ":" + 
      productServicePort + "/product/";
      recommendationServiceUrl = "http://" + recommendationServiceHost
      + ":" + recommendationServicePort + "/recommendation?
      productId="; reviewServiceUrl = "http://" + reviewServiceHost + 
      ":" + reviewServicePort + "/review?productId=";
    } 
    
  3. 最后,集成组件通过使用RestTemplate进行实际出站调用来实现三个核心服务的 API 方法:

    public Product getProduct(int productId) {
     String url = productServiceUrl + productId;
     Product product = **restTemplate**.getForObject(url, Product.class);
     return product;
    }
    public List<Recommendation> getRecommendations(int productId) {
        String url = recommendationServiceUrl + productId;
        List<Recommendation> recommendations = 
        **restTemplate**.exchange(url, GET, null, new 
        **ParameterizedTypeReference**<List<Recommendation>>() 
        {}).getBody();
        return recommendations;
    }
    public List<Review> getReviews(int productId) {
        String url = reviewServiceUrl + productId;
        List<Review> reviews = **restTemplate**.exchange(url, GET, null,
        new **ParameterizedTypeReference**<List<Review>>() {}).getBody();
        return reviews;
    } 
    

关于方法实现的一些有趣注释:

  1. 对于getProduct()实现,可以在RestTemplate中使用getForObject()方法。期望的响应是一个Product对象。可以通过在getForObject()调用中指定RestTemplate将映射 JSON 响应到的Product.class类来表示。

  2. 对于getRecommendations()和getReviews()的调用,必须使用更高级的方法exchange()。这是因为RestTemplate会自动将 JSON 响应映射到模型类。getRecommendations()和getReviews()方法期望响应中包含泛型列表,即List<Recommendation>和List<Review>。由于泛型在运行时不会保留任何类型信息,因此我们无法指定这些方法期望在响应中包含泛型列表。相反,我们可以使用 Spring 框架中的一个辅助类ParameterizedTypeReference,它通过在运行时保留类型信息来设计解决此问题。这意味着RestTemplate可以确定将 JSON 响应映射到哪个类。为了使用这个辅助类,我们必须使用更复杂的exchange()方法,而不是RestTemplate上的简单getForObject()方法。

复合 API 实现

最后,我们将查看复合微服务实现的最后一部分:API 实现类ProductCompositeServiceImpl.java。让我们一步一步地来看它:

  1. 与核心服务类似,复合服务实现了其 API 接口ProductCompositeService,并使用@RestController注解标记为 REST 服务:

    package se.magnus.microservices.composite.product.services;
    @RestController
    public class ProductCompositeServiceImpl implements ProductCompositeService { 
    
  2. 实现类需要ServiceUtil豆和它自己的集成组件,因此它们被注入到其构造函数中:

    private final ServiceUtil serviceUtil;
    private ProductCompositeIntegration integration;
    @Autowired
    public ProductCompositeServiceImpl(ServiceUtil serviceUtil, ProductCompositeIntegration integration) {
        this.serviceUtil = serviceUtil;
        this.integration = integration;
    } 
    
  3. 最后,API 方法实现如下:

    @Override
    public ProductAggregate getProduct(int productId) {
    
      Product product = integration.getProduct(productId);
      List<Recommendation> recommendations = 
      integration.getRecommendations(productId);
      List<Review> reviews = integration.getReviews(productId);
    
      return **createProductAggregate**(product, recommendations,
      reviews, serviceUtil.getServiceAddress());
    } 
    

集成组件用于调用三个核心服务,并使用辅助方法createProductAggregate()根据集成组件的调用响应创建ProductAggregate类型的响应对象。

辅助方法createProductAggregate()的实现相当冗长且不是非常重要,因此已从本章中省略;然而,它可以在本书的源代码中找到。

集成组件和复合服务的完整实现可以在 Java 包se.magnus.microservices.composite.product.services中找到。

从功能角度来看,这完成了复合微服务的实现。在下一节中,我们将看到我们如何处理错误。

添加错误处理

在微服务领域中,以结构化和深思熟虑的方式处理错误至关重要,因为在微服务之间使用同步 API(例如 HTTP 和 JSON)进行大量通信。同时,将特定协议的错误处理(如 HTTP 状态码)与业务逻辑分离也很重要。

可以争论说,在实现微服务时应该添加一个单独的业务逻辑层。这应该确保业务逻辑与特定协议的代码分离,使得测试和重用都更容易。为了避免本书中提供的示例中出现不必要的复杂性,我们省略了单独的业务逻辑层,因此微服务直接在@RestController组件中实现其业务逻辑。

我在util项目中创建了一系列 Java 异常,这些异常既被 API 实现使用,也被 API 客户端使用,最初是InvalidInputException和NotFoundException。有关详细信息,请查看 Java 包se.magnus.util.exceptions。

全局 REST 控制器异常处理器

为了将特定协议的错误处理与 REST 控制器中的业务逻辑分开,也就是说,与 API 实现分开,我在util项目中创建了一个实用类,名为GlobalControllerExceptionHandler.java,并使用@RestControllerAdvice注解。

对于 API 实现抛出的每个 Java 异常,实用类都有一个异常处理方法,将 Java 异常映射到适当的 HTTP 响应,即具有适当的 HTTP 状态和 HTTP 响应体。

例如,如果一个 API 实现类抛出InvalidInputException,实用类会将其映射到一个状态码设置为422(UNPROCESSABLE_ENTITY)的 HTTP 响应。以下代码展示了这一点:

@ResponseStatus(**UNPROCESSABLE_ENTITY**)
@ExceptionHandler(**InvalidInputException.class**)
public @ResponseBody HttpErrorInfo handleInvalidInputException(
    ServerHttpRequest request, InvalidInputException ex) {
    return createHttpErrorInfo(UNPROCESSABLE_ENTITY, request, ex);
} 

同样,NotFoundException被映射到404(NOT_FOUND)HTTP 状态码。

每当 REST 控制器抛出这些异常之一时,Spring 都会使用实用类来创建一个 HTTP 响应。

注意,当 Spring 检测到无效请求时,例如请求包含非数字的产品 ID(在 API 声明中,productId被指定为整数),它本身会返回 HTTP 状态码400(BAD_REQUEST)。

关于实用类的完整源代码,请参阅GlobalControllerExceptionHandler.java。

API 实现中的错误处理

API 实现使用util项目中的异常来表示错误。它们将以 HTTPS 状态码的形式返回给 REST 客户端,以指示发生了什么错误。例如,Product微服务实现类ProductServiceImpl.java使用InvalidInputException异常来返回一个表示输入无效的错误,以及使用NotFoundException异常来告诉我们请求的产品不存在。代码如下:

if (productId < 1) throw new **InvalidInputException**("Invalid productId: 
    " + productId);
if (productId == 13) throw new **NotFoundException**("No product found for 
    productId: " + productId); 

由于我们目前没有使用数据库,我们必须模拟何时抛出NotFoundException。

API 客户端中的错误处理

API 客户端,即 Composite 微服务的集成组件,执行相反的操作;它将 422 (UNPROCESSABLE_ENTITY) HTTP 状态码映射到 InvalidInputException,将 404 (NOT_FOUND) HTTP 状态码映射到 NotFoundException。请参阅 ProductCompositeIntegration.java 中的 getProduct() 方法以了解此错误处理逻辑的实现。源代码如下:

catch (HttpClientErrorException ex) {
    switch (HttpStatus.resolve(ex.getStatusCode().value())) {
    case **NOT_FOUND**:
        throw new NotFoundException(getErrorMessage(ex));
    case **UNPROCESSABLE_ENTITY**:
        throw new InvalidInputException(getErrorMessage(ex));
    default:
        LOG.warn("Got an unexpected HTTP error: {}, will rethrow it", 
        ex.getStatusCode());
        LOG.warn("Error body: {}", ex.getResponseBodyAsString());
        throw ex;
    }
} 

在集成组件中对 getRecommendations() 和 getReviews() 的错误处理稍微宽松一些——被视为尽力而为,这意味着如果它成功获取产品信息,但未能获取推荐或评论,仍然被认为是可接受的。然而,会写入日志警告。

详细信息,请参阅 ProductCompositeIntegration.java。

这样就完成了代码和组合微服务的实现。在下一节中,我们将测试微服务和它们公开的 API。

手动测试 API

这样就完成了我们微服务的实现。让我们通过以下步骤来尝试它们:

  1. 将微服务作为后台进程构建并启动。

  2. 使用 curl 调用组合 API。

  3. 停止微服务。

首先,按照以下方式将每个微服务作为后台进程构建和启动:

cd $BOOK_HOME/Chapter03/2-basic-rest-services/
./gradlew build 

构建完成后,我们可以使用以下代码将微服务作为后台进程启动到终端进程:

java -jar microservices/product-composite-service/build/libs/*.jar &
java -jar microservices/product-service/build/libs/*.jar &
java -jar microservices/recommendation-service/build/libs/*.jar &
java -jar microservices/review-service/build/libs/*.jar & 

将会有大量的日志消息写入终端,但几秒钟后,事情会平静下来,我们将在日志中找到以下消息:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_06.png

图 3.6:应用程序启动后的日志消息

这意味着它们都准备好接收请求。以下代码尝试一下:

curl http://localhost:7000/product-composite/1 

在一些日志输出后,我们将得到一个看起来像以下这样的 JSON 响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_07.png

图 3.7:请求后的 JSON 响应

要获取格式化的 JSON 响应,你可以使用 jq 工具:

curl http://localhost:7000/product-composite/1 -s | jq . 

这将产生以下输出(一些细节已被 ... 替换以提高可读性):

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_08.png

图 3.8:格式化的 JSON 响应

如果你想的话,也可以尝试以下命令来验证错误处理是否按预期工作:

# Verify that a 404 (Not Found) error is returned for a non-existing productId (13)
curl http://localhost:7000/product-composite/13 -i
# Verify that no recommendations are returned for productId 113
curl http://localhost:7000/product-composite/113 -s | jq .
# Verify that no reviews are returned for productId 213
curl http://localhost:7000/product-composite/213 -s | jq .
# Verify that a 422 (Unprocessable Entity) error is returned for a productId that is out of range (-1)
curl http://localhost:7000/product-composite/-1 -i
# Verify that a 400 (Bad Request) error is returned for a productId that is not a number, i.e. invalid format
curl http://localhost:7000/product-composite/invalidProductId -i 

最后,你可以使用以下命令关闭微服务:

kill $(jobs -p) 

如果你使用的是 Visual Studio Code 和 Spring Tool Suite 这样的 IDE,你可以使用它们对 Spring Boot Dashboard 的支持,通过一键启动和停止你的微服务。有关如何安装 Spring Tool Suite 的说明,请参阅 github.com/spring-projects/sts4/wiki/Installation。

以下截图显示了在 Visual Studio Code 中使用 Spring Boot Dashboard:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_09.png

图 3.9:Visual Studio Code 中的 Spring Boot 仪表板

在本节中,我们学习了如何手动启动、测试和停止协作微服务的系统景观。这类测试耗时较长,因此显然需要自动化。

在接下来的两个部分中,我们将迈出第一步学习如何自动化测试,测试单个独立微服务以及整个协作微服务系统景观。在这本书的整个过程中,我们将改进我们测试微服务的方式。

独立添加自动化微服务测试

在我们完成实现之前,我们还需要编写一些自动化测试。

目前我们没有太多业务逻辑要测试,因此不需要编写任何单元测试。相反,我们将专注于测试我们的微服务公开的 API;也就是说,我们将使用嵌入的 Web 服务器在集成测试中启动它们,然后使用测试客户端执行 HTTP 请求并验证响应。随着 Spring WebFlux 的出现,提供了一个测试客户端WebTestClient,它提供了一个流畅的 API 来发出请求并在其结果上应用断言。

以下是一个测试复合产品 API 的示例,我们进行以下测试:

  • 发送现有产品的productId并断言我们收到200作为 HTTP 响应代码,以及包含请求的productId以及一个推荐和一个评论的 JSON 响应

  • 发送缺少productId并断言我们收到404作为 HTTP 响应代码,以及包含相关错误信息的 JSON 响应

这两个测试的实现如下所示。第一个测试看起来如下:

@Autowired
private **WebTestClient client**;
@Test
void getProductById() {
  **client**.get()
    .uri("/product-composite/" + PRODUCT_ID_OK)
    .accept(APPLICATION_JSON_UTF8)
    .exchange()
    .expectStatus().isOk()
    .expectHeader().contentType(APPLICATION_JSON_UTF8)
    .expectBody()
    .jsonPath("$.productId").isEqualTo(PRODUCT_ID_OK)
    .jsonPath("$.recommendations.length()").isEqualTo(1)
    .jsonPath("$.reviews.length()").isEqualTo(1);
} 

测试代码的工作方式如下:

  • 测试使用流畅的WebTestClient API 设置要调用的 URL 为"/product-composite/" + PRODUCT_ID_OK,并指定接受的响应格式,JSON。

  • 在使用exchange()方法执行请求之后,测试验证响应状态是OK(200),并且响应格式实际上是 JSON(如请求所示)。

  • 最后,测试检查响应体,并验证它是否包含预期的信息,即productId以及推荐和评论的数量。

第二个测试看起来如下:

@Test
public void getProductNotFound() {
  client.get()
    .uri("/product-composite/" + PRODUCT_ID_NOT_FOUND)
    .accept(APPLICATION_JSON_UTF8)
    .exchange()
    **.expectStatus().isNotFound()**
    .expectHeader().contentType(APPLICATION_JSON_UTF8)
    .expectBody()
    .jsonPath("$.path").isEqualTo("/product-composite/" + 
     PRODUCT_ID_NOT_FOUND)
    **.jsonPath(****"$.message"****).isEqualTo(****"NOT FOUND: "** **+** 
 **PRODUCT_ID_NOT_FOUND);**
} 

关于这个测试代码的一个重要注意事项是:

  • 这个负面测试在结构上与前面的测试非常相似;主要区别在于它验证了它收到了一个错误状态码回执,Not Found(404),并且响应体包含预期的错误信息。

为了独立测试复合产品 API,我们需要模拟其依赖项,即由集成组件ProductCompositeIntegration执行的到其他三个微服务的请求。我们使用Mockito来完成此操作,如下所示:

private static final int **PRODUCT_ID_OK** = 1;
private static final int **PRODUCT_ID_NOT_FOUND** = 2;
private static final int **PRODUCT_ID_INVALID** = 3;
**@MockBean**
private **ProductCompositeIntegration compositeIntegration**;
@BeforeEach
void setUp() {
  when(compositeIntegration.getProduct(PRODUCT_ID_OK)).
    thenReturn(new Product(PRODUCT_ID_OK, "name", 1, "mock-address"));
  when(compositeIntegration.getRecommendations(PRODUCT_ID_OK)).
    thenReturn(singletonList(new Recommendation(PRODUCT_ID_OK, 1, 
    "author", 1, "content", "mock address")));
     when(compositeIntegration.getReviews(PRODUCT_ID_OK)).
    thenReturn(singletonList(new Review(PRODUCT_ID_OK, 1, "author", 
    "subject", "content", "mock address")));
  when(compositeIntegration.getProduct(PRODUCT_ID_NOT_FOUND)).
    thenThrow(new NotFoundException("NOT FOUND: " + 
    PRODUCT_ID_NOT_FOUND));
  when(compositeIntegration.getProduct(PRODUCT_ID_INVALID)).
    thenThrow(new InvalidInputException("INVALID: " + 
    PRODUCT_ID_INVALID));
} 

模拟实现的工作方式如下:

  • 首先,我们声明三个在测试类中使用的常量:PRODUCT_ID_OK、PRODUCT_ID_NOT_FOUND和PRODUCT_ID_INVALID。

  • 接下来,使用注解@MockBean来配置 Mockito 为ProductCompositeIntegration接口设置模拟。

  • 如果在集成组件上调用getProduct()、getRecommendations()和getReviews()方法,且productId设置为PRODUCT_ID_OK,模拟将返回一个正常响应。

  • 如果getProduct()方法被调用,且productId设置为PRODUCT_ID_NOT_FOUND,模拟将抛出NotFoundException异常。

  • 如果getProduct()方法被调用,且productId设置为PRODUCT_ID_INVALID,模拟将抛出InvalidInputException异常。

组合产品 API 的自动化集成测试的完整源代码可以在测试类ProductCompositeServiceApplicationTests.java中找到。

在三个核心微服务暴露的 API 上执行的自动化集成测试是相似的,但更简单,因为它们不需要模拟任何东西!测试的源代码可以在每个微服务的test文件夹中找到。

测试在执行构建时由 Gradle 自动运行:

./gradlew build 

然而,您可以指定只想运行测试(而不是构建的其他部分):

./gradlew test 

这是对如何为微服务编写独立自动化测试的介绍。在下一节中,我们将学习如何编写自动测试微服务景观的测试。在本章中,这些测试将仅部分自动化。在未来的章节中,测试将完全自动化,这是一个重大的改进。

添加微服务景观的半自动化测试

能够使用纯 Java、JUnit 和 Gradle 自动为每个微服务单独运行单元和集成测试,在开发期间非常有用,但当我们转向运维方面时,这就不够了。在运维中,我们还需要一种方法来自动验证协作的微服务系统是否能够满足我们的期望。能够随时运行一个脚本来验证多个协作的微服务在运维中是否按预期工作是非常有价值的——微服务越多,这种验证脚本的值就越高。

因此,我编写了一个简单的bash脚本,可以通过对微服务暴露的 RESTful API 进行调用来验证已部署的系统景观的功能。该脚本基于我们上面学习并使用的curl命令。脚本使用jq验证返回码和 JSON 响应的部分,脚本包含两个辅助函数assertCurl()和assertEqual(),以使测试代码紧凑且易于阅读。

例如,发送一个普通请求并期望状态码为200,以及断言返回一个包含请求的productId、三个推荐和三个评论的 JSON 响应,看起来如下所示:

# Verify that a normal request works, expect three recommendations and three reviews
assertCurl 200 "curl http://$HOST:${PORT}/product-composite/1 -s"
assertEqual 1 $(echo $RESPONSE | jq .productId)
assertEqual 3 $(echo $RESPONSE | jq ".recommendations | length")
assertEqual 3 $(echo $RESPONSE | jq ".reviews | length") 

验证我们是否收到404 (Not Found)作为 HTTP 响应代码(当我们尝试查找不存在的产品时)如下所示:

# Verify that a 404 (Not Found) error is returned for a non-existing productId (13)
assertCurl 404 "curl http://$HOST:${PORT}/product-composite/13 -s" 

测试脚本test-em-all.bash实现了在手动测试 API部分描述的手动测试,该部分可以在顶级文件夹$BOOK_HOME/Chapter03/2-basic-rest-services中找到。随着我们在后续章节中向系统景观添加更多功能,我们将扩展测试脚本的功能。

在第二十章,监控微服务中,我们将学习关于自动监控运行中的系统景观的补充技术。在这里,我们将了解一个持续监控已部署微服务状态的监控工具,以及如果收集的指标超过配置的阈值(例如 CPU 或内存过度使用),如何发出警报。

尝试运行测试脚本

要尝试测试脚本,请执行以下步骤:

  1. 首先,启动微服务,就像我们之前做的那样:

    cd $BOOK_HOME/Chapter03/2-basic-rest-services
    java -jar microservices/product-composite-service/build/libs/*.jar & 
    java -jar microservices/product-service/build/libs/*.jar &
    java -jar microservices/recommendation-service/build/libs/*.jar &
    java -jar microservices/review-service/build/libs/*.jar & 
    
  2. 一旦它们全部启动,运行测试脚本:

    ./test-em-all.bash 
    

    预期输出将类似于以下内容:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_03_10.png

图 3.10:运行测试脚本后的输出

  1. 通过以下命令关闭微服务来完成这一部分:

    kill $(jobs -p) 
    

在本节中,我们迈出了自动化测试协作微服务系统景观的第一步,所有这些将在后续章节中得到改进。

摘要

我们现在已经使用 Spring Boot 构建了我们的一些微服务。在介绍了我们将在这本书中使用的微服务景观之后,我们学习了如何使用 Spring Initializr 为每个微服务创建骨架项目。

接下来,我们学习了如何使用 Spring WebFlux 为三个核心服务添加 API,并实现了一个复合服务,该服务使用三个核心服务的 API 来创建它们中信息的聚合视图。复合服务使用 Spring 框架中的RestTemplate类对核心服务公开的 API 执行 HTTP 请求。在服务中添加错误处理逻辑后,我们在微服务景观上运行了一些手动测试。

我们通过学习如何为独立运行的微服务和作为系统景观一起工作的微服务添加测试来结束本章。为了为复合服务提供受控的隔离,我们使用 Mockito 模拟了其与核心服务的依赖关系。整个系统景观的测试是通过一个 Bash 脚本执行的,该脚本使用curl对复合服务的 API 进行调用。

在具备这些技能的基础上,我们准备迈出下一步,在下一章中进入 Docker 和容器世界!其中之一,我们将学习如何使用 Docker 完全自动化协作微服务系统景观的测试。

问题

  1. 使用spring init Spring Initializr CLI 工具创建新的 Spring Boot 项目时,列出可用依赖项的命令是什么?

  2. 如何设置 Gradle 以使用一条命令构建多个相关项目?

  3. @PathVariable和@RequestParam注解用于什么?

  4. 如何在 API 实现类中将特定协议的错误处理与业务逻辑分离?

  5. Mockito 用于什么?

第四章:使用 Docker 部署我们的微服务

在本章中,我们将开始使用 Docker 并将我们的微服务放入容器中!

到本章结束时,我们将运行完全自动化的微服务测试,这些测试将启动所有微服务作为 Docker 容器,除了 Docker 引擎外不需要任何基础设施。我们还将运行一系列测试以验证微服务按预期协同工作,最后关闭所有微服务,不留任何我们执行的测试痕迹。

能够以这种方式测试多个协作的微服务非常有用。作为开发者,我们可以验证微服务在我们本地的开发者机器上是否正常工作。我们还可以在构建服务器上运行完全相同的测试,以自动验证源代码的更改不会在系统级别破坏测试。此外,我们不需要为运行这些类型的测试分配专门的基础设施。在接下来的章节中,我们将看到如何将数据库和队列管理器添加到我们的测试环境中,所有这些都将作为 Docker 容器运行。

然而,这并不能取代自动单元和集成测试的需求,这些测试在隔离的情况下测试单个微服务。它们和以前一样重要。

对于生产使用,正如我们在本书中之前提到的,我们需要一个容器编排器,如 Kubernetes。我们将在本书的后面部分回到容器编排器和 Kubernetes。

本章将涵盖以下主题:

  • Docker 简介

  • Docker 和 Java – 历史上,Java 对容器的友好度并不高,但这种情况在 Java 10 中发生了变化,因此让我们看看 Docker 和 Java 如何结合在一起

  • 使用 Docker 与单个微服务

  • 使用 Docker Compose 管理微服务景观

  • 自动化协作微服务的测试

技术要求

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

  • 第二十一章,macOS 安装说明

  • 第二十二章,使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例全部来自 $BOOK_HOME/Chapter04 的源代码。

如果你想查看本章源代码中应用的变化,即查看添加 Docker 支持所需的工作,你可以将其与 第三章,创建一组协作微服务 的源代码进行比较。你可以使用你喜欢的 diff 工具比较两个文件夹,$BOOK_HOME/Chapter03/2-basic-rest-services 和 $BOOK_HOME/Chapter04。

Docker 简介

正如我们在 第二章,Spring Boot 简介 中已经提到的,Docker 在 2013 年使容器作为轻量级虚拟机的替代方案的概念变得非常流行。为了快速回顾:容器实际上是在使用 Linux 命名空间提供容器之间隔离的 Linux 主机上处理的,并且使用 Linux 控制组(cgroups)来限制容器可以消耗的 CPU 和内存量。

与使用虚拟机管理程序在每个虚拟机中运行操作系统的完整副本的虚拟机相比,容器中的开销只是虚拟机开销的一小部分。这导致启动时间更快,占用空间显著降低。然而,容器并不被认为像虚拟机那样安全。看看下面的图示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_01.png

图 4.1:虚拟机与容器对比

该图说明了虚拟机和容器之间的资源使用差异,表明同一类型的服务器可以运行比虚拟机多得多的容器。主要的好处是容器不需要像虚拟机那样运行自己的操作系统实例。

运行我们的第一个 Docker 命令

让我们尝试通过使用 Docker 的 run 命令启动 Ubuntu 服务器来启动一个容器:

docker run -it --rm ubuntu 

使用前面的命令,我们要求 Docker 创建一个运行 Ubuntu 的容器,基于官方 Docker 镜像中可用的最新版本。使用 -it 选项是为了我们可以使用终端与容器进行交互,而 --rm 选项告诉 Docker 在我们退出终端会话后删除容器;否则,容器将保持 Docker 引擎中的 Exited 状态。

第一次使用我们未自行构建的 Docker 镜像时,Docker 将从默认的 Docker 仓库(Docker Hub)下载它(hub.docker.com)。这需要一些时间,但后续使用该 Docker 镜像时,容器只需几秒钟就能启动!

一旦下载了 Docker 镜像并启动了容器,Ubuntu 服务器应该会响应如下提示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_02.png

图 4.2:Ubuntu 服务器响应

我们可以通过例如询问它运行的是哪个版本的 Ubuntu 来尝试容器:

cat /etc/os-release | grep 'VERSION=' 

应该会响应如下:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_03.png

图 4.3:Ubuntu 版本响应

我们可以使用 exit 命令退出容器,并使用 docker ps -a 命令验证 Ubuntu 容器不再退出。我们需要使用 -a 选项来查看已停止的容器;否则,只会显示正在运行的容器。

如果你更喜欢 CentOS 而不是 Ubuntu,你可以尝试使用 docker run --rm -it centos 命令。

一旦 CentOS 服务器在其容器中开始运行,例如,你可以使用 cat /etc/redhat-release 命令来询问正在运行哪个版本的 CentOS。它应该会响应如下:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_04.png

图 4.4:CentOS 版本响应

使用 exit 命令离开容器以删除它。

如果在某个时候,你发现 Docker Engine 中有很多不想要的容器,并且你想从零开始,也就是说,删除它们所有,你可以运行以下命令:

docker rm -f $(docker ps -aq) 

docker rm -f 命令停止并删除由命令指定的容器 ID 的容器。docker ps -aq 命令列出 Docker Engine 中所有运行和停止的容器的容器 ID。-q 选项减少了 docker ps 命令的输出,使其只列出容器 ID。

现在我们已经了解了 Docker 是什么,我们可以继续学习如何在 Docker 中运行 Java。

在 Docker 中运行 Java

在过去的几年里,已经尝试了多种方法来在 Docker 中以良好的方式运行 Java。最重要的是,Java 在历史上并不擅长尊重 Docker 容器中为内存和 CPU 设置的限制。

目前,官方的 Java Docker 镜像来自 OpenJDK 项目:hub.docker.com/_/openjdk/。我们将使用来自 Eclipse Temurin 项目的替代 Docker 镜像。它包含来自 OpenJDK 项目的相同二进制文件,但提供了比 OpenJDK 项目的 Docker 镜像更好地满足我们需求的变体。

在本节中,我们将使用包含完整 JDK(Java 开发工具包)及其所有工具的 Docker 镜像。当我们开始在“使用 Docker 与单个微服务”部分中打包我们的微服务到 Docker 镜像时,我们将使用一个更紧凑的基于 JRE(Java 运行时环境)的 Docker 镜像,它只包含运行时所需的 Java 工具。

如前所述,Java 的早期版本并不擅长遵守使用 Linux cgroups 为 Docker 容器指定的配额;它们只是简单地忽略了这些设置。

因此,Java 不是根据容器中可用的内存来在 JVM 内部分配内存,而是像它有权访问 Docker 主机上的所有内存一样分配内存。当尝试分配比允许的更多的内存时,Java 容器会被主机以“内存不足”的错误消息杀死。同样,Java 根据 Docker 主机上的总可用 CPU 核心数来分配与 CPU 相关的资源,如线程池,而不是根据为容器 JVM 运行提供的 CPU 核心数。

在 Java SE 9 中,提供了基于容器的 CPU 和内存限制的初始支持,在 Java SE 10 中得到了很大改进。

让我们看看 Java SE 17 对其运行的容器中设置的限制如何响应!

在接下来的测试中,我们将在 MacBook Pro 上的虚拟机内部运行 Docker 引擎,充当 Docker 主机。Docker 主机被配置为使用8 个 CPU核心和16 GB 的内存。

我们将首先看看如何将可用的 CPU 数量限制在运行 Java 的容器中。之后,我们将对内存进行同样的限制。

限制可用 CPU

让我们先找出 Java 在没有应用任何限制的情况下看到多少可用处理器(即 CPU 核心)。我们可以通过将 Java 语句Runtime.getRuntime().availableprocessors()发送到 Java CLI 工具jshell来实现。我们将使用包含完整 Java 17 JDK 的 Docker 镜像在容器中运行jshell。此镜像的 Docker 标签是eclipse-temurin:17。命令如下所示:

echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i eclipse-temurin:17 jshell -q 

此命令将字符串Runtime.getRuntime().availableProcessors()发送到 Docker 容器,该容器将使用jshell处理该字符串。我们将得到以下响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_05.png

图 4.5:显示可用 CPU 核心数量的响应

8核心的响应符合预期,因为 Docker 主机被配置为使用8个 CPU 核心。让我们继续,并使用--cpus 3 Docker 选项将 Docker 容器限制为只能使用三个 CPU 核心,然后询问 JVM 它看到了多少可用处理器:

echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i --cpus=3 eclipse-temurin:17 jshell -q 

JVM 现在响应为Runtime.getRuntime().availableProcessors()$1 ==> 3;这意味着 Java SE 17 尊重容器中的设置,因此能够正确配置如线程池等 CPU 相关资源!

限制可用内存

在可用内存的数量方面,让我们询问 JVM 它认为可以为堆分配的最大大小。我们可以通过使用-XX:+PrintFlagsFinal Java 选项请求 JVM 额外的运行时信息,然后使用grep命令过滤出MaxHeapSize参数,如下所示:

docker run -it --rm eclipse-temurin:17 java -XX:+PrintFlagsFinal | grep "size_t MaxHeapSize" 

在为 Docker 主机分配了 16 GB 的内存后,我们将得到以下响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_06.png

图 4.6:显示 MaxHeapSize 的响应

在没有 JVM 内存限制(即不使用 JVM 参数-Xmx)的情况下,Java 将为它的堆分配容器可用内存的四分之一。因此,我们预计它将为堆分配多达 4 GB。从前面的屏幕截图可以看出,响应为 4,188,012,544 字节。这等于4,188,012,544 / 1024 / 1024 = 3,994 MB,接近预期的 4 GB。

如果我们使用 Docker 选项-m=1024M将 Docker 容器限制为只能使用最多 1 GB 的内存,我们预计会看到更低的内存最大分配。运行以下命令:

docker run -it --rm -m=1024M eclipse-temurin:17 java -XX:+PrintFlagsFinal | grep "size_t MaxHeapSize" 

将导致响应 268,435,456 字节,这等于 268,435,456 / 1024 / 1024= 256 MB。256 MB 是 1 GB 的四分之一,所以,这正如预期的那样。

我们可以像往常一样,自己设置 JVM 的最大堆大小。例如,如果我们想允许 JVM 使用我们为其堆预留的 1 GB 中的 600 MB,我们可以使用 JVM 选项-Xmx600m来指定,如下所示:

docker run -it --rm -m=1024M eclipse-temurin:17 java -Xmx600m -XX:+PrintFlagsFinal -version | grep "size_t MaxHeapSize" 

JVM 将响应 629,145,600 字节 = 629,145,600 / 1024 / 1024= 600 MB,再次符合预期。

让我们用一个“内存不足”的测试来结束,以确保这真的有效!

我们将在一个已经分配了 1 GB 内存的容器中运行的 JVM 中使用jshell分配一些内存;也就是说,它有一个 256 MB 的最大堆大小。

首先,尝试分配一个 100 MB 的字节数组:

echo 'new byte[100_000_000]' | docker run -i --rm -m=1024M eclipse-temurin:17 jshell -q 

命令将响应 $1 ==>,这意味着它工作得很好!

通常,jshell会打印出命令的结果值,但 100 MB 的字节全部设置为 0 有点太多,所以什么也没有打印出来。

现在,让我们尝试分配一个大于最大堆大小的字节数组,例如,500 MB:

echo 'new byte[500_000_000]' | docker run -i --rm -m=1024M eclipse-temurin:17 jshell -q 

JVM 看到它无法执行该操作,因为它尊重容器的最大内存设置,并立即响应Exception java.lang.OutOfMemoryError: Java heap space。太好了!

因此,总结一下,我们现在已经看到了 Java 如何尊重其容器中可用的 CPU 和内存设置。让我们继续前进,为我们的一个微服务构建第一个 Docker 镜像!

使用 Docker 与一个微服务

现在我们已经了解了 Java 在容器中的工作方式,我们可以开始使用 Docker 与我们的一个微服务一起使用。在我们能够将我们的微服务作为 Docker 容器运行之前,我们需要将其打包到一个 Docker 镜像中。要构建一个 Docker 镜像,我们需要一个 Dockerfile,所以我们将从那里开始。接下来,我们需要为我们的微服务配置一个 Docker 特定的配置。由于在容器中运行的微服务与其他微服务是隔离的——它有自己的 IP 地址、主机名和端口——它需要与在相同主机上与其他微服务一起运行时不同的配置。

例如,由于其他微服务不再运行在相同的主机上,所以不会发生端口冲突。在 Docker 中运行时,我们可以为所有微服务使用默认端口8080,而没有任何端口冲突的风险。另一方面,如果我们需要与其他微服务通信,我们就不能再像在相同主机上运行它们时那样使用localhost了。

微服务中的源代码不会因为将微服务在容器中运行而受到影响,只有它们的配置!

为了处理在本地运行时(没有 Docker)和作为 Docker 容器运行微服务时所需的不同的配置,我们将使用 Spring 配置文件。自 第三章,创建一组协作微服务 以来,我们一直在使用默认的 Spring 配置文件在本地运行时(没有 Docker)。现在,我们将创建一个新的名为 docker 的 Spring 配置文件,用于我们在 Docker 中以容器形式运行微服务时使用。

源代码中的更改

我们将从 product 微服务开始,该微服务可以在源代码的 $BOOK_HOME/Chapter04/microservices/product-service/ 中找到。在下一节中,我们将将其应用于其他微服务。

首先,我们在属性文件 application.yml 的末尾添加 Spring 配置文件:

---
spring.config.activate.on-profile: docker
server.port: 8080 

Spring 配置文件可以用来指定特定环境的配置,在这种情况下,这是一个仅在运行微服务时在 Docker 容器中使用的配置。其他例子包括特定于 dev、test 和 production 环境的配置。配置文件中的值会覆盖默认配置文件中的值。通过使用 YAML 文件,可以在同一个文件中放置多个 Spring 配置文件,它们之间用 --- 分隔。

我们现在唯一更改的参数是正在使用的端口;当在容器中运行微服务时,我们将使用默认端口 8080。

接下来,我们将创建用于构建 Docker 镜像的 Dockerfile。如 第二章,Spring Boot 简介 中所述,Dockerfile 可以非常简单:

FROM **openjdk:****17**
EXPOSE **8080**
ADD **./build/libs/*.jar app.jar**
ENTRYPOINT **[****"java"****,****"-jar"****,****"/app.jar"****]** 

需要注意的一些事项包括:

  • Docker 镜像将基于官方的 OpenJDK Docker 镜像,并使用版本 17。

  • 端口 8080 将暴露给其他 Docker 容器。

  • 重量级 JAR 文件将被添加到 Docker 镜像中,来自 Gradle 构建库的 build/libs。

  • Docker 用于启动基于此 Docker 镜像的容器的命令是 java -jar /app.jar。

这种简单的方法有几个缺点:

  • 我们正在使用 Java SE 17 的完整 JDK,包括编译器和其他开发工具。这使得 Docker 镜像变得不必要地大,并且从安全角度来看,我们不想将不必要的工具带入镜像中。

    因此,我们更愿意使用一个仅包含运行 Java 程序所需的程序和库的基础镜像来构建 Java SE 17 JRE。不幸的是,OpenJDK 项目没有为 Java SE 17 JRE 提供一个 Docker 镜像。

  • 重量级 JAR 文件在 Docker 容器启动时需要时间来解包。更好的方法是,在构建 Docker 镜像时解包重量级 JAR 文件。

  • fat JAR 文件非常大,如下所示,大约有 20 MB。如果我们想在开发过程中对 Docker 镜像中的应用程序代码进行可重复更改,这将导致 Docker build 命令的使用次优。由于 Docker 镜像是按层构建的,我们将得到一个非常大的层,每次都需要替换,即使在应用程序代码中只更改了一个 Java 类的情况下也是如此。

  • 一个更好的方法是按不同的层划分内容,其中不经常更改的文件添加到第一层,而更改最频繁的文件放置在最后一层。这将导致 Docker 层缓存机制的良好使用。对于在更改某些应用程序代码时不会更改的第一稳定层,Docker 将直接使用缓存而不是重建它们。这将导致微服务 Docker 镜像构建更快。

关于 OpenJDK 项目缺少 Java SE 17 JRE Docker 镜像的问题,还有其他开源项目将 OpenJDK 二进制文件打包到 Docker 镜像中。其中最广泛使用的一个项目是 Eclipse Temurin (adoptium.net/temurin/)。Temurin 项目提供了他们 Docker 镜像的全 JDK 版本和最小化 JRE 版本。

当涉及到在 Docker 镜像中处理 fat JAR 文件的次优打包时,Spring Boot 在 v2.3.0 版本中解决了这个问题,使得将 fat JAR 文件的内容提取到多个文件夹中成为可能。默认情况下,Spring Boot 在提取 fat JAR 文件后会创建以下文件夹:

  • dependencies,包含所有依赖项作为 JAR 文件

  • spring-boot-loader,包含知道如何启动 Spring Boot 应用程序的 Spring Boot 类

  • snapshot-dependencies,包含任何快照依赖项

  • application,包含应用程序类文件和资源

Spring Boot 文档建议为上述列出的每个文件夹创建一个 Docker 层。在用基于 JRE 的镜像替换基于 JDK 的 Docker 镜像并添加将 fat JAR 文件展开到 Docker 镜像中适当层的指令后,Dockerfile 看起来是这样的:

**FROM** **eclipse-temurin:17.0.5_8-jre-focal as builder**
WORKDIR extracted
ADD ./build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
**FROM** **eclipse-temurin:17.0.5_8-jre-focal**
WORKDIR application
COPY --from=builder extracted/dependencies/ ./
COPY --from=builder extracted/spring-boot-loader/ ./
COPY --from=builder extracted/snapshot-dependencies/ ./
COPY --from=builder extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"] 

为了在 Dockerfile 中处理 fat JAR 文件的提取,我们使用 多阶段构建,这意味着有一个名为 builder 的第一步,用于处理提取。第二个阶段构建实际用于运行时的 Docker 镜像,从第一阶段按需选择文件。使用这种技术,我们可以在 Dockerfile 中处理所有打包逻辑,同时将最终 Docker 镜像的大小保持在最小:

  1. 第一个阶段从以下行开始:

    FROM eclipse-temurin:17.0.5_8-jre-focal as builder 
    

    从这一行开始,我们可以看到使用了来自 Temurin 项目的 Docker 镜像,并且它包含 Java SE JRE for v17.0.5_8。我们还可以看到该阶段被命名为 builder。

  2. builder 阶段将工作目录设置为 extracted 并将来自 Gradle 构建库 build/libs 的胖 JAR 文件添加到该文件夹中。

  3. builder 阶段接着运行命令 java -Djarmode=layertools -jar app.jar extract,这将执行将胖 JAR 文件提取到其工作目录,即 extracted 文件夹中的操作。

  4. 下一个和最后一个阶段从以下行开始:

    FROM eclipse-temurin:17.0.5_8-jre-focal 
    

    它使用与第一阶段相同的基 Docker 镜像,并将 application 文件夹作为其工作目录。它将 builder 阶段的展开文件按文件夹逐个复制到 application 文件夹中。这样,每个文件夹创建一个层,如上所述。参数 --from=builder 用于指示 Docker 从 builder 阶段的文件系统中选择文件。

  5. 在公开适当的端口后,在这个例子中是 8080,Dockerfile 通过告诉 Docker 运行哪个 Java 类来启动微服务,即 org.springframework.boot.loader.JarLauncher,来完成封装。

在了解源代码中所需更改后,我们准备好构建我们的第一个 Docker 镜像。

构建 Docker 镜像

要构建 Docker 镜像,我们首先需要为 product-service 构建我们的部署工件(即胖 JAR 文件):

cd $BOOK_HOME/Chapter04
./gradlew :microservices:product-service:build 

由于我们只想构建 product-service 以及它所依赖的项目(即 api 和 util 项目),我们不使用正常的 build 命令,该命令构建所有微服务。相反,我们使用一个变体,告诉 Gradle 只构建 product-service 项目::microservices:product-service:build。

我们可以在 Gradle 构建库中找到胖 JAR 文件,位于 build/libs。ls -l microservices/product-service/build/libs 命令将报告如下:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_07.png

图 4.7:查看胖 JAR 文件详情

如您所见,JAR 文件的大小接近 20 MB – 没有 wonder 他们被称为胖 JAR 文件!

如果你对其实际内容感到好奇,你可以使用 unzip -l microservices/product-service/build/libs/product-service-1.0.0-SNAPSHOT.jar 命令来查看。

接下来,我们将构建 Docker 镜像并将其命名为 product-service,如下所示:

cd microservices/product-service
docker build -t product-service . 

Docker 将使用当前目录中的 Dockerfile 来构建 Docker 引擎。镜像将被标记为 product-service 并存储在 Docker 引擎的本地。

使用以下命令验证我们得到了预期的 Docker 镜像:

docker images | grep product-service 

预期输出如下:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_08.png

图 4.8:验证我们构建了 Docker 镜像

因此,现在我们已经构建了镜像,让我们看看我们如何启动服务。

启动服务

让我们使用以下命令启动 product 微服务作为容器:

docker run --rm -p8080:8080 -e "SPRING_PROFILES_ACTIVE=docker" product-service 

这是我们可以从命令中推断出的内容:

  1. docker run:docker run 命令将启动容器并在终端显示日志输出。只要容器运行,终端就会锁定。

  2. 我们已经看到了 --rm 选项;它将告诉 Docker 在我们从终端使用 Ctrl + C 停止执行后清理容器。

  3. -p8080:8080 选项将容器中的端口 8080 映射到 Docker 主机的端口 8080,这使得从外部调用它成为可能。在 Docker Desktop for Mac 的情况下,它在一个本地的 Linux 虚拟机中运行 Docker,端口也将被转发到 macOS,并在 localhost 上提供。记住,我们只能有一个容器映射到 Docker 主机上的特定端口!

  4. 使用 -e 选项,我们可以为容器指定环境变量,在这个例子中,是 SPRING_PROFILES_ACTIVE=docker。SPRING_PROFILES_ACTIVE 环境变量用于告诉 Spring 使用哪个配置文件。在我们的例子中,我们希望 Spring 使用 docker 配置文件。

  5. 最后,我们有 product-service,这是我们上面构建的 Docker 镜像的名称,Docker 将使用它来启动容器。

预期的输出如下:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_09.png

图 4.9:启动产品微服务后的输出

从前面的屏幕截图我们可以看到:

  • Spring 使用的配置文件是 docker。在输出中查找 The following profiles are active: docker 以验证这一点。

  • 容器分配的端口是 8080。在输出中查找 Netty started on port8080 以验证这一点。

  • 一旦写入日志消息 Started ProductServiceApplication,微服务就准备好接受请求了!

我们可以使用 localhost 上的 8080 端口与微服务进行通信,如前所述。在另一个终端窗口中尝试以下命令:

curl localhost:8080/product/3 

以下是我们预期的输出:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_10.png

图 4.10:请求产品 3 的信息

这与我们在上一章中收到的输出类似,但有一个主要区别:我们现在有了 "service Address":"9dc086e4a88b/172.17.0.2:8080" 的内容,端口是 8080,正如预期的那样,IP 地址 172.17.0.2 是 Docker 内部网络分配给容器的 IP 地址——但主机名 9dc086e4a88b 是从哪里来的?

向 Docker 请求所有正在运行的容器:

docker ps 

我们将看到以下内容:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_11.png

图 4.11:所有正在运行的容器

如前所述,我们可以看到主机名等同于容器的 ID,如果你想知道哪个容器实际响应了你的请求,这是很好的信息!

使用终端中的 Ctrl + C 命令停止容器,完成这个步骤后,我们现在可以继续在终端中分离运行容器。

以分离模式运行容器

好的,那很棒,但如果我们不想从启动容器的终端锁定它怎么办?在大多数情况下,对于每个正在运行的容器,有一个锁定的终端会感到不方便。是时候学习如何以 分离 的方式启动容器——在不锁定终端的情况下运行容器了!

我们可以通过添加 -d 选项,同时使用 --name 选项给它起一个名字来实现这一点。给容器起名字是可选的,如果我们不指定,Docker 会自动生成一个名字,但使用我们指定的名字可以更方便地向分离的容器发送命令。由于我们将在完成容器操作后显式地停止和删除容器,所以不再需要 --rm 选项:

docker run -d -p8080:8080 -e "SPRING_PROFILES_ACTIVE=docker" --name my-prd-srv product-service 

如果我们再次运行 docker ps 命令,我们将看到我们的新容器,名为 my-prd-srv:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_12.png

图 4.12:以分离模式启动容器

但我们如何从我们的容器中获取日志输出?

认识 docker logs 命令:

docker logs my-prd-srv -f 

-f 选项指示命令跟随日志输出,也就是说,当所有当前日志输出都写入终端时,命令不会结束,而是等待更多输出。如果你期望看到很多你不想看到的旧日志消息,你也可以添加 --tail 0 选项,这样你只能看到新的日志消息。或者,你可以使用 --since 选项并指定一个绝对时间戳或相对时间,例如,--since 5m,以查看最多五分钟前的日志消息。

用一个新的 curl 请求来尝试一下。你应该会看到一个新的日志消息已经写入终端的日志输出中。

通过停止和删除容器来完成这个步骤:

docker rm -f my-prd-srv 

-f 选项强制 Docker 删除容器,即使它正在运行。在删除容器之前,Docker 会自动停止容器。

现在我们知道了如何使用 Docker 与微服务一起使用,我们可以看看如何借助 Docker Compose 来管理微服务景观。

使用 Docker Compose 管理微服务景观

我们已经看到如何将单个微服务作为 Docker 容器运行,但关于如何管理整个微服务系统景观呢?

正如我们之前提到的,这就是 docker-compose 的目的。通过使用单个命令,我们可以构建、启动、记录和停止作为 Docker 容器运行的多个协作微服务。

源代码的更改

要使用 Docker Compose,我们需要创建一个配置文件,docker-compose.yml,该文件描述了 Docker Compose 将为我们管理的微服务。我们还需要为剩余的微服务设置 Dockerfile,并为每个微服务添加一个特定的 Docker Spring 配置文件。所有四个微服务都有自己的 Dockerfile,但它们看起来都与前面的相同。

当涉及到 Spring 配置文件时,三个核心服务product-、recommendation-和review-service具有相同的docker配置文件,它只指定在作为容器运行时应该使用默认端口8080。

对于product-composite-service,事情要复杂一些,因为它需要知道核心服务在哪里。当我们在本地上运行所有服务时,它被配置为使用 localhost 和每个核心服务的单独端口号,7001-7003。当在 Docker 中运行时,每个服务将有自己的主机名,但可以在相同的端口号8080上访问。在这里,product-composite-service的docker配置文件如下所示:

---
**spring.config.activate.on-profile:****docker**
server.port: 8080
app:
  product-service:
    host: product
    port: 8080
  recommendation-service:
    host: recommendation
    port: 8080
  review-service:
    host: review
    port: 8080 

此配置存储在属性文件application.yml中。

product、recommendation和review主机名是从哪里来的?

这些配置在docker-compose.yml文件中指定,该文件位于$BOOK_HOME/Chapter04文件夹中。它看起来是这样的:

version: '2.1'
services:
  product:
    build: microservices/product-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  recommendation:
    build: microservices/recommendation-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  review:
    build: microservices/review-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  product-composite:
    build: microservices/product-composite-service
    mem_limit: 512m
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker 

对于每个微服务,我们指定以下内容:

  • 微服务的名称。这也将是内部 Docker 网络中容器的主机名。

  • 一个build指令,指定用于构建 Docker 镜像的 Dockerfile 的位置。

  • 512 MB 的内存限制。对于本书的范围,512 MB 应该足够所有我们的微服务使用。对于本章,它可以设置为更低的值,但随着我们在接下来的章节中添加更多功能到微服务中,它们的内存需求将会增加。

  • 将为容器设置的环境变量。在我们的情况下,我们使用这些来指定要使用哪个 Spring 配置文件。

对于product-composite服务,我们还将指定端口映射 - 我们将公开其端口,以便可以从 Docker 外部访问。其他微服务将无法从外部访问。接下来,我们将看到如何启动微服务景观。

在第10 章,使用 Spring Cloud Gateway 隐藏微服务背后的边缘服务器,以及第11 章,保护 API 访问,我们将学习更多关于如何锁定和确保外部访问微服务系统的方法。

启动微服务景观

在所有必要的代码更改到位后,我们可以构建我们的 Docker 镜像,启动微服务景观,并运行一些测试以验证其按预期工作。为此,我们需要执行以下操作:

  1. 首先,我们使用 Gradle 构建我们的部署工件,然后使用 Docker Compose 构建 Docker 镜像:

    cd $BOOK_HOME/Chapter04
    ./gradlew build
    docker-compose build 
    
  2. 然后,我们需要验证我们是否可以看到我们的 Docker 镜像,如下所示:

    docker images | grep chapter04 
    

    我们应该看到以下输出:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_13.png图 4.13:验证我们的 Docker 镜像

  1. 使用以下命令启动微服务环境:

    docker-compose up -d 
    

-d选项将使 Docker Compose 以分离模式运行容器,与 Docker 相同。

我们可以通过以下命令监控每个容器日志的输出,以跟踪启动过程:

docker-compose logs -f 

docker-compose logs命令支持与前面描述的docker logs相同的-f和--tail选项。

docker-compose logs命令也支持将日志输出限制为容器组。只需在logs命令后添加您想查看日志输出的容器的名称。例如,要仅查看product和review服务的日志输出,请使用docker-compose logs -f product review。

当所有四个微服务都报告它们已启动时,我们就准备好尝试微服务环境了。寻找以下内容:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_14.png

图 4.14:启动所有四个微服务

注意,每个日志消息都带有产生输出的容器的名称作为前缀!

现在,我们已经准备好运行一些测试来验证这是否按预期工作。与我们在上一章中直接在本地主机上运行相比,当我们调用 Docker 中的组合服务时,需要更改的唯一端口是端口号。我们现在使用端口8080:

curl localhost:8080/product-composite/123 -s | jq . 

我们将得到相同类型的响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_15.png

图 4.15:调用组合服务

然而,有一个很大的不同之处——响应中serviceAddresses报告的主机名和端口:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_16.png

图 4.16:查看服务地址

在这里,我们可以看到分配给每个 Docker 容器的主机名和 IP 地址。

我们完成了;现在只剩下一步了:

docker-compose down 

前面的命令将关闭微服务环境。到目前为止,我们已经看到如何通过手动执行 Bash 命令来测试协作微服务。在下一节中,我们将看到如何增强我们的测试脚本来自动化这些手动步骤。

自动化协作微服务的测试

当手动管理一组微服务时,Docker Compose 非常有帮助。在本节中,我们将更进一步,将 Docker Compose 集成到我们的测试脚本test-em-all.bash中。测试脚本将自动启动微服务环境,运行所有必要的测试以验证微服务环境按预期工作,最后将其关闭,不留任何痕迹。

测试脚本位于$BOOK_HOME/Chapter04/test-em-all.bash。

在测试脚本运行测试套件之前,它将检查测试脚本调用中是否存在 start 参数。如果找到,它将使用以下代码重新启动容器:

if [[ $@ == *"start"* ]]
then
    echo "Restarting the test environment..."
    echo "$ docker-compose down --remove-orphans"
    docker-compose down --remove-orphans
    echo "$ docker-compose up -d"
    docker-compose up -d
fi 

之后,测试脚本将等待 product-composite 服务响应 OK:

waitForService http://$HOST:${PORT}/product-composite/1 

waitForService Bash 函数的实现如下:

function testUrl() {
    url=$@
    if curl $url -ks -f -o /dev/null
    then
          return 0
    else
          return 1
    fi;
}
**function****waitForService****() {**
    url=$@
    echo -n "Wait for: $url... "
    n=0
    until testUrl $url
    do
        n=$((n + 1))
        if [[ $n == 100 ]]
        then
            echo " Give up"
            exit 1
        else
            sleep 3
            echo -n ", retry #$n "
        fi
    done
    echo "DONE, continues..."
} 

waitForService 函数使用 curl 向提供的 URL 发送 HTTP 请求。请求会重复发送,直到 curl 响应表示它从请求中收到了成功的响应。函数在每次尝试之间等待 3 秒,并在 100 次尝试后放弃,停止脚本并失败。

接下来,所有测试都像之前一样执行。之后,如果脚本在调用参数中找到 stop 参数,它将拆除场景:

if [[ $@ == *"stop"* ]]
then
    echo "We are done, stopping the test environment..."
    echo "$ docker-compose down"
    docker-compose down
fi 

注意,如果某些测试失败,测试脚本不会拆除场景;它将简单地停止,留下场景以供错误分析!

测试脚本还更改了默认端口,从我们在不使用 Docker 运行微服务时使用的 7000,更改为 Docker 容器使用的 8080。

让我们试试看!为了启动场景,运行测试,然后运行以下命令来拆除:

./test-em-all.bash start stop 

以下是一些关注启动和关闭阶段的测试运行示例输出。实际测试的输出已被移除(它们与上一章相同):

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_17.png

图 4.17:测试运行示例输出

运行这些测试后,我们可以继续了解如何排除失败的测试。

故障排除测试运行

如果运行 ./test-em-all.bash start stop 的测试失败,以下步骤可以帮助您识别问题,并在问题解决后重新启动测试:

  1. 首先,使用以下命令检查运行中的微服务状态:

    docker-compose ps 
    

    如果所有微服务都正常运行且状态良好,您将收到以下输出:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_18.png

图 4.18:检查运行中的微服务状态

  1. 如果任何微服务没有 Up 的状态,请使用 docker-compose logs 命令检查它们的日志输出中的任何错误。例如,如果您想检查 product 服务的日志输出,您将使用以下命令:

    docker-compose logs product 
    

    在这个阶段,由于微服务非常简单,因此不容易记录错误。相反,这里提供了一个来自 第六章,添加持久性 中 product 微服务的示例错误日志。假设以下内容出现在其日志输出中:

    https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_04_19.png图 4.19:日志输出中的示例错误信息

    从上面的日志输出中可以看出,product微服务无法连接到其 MongoDB 数据库。鉴于数据库也作为由同一 Docker Compose 文件管理的 Docker 容器运行,可以使用docker-compose logs命令查看数据库的问题。

    如果需要,可以使用docker-compose restart命令重新启动失败的容器。例如,如果你想重新启动product微服务,可以使用以下命令:

    docker-compose restart product 
    

    如果容器丢失,例如由于崩溃,可以使用docker-compose up -d --scale命令启动它。例如,对于product微服务,你会使用以下命令:

    docker-compose up -d --scale product=1 
    

    如果日志输出中的错误表明 Docker 正在耗尽磁盘空间,可以使用以下命令回收部分空间:

    docker system prune -f --volumes 
    
  2. 一旦所有微服务都已启动、运行且状态良好,再次运行测试脚本,但不要启动微服务:

    ./test-em-all.bash 
    

    现在测试应该可以顺利运行了!

  3. 测试完成后,记得拆除系统环境:

    docker-compose down 
    

最后,关于一个组合命令的提示,该命令从源代码构建运行时工件和 Docker 镜像,然后执行 Docker 中的所有测试:

./gradlew clean build && docker-compose build && ./test-em-all.bash start stop 

这非常适合在将新代码推送到 Git 仓库或作为构建服务器构建管道的一部分之前检查一切是否正常工作!

摘要

在本章中,我们看到了如何使用 Docker 简化一组协作微服务的测试。

我们了解到,自 Java SE v10 以来,Java SE 会尊重我们对容器施加的限制,即它们可以使用多少 CPU 和内存。我们还看到了将基于 Java 的微服务作为 Docker 容器运行所需的最小步骤。多亏了 Spring 配置文件,我们可以在 Docker 中运行微服务而无需对代码进行任何修改。

最后,我们看到了 Docker Compose 如何通过单条命令帮助我们管理一组协作的微服务,无论是手动操作,还是与测试脚本(如test-em-all.bash)集成时的自动操作。

在下一章中,我们将研究如何使用 OpenAPI/Swagger 描述添加 API 的一些文档。

问题

  1. 虚拟机和 Docker 容器之间有哪些主要区别?

  2. Docker 中的命名空间和 cgroups 的目的是什么?

  3. 如果一个 Java 应用程序不遵守容器中的最大内存设置并分配了超出允许范围的内存,会发生什么?

  4. 我们如何使基于 Spring 的应用程序作为 Docker 容器运行,而无需修改其源代码?

  5. 为什么以下 Docker Compose 代码片段无法工作?

     review:
        build: microservices/review-service
        ports:
          - "8080:8080"
        environment:
          - SPRING_PROFILES_ACTIVE=docker
      product-composite:
        build: microservices/product-composite-service
        ports:
          - "8080:8080"
        environment:
          - SPRING_PROFILES_ACTIVE=docker 
    

第五章:使用 OpenAPI 添加 API 描述

一个 API(如 RESTful 服务)的价值在很大程度上取决于其消费的难易程度。良好的、易于访问的文档是 API 是否有用的一个重要部分。在本章中,我们将学习如何使用 OpenAPI 规范来记录我们可以从微服务景观外部访问的 API。

正如我们在 第二章,Spring Boot 简介 中提到的,OpenAPI 规范(以前称为 Swagger 规范)是记录 RESTful 服务时最常用的规范之一。许多领先的 API 网关都原生支持 OpenAPI 规范。我们将学习如何使用开源项目 springdoc-openapi 来生成此类文档。我们还将学习如何嵌入 API 文档查看器,Swagger UI 查看器,它可以用来检查 API 文档并发出 API 请求。

到本章结束时,我们将拥有由 product-composite-service 微服务暴露的基于 OpenAPI 的 API 文档。该微服务还将暴露一个 Swagger UI 查看器,我们可以用它来可视化并测试 API。

本章将涵盖以下主题:

  • springdoc-openapi 的使用介绍

  • 将 springdoc-openapi 添加到源代码中

  • 构建和启动微服务景观

  • 尝试使用 OpenAPI 文档

技术要求

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

  • 第二十一章 为 mac OS 安装说明

  • 第二十二章 使用 WSL 2 和 Ubuntu 为 Microsoft Windows 安装说明

本章中的代码示例全部来自 $BOOK_HOME/Chapter05 中的源代码。

如果您想查看对本章源代码所做的更改,即查看使用 springdoc-openapi 创建基于 OpenAPI 的 API 文档所需要的内容,您可以将其与 第四章,使用 Docker 部署我们的微服务 的源代码进行比较。您可以使用您喜欢的 diff 工具比较两个文件夹,即 $BOOK_HOME/Chapter04 和 $BOOK_HOME/Chapter05。

springdoc-openapi 的使用介绍

使用 springdoc-openapi 使得将 API 的文档与实现 API 的源代码保持在一起成为可能。通过 springdoc-openapi,您可以在运行时通过检查代码中的 Java 注解来动态创建 API 文档。对我来说,这是一个重要的功能。如果 API 文档与 Java 源代码分开维护,它们最终会相互分离。根据我的经验,这种情况往往比预期的要早发生。

在 springdoc-openapi 创建之前,另一个开源项目SpringFox(springfox.github.io/springfox/)提供了类似的功能。近年来,SpringFox 项目没有积极维护,作为对此的反应,创建了 springdoc-openapi 项目。SpringFox 用户的迁移指南可以在springdoc.org/#migrating-from-springfox找到。

总的来说,将组件的接口与其实现分离是很重要的。在记录 RESTful API 方面,我们应该将 API 文档添加到描述 API 的 Java 接口中,而不是添加到实现 API 的 Java 类中。为了简化更新 API 文档的文本部分(例如,较长的描述),我们可以将描述放在属性文件中,而不是直接放在 Java 代码中。

除了动态创建 API 规范之外,springdoc-openapi 还附带了一个名为 Swagger UI 的嵌入式 API 查看器。我们将配置product-composite-service服务以暴露 Swagger UI 来查看其 API。

尽管 Swagger UI 在开发和测试阶段非常有用,但由于安全原因,通常不会在生产环境中的 API 上公开。在许多情况下,API 通过 API 网关公开。今天,大多数 API 网关产品都支持基于 OpenAPI 文档公开 API 文档。因此,而不是公开 Swagger UI,API 的 OpenAPI 文档(由 springdoc-openapi 生成)被导出到一个可以安全发布 API 文档的 API 网关。

如果预期 API 将由第三方开发者使用,可以设置一个包含文档和工具的开发者门户,例如用于自我注册。Swagger UI 可以在开发者门户中使用,允许开发者通过阅读文档并使用测试实例尝试 API 来了解 API。

在第十一章“保护 API 访问”中,我们将学习如何使用 OAuth 2.1 锁定对 API 的访问。我们还将学习如何配置 Swagger UI 组件以获取 OAuth 2.1 访问令牌,并在用户通过 Swagger UI 尝试 API 时使用这些令牌。

以下截图是 Swagger UI 的一个示例:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_01.png

图 5.1:Swagger UI 示例

在前面的图中,一些目前不重要的截图部分已被替换为“…”。我们将在本章的后面部分回到这些细节。

要启用 springdoc-openapi 创建 API 文档,我们需要在我们的构建文件中添加一些依赖项,并在定义 RESTful 服务的 Java 接口中添加一些注解。如上所述,我们还将 API 文档的描述部分放在属性文件中。

如果文档的部分内容已经放置在属性文件中以简化 API 文档的更新,那么重要的是属性文件必须与源代码在相同的生命周期和版本控制下处理。否则,存在它们开始与实现脱节的风险,即变得过时。

介绍了 springdoc-openapi 之后,让我们看看如何通过在源代码中进行必要的更改来开始使用它。

将 springdoc-openapi 添加到源代码中

要添加关于由product-composite-service微服务公开的外部 API 的基于 OpenAPI 的文档,我们需要更改两个项目的源代码:

  • product-composite-service:在这里,我们将设置 Java 应用程序类ProductCompositeServiceApplication中的 springdoc-openapi 配置,并添加一些与 API 相关的一般信息。

  • api:在这里,我们将向 Java 接口ProductCompositeService添加注解,描述每个 RESTful 服务和其操作。在这个阶段,我们只有一个 RESTful 服务和一个操作,接受对/product-composite/{productId}的 HTTP GET 请求,用于请求有关特定产品的组合信息。

用于描述 API 操作的实际文本将被放置在product-composite-service项目的默认属性文件application.yml中。

在我们开始使用 springdoc-openapi 之前,我们需要将其添加到 Gradle 构建文件中的依赖项。所以,让我们从这里开始吧!

将依赖项添加到 Gradle 构建文件中

springdoc-openapi 项目被划分为多个模块。对于api项目,我们只需要包含我们将用于文档化的 API 注解的模块。我们可以将其添加到api项目的构建文件build.gradle中,如下所示:

implementation 'org.springdoc:springdoc-openapi-starter-common:2.0.2' 

product-composite-service项目需要一个功能更全面的模块,该模块包含 Swagger UI 查看器和 Spring WebFlux 的支持。我们可以在构建文件build.gradle中添加依赖项,如下所示:

implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.0.2' 

需要添加的所有依赖项都已添加;现在让我们进行配置。

将 OpenAPI 配置和一般 API 文档添加到 ProductCompositeService

要在product-composite-service微服务中启用 springdoc-openapi,我们必须添加一些配置。为了保持源代码紧凑,我们将直接将其添加到应用程序类ProductCompositeServiceApplication.java中。

如果您愿意,可以将 springdoc-openapi 的配置放置在单独的 Spring 配置类中。

首先,我们需要定义一个 Spring Bean,它返回一个OpenAPI Bean。源代码看起来是这样的:

@Bean
public OpenAPI getOpenApiDocumentation() {
  return **new****OpenAPI****()**
    .info(new Info().title(apiTitle)
      .description(apiDescription)
      .version(apiVersion)
      .contact(new Contact()
        .name(apiContactName)
        .url(apiContactUrl)
        .email(apiContactEmail))
      .termsOfService(apiTermsOfService)
      .license(new License()
        .name(apiLicense)
        .url(apiLicenseUrl)))
    .externalDocs(new ExternalDocumentation()
      .description(apiExternalDocDesc)
      .url(apiExternalDocUrl));
} 

从前面的代码中,我们可以看到配置包含有关 API 的一般描述性信息,例如:

  • API 的名称、描述、版本和联系方式

  • 使用条款和许可信息

  • 如果有的话,有关 API 的外部信息链接

用于配置OpenAPI bean 的api变量是通过 Spring @Value注解从属性文件初始化的。具体如下:

 @Value("${api.common.version}")         String apiVersion;
  @Value("${api.common.title}")           String apiTitle;
  @Value("${api.common.description}")     String apiDescription;
  @Value("${api.common.termsOfService}")  String apiTermsOfService;
  @Value("${api.common.license}")         String apiLicense;
  @Value("${api.common.licenseUrl}")      String apiLicenseUrl;
  @Value("${api.common.externalDocDesc}") String apiExternalDocDesc;
  @Value("${api.common.externalDocUrl}")  String apiExternalDocUrl;
  @Value("${api.common.contact.name}")    String apiContactName;
  @Value("${api.common.contact.url}")     String apiContactUrl;
  @Value("${api.common.contact.email}")   String apiContactEmail; 

实际值在属性文件application.yml中设置,如下所示:

api:
  common:
    version: 1.0.0
    title: Sample API
    description: Description of the API...
    termsOfService: MY TERMS OF SERVICE
    license: MY LICENSE
    licenseUrl: MY LICENSE URL
    externalDocDesc: MY WIKI PAGE
    externalDocUrl: MY WIKI URL
    contact:
      name: NAME OF CONTACT
      url: URL TO CONTACT
      email: contact@mail.com 

属性文件还包含一些针对 springdoc-openapi 的配置:

springdoc:
  swagger-ui.path: /openapi/swagger-ui.html
  api-docs.path: /openapi/v3/api-docs
  packagesToScan: se.magnus.microservices.composite.product
  pathsToMatch: /** 

配置参数有以下目的:

  • springdoc.swagger-ui.path 和 springdoc.api-docs.path 用于指定嵌入的 Swagger UI 查看器使用的 URL 在路径/openapi下可用。在本书的后续内容中,当我们添加不同类型的边缘服务器并解决安全挑战时,这将简化边缘服务器的配置。有关更多信息,请参阅以下章节:

    • 第十章,使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务

    • 第十一章,保护 API 访问

    • 第十七章,通过实现 Kubernetes 功能简化系统景观,替换 Spring Cloud Gateway部分

    • 第十八章,使用服务网格提高可观察性和管理,用 Istio Ingress Gateway 替换 Kubernetes Ingress 控制器部分

  • springdoc.packagesToScan 和 springdoc.pathsToMatch 控制 springdoc-openapi 在代码库中搜索注解的位置。我们能够给 springdoc-openapi 提供的范围越窄,扫描的速度就越快。

详细信息请参阅product-composite-service项目中的应用程序类ProductCompositeServiceApplication.java和application.yml属性文件。现在我们可以继续了解如何在api项目中的 Java 接口ProductCompositeService.java中添加 API 特定文档。

向 ProductCompositeService 接口添加 API 特定文档

为了记录实际的 API 及其 RESTful 操作,我们将在api项目中的ProductCompositeService.java接口声明上添加一个@Tag注解。对于 API 中的每个 RESTful 操作,我们将在相应的 Java 方法上添加一个@Operation注解,以及@ApiResponse注解,以描述操作及其预期的响应。我们将描述成功和错误响应。

除了在运行时读取这些注解外,springdoc-openapi 还会检查 Spring 注解,例如@GetMapping注解,以了解操作所接受的输入参数以及如果产生成功响应,响应将呈现什么样子。为了了解潜在错误响应的结构,springdoc-openapi 将寻找@RestControllerAdvice和@ExceptionHandler注解。在第三章创建一组协作微服务中,我们在util项目中添加了一个实用类,GlobalControllerExceptionHandler.java。

这个类被注解为 @RestControllerAdvice。有关详细信息,请参阅 全局 REST 控制器异常处理器 部分。异常处理器负责处理 404 (NOT_FOUND) 和 422 (UNPROCESSABLE_ENTITY) 错误。为了允许 springdoc-openapi 正确记录 Spring WebFlux 在发现请求中的不正确输入参数时生成的 400 (BAD_REQUEST) 错误,我们还在 GlobalControllerExceptionHandler.java 中添加了一个 @ExceptionHandler 用于 400 (BAD_REQUEST) 错误。

资源级别的 API 文档,对应于 Java 接口声明,如下所示:

@Tag(name = "ProductComposite", description = 
  "REST API for composite product information.")
public interface ProductCompositeService { 

对于 API 操作,我们将 @Operation 和 @ApiResponse 注解中使用的实际文本提取到了属性文件中。注解包含属性占位符,如 ${name-of-the-property},springdoc-openapi 将在运行时使用这些占位符从属性文件中查找实际文本。API 操作的文档如下所示:

@Operation( 
  summary = 
    "${api.product-composite.get-composite-product.description}",
  description = 
    "${api.product-composite.get-composite-product.notes}")
@ApiResponses(value = {
  @ApiResponse(responseCode = "200", description = 
    "${api.responseCodes.ok.description}"),
  @ApiResponse(responseCode = "**400**", description = 
    "${api.responseCodes.badRequest.description}"),
  @ApiResponse(responseCode = "**404**", description =   
    "${api.responseCodes.notFound.description}"),
  @ApiResponse(responseCode = "**422**", description =   
    "${api.responseCodes.unprocessableEntity.description}")
})
**@GetMapping**(
  value = **"/product-composite/{productId}"**,
  produces = "application/json")
ProductAggregate getProduct**(****@PathVariable****int** **productId)**; 

从前面的源代码中,springdoc-openapi 将能够提取以下关于操作的信息:

  • 该操作接受对 URL /product-composite/{productid} 的 HTTP GET 请求,其中 URL 的最后一部分 {productid} 被用作请求的输入参数。

  • 成功的响应将生成与 Java 类 ProductAggregate 对应的 JSON 结构。

  • 在发生错误的情况下,将返回 HTTP 错误代码 400、404 或 422,并在响应体中包含错误信息,如 Java 类 GlobalControllerExceptionHandler.java 中的 @ExceptionHandler 所描述的,如上所述。

对于 @Operation 和 @ApiResponse 注解中指定的值,我们可以直接使用属性占位符,而无需使用 Spring @Value 注解。实际值在属性文件 application.yml 中设置,如下所示:

**api:**
**responseCodes:**
**ok.description:****OK**
    badRequest.description: Bad Request, invalid format of the request. See response message for more information
    notFound.description: Not found, the specified id does not exist
    unprocessableEntity.description: Unprocessable entity, input parameters caused the processing to fail. See response message for more information
  product-composite:
    get-composite-product:
      description: Returns a composite view of the specified product id
      notes: **|**
        # Normal response
        If the requested product id is found the method will return information regarding:
        1\. Base product information
        1\. Reviews
        1\. Recommendations
        1\. Service Addresses\n(technical information regarding the addresses of the microservices that created the response)
        # Expected partial and error responses
        In the following cases, only a partial response be created (used to simplify testing of error conditions)
        ## Product id 113
        200 - Ok, but no recommendations will be returned
        ## Product id 213
        200 - Ok, but no reviews will be returned
        ## Non-numerical product id
        400 - A **Bad Request** error will be returned
        ## Product id 13
        404 - A **Not Found** error will be returned
        ## Negative product ids
        422 - An **Unprocessable Entity** error will be returned 

从前面的配置中,我们可以了解以下内容:

  • 例如,属性占位符 ${api.responseCodes.ok.description} 将被翻译为 OK。注意基于 YAML 的属性文件的层次结构:

    api:
      responseCodes:
        ok.description: OK 
    
  • 多行值以 | 开头,例如属性 api.get-composite-product.description.notes 的值。此外,请注意 springdoc-openapi 支持使用 Markdown 语法提供多行描述。

有关详细信息,请参阅 api 项目中的服务接口类 ProductCompositeService.java 和 product-composite-service 项目中的属性文件 application.yml。

如果你想了解更多关于 YAML 文件结构的信息,请查看规范:yaml.org/spec/1.2/spec.html。

构建 和 启动 微服务环境

在我们尝试 OpenAPI 文档之前,我们需要构建并启动微服务环境!

这可以通过以下命令完成:

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

你可能会遇到一个关于端口 8080 已经被分配的错误消息。这看起来如下:

ERROR: for product-composite Cannot start service product-composite: driver failed programming external connectivity on endpoint chapter05_product-composite_1 (0138d46f2a3055ed1b90b3b3daca92330919a1e7fec20351728633222db5e737): Bind for 0.0.0.0:8080 failed: port is already allocated 

如果是这样,你可能忘记从上一章拉取微服务景观。要找出正在运行的容器的名称,请运行以下命令:

 docker ps --format {{.Names}} 

当上一章的微服务景观仍在运行时的一个示例响应如下:

chapter05_review_1
chapter05_product_1
chapter05_recommendation_1
chapter04_review_1
chapter04_product-composite_1
chapter04_product_1
chapter04_recommendation_1 

如果你在命令输出中发现了来自其他章节的容器,例如,来自 第四章,使用 Docker 部署我们的微服务,就像前面的例子中那样,你需要跳转到该章节的源代码文件夹并拉取其容器:

cd ../Chapter04
docker-compose down 

现在,你可以启动本章缺失的容器:

cd ../Chapter05
docker-compose up -d 

注意,由于其他容器已经成功启动,命令仅启动了缺失的容器 product-composite:

Starting chapter05_product-composite_1 ... done 

要等待微服务景观启动并验证其是否正常工作,你可以运行以下命令:

./test-em-all.bash 

注意,测试脚本 test-em-all.bash 已扩展,包含一组测试,以验证 Swagger UI 端点按预期工作:

# Verify access to Swagger and OpenAPI URLs
echo "Swagger/OpenAPI tests"
assertCurl 302 "curl -s  http://$HOST:$PORT/openapi/swagger-ui.html"
assertCurl 200 "curl -sL http://$HOST:$PORT/openapi/swagger-ui.html"
assertCurl 200 "curl -s  http://$HOST:$PORT/openapi/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config"
assertCurl 200 "curl -s  http://$HOST:$PORT/openapi/v3/api-docs"
assertEqual "3.0.1" "$(echo $RESPONSE | jq -r .openapi)"
assertEqual "http://$HOST:$PORT" "$(echo $RESPONSE | jq -r '.servers[0].url')"
assertCurl 200 "curl -s  http://$HOST:$PORT/openapi/v3/api-docs.yaml" 

在微服务成功启动后,我们可以继续尝试使用其嵌入的 Swagger UI 查看器测试 product-composite 微服务暴露的 OpenAPI 文档。

尝试 OpenAPI 文档

要浏览 OpenAPI 文档,我们将使用嵌入的 Swagger UI 查看器。如果我们在一个网络浏览器中打开 localhost:8080/openapi/swagger-ui.html URL,我们将看到一个类似于以下截图的网页:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_02.png

图 5.2:带有 Swagger UI 查看器的 OpenAPI 文档

在这里,我们可以确认以下内容:

  1. 我们在 springdoc-openapi OpenAPI bean 中指定的通用信息以及指向实际 OpenAPI 文档的链接,/openapi/v3/api-docs,指向 http://localhost:8080/openapi/v3/api-docs。

    注意,这是可以导出到 API 网关的 OpenAPI 文档链接,如上节 springdoc-openapi 使用介绍 中所述。

  2. API 资源列表;在我们的例子中,是 ProductComposite API。

  3. 在页面底部,有一个我们可以检查 API 中使用的模式的区域。

    按照以下步骤进行 API 文档的检查。

  4. 点击 ProductComposite API 资源以展开它。您将获得该资源上可用的操作列表。您将只看到一个操作,/product-composite/{productId}。

  5. 点击它以展开。您将看到我们指定在 ProductCompositeService Java 接口中的操作文档:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_03.png

图 5.3:ProductComposite API 文档

在这里,我们可以看到以下内容:

  • 操作的一行描述。

  • 一个包含操作细节的章节,包括它支持的输入参数。注意 @ApiOperation 注解中 notes 字段中的 Markdown 语法已经被很好地渲染了!

如果你向下滚动网页,你也会找到有关预期响应及其结构的文档,包括正常 200(OK)响应的文档…

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_04.png

图 5.4:200 响应的文档

…以及我们之前定义的各种 4xx 错误响应,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_05.png

图 5.5:4xx 响应的文档

对于每个文档化的潜在错误响应,我们可以了解其含义和响应体的结构。

如果我们向上滚动到参数描述,我们会找到 尝试一下 按钮。如果我们点击该按钮,我们可以填写实际的参数值,并通过点击 执行 按钮向 API 发送请求。例如,如果我们将在 productId 字段中输入 123,我们将得到以下响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_06.png

图 5.6:发送现有产品请求后的响应

我们将得到预期的 200(OK)作为响应代码,以及我们已熟悉的 JSON 结构在响应体中!

如果我们输入一个错误的输入,例如 -1,我们将得到一个适当的错误代码作为响应代码,422,以及响应体中的基于 JSON 的错误描述:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_05_07.png

图 5.7:发送无效输入后的响应

注意响应体中的 message 字段清楚地指出了问题:“Invalid productId: -1”。

如果你想在不使用 Swagger UI 查看器的情况下尝试调用 API,你可以从 响应 部分复制相应的 curl 命令,并在终端窗口中运行它,如前一个截图所示:

curl -X GET "http://localhost:8080/product-composite/123" -H "accept: application/json" 

太棒了,不是吗?

摘要

良好的 API 文档对于其接受至关重要,而 OpenAPI 是在文档化 RESTful 服务时最常用的规范之一。springdoc-openapi 是一个开源项目,它通过检查 Spring WebFlux 和 Swagger 注解,使得在运行时动态创建基于 OpenAPI 的 API 文档成为可能。API 的文本描述可以从 Java 源代码中的注解中提取出来,并放置在属性文件中以方便编辑。springdoc-openapi 可以配置为将内嵌的 Swagger UI 查看器引入微服务中,这使得阅读微服务公开的 API 以及从查看器中尝试它们变得非常容易。

现在,关于通过添加持久性来给我们的微服务带来活力,也就是说,将那些微服务的数据保存到数据库中的能力,我们该怎么办?为此,我们需要添加一些更多的 API,以便我们可以创建和删除由微服务处理的信息。前往下一章了解更多!

问题

  1. Springdoc-openapi 是如何帮助我们为 RESTful 服务创建 API 文档的?

  2. Springdoc-openapi 支持哪种 API 文档规范?

  3. springdoc-openapi 的OpenAPI bean 的用途是什么?

  4. 列举一些 springdoc-openapi 在运行时读取的注解,以动态创建 API 文档。

  5. 在 YAML 文件中,代码“: |"代表什么意思?

  6. 如何在不再次使用查看器的情况下重复调用使用嵌入式 Swagger UI 查看器执行的 API 调用?

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/QR_Code1849216352344398875.png

第六章:添加持久化

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

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

本章将涵盖以下主题:

  • 将持久化层添加到核心微服务

  • 编写关注持久化的自动化测试

  • 在服务层中使用持久化层

  • 扩展组合服务 API

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

  • 新 API 和持久化层的手动测试

  • 更新微服务景观的自动化测试

技术要求

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

  • 第二十一章,macOS 安装说明

  • 第二十二章,使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

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

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

如果你想查看本章源代码中应用的变化,即查看使用 Spring Data 将持久化添加到微服务中所需的内容,你可以将其与第五章,使用 OpenAPI 添加 API 描述的源代码进行比较。你可以使用你喜欢的diff工具比较两个文件夹,$BOOK_HOME/Chapter05和$BOOK_HOME/Chapter06。

在深入细节之前,让我们看看我们将走向何方。

本章目标

到本章结束时,我们微服务中的层级结构将如下所示:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_01.png

图 6.1:我们追求的微服务景观

协议层处理特定协议的逻辑。它非常薄,仅由api项目中的RestController注解和util项目中的通用GlobalControllerExceptionHandler组成。每个微服务的主要功能都位于各自的服务层。

product-composite服务包含一个集成层,用于处理与三个核心微服务的通信。核心微服务都将有一个持久层,用于与它们的数据库通信。

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_02.png

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

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_03.png

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

mongo和mysql命令的输出已被缩短以提高可读性。

让我们看看如何实现这一点。我们将首先为我们的核心微服务添加持久性功能!

向核心微服务添加持久层

让我们从向核心微服务添加持久层开始。除了使用 Spring Data,我们还将使用一个 Java Bean 映射工具,MapStruct,它使得在 Spring Data 实体对象和 API 模型类之间转换变得容易。有关更多详细信息,请参阅mapstruct.org/。

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

添加依赖项

我们将使用 MapStruct v1.5.3,因此我们将首先在各个核心微服务的构建文件build.gradle中定义一个变量来持有版本信息:

ext {
  mapstructVersion = "1.5.3.Final"
} 

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

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

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

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

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

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

如果你使用的是 IntelliJ IDEA,还需要确保启用了注解处理支持。打开首选项,导航到构建、执行、部署 | 编译器 | 注解处理器。请确保名为启用注解处理的复选框被选中!

对于product和recommendation微服务,我们声明以下对 Spring Data MongoDB 的依赖项:

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

对于review微服务,我们声明了对 Spring Data JPA 和 MySQL JDBC 驱动的依赖项如下:

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

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

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。与 API 模型类相比,实体类中我们将添加两个字段,id和version。

id字段用于存储每个存储实体的数据库标识符,对应于使用关系型数据库时的主键。我们将委托 Spring Data 生成id字段的唯一值的责任。根据所使用的数据库,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注解来标记id和version字段,以便 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 注解为基于 productId 和 recommendationId 字段的复合业务键创建一个唯一的复合索引。

最后,用于在 SQL 数据库(如 MySQL)中存储实体的 Review 实体类中最有趣的部分如下:

**@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 的实体类——映射到名为 reviews 的 SQL 数据库表。

  • @Table 注解也用于指定将基于 productId 和 reviewId 字段创建一个唯一的复合索引,用于复合业务键。

  • @Id 和 @Version 注解用于标记 id 和 version 字段,以便 Spring Data 使用,如前所述。为了指导 Spring Data 使用 JPA 自动为 id 字段生成唯一的 id 值,我们使用了 @GeneratedValue 注解。

实体类的完整源代码,请参阅每个核心微服务项目中的 persistence 包。

在 Spring Data 中定义仓库

Spring Data 提供了一套用于定义仓库的接口。我们将使用 CrudRepository 和 PagingAndSortingRepository 接口:

  • CrudRepository 接口提供了在数据库中存储的数据上执行基本创建、读取、更新和删除操作的标准方法。

  • PagingAndSortingRepository 接口为 CrudRepository 接口添加了对分页和排序的支持。

我们将使用 CrudRepository 接口作为 Recommendation 和 Review 仓库的基础,也将 PagingAndSortingRepository 接口作为 Product 仓库的基础。

我们还将为我们的仓库添加一些额外的查询方法,用于使用业务键 productId 查找实体。

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

Product 仓库类看起来是这样的:

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

由于 findByProductId 方法可能返回零个或一个产品实体,因此返回值被标记为可选的,通过将其包装在 Optional 对象中来实现。

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包。

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

编写关注持久化的自动化测试

在编写持久化测试时,我们希望在测试开始时启动数据库,在测试完成后将其关闭。然而,我们不想让测试等待其他资源启动,例如,一个运行时所需的 Web 服务器(如 Netty)。

Spring Boot 提供了两个针对此特定要求的类级别注解:

  • @DataMongoTest:这个注解在测试开始时启动一个 MongoDB 数据库。

  • @DataJpaTest:这个注解在测试开始时启动一个 SQL 数据库:

    • 默认情况下,Spring Boot 会将测试配置为回滚 SQL 数据库的更新,以最小化对其他测试的负面影响。在我们的案例中,这种行为将导致一些测试失败。因此,使用类级别的注解@Transactional(propagation = NOT_SUPPORTED)禁用了自动回滚。

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

使用 Testcontainers

Testcontainers (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:8.0.32"); 

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

这种方法的缺点是每个测试类都会使用自己的 Docker 容器。在 Docker 容器中启动 MySQL 需要几秒钟,通常在我的 Mac 上需要 10 秒钟。运行多个使用相同类型测试容器的测试类将为每个测试类增加这种延迟。为了避免这种额外的延迟,我们可以使用单容器模式(见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:8.0.32").withStartupTimeoutSeconds(300);

  **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 的product和review微服务,已添加相应的基类MongoDbTestBase。

默认情况下,Testcontainers 的日志输出相当详细。可以在src/test/resource文件夹中放置一个Logback配置文件来限制日志输出的数量。Logback 是一个日志框架(logback.qos.ch),它通过使用spring-boot-starter-webflux依赖项包含在微服务中。有关详细信息,请参阅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日志级别,丢弃 Testcontainers 库发出的DEBUG和TRACE日志记录。

有关 Spring Boot 对日志的支持和 Logback 的使用详情,请参阅docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-configure-logback-for-logging。

最后,当使用@DataMongoTest和@DataJpaTest注解而不是@SpringBootTest注解,仅在集成测试期间启动 MongoDB 和 SQL 数据库时,还有一件事需要考虑。@DataJpaTest注解默认设计为启动嵌入式数据库。由于我们想使用容器化数据库,我们必须禁用此功能。

对于@DataJpaTest注解,可以通过使用类似这样的@AutoConfigureTestDatabase注解来完成:

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

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

编写持久化测试

对于三个核心微服务的持久化测试彼此相似,因此我们只需通过product微服务的持久化测试来了解。

测试类PersistenceTests声明了一个带有@BeforeEach注解的方法setupDb(),该方法在每个测试方法执行前执行。设置方法会从数据库中移除之前测试中创建的任何实体,并插入一个测试方法可以用来作为测试基础的实体:

@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字段被 Spring Data 设置为0,因此我们期望更新后它为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()返回的实体与setup方法存储的实体看起来相同。

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

@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());
} 

从代码中可以观察到以下内容:

  • 首先,测试读取相同的实体两次,并将其存储在两个不同的变量entity1和entity2中。

  • 接下来,它使用其中一个变量entity1来更新实体。数据库中实体的更新将导致 Spring Data 自动增加实体的version字段。另一个变量entity2现在包含过时数据,这体现在其version字段上,该字段持有比数据库中相应值更低的值。

  • 当测试尝试使用包含过时数据的变量entity2更新实体时,它预期会失败,并抛出OptimisticLockingFailureException异常。

  • 测试通过断言数据库中的实体反映了第一次更新,即包含名称"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);
} 

上述代码的解释:

  • 测试首先删除任何现有数据,然后插入具有productId字段从1001到1010的 10 个实体。

  • 接下来,它创建PageRequest,请求每页4个实体,并按ProductId升序排序。

  • 最后,它使用辅助方法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。

可以使用类似以下命令的 Gradle 执行product微服务中的持久化测试:

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

运行测试后,它应该响应如下:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_04.png

图 6.4:BUILD SUCCESSFUL 响应

在设置持久化层之后,我们可以更新核心微服务中的服务层以使用持久化层。

在服务层中使用持久化层

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

  1. 记录数据库连接 URL

  2. 添加新 API

  3. 从服务层调用持久化层

  4. 声明 Java Bean 映射器

  5. 更新服务测试

记录数据库连接 URL

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

例如,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方法的调用将像以下内容一样写入日志:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_05.png

图 6.5:预期的日志输出

对于完整的源代码,请查看每个核心微服务项目中的主应用程序类,例如product-service项目中的ProductServiceApplication。

添加新的 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)。

recommendation和review实体的 API 操作看起来类似;然而,请注意,当涉及到recommendation和review实体的删除操作时,它将删除指定productId的所有recommendations和reviews。

对于完整的源代码,请查看api项目中核心微服务的接口声明(ProductService、RecommendationService和ReviewService)。

从服务层调用持久层

在服务层使用持久层的源代码对所有核心微服务都是结构化的。因此,我们只需查看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 模型类和实体类之间使用两个映射方法apiToEntity()和entityToApi()进行 Java bean 的转换。我们为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()方法来查找产品实体。由于存储库方法返回一个Optional产品,我们可以使用Optional类中的orElseThrow()方法方便地抛出NotFoundException异常,如果未找到产品实体。在返回产品信息之前,使用serviceUtil对象来填写当前使用的微服务地址。

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

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

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

要查看完整的源代码,请参阅每个核心微服务项目中的服务实现类,例如,在product-service项目中查看ProductServiceImpl。

声明 Java bean 映射器

那么,关于神奇的 Java bean 映射器,又是怎样的呢?

如前所述,MapStruct 用于声明我们的映射类。在所有三个核心微服务中,MapStruct 的使用方式相似,因此我们只需查看product微服务中映射对象的源代码。

product服务的mapper类如下所示:

@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()方法被注解为忽略 API 模型类中缺失的id和version字段。

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-service项目中,ProductMapperImpl.java。

要查看完整的源代码,请参阅每个核心微服务项目中的映射类,例如,在product-service项目中查看ProductMapper。

更新服务测试

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

添加的测试在所有三个核心微服务中都是相似的,所以我们只通过product微服务中的服务测试源代码进行说明。

为了确保每个测试都有一个已知的状态,声明了一个setupDb()设置方法,并使用@BeforeEach注解,因此它在每个测试之前执行。设置方法删除任何之前创建的实体:

@Autowired
private ProductRepository repository;
@**BeforeEach**
void **setupDb**() {
   repository.deleteAll();
} 

创建 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();**
} 

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

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

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

扩展复合服务 API

在本节中,我们将了解如何通过创建和删除复合实体的操作来扩展复合 API。我们将按照以下步骤进行:

  1. 向复合服务 API 添加新操作

  2. 将方法添加到集成层

  3. 实现新的复合 API 操作

  4. 更新复合服务测试

向复合服务 API 添加新操作

创建和删除实体以及处理聚合实体的复合版本与核心服务 API 中的创建和删除操作类似。主要区别是它们添加了用于基于 OpenAPI 的文档的注解。有关 OpenAPI 注解@Operation和@ApiResponse的用法说明,请参阅第五章,使用 OpenAPI 添加 API 描述,特别是将 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); 

对于完整的源代码,请参阅api项目中的 Java 接口ProductCompositeService。

我们还需要像以前一样,将 API 文档的描述性文本添加到product-composite项目中的属性文件application.yml中:

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 文档将看起来像这样:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_06.png

图 6.6:更新后的 OpenAPI 文档

在本章的后面部分,我们将使用 Swagger UI 查看器来尝试新的组合 API 操作。

向集成层添加方法

在我们能够在组合服务中实现新的创建和删除 API 之前,我们需要扩展集成层,使其能够调用核心微服务的 API 中的底层创建和删除操作。

集成层中调用三个核心微服务的创建和删除操作的方法简单直接,彼此相似,所以我们只将通过调用product微服务的方法的源代码进行说明。

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);
    }
} 

它的实现方式与创建方法相同,但执行的是一个 HTTP delete请求。

集成层的完整源代码可以在product-composite项目中的ProductCompositeIntegration类中找到。

实现新的组合 API 操作

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

组合创建方法将聚合产品对象拆分为离散的product、recommendation和review对象,并在集成层中调用相应的创建方法:

@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;
    }
} 

组合删除方法简单地调用集成层中的三个删除方法,以删除底层数据库中的相应实体:

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

服务实现的完整源代码可以在product-composite项目中的ProductCompositeServiceImpl类中找到。

对于愉快的场景,这种实现将工作得很好,但如果考虑各种错误场景,我们会看到这种实现将引起麻烦!

例如,如果底层核心微服务因内部、网络或数据库问题暂时不可用,会怎样呢?

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

在下一章,第七章,开发响应式微服务中,我们将看到如何使用同步 API(如 RESTful API)来解决这个问题。

现在,让我们带着这个脆弱的设计继续前进。

更新组合服务测试

如同在 第三章 中提到的,创建一组协作的微服务(参考 在隔离中添加自动微服务测试 部分),测试组合服务仅限于使用简单的模拟组件,而不是实际的核心服务。这限制了我们对更复杂场景的测试,例如,在底层数据库中尝试创建重复项时的错误处理。

因此,组合创建和删除 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);
} 

服务测试的完整源代码可以在 product-composite 项目的 ProductCompositeServiceApplicationTests 类中找到。

这些都是在源代码中需要进行的所有更改。在我们能够一起测试微服务之前,我们必须学习如何将数据库添加到由 Docker Compose 管理的系统景观中。

将数据库添加到 Docker Compose 景观中

现在,我们已经准备好了所有源代码。在我们能够启动微服务景观并尝试使用新的持久层一起测试新的 API 之前,我们必须启动一些数据库。

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

Docker Compose 配置

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

 mongodb:
    image: **mongo:6.0.4**
    mem_limit: 512m
    ports:
      - **"27017:27017"**
    command: mongod
    **healthcheck**:
      test: "mongo --eval 'db.stats().ok'"
      interval: 5s
      timeout: 2s
      retries: 60
  mysql:
    image: **mysql:8.0.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 

有关前面代码的说明:

  • 我们将使用 MongoDB v6.0.4 和 MySQL 8.0.32 的官方 Docker 镜像,并将它们的默认端口 27017 和 3306 转发到 Docker 主机,当使用 Docker Desktop for Mac 时,这些端口也将在 localhost 上可用。

  • 对于 MySQL,我们还声明了一些环境变量,定义以下内容:

    • 根密码

    • 容器启动时将创建的数据库名称

    • 在容器启动时为数据库设置的用户的用户名和密码

  • 我们还声明了一个健康检查,Docker 将运行以确定 MongoDB 和 MySQL 数据库的状态。

为了避免微服务在它们启动和运行之前尝试连接到数据库,product 和 recommendation 服务被声明为依赖于 MongoDB 数据库,如下所示:

depends_on:
  mongodb:
    condition: service_healthy 

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

depends_on:
  mysql:
    condition: service_healthy 

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

数据库连接配置

数据库已就绪后,我们现在需要设置核心微服务的配置,以便它们知道如何连接到它们的数据库。这通过每个核心微服务的配置文件application.yml在product-service、recommendation-service和review-service项目中设置。

product和recommendation服务的配置相似,因此我们只需查看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** 

代码的前重要部分:

  • 在不使用 Docker 并使用默认 Spring 配置文件的情况下运行时,数据库预期可以通过localhost:27017进行访问。

  • 将MongoTemplate的日志级别设置为DEBUG将允许我们在日志中看到哪些 MongoDB 语句被执行。

  • 在使用 Spring 配置文件docker在 Docker 内部运行时,数据库预期可以通过mongodb:27017进行访问。

影响其如何连接 SQL 数据库的review服务的配置如下:

**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 

代码的前解释:

  • 默认情况下,Hibernate 将由 Spring Data JPA 用作 JPA 的 EntityManager。

  • spring.jpa.hibernate.ddl-auto属性用于告诉 Spring Data JPA 在启动时创建新表或更新现有 SQL 表。

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

  • 在不使用 Docker 的情况下,使用默认的 Spring 配置文件,数据库预期可以通过localhost使用默认端口3306进行访问。

  • 默认情况下,Spring Data JPA 使用 HikariCP 作为 JDBC 连接池。为了最小化在硬件资源有限的计算机上的启动问题,initializationFailTimeout参数设置为 60 秒。这意味着 Spring Boot 应用程序将在启动时最多等待 60 秒以建立数据库连接。

  • Hibernate 的日志级别设置将导致 Hibernate 打印使用的 SQL 语句和实际使用的值。请注意,当在生产环境中使用时,出于隐私原因,应避免将实际值写入日志。

  • 在使用 Spring 配置文件docker在 Docker 内部运行时,数据库预期可以通过mysql主机名使用默认端口3306进行访问。

在此配置就绪后,我们就可以启动系统景观了。但在我们这样做之前,让我们学习如何运行数据库 CLI 工具。

MongoDB 和 MySQL CLI 工具

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

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

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

docker-compose exec mongodb mongosh ––quiet
> 

输入exit以离开mongo CLI。

要在mysql容器内启动 MySQL CLI 工具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 Compose 启动系统景观。接下来,我们将使用 Swagger UI 查看器进行一些手动测试。最后,我们将使用数据库 CLI 工具查看插入到数据库中的数据。

使用以下命令构建并启动系统景观:

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

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

  1. 点击ProductComposite服务和POST方法以展开它们。

  2. 点击尝试操作按钮,然后下滑到 body 字段。

  3. 将productId字段的默认值0替换为123456。

  4. 滚动到执行按钮并点击它。

  5. 验证返回的响应代码是200。

以下是在点击执行按钮后的示例截图:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_07.png

图 6.7:测试服务器响应

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_08.png

图 6.8:docker-compose up 命令的日志输出

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

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

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

预期得到如下响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_09.png

图 6.9:查找产品

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

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

预期会收到如下响应:

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_10.png

图 6.10:查找推荐内容

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_11.png

图 6.11:查找评论

通过中断docker-compose up命令并按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函数在waitForService函数之后直接调用:

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

waitForService函数的主要目的是验证所有微服务都已启动并运行。在前一章中,使用了复合产品服务的 get API。在本章中,使用的是 delete API。当使用 get API 时,如果实体未找到,则只会调用product核心微服务;不会调用recommendation和review服务来验证它们是否已启动并运行。对 delete API 的调用也将确保productId 13上的未找到测试成功。在下一章中,我们将看到如何为检查微服务景观的健康状态定义特定的 API。

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

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

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

https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/msvc-spbt3-spcld/img/B19825_06_12.png

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

这标志着微服务景观自动化测试更新的结束。

摘要

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

我们也看到了持久化层如何被服务层使用,以及我们如何添加创建和删除实体(无论是核心的还是组合的)的 API。

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

然而,在本章中确定了一个主要问题。使用同步 API 更新(创建或删除)一个组合实体——一个其部分存储在多个微服务中的实体——如果所有涉及的微服务都没有成功更新,可能会导致不一致性。这通常是不被接受的。这引出了下一章,我们将探讨为什么以及如何构建反应式微服务,即可扩展且健壮的微服务。

问题

  1. 基于实体和仓库的通用编程模型 Spring Data 可以用于不同类型的数据库引擎。从本章的源代码示例中,MySQL 和 MongoDB 的持久化代码最重要的区别是什么?

  2. 使用 Spring Data 实现乐观锁需要哪些要求?

  3. MapStruct 用于什么?

  4. 如果一个操作是幂等的,这意味着什么?为什么这很有用?

  5. 我们如何在不使用 API 的情况下访问存储在 MySQL 和 MongoDB 数据库中的数据?

如何把微服务(Springboot)注册到SpringCloud Eureka中 前提是: (1)必须有一个springboot项目存在,作为服务的提供者或者消费者,在这个基础上再进行Eureka注册与发现! (2)必须提供一个Eureka Server服务,并且可以通过类似于http://localhost:8761/访问到Eureka Server服务 1.在pom.xml中引入spring cloud依赖,进行版本管理 <!-- 引入spring cloud的依赖 --> <dependencyManagement> <dep 阅读详情

相关推荐

《深入理解SpringCloud与微服务构建》_sample.pdf

《深入理解SpringCloud与微服务构建》_sample.pdf 《深入理解SpringCloud与微服务构建》_sample.pdf 《深入理解SpringCloud与微服务构建》_sample.pdf 《深入理解SpringCloud与微服务构建》_sample.pdf

SpringBoot微服务架构学习

YAML是一种直观的能够被电脑识别的数据序列化格式,并且能够被人阅读,容易和脚本语言交互的,可以被支持YAML库的不同的编程语言程序导入;profile功能就是用来进行动态配置切换的。· 我们的工程继承spring-boot-starter-parent,引入starter之后,通过依赖传递,可以简单方便获得需要的jar包,并且不会存在版本冲突等问题。@ResponseBody 的作用是指该类中所有的API接口返回的数据,对应的方法返回Map或是其他Object,都会以Json字符串的形式返回给客户端。

yjh_________的博客 2134

方志朋版——深入理解Spring Cloud与微服务构建.pdf

方志朋版——深入理解Spring Cloud与微服务构建,学习 Cloud可以看下,内容不太深

微服务基础技能 SpringBoot详解!

微服务基础技能 SpringBoot详解!一片大文章从入门到实战不来看看吗?

SYJ的博客 4466

深入理解spring cloud与微服务构建 pdf高清完整版

深入理解spring cloud与微服务构建 pdf高清完整版下载 带目录

生成式引擎优化(GEO):启动GEO优化的5个有效策略

随着生成式AI的普及,传统SEO正被GEO(生成式引擎优化)取代。本文提出GEO五大核心策略:用户意图解析、结构化内容设计、语义关键词优化、对话式内容创作和权威信源建设。研究表明,优化后的内容在AI答案中的引用率提升300%,点击率增长27%。文章还介绍了BERT模型、Schema标记等关键技术工具,并指出未来GEO将向多模态优化、实时数据更新方向发展。企业需避免简单堆砌关键词,应建立长期内容策略,通过结构化、权威性内容在AI时代保持竞争优势。

解锁AI流量新红利!🚀 GEO优化(生成引擎优化)助您掌控AI搜索,让AI成为您的品牌最佳代言人! 2786

Springboot(3.0)->Springcloud(2022.0.1)踩坑指南

springcloud碎碎念

xq_886的博客 1149

华为OD机试真题 - 矩阵匹配 牛客网

2024华为OD机试真题,代码包含语言java、js、python、c 代码基本都有详细注释。 题目描述 从一个 N * M(N ≤ M)的矩阵中选出 N 个数,任意两个数字不能在同一行或同一列,求选出来的 N 个数中第 K 大的数字的最小值是多少。 输入描述 输入矩阵要求:1 ≤ K ≤ N ≤ M ≤ 150 输入格式: N M K N*M矩阵 输出描述 N*M 的矩阵中可以选出 M! / N! 种组合数组,每个组合数组种第 K 大的数中的最小值。无需考虑重复数字,直接取字典排序结果即可。

2301_76543445的博客 498

SpringCloud微服务项目实战(一)---搭建SpringBoot项目

问题:新建子模块后,pom文件的图标是xml文件标识,而不是m标识,而且目录结构是com-xxx-xxx这样一层一层的,而不是package包名一起的解决:idea工作台左侧右键pom文件,点击Add As Maven Project,如下图问题:删除子模块后,再新增同样名称的子模块,pom文件不生效且出现了删除线。解决:File–Settings–Build–Maven–IgnoredFiles中将对勾取消,如下图。

xiaobai178的博客 6019

SOLIDWORKS转CAD字体终极选择:TrueType vs SHX,哪个才是你图纸协作的“安全牌”?

本文深入探讨SOLIDWORKS转CAD工程图中的字体选择问题,对比TrueType与SHX字体在DWG文件协作中的表现。通过真实案例揭示字体映射的兼容性陷阱,提供企业级字体管理方案,帮助工程师规避版本差异、系统依赖和法律风险,确保图纸协作的稳定性。

weixin_34233974的博客 265

在Matlab中开发AutoSAR模型的流程(自下而上)

前面介绍了AutoSAR软件组件开发的两种方式,其中自下向上的方式,是从模型出发,流程如下。

小胡同学的工程师学习之路 5367

Spring Boot 中的微服务架构:原理和使用

首先,需要定义每个服务的接口。接口应该定义清楚服务提供的功能和输入输出参数。接口可以使用 Java 接口或者 RESTful API 来实现。Spring Boot 中的微服务架构是一种非常灵活、可靠、可扩展的架构模式。它可以带来更高的开发效率、更好的系统可靠性和容错性。在 Spring Boot 中,可以使用各种技术栈来实现微服务架构,例如 Spring Cloud、Feign、Ribbon、Hystrix、Eureka、Zuul 等。

徐师兄的博客 5739

STM32-DAC定时器触发三角波生成【频率与幅度精准控制】

本文详细介绍了STM32 DAC定时器触发三角波生成的原理与实现方法,涵盖硬件配置、频率与幅度精准控制技巧及代码实现。通过定时器触发DAC模块,可灵活调节三角波的频率和幅度,适用于信号发生器等高精度应用场景。

weixin_29161785的博客 453

SpringBoot和SpringCloud的区别?归纳理解

SpringBoot和SpringCloud的区别? 一、SpringBoot是什么? SpringBoot是一个快速开发的轻量级框架,帮助快速整合第三方常用框架,完全采用注解化(使用注解启动SpringMVC),简化XML配置,内置HTTP服务器(Tomcat、Jetty)。作用是简化Spring应用的初始搭建及开发,解决各种jar包版本冲突问题。 二、SpringCloud是什么? SpringCloud是一系列框架的有序集合,是一个分布式服务治理的框架,本身不会提供具体功能性的操作,是一个为开发

weixin_44852524的博客 2万+

Codex 实战 Skills:自动采集一天之内的 Git 提交,一键排版成精美工作日报并发送邮件

特性手动撰写脚本辅助(仅聚合)AI 增强自动化(本文方案)时间成本高(30-60分钟/天)中(5-10分钟/天)低(<1分钟/天)内容质量依赖个人总结能力原始数据堆砌,缺乏提炼专业、结构化、有业务视角信息完整性易遗漏完整,但难以阅读完整,且经过分类筛选维护成本无低中(需维护 Skill 配置)可扩展性低中高(可轻松添加周报、月报)适用场景所有开发者初级开发者追求效率的高级开发者本文详细介绍了一种基于 Codex Agent Skills 的自动日报生成方案。

yueyeguzhuo的专栏 102

小龙虾们的好伙伴专栏导航

本文系统解析了OpenClaw技术架构,包括Channel层和网关层的设计实现,并与AgentSkills等技术方案进行对比。同时提供了OpenClaw的系列实用指南,涵盖小龙虾培养、客户资源管理、系统自动化巡检以及技术趋势助手构建等应用场景,帮助用户从零成本入门到高效落地应用,实现业务全流程自动化管理。

wend的博客 213

【java】springboot和springcloud区别

springboot:spring启动启动器,spring启动自动配置,spring启动执行器,spring启动 CLI,spring启动初始化。springcloud:springcloud的设计目的是为了管理同一项目中的各项微服务,因此二者是完全不同的两个软件开发框架。springcloud:是建立在SpringBoot上的服务框架,进一步简化了配置,它整合了一全套简单、便捷且通俗易用的框架。springboot:一个快速开发框架,它简化了传统MVC的XML配置,使配置变得更加方便、简洁。

DreamSun的博客 8159

STM32N6570-DK识别STLINK问题,如何解决?

🏆 本文收录于《全栈Bug调优(实战版)》专栏,致力于分享我在项目实战过程中遇到的各类Bug及其原因,并提供切实有效的解决方案。无论你是初学者还是经验丰富的开发者,本文将为你指引出一条更高效的Bug修复之路,助你早日登顶,迈向财富自由的梦想🚀!同时,欢迎大家关注、收藏、订阅本专栏,更多精彩内容正在持续更新中。让我们一起进步,Up!Up!Up!    备注: 部分问题/难题源自互联网,经过精心筛选和整理,结合数位十多年大厂实战经验资深大佬经验总结所得,数条可行方案供所需之人参考。

**My Coding Family** 2209

SpringBoot vs SpringCloud:微服务时代的“最佳拍档“

SpringBoot是"砖",SpringCloud是"图纸"SpringBoot负责把每一块砖烧好SpringCloud负责把这些砖砌成高楼大厦SpringBoot是"演员",SpringCloud是"导演"SpringBoot负责演好自己的角色SpringCloud负责整个剧情的调度SpringBoot是"特种兵",SpringCloud是"指挥部"SpringBoot负责单兵作战SpringCloud负责全局指挥框架官方定义通俗理解SpringBoot快速构建单个微服务。

✨ 欢迎来到【Seal ^_^ 的优快云博客】!✨ 1117

温度PID控制PLC程序

温度PID稳定控制程序,适用于欧姆龙PLC,对其他PLC也具有很大参考价值

Probability and Statistics - 4th edition

Probability and Statistics - 4th edition

SpringBoot与SpringCloud的关系与区别

SpringBoot与SpringCloud的关系与区别 一、SpringBoot和SpringCloud简介 1、SpringBoot:是一个快速开发框架,通过用MAVEN依赖的继承方式,帮助我们快速整合第三方常用框架,完全采用注解化(使用注解方式启动SpringMVC),简化XML配置,内置HTTP服务器(Tomcat,Jetty),最终以Java应用程序进行执行。 2、SpringCloud...

weixin_45031351 4万+

计算机控制技术及应用

《计算机控制技术及应用》通过总结作者的科研成果和吸收国内外的先进理论、方法和技术,以计算机控制系统的网络化、开放化、智能化和集成化发展趋势为主线索,主要介绍计算机控制系统的基本概念和计算机控制系统的典型形式,计算机控制系统的数学描述与基本分析方法,以及数字PID控制器、最少拍控制系统、达林算法、状态变量设计法等设计方法及其各种改进算法。重点突出计算机控制系统输入输出通道接口的设计方法和典型控制系统的设计案例与实现方法等。

什么是微服务,SpringBoot和SpringCloud的关系和区别

什么是微服务? 就目前而言对于微服务业界没有一个统一的,标准的定义.但通常而言,微服务是一种架构模式或者说是一种架构风格, 它提倡单一应用程序划分为一组小的服务,每个服务在其独立的自己的进程中,服务之间相互协调,互相配合,为用户提 供最终价值.服务之间采用轻量级的通信机制互相沟通(基于Http的Restful API)每个服务都围绕着具体的业务进行 构...

超级无敌皮皮超的博客 1万+

假人有限元模型

假人模型,车辆碰撞假人有限元模型,适合做乘员安全的研究者利用

微服务、SpringBoot、SpringCloud 三者的区别

微服务是一种开发软件的架构和组织方法,是一种架构概念/风格/形式,其中软件由通过明确定义的 API 进行通信的小型独立服务组成。这些服务由各个小型独立团队负责。微服务最早由与于 2014 年共同提出。看过上面的定义后可能你还不太理解,通俗一点的说法就是,假设开发一个网约车系统,传统的做法是整个系统使用一整套的代码,是一个整体,通常使用一种核心语言开发,例如:Java开发的可能直接使用一个 jar 包部署。

Muti-Agent 3304

SpringBoot3 和 SpringCLoud 微服务(一)

本书是关于使用 Spring Boot 3 和 Spring Cloud 构建生产就绪微服务的。十年前,当我开始探索微服务时,我正在寻找这样的书籍。本书是在我学习并掌握了用于开发、测试、部署和管理协作微服务景观的开源软件之后开发的。本书主要涵盖 Spring Boot、Spring Cloud、Docker、Kubernetes、Istio、EFK 堆栈、Prometheus 和 Grafana。这些开源工具各自都非常出色,但理解如何以有利的方式将它们结合使用可能具有挑战性。

龙哥盟 997

SpringCloud和微服务之间的关系

前言 在搞清楚SpringCloud和微服务的关系之前,我们需要先了解两个点: 1、微服务是什么 2、SpringCloud是什么 该篇文章力求用“”精简“”的话来说明 文章目录前言1、微服务是什么2、SprignCloud是什么3、SpringCloud和微服务之间的关系总结 1、微服务是什么 就两句话 (1)微服务就是一种架构风格 (2)微服务就是把一个项目拆分成独立的多个服务,并且多个服务是可以独立运行的,而每个服务都会占用线程。 关于微服务的详细说明可以看这篇文章:什么是微服务 2、Sprig

weixin_47316183的博客 3214

SpringBoot和SpringCloud微服务目录结构

一、SpringBoot之父子模块微服务项目,SpringBoot+Mybatis+TkMapper+Dubbo目录结构如下: 1、demo-api constant:枚举类文件 dto:request和response文件 exception:异常枚举类文件 service:对外提供接口文件 2、demo-provider config:配置文件 dal——>entity:实体类文件 dal——>persistence:mapper类文件 dal——>persistence——&

netuser1937的博客 4452
上一篇: SpringBoot3 和 SpringCLoud 微服务(一)
下一篇: SpringBoot3 和 SpringCLoud 微服务(三)
绝不原创的飞龙
绝不原创的飞龙 优质创作者: 人工智能、数据科学与机器学习技术领域 优质创作者: 人工智能、数据科学与机器学习技术领域
博客等级 码龄15年 4万+粉丝 2万+原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值