在 Spring Boot 开发模式中使用 Testcontainers 和 Docker

在本文中,你将学习如何使用 Spring Boot 内置的 Testcontainers 和 Docker Compose 支持,在开发模式下运行外部服务。Spring Boot 在当前的最新版本 3.1 中引入了这些功能,你已经可以在 Spring Boot 应用的测试中利用 Testcontainers。在应用程序启动时运行外部数据库、message broker 或其他外部服务的功能是我一直期待的。尤其是竞争框架 Quarkus 已经提供了名为 Dev Services 的类似功能,这在我的开发过程中非常有用。此外,还有另一个令人兴奋的功能 - 与 Docker Compose 集成。

源代码

如果你想自己尝试,可以查看我的源代码。因为我经常使用 Testcontainers,所以你可以在我的多个仓库中找到示例。下面是我们今天要使用的仓库列表:

你可以克隆它们,然后按照指导查看如何在开发模式下使用 Spring Boot 内置的 Testcontainers 和 Docker Compose 支持。

在测试中使用 Testcontainers

让我们从标准使用示例开始。第一个仓库中有一个连接 Mongo 数据库的 Spring Boot 应用程序。为了构建自动测试,我们必须包含以下 Maven 依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>mongodb</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>

现在,我们可以创建测试了。我们需要用 @Testcontainers 来注解我们的测试类。然后,我们必须声明 MongoDBContainer Bean。在 Spring Boot 3.1 之前,我们必须使用 DynamicPropertyRegistry 来设置由 Testcontainers 自动生成的 Mongo 地址。

@SpringBootTest(webEnvironment = 
   SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PersonControllerTest {

   @Container
   static MongoDBContainer mongodb = 
      new MongoDBContainer("mongo:5.0");

   @DynamicPropertySource
   static void registerMongoProperties(DynamicPropertyRegistry registry) {
      registry.add("spring.data.mongodb.uri", mongodb::getReplicaSetUrl);
   }

   // ... test methods

}

幸运的是,从 Spring Boot 3.1 开始,我们可以使用 @ServiceConnection 注解来进行简化。下面是采用最新方法的完整测试实现。它验证了应用公开的一些 REST 端点。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PersonControllerTest {

    private static String id;

    @Container
    @ServiceConnection
    static MongoDBContainer mongodb = new MongoDBContainer("mongo:5.0");

    @Autowired
    TestRestTemplate restTemplate;

    @Test
    @Order(1)
    void add() {
        Person p = new Person(null, "Test", "Test", 100, Gender.FEMALE);
        Person personAdded = restTemplate
            .postForObject("/persons", p, Person.class);
        assertNotNull(personAdded);
        assertNotNull(personAdded.getId());
        assertEquals(p.getLastName(), personAdded.getLastName());
        id = personAdded.getId();
    }

    @Test
    @Order(2)
    void findById() {
        Person person = restTemplate
            .getForObject("/persons/{id}", Person.class, id);
        assertNotNull(person);
        assertNotNull(person.getId());
        assertEquals(id, person.getId());
    }

    @Test
    @Order(2)
    void findAll() {
        Person[] persons = restTemplate
            .getForObject("/persons", Person[].class);
        assertEquals(6, persons.length);
    }

}

现在,我们可以使用标准的 Maven 命令构建项目。然后,Testcontainers 会在测试前自动启动 Mongo 数据库。当然,我们需要在机器上运行 Docker。

$ mvn clean package

测试运行正常。但如果我们想在本地运行应用进行开发,会发生什么情况呢?我们可以直接从 IDE 或使用 mvn spring-boot:run Maven 命令运行应用的 main class。下面是我们的 main class:

@SpringBootApplication
@EnableMongoRepositories
public class SpringBootOnKubernetesApp implements ApplicationListener<ApplicationReadyEvent> {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootOnKubernetesApp.class, args);
    }

    @Autowired
    PersonRepository repository;

    @Override
    public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) {
        if (repository.count() == 0) {
            repository.save(new Person(null, "XXX", "FFF", 20, Gender.MALE));
            repository.save(new Person(null, "AAA", "EEE", 30, Gender.MALE));
            repository.save(new Person(null, "ZZZ", "DDD", 40, Gender.FEMALE));
            repository.save(new Person(null, "BBB", "CCC", 50, Gender.MALE));
            repository.save(new Person(null, "YYY", "JJJ", 60, Gender.FEMALE));
        }
    }
}

