在 Spring Boot 开发模式中使用 Testcontainers 和 Docker
在本文中,你将学习如何使用 Spring Boot 内置的 Testcontainers 和 Docker Compose 支持,在开发模式下运行外部服务。Spring Boot 在当前的最新版本 3.1 中引入了这些功能,你已经可以在 Spring Boot 应用的测试中利用 Testcontainers。在应用程序启动时运行外部数据库、message broker 或其他外部服务的功能是我一直期待的。尤其是竞争框架 Quarkus 已经提供了名为 Dev Services 的类似功能,这在我的开发过程中非常有用。此外,还有另一个令人兴奋的功能 - 与 Docker Compose 集成。
源代码
如果你想自己尝试,可以查看我的源代码。因为我经常使用 Testcontainers,所以你可以在我的多个仓库中找到示例。下面是我们今天要使用的仓库列表:
- https://github.com/piomin/sample-spring-boot-on-kubernetes.git
- https://github.com/piomin/sample-spring-microservices-advanced.git
- https://github.com/piomin/sample-spring-kafka-microservices.git
你可以克隆它们,然后按照指导查看如何在开发模式下使用 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 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 Devtools 模块,以便在源代码更改后自动重启应用程序。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
让我们看看发生了什么。一旦我们修改了源代码,Spring Devtools 就会重启应用程序和 Mongo 容器。你可以在应用程序日志和正在运行的 Docker 容器列表中验证这一点。如你所见,Testcontainer ryuk
已于一分钟前启动,而 Mongo 则是在 9 秒前应用重启后重新启动的。
为了防止在使用 Devtools 重启应用程序时重新启动容器,我们需要用 @RestartScope
对 MongoDBContainer
Bean 进行注解。
@TestConfiguration
public class MongoDBContainerDevMode {
@Bean
@ServiceConnection
@RestartScope
MongoDBContainer mongoDBContainer() {
return new MongoDBContainer("mongo:5.0");
}
}
现在,Devtools 只重启应用程序,而不会重启容器。
在多个应用程序间共享容器
在前面的示例中,我们有一个在单个容器上连接数据库的应用。现在,我们将切换到带有一些微服务的 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-service
和 stock-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。如你所见,我运行了所有的三个应用。
现在,如果我们显示正在运行的容器列表,所有应用程序之间只共享一个 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-service
和 account-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
文件中使用了两个镜像:
最后,你唯一需要做的就是运行 customer-service
应用。让我们再次切换到 customer-service
目录(包含 spring-boot-docker-compose
依赖),执行 mvn spring-boot:run
(指定 profile):
$ mvn spring-boot:run -Pcompose
如你所见,我们的应用程序找到了 docker-compose.yml
。
一旦我们启动应用程序,它也会启动所有需要的容器。
例如,我们可以查看 http://localhost:8761
上的 Eureka 控制台。那里注册了两个应用。account-service
在 Docker 上运行,而 customer-service
则在本地启动。
结语
Spring Boot 3.1 在容器化方面进行了多项改进。尤其是在开发过程中与应用程序一起运行 Testcontainers 的相关功能是我翘首以盼的。希望本文可以帮到你。
参考:https://piotrminkowski.com/2023/05/26/spring-boot-development-mode-with-testcontainers-and-docker/