当然,除非先启动 Mongo 数据库,否则我们的应用程序将无法连接它。如果使用 Docker,我们首先需要执行 docker run 命令,运行 MongoDB 并将其暴露在本地端口上。

Spring Boot Testcontainers 输出日志

在 Spring Boot 开发模式下使用 Testcontainers

幸运的是,有了 Spring Boot 3.1,我们可以简化这一过程。在启动应用程序之前,我们不需要 Mongo。我们需要做的是使用 Testcontainers 启用开发模式。首先,我们应在包含以下 Maven 依赖(test scope):

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>

然后,我们需要在 @TestConfiguration 类中定义要与应用程序一起启动的容器。对我来说,这只是一个 MongoDB 容器,如下所示:

@TestConfiguration
public class MongoDBContainerDevMode {

    @Bean
    @ServiceConnection
    MongoDBContainer mongoDBContainer() {
        return new MongoDBContainer("mongo:5.0");
    }

}

之后,我们必须 “覆写” Spring Boot 的 main class。它的名称应与 main class 相同,后缀名为 Test。然后,我们在 SpringApplication.from(...) 方法中传递当前的 main class 。我们还需要使用 with(...) 方法设置 @TestConfiguration 类。

public class SpringBootOnKubernetesAppTest {

    public static void main(String[] args) {
        SpringApplication.from(SpringBootOnKubernetesApp::main)
                .with(MongoDBContainerDevMode.class)
                .run(args);
    }

}

最后,我们可以直接从 IDE 启动 “test” main class,也可以直接执行以下 Maven 命令:

$ mvn spring-boot:test-run

应用程序启动后,你将看到 Mongo 容器已启动并运行,与它的连接也已建立。

Spring Boot 应用日志

在开发模式下,还可以包含 Spring Devtools 模块,以便在源代码更改后自动重启应用程序。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-devtools</artifactId>
  <optional>true</optional>
</dependency>

让我们看看发生了什么。一旦我们修改了源代码,Spring Devtools 就会重启应用程序和 Mongo 容器。你可以在应用程序日志和正在运行的 Docker 容器列表中验证这一点。如你所见,Testcontainer ryuk 已于一分钟前启动,而 Mongo 则是在 9 秒前应用重启后重新启动的。

Spring Boot 日志

为了防止在使用 Devtools 重启应用程序时重新启动容器,我们需要用 @RestartScopeMongoDBContainer Bean 进行注解。

@TestConfiguration
public class MongoDBContainerDevMode {

    @Bean
    @ServiceConnection
    @RestartScope
    MongoDBContainer mongoDBContainer() {
        return new MongoDBContainer("mongo:5.0");
    }

}

现在,Devtools 只重启应用程序,而不会重启容器。

Spring Boot 日志

在多个应用程序间共享容器

在前面的示例中,我们有一个在单个容器上连接数据库的应用。现在,我们将切换到带有一些微服务的 repository,这些微服务通过 Kafka broker 相互通信。假设我想同时开发和测试这三个应用程序。当然,我们的服务需要有以下 Maven 依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>kafka</artifactId>
  <version>1.18.1</version>
  <scope>test</scope>
</dependency>

然后,我们需要做一件与之前非常相似的事情 - 声明包含所需容器列表的 @TestConfiguration Bean。不过,这次我们需要让 Kafka 容器在多个应用程序之间重复使用。为此,我们将在 KafkaContainer 上调用 withReuse(true)。顺便说一下,我们也可以使用 Kafka Raft 模式来代替 Zookeeper。

@TestConfiguration
public class KafkaContainerDevMode {

    @Bean
    @ServiceConnection
    public KafkaContainer kafka() {
        return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"))
                .withKraft()
                .withReuse(true);
    }

}

与之前一样,我们必须创建一个使用 @TestConfiguration Bean 的 “test” main class。我们将为 repository 中的其他两个应用:payment-servicestock-service 做同样的事情。

public class OrderAppTest {

    public static void main(String[] args) {
        SpringApplication.from(OrderApp::main)
                .with(KafkaContainerDevMode.class)
                .run(args);
    }

}

让我们运行三个微服务。注意,可以直接从 IDE 或使用 mvn spring-boot:test-run 命令运行 “test” main class。如你所见,我运行了所有的三个应用。

Spirng 微服务应用的日志

现在,如果我们显示正在运行的容器列表,所有应用程序之间只共享一个 Kafka broker。

正在运行的容器列表

使用 Spring Boot 对 Docker Compose 的支持

从 3.1 版开始,Spring Boot 提供了对 Docker Compose 的内置支持。让我们切换到上一个示例 repository。它由几个连接到 Mongo 数据库和 Netflix Eureka discovery server 的微服务组成。我们可以进入其中一个微服务的目录,例如 customer-service。假设我们包含以下 Maven 依赖项,Spring Boot 会在当前工作目录中查找 Docker Compose 配置文件。让我们只针对特定的 Maven profile 激活该机制:

<profiles>
  <profile>
    <id>compose</id>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-docker-compose</artifactId>
        <optional>true</optional>
      </dependency>
    </dependencies>
  </profile>
</profiles>

我们的目标是在运行 customer-service 应用之前运行所有必要的外部服务。customer-service 应用连接到 Mongo 和 Eureka,并调用 account-service 暴露的端点。下面是与 account-service 通信的 REST 客户端的实现。

@FeignClient("account-service")
public interface AccountClient {

    @RequestMapping(method = RequestMethod.GET, value = "/accounts/customer/{customerId}")
    List<Account> getAccounts(@PathVariable("customerId") String customerId);

}

我们需要准备包含所有必要容器定义的 docker-compose.yml。如你所见,这里有 mongo 服务和两个应用 discovery-serviceaccount-service,它们使用本地 Docker 镜像。

version: "3.8"
services:
  mongo:
    image: mongo:5.0
    ports:
      - "27017:27017"
  discovery-service:
    image: sample-spring-microservices-advanced/discovery-service:1.0-SNAPSHOT
    ports:
      - "8761:8761"
    healthcheck:
      test: curl --fail http://localhost:8761/eureka/v2/apps || exit 1
      interval: 4s
      timeout: 2s
      retries: 3
    environment:
      SPRING_PROFILES_ACTIVE: docker
  account-service:
    image: sample-spring-microservices-advanced/account-service:1.0-SNAPSHOT
    ports:
      - "8080"
    depends_on:
      discovery-service:
        condition: service_healthy
    links:
      - mongo
      - discovery-service
    environment:
      SPRING_PROFILES_ACTIVE: docker

在运行服务之前,让我们使用我们的应用构建镜像。我们也可以使用 Spring Boot 内置的基于 Buildpacks 的机制,但我在这方面遇到了一些问题。

<profile>
  <id>build-image</id>
  <build>
    <plugins>
      <plugin>
        <groupId>com.google.cloud.tools</groupId>
        <artifactId>jib-maven-plugin</artifactId>
        <version>3.3.2</version>
        <configuration>
          <to>
            <image>sample-spring-microservices-advanced/${project.artifactId}:${project.version}</image>
          </to>
        </configuration>
        <executions>
          <execution>
            <goals>
              <goal>dockerBuild</goal>
            </goals>
            <phase>package</phase>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</profile>

让我们在 repository 根目录下执行以下命令:

$ mvn clean package -Pbuild-image -DskipTests

构建成功后,我们可以使用 docker images 命令来验证可用镜像的列表。正如你所看到的,我们的 docker-compose.yml 文件中使用了两个镜像:

docker-compose.yml 中使用的镜像

最后,你唯一需要做的就是运行 customer-service 应用。让我们再次切换到 customer-service 目录(包含 spring-boot-docker-compose 依赖),执行 mvn spring-boot:run(指定 profile):

$ mvn spring-boot:run -Pcompose

如你所见,我们的应用程序找到了 docker-compose.yml

Spring Boot 应用日志

一旦我们启动应用程序,它也会启动所有需要的容器。

Spring Boot 应用日志

例如,我们可以查看 http://localhost:8761 上的 Eureka 控制台。那里注册了两个应用。account-service 在 Docker 上运行,而 customer-service 则在本地启动。

Eureka 控制台

结语

Spring Boot 3.1 在容器化方面进行了多项改进。尤其是在开发过程中与应用程序一起运行 Testcontainers 的相关功能是我翘首以盼的。希望本文可以帮到你。


参考:https://piotrminkowski.com/2023/05/26/spring-boot-development-mode-with-testcontainers-and-docker/