🏆本文收录于《滚雪球学SpringBoot 3.x》,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
  
该专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】,你想学习的都被收集在内,快速投入学习!!两不误。
  
若还想学习更多,可直接订阅 《Spring Boot实战合集》,一次订阅,持续学习,后续更新内容无需重复付费,适合长期收藏与系统进阶。

演示环境说明:

  • 开发工具:IDEA 2021.3
  • JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
  • Spring Boot版本:3.5.4(于25年7月24日发布)
  • Maven版本:3.8.2 (或更高)
  • Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
  • 操作系统:Windows 11

全文目录:

1. 为什么今天要重新认识 Testcontainers?

很多开发者第一次接触 Testcontainers,往往是在自动化测试场景中:

  • 跑集成测试时临时起一个 MySQL;
  • 测试 Redis 缓存逻辑时临时起一个 Redis;
  • 测试消息队列流程时临时起一个 Kafka;
  • 测试结束之后再自动清理容器。

这种认知当然没有错,但它只说对了一半。

真正强大的地方在于:Testcontainers 从来都不只是测试工具,它本质上是一个“用 Java 代码声明并控制容器生命周期”的基础设施能力。

换句话说,只要你的项目运行时需要依赖某些外部服务,而这些服务本身可以容器化,那么 Testcontainers 就不应该只在 test 目录里出现,它同样可以服务于:

  • 本地开发环境;
  • Demo 演示环境;
  • 培训教学环境;
  • 临时联调环境;
  • CI 中的非测试启动阶段;
  • 某些轻量开发工作站。

过去,我们在新电脑拉起一个项目时,最容易遇到的问题不是 Java 代码编译不过,而是环境配置不一致:

  • 你本地装的是 MySQL 8.0,我装的是 5.7;
  • 你 Redis 开了密码,我没开;
  • 你 Kafka 用 KRaft,我还在 ZooKeeper 模式;
  • 你容器映射了 3306,我本机 3306 已经被别的服务占了;
  • 你 README 里写了三页安装文档,新同学照着操作依然失败。

这些问题的共同本质是:开发依赖环境不是代码的一部分,而是散落在每个人电脑上的隐式状态。

而 Testcontainers 的价值,恰恰是把这部分“隐式状态”重新拉回到代码世界里,让开发依赖基础设施也具备版本化、可追踪、可复现、可启动、可销毁的能力。

这就是本文要讲的核心主题:

Testcontainers 不止用于测试,它完全可以成为 Spring Boot 3.x 项目的本地开发基础设施引擎。

2. Spring Boot 3.x 为什么非常适合本地开发容器化?

如果把时间拨回到 Spring Boot 2.x 早期,虽然我们也能在代码中手动启动容器,但整体体验并不算丝滑。开发者通常需要手动处理:

  • 容器启动时机;
  • 应用配置注入;
  • 容器地址和端口绑定;
  • Bean 初始化顺序;
  • 开发环境与测试环境之间的配置复用;
  • 资源销毁与重复启动问题。

到了 Spring Boot 3.x,尤其是 3.1 及之后,官方对 Testcontainers 的支持有了明显提升。它不只是“兼容”,而是开始把 Testcontainers 当作一种正式的开发与测试集成方式来看待。

这背后有几个关键变化。

2.1 Spring Boot 3.x 的技术基础更现代

Spring Boot 3.x 建立在更现代的 Java 与 Spring Framework 基础之上,配合 Jakarta EE 9+ 命名空间迁移,使整个生态在以下方面更统一:

  • 对云原生和容器化理念的接受度更高;
  • 对外部化配置、自动装配和环境隔离的抽象更成熟;
  • 对开发体验(Developer Experience)更重视;
  • 对微服务依赖组件(数据库、缓存、消息队列)的集成更规范。

这意味着:

Spring Boot 3.x 天生就更适合和“声明式基础设施”结合。

2.2 Service Connection 让容器与应用连接更自然

Spring Boot 3.x 引入了对 @ServiceConnection 的支持,极大降低了 Testcontainers 接入成本。

以前,开发者经常要手写一堆属性:

  • spring.datasource.url
  • spring.datasource.username
  • spring.datasource.password
  • spring.data.redis.host
  • spring.data.redis.port
  • spring.kafka.bootstrap-servers

而在较新的 Spring Boot 3.x 集成方式中,某些容器可以被 Spring Boot 自动识别为服务连接来源,从而自动把连接信息装配给相关自动配置模块。

这带来的改变不是“少写几行配置”这么简单,而是:

容器不再只是测试代码里的辅助对象,而是可以正式进入 Spring 应用上下文,成为开发运行时的一部分。

2.3 开发环境也开始追求可复现

现代团队越来越强调“开发环境即代码(Development Environment as Code)”。

测试环境要可复现,开发环境同样应该可复现。Spring Boot 3.x 正好提供了把这种理念落地的好机会:

  • 自动配置负责应用层;
  • Testcontainers 负责依赖服务层;
  • 配置文件负责环境切换;
  • Bean 生命周期负责容器与应用协同启动。

因此,Spring Boot 3.x 与 Testcontainers 的组合,不只是工具组合,而是一种新的本地开发组织方式。

3. 什么叫“Testcontainers 不止用于测试”?

很多人看到 Testcontainers 这个名字,会下意识认为它只能在测试代码里使用。这其实是名称带来的误导。

从能力上说,Testcontainers 提供的是:

  1. 用 Java API 声明容器;
  2. 以编程方式启动、停止、复用容器;
  3. 获取动态映射端口与连接参数;
  4. 等待容器就绪;
  5. 执行初始化脚本;
  6. 组合多个依赖服务;
  7. 集成 Docker 网络与日志。

你会发现,这些能力没有一项天然只能用于测试。

它只是最先在测试领域被广泛接受,因为测试最容易需要“临时起一个真实依赖服务”。

但如果我们换个角度想:

本地开发不也同样需要一个真实依赖服务吗?

例如你正在开发一个订单系统,应用依赖:

  • MySQL 存订单;
  • Redis 做缓存与分布式锁;
  • Kafka 发订单创建事件;
  • 可能还需要 Elasticsearch、MinIO、RabbitMQ 等等。

传统做法通常有三种:

方案一:每个人本机手动安装

优点:

  • 看似直接;
  • 不依赖 Docker;
  • 某些人已经熟悉。

缺点:

  • 版本不统一;
  • 迁移麻烦;
  • 污染本机环境;
  • 新同学上手成本高;
  • 多项目依赖冲突严重。

方案二:手写 Docker Compose

优点:

  • 比手动安装更统一;
  • 容器可以一起启动;
  • 配置集中管理。

缺点:

  • 仍然和应用代码是两套系统;
  • 容器配置与 Spring 配置之间需要人为维护映射;
  • 对不同项目复制粘贴严重;
  • 难以细粒度嵌入应用生命周期。

方案三:用 Testcontainers 直接在应用启动时声明依赖

优点:

  • 依赖服务配置与应用代码强绑定;
  • 启动逻辑可编程;
  • 端口动态分配,减少冲突;
  • Spring Boot 能自动消费连接信息;
  • 更适合做 demo、培训、快速拉起与本地联调;
  • 生命周期更可控。

缺点:

  • 需要团队接受“开发运行时容器化”思维;
  • 首次镜像拉取会花时间;
  • 并不适合所有类型的依赖服务。

所以,“不止用于测试”的真正含义不是一句口号,而是:

把外部依赖服务通过代码纳入 Spring Boot 应用的开发运行时管理范围。

4. 本地开发容器化的核心思想

在真正开始写代码之前,我们先把思路讲透。很多技术文章的问题是代码很多,但读者并没有真正建立系统性理解,最后只能机械照抄,稍微换个场景就不会了。

这里我用一句话概括:

本地开发容器化,就是把原本需要手工安装、配置、启动和清理的依赖服务,改成由应用在启动时自动声明、自动拉起、自动注入、自动回收。

这背后至少包含四个核心动作。

4.1 声明依赖

把 MySQL、Redis、Kafka 这些依赖服务,明确写进代码里,而不是只写在 README 文档里。

过去的 README 常见写法是:

  1. 安装 MySQL;
  2. 创建数据库 demo
  3. 创建用户 demo/demo123
  4. 启动 Redis;
  5. 启动 Kafka;
  6. 修改配置文件后运行项目。

这种写法的问题在于:文档不是系统,文档不会执行。

而容器声明则是把这段“人工操作说明”变成可执行代码。

4.2 启动依赖

声明还不够,你还需要一个统一机制,在应用启动前把这些依赖真的拉起来。

Testcontainers 帮你完成:

  • 镜像拉取;
  • 容器创建;
  • 端口映射;
  • 健康检查;
  • 等待服务就绪;
  • 暴露连接参数。

4.3 注入连接信息

MySQL 启动后,真正的 JDBC 地址通常不是固定死的,因为本地端口可能动态映射。Redis、Kafka 也是类似情况。

所以应用层不能再假设:

  • MySQL 一定在 localhost:3306
  • Redis 一定在 localhost:6379
  • Kafka 一定在 localhost:9092

而要改成:

  • 由容器提供真实连接信息;
  • 由 Spring Boot 自动或半自动注入。

这一步是 Spring Boot 3.x 与 Testcontainers 配合最有价值的地方。

4.4 销毁与复用

开发环境和测试环境最大的不同之一,是“开发者可能会反复启动应用”。

因此我们还必须考虑:

  • 容器关闭时机;
  • 是否启用容器复用;
  • 是否保留数据;
  • 如何避免孤儿容器;
  • 如何清理卷与网络资源。

只有把这些问题想清楚,Testcontainers 才能真正胜任本地开发容器化,而不只是临时演示工具。

5. 一张图看懂整体架构

先看整体模型图。

从这张图你可以看到,本地开发容器化并不是“应用起来后再手工连接 Docker”,而是:

  1. Spring 容器开始启动;
  2. 开发容器配置类被加载;
  3. Testcontainers 容器对象作为 Bean 被创建;
  4. 容器先启动并就绪;
  5. Spring Boot 获得连接信息;
  6. 数据源、缓存、消息组件自动完成配置;
  7. 最终业务代码像连接真实环境一样工作。

这就实现了从“安装式依赖”到“声明式依赖”的转变。

6. 环境准备:从 0 到 1 搭建运行基础

在写代码前,先明确本文示例所基于的技术栈。

6.1 推荐环境

  • JDK 17 或以上
  • Maven 3.9+
  • Docker Desktop / Colima / Rancher Desktop / Linux Docker Engine
  • Spring Boot 3.1+(建议 3.2 或 3.3 及以上分支中任一稳定版本)
  • Testcontainers 对应稳定版本

说明:Spring Boot 3.x 对 Java 17 及以上支持更友好,很多文章也会以 Java 17 作为起点。本篇示例统一按 Java 17 编写。

6.2 为什么必须有 Docker?

Testcontainers 虽然是 Java 库,但它底层仍然需要一个可用的容器运行时。最常见的就是 Docker。

它并不是“替代 Docker”,而是“通过 Java 编程方式使用 Docker”。

所以本篇主题虽然叫“本地 MySQL/Redis/Kafka 免安装”,本质含义是:

  • 不再单独安装这些服务;
  • 但你仍然需要一个统一的容器运行时;
  • 这个运行时通常就是 Docker。

6.3 Maven 依赖

下面先给出一个基础版 Maven 配置,后文所有示例都基于它演进。

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>boot-testcontainers-dev</artifactId>
    <version>1.0.0</version>
    <name>boot-testcontainers-dev</name>
    <description>Spring Boot 3.x 本地开发容器化示例</description>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Web 开发依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- JPA 依赖,用于操作 MySQL -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Redis 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Kafka 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-kafka</artifactId>
        </dependency>

        <!-- Actuator,便于观察运行状态 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- MySQL 驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Testcontainers 核心 -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
        </dependency>

        <!-- MySQL 容器支持 -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>mysql</artifactId>
        </dependency>

        <!-- Kafka 容器支持 -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>kafka</artifactId>
        </dependency>

        <!-- Spring Boot 对 Testcontainers 的支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
        </dependency>

        <!-- 开发辅助:配置处理器 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Lombok,可选;不用也没关系 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

代码说明

这份依赖里有几个特别重要的点。

第一,spring-boot-testcontainers 虽然名字里也带 test,但它的价值并不局限于测试目录,它提供的是 Spring Boot 与 Testcontainers 的集成能力。

第二,MySQL 与 Kafka 有专门模块,因为它们的容器行为与连接信息获取方式更专业化。

第三,Redis 在很多场景下可以直接用 GenericContainer 完成,不一定非要用专门模块。

7. 第一个案例:把 MySQL 作为 Spring Bean 启动

现在开始进入本文最关键的部分:把依赖服务声明成 Spring Bean。

这里先做一个最小闭环,只启动 MySQL,让 Spring Boot 应用直接连上它。

7.1 项目结构设计

建议采用下面这种结构:

src/main/java
└── com/example/demo
    ├── DemoApplication.java
    ├── config
    │   └── LocalDevContainersConfig.java
    ├── order
    │   ├── Order.java
    │   ├── OrderRepository.java
    │   ├── OrderService.java
    │   └── OrderController.java
    └── runner
        └── StartupRunner.java

7.2 启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 应用启动类
 */
@SpringBootApplication
public class DemoApplication {

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

7.3 本地开发容器配置类

package com.example.demo.config;

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.MySQLContainer;

/**
 * 本地开发容器配置类
 *
 * 说明:
 * 1. 仅在 local-dev 环境下生效;
 * 2. 把 MySQL 容器声明为 Spring Bean;
 * 3. 使用 @ServiceConnection 让 Spring Boot 自动识别数据库连接信息。
 */
@Configuration
@Profile("local-dev")
public class LocalDevContainersConfig {

    /**
     * 声明一个 MySQL 容器 Bean。
     *
     * @return MySQLContainer 实例
     */
    @Bean
    @ServiceConnection
    public MySQLContainer<?> mysqlContainer() {
        return new MySQLContainer<>("mysql:8.0.36")
                // 设置数据库名
                .withDatabaseName("demo_db")
                // 设置用户名
                .withUsername("demo")
                // 设置密码
                .withPassword("demo123")
                // 可选:初始化 SQL 脚本,路径放在 classpath 下
                .withInitScript("sql/init.sql");
    }
}

这一段为什么重要?

这段代码是全文的关键转折点。

它意味着:

  • MySQL 不再是“你自己装一个”;
  • 也不再是“你先 docker run 一下”;
  • 而是应用本身在 local-dev 环境启动时,自动声明并启动自己的 MySQL。

更重要的是,@ServiceConnection 告诉 Spring Boot:

这个 Bean 不是普通对象,而是一个可提供服务连接信息的容器。

于是 Spring Boot 能自动把该容器对应的 JDBC URL、用户名、密码等注入到数据源自动配置中。

也就是说,我们甚至不需要在 application.yml 里写死 MySQL 地址。

7.4 初始化 SQL 脚本

src/main/resources/sql/init.sql

-- 创建订单表
CREATE TABLE IF NOT EXISTS t_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL,
    product_name VARCHAR(128) NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL
);

7.5 实体类

package com.example.demo.order;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 订单实体类
 */
@Entity
@Table(name = "t_order")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /** 订单编号 */
    @Column(name = "order_no", nullable = false, length = 64)
    private String orderNo;

    /** 商品名称 */
    @Column(name = "product_name", nullable = false, length = 128)
    private String productName;

    /** 订单金额 */
    @Column(name = "amount", nullable = false)
    private BigDecimal amount;

    /** 订单状态 */
    @Column(name = "status", nullable = false, length = 32)
    private String status;

    /** 创建时间 */
    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public String getProductName() {
        return productName;
    }

    public void setProductName(String productName) {
        this.productName = productName;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public void setAmount(BigDecimal amount) {
        this.amount = amount;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }
}

7.6 Repository

package com.example.demo.order;

import org.springframework.data.jpa.repository.JpaRepository;

/**
 * 订单仓储接口
 */
public interface OrderRepository extends JpaRepository<Order, Long> {
}

7.7 Service

package com.example.demo.order;

import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

/**
 * 订单服务类
 */
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    /**
     * 创建订单
     *
     * @param productName 商品名称
     * @param amount      金额
     * @return 保存后的订单
     */
    public Order createOrder(String productName, BigDecimal amount) {
        Order order = new Order();
        order.setOrderNo("ORD-" + UUID.randomUUID().toString().replace("-", ""));
        order.setProductName(productName);
        order.setAmount(amount);
        order.setStatus("CREATED");
        order.setCreatedAt(LocalDateTime.now());
        return orderRepository.save(order);
    }

    /**
     * 查询所有订单
     *
     * @return 订单列表
     */
    public List<Order> findAll() {
        return orderRepository.findAll();
    }
}

7.8 Controller

package com.example.demo.order;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;
import java.util.List;

/**
 * 订单控制器
 */
@RestController
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    /**
     * 创建订单接口
     */
    @PostMapping("/orders")
    public Order createOrder(@RequestParam String productName,
                             @RequestParam BigDecimal amount) {
        return orderService.createOrder(productName, amount);
    }

    /**
     * 查询订单列表接口
     */
    @GetMapping("/orders")
    public List<Order> listOrders() {
        return orderService.findAll();
    }
}

7.9 application.yml

spring:
  application:
    name: boot-testcontainers-dev
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: true
    properties:
      hibernate:
        format_sql: true

management:
  endpoints:
    web:
      exposure:
        include: health,info,beans,env

7.10 本地开发 profile

spring:
  config:
    activate:
      on-profile: local-dev

7.11 启动方式

mvn spring-boot:run -Dspring-boot.run.profiles=local-dev

应用启动时,你会发现:

  1. Spring Boot 开始启动;
  2. LocalDevContainersConfig 被加载;
  3. MySQL 容器自动拉起;
  4. Spring Boot 自动拿到数据库连接信息;
  5. JPA 正常初始化;
  6. 应用接口可直接访问。

这就是“开发环境免安装 MySQL”的真正含义。

你没有安装 MySQL 服务,没有手工建库,没有手工配地址,但应用照样连上了真实 MySQL。

8. 第二个案例:把 Redis 作为 Spring Bean 启动

仅有数据库还不够。现代 Spring Boot 项目几乎都会配缓存。接下来,我们把 Redis 也纳入本地开发容器体系。

8.1 Redis 容器配置

package com.example.demo.config;

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

/**
 * Redis 本地开发容器配置
 */
@Configuration
@Profile("local-dev")
public class RedisContainerConfig {

    /**
     * Redis 容器 Bean
     *
     * 这里使用 GenericContainer 即可满足常见开发需求。
     */
    @Bean
    @ServiceConnection(name = "redis")
    public GenericContainer<?> redisContainer() {
        return new GenericContainer<>(DockerImageName.parse("redis:7.2"))
                // 暴露 Redis 默认端口
                .withExposedPorts(6379)
                // 指定启动命令,关闭持久化,适合本地开发演示
                .withCommand("redis-server", "--appendonly", "no");
    }
}

为什么这里要写 name = "redis"

因为 GenericContainer 本身是通用容器,Spring Boot 无法仅凭类型知道它代表 Redis。

所以通过 @ServiceConnection(name = "redis") 显式告诉 Spring Boot:

这个通用容器对外提供的是 Redis 服务连接。

这样 Redis 自动配置就能识别它。

8.2 Redis 业务示例:订单查询缓存

先写一个简单缓存服务,把订单查询结果放进 Redis。

package com.example.demo.order;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Optional;

/**
 * 订单缓存服务
 */
@Service
public class OrderCacheService {

    private final StringRedisTemplate stringRedisTemplate;

    public OrderCacheService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 缓存订单状态
     *
     * @param orderNo 订单编号
     * @param status  订单状态
     */
    public void cacheOrderStatus(String orderNo, String status) {
        String key = "order:status:" + orderNo;
        // 设置缓存,并指定 10 分钟过期
        stringRedisTemplate.opsForValue().set(key, status, Duration.ofMinutes(10));
    }

    /**
     * 查询缓存中的订单状态
     *
     * @param orderNo 订单编号
     * @return 状态结果
     */
    public Optional<String> getOrderStatus(String orderNo) {
        String key = "order:status:" + orderNo;
        return Optional.ofNullable(stringRedisTemplate.opsForValue().get(key));
    }
}

8.3 在订单创建时写入缓存

package com.example.demo.order;

import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

/**
 * 订单服务类
 */
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderCacheService orderCacheService;

    public OrderService(OrderRepository orderRepository,
                        OrderCacheService orderCacheService) {
        this.orderRepository = orderRepository;
        this.orderCacheService = orderCacheService;
    }

    /**
     * 创建订单
     */
    public Order createOrder(String productName, BigDecimal amount) {
        Order order = new Order();
        order.setOrderNo("ORD-" + UUID.randomUUID().toString().replace("-", ""));
        order.setProductName(productName);
        order.setAmount(amount);
        order.setStatus("CREATED");
        order.setCreatedAt(LocalDateTime.now());

        Order saved = orderRepository.save(order);

        // 写入 Redis 缓存
        orderCacheService.cacheOrderStatus(saved.getOrderNo(), saved.getStatus());
        return saved;
    }

    public List<Order> findAll() {
        return orderRepository.findAll();
    }
}

8.4 增加缓存查询接口

package com.example.demo.order;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

/**
 * 订单控制器
 */
@RestController
public class OrderController {

    private final OrderService orderService;
    private final OrderCacheService orderCacheService;

    public OrderController(OrderService orderService,
                           OrderCacheService orderCacheService) {
        this.orderService = orderService;
        this.orderCacheService = orderCacheService;
    }

    @PostMapping("/orders")
    public Order createOrder(@RequestParam String productName,
                             @RequestParam BigDecimal amount) {
        return orderService.createOrder(productName, amount);
    }

    @GetMapping("/orders")
    public List<Order> listOrders() {
        return orderService.findAll();
    }

    /**
     * 查询 Redis 中缓存的订单状态
     */
    @GetMapping("/orders/cache/{orderNo}")
    public Map<String, Object> getCachedStatus(@PathVariable String orderNo) {
        return Map.of(
                "orderNo", orderNo,
                "status", orderCacheService.getOrderStatus(orderNo).orElse("NOT_FOUND")
        );
    }
}

到这里,你的本地开发环境已经无需单独安装 MySQL 和 Redis。

这比传统 docker-compose.yml 最大的优势在于:

  • Redis 和 MySQL 何时启动由 Spring 决定;
  • 配置注入由 Spring Boot 完成;
  • 开发 profile 控制是否启用;
  • 业务代码无需关心服务地址写死在哪里。

9. 第三个案例:把 Kafka 作为 Spring Bean 启动

很多团队本地开发最头疼的服务之一其实是消息队列。数据库和缓存大家相对熟悉,但 Kafka 的本地安装、版本适配、网络配置、主题管理往往更容易出问题。

这也是 Testcontainers 在本地开发中最能体现价值的场景之一。

9.1 Kafka 容器配置

package com.example.demo.config;

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.kafka.KafkaContainer;
import org.testcontainers.utility.DockerImageName;

/**
 * Kafka 本地开发容器配置
 */
@Configuration
@Profile("local-dev")
public class KafkaContainerConfig {

    /**
     * Kafka 容器 Bean
     */
    @Bean
    @ServiceConnection
    public KafkaContainer kafkaContainer() {
        return new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));
    }
}

注:不同版本 Testcontainers / Kafka 镜像在 API 与镜像选择上可能略有差异,实际项目中请以当前依赖版本支持情况为准。本文示例重点是思路与组织方式。

9.2 Kafka 配置

spring:
  kafka:
    consumer:
      group-id: order-local-group
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

因为 @ServiceConnection 已经提供了 bootstrap-servers,这里无需手写 Kafka 地址。

9.3 发送订单创建事件

package com.example.demo.order;

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

/**
 * 订单事件生产者
 */
@Service
public class OrderEventProducer {

    private final KafkaTemplate<String, String> kafkaTemplate;

    public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    /**
     * 发送订单创建事件
     *
     * @param orderNo 订单编号
     */
    public void sendOrderCreatedEvent(String orderNo) {
        // 发送到 order-created 主题
        kafkaTemplate.send("order-created", orderNo, "订单已创建:" + orderNo);
    }
}

9.4 消费订单创建事件

package com.example.demo.order;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

/**
 * 订单事件消费者
 */
@Component
public class OrderEventConsumer {

    private static final Logger log = LoggerFactory.getLogger(OrderEventConsumer.class);

    /**
     * 监听订单创建主题
     *
     * @param message 消息内容
     */
    @KafkaListener(topics = "order-created", groupId = "order-local-group")
    public void onOrderCreated(String message) {
        log.info("收到 Kafka 消息:{}", message);
    }
}

9.5 创建订单时发送消息

package com.example.demo.order;

import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

/**
 * 订单服务类
 */
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderCacheService orderCacheService;
    private final OrderEventProducer orderEventProducer;

    public OrderService(OrderRepository orderRepository,
                        OrderCacheService orderCacheService,
                        OrderEventProducer orderEventProducer) {
        this.orderRepository = orderRepository;
        this.orderCacheService = orderCacheService;
        this.orderEventProducer = orderEventProducer;
    }

    /**
     * 创建订单
     */
    public Order createOrder(String productName, BigDecimal amount) {
        Order order = new Order();
        order.setOrderNo("ORD-" + UUID.randomUUID().toString().replace("-", ""));
        order.setProductName(productName);
        order.setAmount(amount);
        order.setStatus("CREATED");
        order.setCreatedAt(LocalDateTime.now());

        Order saved = orderRepository.save(order);

        // 写入缓存
        orderCacheService.cacheOrderStatus(saved.getOrderNo(), saved.getStatus());

        // 发送 Kafka 事件
        orderEventProducer.sendOrderCreatedEvent(saved.getOrderNo());

        return saved;
    }

    public List<Order> findAll() {
        return orderRepository.findAll();
    }
}

现在你已经做到了:

  • 本地无需安装 MySQL;
  • 本地无需安装 Redis;
  • 本地无需安装 Kafka;
  • 直接运行 Spring Boot 应用即可获得完整依赖服务。

这已经不是“用容器做测试”了,而是完整的 本地开发容器化

10. Spring Boot 3.x 中与 Testcontainers 集成的几种方式

写到这里,很多读者会问:是不是只有“把容器声明成 Bean”这一种方式?

答案是:不是。

在 Spring Boot 3.x 中,常见整合方式至少有以下几类。

10.1 方式一:测试类中使用 @Container

这是最传统的 Testcontainers 使用方式。

特点:

  • 主要服务于测试;
  • 生命周期绑定测试类;
  • 不适合直接拿来做本地开发运行时。

示意代码:

@Testcontainers
@SpringBootTest
class OrderServiceTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
}

这种写法大家比较熟,但它不适合作为本文主角,因为我们要的是“应用启动即拥有依赖服务”。

10.2 方式二:通过 DynamicPropertySource 注入属性

这是在测试里也常见的一种方式。

特点:

  • 灵活;
  • 需要手工注册属性;
  • @ServiceConnection 更底层。

示意:

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

这种方式在本地开发也能用,但写法更“手工”,不如 Spring Boot 3.x 的 @ServiceConnection 优雅。

10.3 方式三:将容器声明为 Bean,并通过 @ServiceConnection 集成

这正是本文重点推荐的方式。

特点:

  • 容器进入 Spring 上下文;
  • 适用于开发运行时;
  • 与 Profile、自动配置、Bean 生命周期天然契合;
  • 代码组织更符合 Spring Boot 思维。

10.4 方式四:启动器类中手工启动容器并设置系统属性

例如在 main 方法中先启动容器,再 System.setProperty(...)

这种方式理论上也能做,但并不优雅。问题包括:

  • 和 Spring 生命周期割裂;
  • 配置注入太原始;
  • 可维护性差;
  • 不利于多容器扩展。

小结

如果你的目标是:

  • 让本地开发免安装依赖服务;
  • 让容器和 Spring Boot 3.x 深度融合;
  • 让配置管理、生命周期、自动装配更自然;

那么最推荐的方式就是:

在特定 Profile 下,把依赖服务声明成 Spring Bean,并借助 @ServiceConnection 连接到 Spring Boot 自动配置体系。

11. 为什么把依赖服务声明成 Spring Bean 更优雅?

这一节非常重要,因为它解释的是“为什么要这么设计”,而不是“怎么写代码”。

很多初学者会觉得:“能跑不就行了吗?为什么非要做成 Bean?”

从工程视角看,这里面至少有六层收益。

11.1 统一生命周期

Bean 的本质不是“一个对象”,而是“受 Spring 容器管理的对象”。

一旦容器服务被声明为 Bean,它就自动拥有:

  • 明确的创建时机;
  • 受控的初始化顺序;
  • 可感知的依赖关系;
  • 可统一的销毁流程。

这和“在 main 方法里随便 new 一下容器再 start”完全不是一个层级。

11.2 与 Profile 天然结合

你通常不会希望生产环境也去启动本地开发容器。因此,最合理的办法就是:

  • local-dev:启用本地开发容器;
  • test:用于测试容器;
  • prod:连接真实基础设施。

Bean 配合 @Profile 可以让这套切换非常自然。

11.3 与自动配置自然衔接

Spring Boot 的强项是自动配置,而自动配置的前提是:

  • 有确定的 Bean;
  • 有环境属性;
  • 有可识别的连接信息。

容器声明为 Bean 后,Spring Boot 才能更容易推断这些信息。

11.4 更容易扩展与组合

今天你只有 MySQL,明天你可能加 Redis,后天可能加 Kafka,再后面还可能加 Elasticsearch、MinIO、MailHog、RabbitMQ。

如果容器都是 Bean,那么:

  • 每个服务都有自己配置类;
  • 每个服务都可按 Profile 打开/关闭;
  • 每个服务都能被独立替换;
  • 整体结构会非常清晰。

11.5 更适合团队协作

团队协作最怕“约定靠嘴”。

而 Spring Bean 化意味着:

  • 依赖服务不再依靠口头说明;
  • 新同学只要运行指定 Profile 即可;
  • CI 也更容易复用同样的启动模式;
  • 文档不再承担过多“环境安装说明”职责。

11.6 更符合“基础设施即代码”理念

今天我们已经接受:

  • 配置可以代码化;
  • 测试可以代码化;
  • 部署可以代码化;
  • 运维脚本可以代码化。

那么开发依赖环境当然也应该尽可能代码化。

把依赖服务声明成 Bean,正是把开发基础设施正式纳入应用代码体系的一种做法。

12. 多容器协作:一个应用同时连接 MySQL + Redis + Kafka

前面三个案例是分别演示,真正项目里通常是多依赖一起存在。下面给出一个聚合版配置。

12.1 聚合配置类

package com.example.demo.config;

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.kafka.KafkaContainer;
import org.testcontainers.utility.DockerImageName;

/**
 * 本地开发容器聚合配置
 */
@Configuration
@Profile("local-dev")
public class LocalDevelopmentContainersConfiguration {

    /**
     * MySQL 容器
     */
    @Bean
    @ServiceConnection
    public MySQLContainer<?> mysqlContainer() {
        return new MySQLContainer<>("mysql:8.0.36")
                .withDatabaseName("demo_db")
                .withUsername("demo")
                .withPassword("demo123")
                .withInitScript("sql/init.sql");
    }

    /**
     * Redis 容器
     */
    @Bean
    @ServiceConnection(name = "redis")
    public GenericContainer<?> redisContainer() {
        return new GenericContainer<>(DockerImageName.parse("redis:7.2"))
                .withExposedPorts(6379)
                .withCommand("redis-server", "--appendonly", "no");
    }

    /**
     * Kafka 容器
     */
    @Bean
    @ServiceConnection
    public KafkaContainer kafkaContainer() {
        return new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));
    }
}

12.2 多依赖协作模型图

这个模型虽然简单,但它说明了一件非常关键的事:

从应用视角看,MySQL、Redis、Kafka 不再是三套外部安装说明,而是三种由应用启动过程主动申请并接入的开发依赖。

这是开发体验上的巨大变化。

13. 完整实战:订单系统本地开发容器化

接下来我们把前面的能力整合成一个更完整的案例。这个案例会模拟一个最小订单系统,具备如下流程:

  1. 接收创建订单请求;
  2. 订单写入 MySQL;
  3. 订单状态写入 Redis;
  4. 发送 Kafka 消息;
  5. 消费 Kafka 消息做日志记录;
  6. 提供订单查询接口;
  7. 提供缓存状态查看接口。

13.1 请求流程图

这张图把整个业务闭环串起来了。你会发现,虽然依赖服务都运行在容器里,但从业务代码角度看,它们和“真实环境服务”没有本质区别。

这就是 Testcontainers 最强的地方:

它让本地开发尽量接近真实运行依赖,而不是只靠内存模拟。

13.2 DTO 设计

package com.example.demo.order;

import java.math.BigDecimal;

/**
 * 创建订单请求对象
 */
public class CreateOrderRequest {

    /** 商品名称 */
    private String productName;

    /** 金额 */
    private BigDecimal amount;

    public String getProductName() {
        return productName;
    }

    public void setProductName(String productName) {
        this.productName = productName;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public void setAmount(BigDecimal amount) {
        this.amount = amount;
    }
}

13.3 更规范的 Controller

package com.example.demo.order;

import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * 订单控制器
 */
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;
    private final OrderCacheService orderCacheService;

    public OrderController(OrderService orderService,
                           OrderCacheService orderCacheService) {
        this.orderService = orderService;
        this.orderCacheService = orderCacheService;
    }

    /**
     * 创建订单
     */
    @PostMapping
    public Order create(@RequestBody CreateOrderRequest request) {
        return orderService.createOrder(request.getProductName(), request.getAmount());
    }

    /**
     * 查询所有订单
     */
    @GetMapping
    public List<Order> list() {
        return orderService.findAll();
    }

    /**
     * 查询缓存状态
     */
    @GetMapping("/{orderNo}/status-cache")
    public Map<String, Object> statusCache(@PathVariable String orderNo) {
        return Map.of(
                "orderNo", orderNo,
                "status", orderCacheService.getOrderStatus(orderNo).orElse("NOT_FOUND")
        );
    }
}

13.4 启动后自动打印连接信息(调试用)

package com.example.demo.runner;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.kafka.KafkaContainer;

/**
 * 应用启动完成后打印容器连接信息,便于调试观察
 */
@Component
@Profile("local-dev")
public class StartupRunner implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(StartupRunner.class);

    private final MySQLContainer<?> mysqlContainer;
    private final GenericContainer<?> redisContainer;
    private final KafkaContainer kafkaContainer;

    public StartupRunner(MySQLContainer<?> mysqlContainer,
                         GenericContainer<?> redisContainer,
                         KafkaContainer kafkaContainer) {
        this.mysqlContainer = mysqlContainer;
        this.redisContainer = redisContainer;
        this.kafkaContainer = kafkaContainer;
    }

    @Override
    public void run(ApplicationArguments args) {
        log.info("===== 本地开发容器已就绪 =====");
        log.info("MySQL JDBC URL: {}", mysqlContainer.getJdbcUrl());
        log.info("MySQL Username: {}", mysqlContainer.getUsername());
        log.info("Redis Host: {}", redisContainer.getHost());
        log.info("Redis Port: {}", redisContainer.getMappedPort(6379));
        log.info("Kafka Bootstrap Servers: {}", kafkaContainer.getBootstrapServers());
    }
}

为什么这里值得特别讲?

这段代码不是必须的,但很适合教学与排障。

因为很多初学者会怀疑:

  • 容器真的启动了吗?
  • 应用到底连到了哪个地址?
  • 为什么我没写 3306,数据库也能连通?
  • Kafka 的地址是不是自动注入了?

通过打印连接信息,读者就能直观看到:

  • Testcontainers 使用了动态端口;
  • Spring Boot 不是依赖固定端口,而是依赖真实映射结果;
  • 容器 Bean 确实已经进入应用上下文。

14. 代码逐段解析:每个类到底做了什么?

很多教程最大的问题不是没代码,而是代码贴完就结束。对于初学者来说,这很容易形成“看懂了假象”,真正让他自己写时还是无从下手。

所以这一节我们把关键角色逐个拆开解释。

14.1 配置类:负责声明依赖基础设施

LocalDevelopmentContainersConfiguration 的职责不是业务,而是“开发运行基础设施声明”。

你可以把它理解为:

  • 业务代码是应用层;
  • 容器配置类是开发基础设施层;
  • 两者通过 Spring 容器连接起来。

这是一种很好的分层方式。

如果没有这层,容器相关代码就会散落在启动类、main 方法、工具类、测试类甚至文档里,长期来看会非常混乱。

14.2 @Profile("local-dev"):负责限定使用场景

这一点非常关键。

因为 Testcontainers 本地开发容器化不是要替代生产基础设施,而是要服务于本地开发体验。通过 Profile 隔离,你就能做到:

  • 本地开发时:自动起容器;
  • 集成环境时:连接环境中已有服务;
  • 生产环境时:连接正式中间件。

这意味着本文方案是增强开发体验,而不是粗暴地把所有环境混为一谈。

14.3 @ServiceConnection:负责连接 Spring Boot 自动配置

这是 Spring Boot 3.x 非常值得学习的点。

如果没有它,你得手写大量连接属性注册逻辑;而有了它,Spring Boot 会尽量理解这个容器提供的服务类型,并自动喂给相关自动配置模块。

换句话说,它相当于在容器和 Spring Boot 自动配置之间搭了一座桥。

14.4 业务层完全不需要知道容器细节

这是工程设计中非常漂亮的一点。

OrderServiceOrderControllerOrderCacheServiceOrderEventProducer 这些业务类都不关心:

  • MySQL 是本机安装的还是容器启动的;
  • Redis 是固定端口还是随机映射;
  • Kafka 是手工部署还是代码拉起。

它们只关心:

  • 数据源能不能用;
  • RedisTemplate 能不能注入;
  • KafkaTemplate 能不能发送消息。

这说明我们的设计达到了一个很重要的目标:

基础设施变化被隔离在配置层,业务层保持稳定。

这就是好架构的特征之一。

15. 配置设计:不同环境如何切换

很多人一看到本地开发容器化,第一反应是:“那我线上怎么办?测试怎么办?”

其实这并不是冲突关系,而是环境分层问题。

15.1 推荐的环境划分

建议最少拆成三类:

  • local-dev:本地开发容器化;
  • test:自动化测试;
  • prod:生产或类生产环境。

15.2 推荐的配置组织方式

application.yml

spring:
  application:
    name: boot-testcontainers-dev
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: true
    properties:
      hibernate:
        format_sql: true

management:
  endpoints:
    web:
      exposure:
        include: health,info,beans,env

application-local-dev.yml

spring:
  config:
    activate:
      on-profile: local-dev
  jpa:
    open-in-view: false
  kafka:
    consumer:
      group-id: order-local-group
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

application-prod.yml

spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: jdbc:mysql://prod-mysql:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai
    username: prod_user
    password: prod_password
  data:
    redis:
      host: prod-redis
      port: 6379
  kafka:
    bootstrap-servers: prod-kafka-1:9092,prod-kafka-2:9092

配置设计原则

核心原则是:

  • 通用配置放公共文件;
  • 环境差异配置放对应 profile;
  • 本地开发尽量不写死外部依赖地址;
  • 生产环境明确连接真实基础设施。

这样配置边界会非常清晰。

16. 生命周期管理:容器什么时候启动、什么时候关闭?

现在我们来讲一个特别容易被忽略、但实际上决定这套方案是否好用的问题:生命周期。

如果你只会把容器跑起来,却不理解生命周期,那你的开发环境很可能越用越乱。

16.1 容器什么时候启动?

当容器被声明为 Spring Bean 后,它的启动通常发生在 Bean 初始化阶段或 Spring Boot 与 Testcontainers 集成处理过程中。

从整体顺序看,大致如下:

也就是说,依赖容器需要先于真正依赖它的业务基础组件就绪。

这也是为什么 Testcontainers 与 Spring Boot 深度整合很重要:否则你必须手动保证初始化先后顺序。

16.2 容器什么时候关闭?

正常情况下,当 JVM 退出时,Testcontainers 会配合资源回收机制关闭容器。对于声明为 Bean 的容器,在应用上下文关闭时也会触发销毁逻辑。

但这里要区分两类场景:

场景一:一次性运行

比如:

  • 演示一个 demo;
  • 本地启动后测试功能;
  • 应用关闭后不再需要依赖环境。

这种场景下,容器随应用关闭一起回收,最符合直觉。

场景二:反复重启应用

比如开发者在 IDE 中频繁重启、热部署、调试。

这时如果每次都完全销毁再重建容器,就可能带来:

  • 启动等待变长;
  • 数据被重置;
  • 镜像与容器操作开销增大。

因此你需要考虑“容器复用策略”,这一点后面会详细讲。

17. 资源清理:如何避免“容器越跑越多”

谈本地开发容器化,必须认真讲资源清理,否则文章就是不负责任的。

因为很多初学者照着示例做完,最容易遇到的不是“起不来”,而是“后来越来越乱”:

  • 容器残留;
  • 匿名卷越来越多;
  • 镜像占空间;
  • Docker 网络堆积;
  • 本机磁盘被吃满。

17.1 容器回收的基本原则

你要知道,Testcontainers 的设计初衷就是临时容器,因此它通常具备较好的自动清理能力。但这并不意味着你可以完全不关心清理。

至少要理解以下几种资源:

  • 容器本身;
  • 镜像;
  • 数据卷;
  • 网络;
  • 日志占用;
  • 本地缓存。

17.2 为什么本地开发更容易出现清理问题

因为测试通常是“跑完就结束”,而本地开发是“反复启停”。

一旦以下情况出现,就容易留下痕迹:

  • IDE 强制终止进程;
  • Docker 本身异常重启;
  • 容器复用配置使用不当;
  • 开发者手工 docker stop 但没删卷;
  • 多个项目共享镜像但没有统一策略。

17.3 你应该建立的清理意识

建议团队内部至少约定以下几条:

  1. 本地开发容器只服务当前项目,不要顺手拿来当长期数据库;
  2. 演示性环境默认不追求数据永久保存;
  3. 周期性清理未使用容器和卷;
  4. 对是否启用复用做团队统一约定;
  5. 对镜像版本统一管理,避免每个人各拉各的。

17.4 示例:在文档中显式说明清理命令

虽然我们不靠文档来启动环境,但文档仍然应该告诉开发者如何清理。

# 查看当前容器
 docker ps -a

# 删除已停止容器
 docker container prune -f

# 删除未使用镜像
 docker image prune -f

# 删除未使用卷(谨慎)
 docker volume prune -f

# 删除未使用网络
 docker network prune -f

为什么这一节不能省略?

因为一篇真正负责任的工程实践文章,不应该只谈“如何启动”,还必须谈“如何收尾”。

如果你只教读者把容器跑起来,却不告诉他长期使用后怎么维护,那就很容易把本来提升体验的方案,变成新的环境负担。

18. 性能与体验优化:让本地开发更顺手

很多人第一次尝试本地开发容器化时,会担心一个问题:

这样会不会很慢?

答案是:有成本,但通常可接受,而且可以优化。

18.1 首次启动慢是正常的

第一次启动时,通常要经历:

  • 拉镜像;
  • 创建容器;
  • 初始化数据库;
  • Kafka 完成 broker 启动;
  • Spring Boot 再完成应用初始化。

因此第一次运行比“本机已长期安装好的服务”慢,是正常现象。

但首次成本不代表长期成本高。

18.2 为什么长期体验反而可能更好

因为一旦你不再手工维护 MySQL、Redis、Kafka:

  • 不再单独升级和修补本机安装;
  • 不再处理端口冲突;
  • 不再查“你到底连的是哪一个实例”;
  • 新项目切换更轻松;
  • 新同学入门更省时间。

从整体工程时间成本看,很多团队会发现这笔账是划算的。

18.3 可选优化:启用容器复用

某些场景下,你可能希望应用重启时不销毁容器,而是继续复用已有容器,以提升开发效率。

这一策略的核心价值在于:

  • 减少重复启动时间;
  • 保留临时数据;
  • 提高频繁调试效率。

但要注意,复用并不适合所有团队,因为它会带来状态残留问题。

示例:对容器启用复用

@Bean
@ServiceConnection
public MySQLContainer<?> mysqlContainer() {
    return new MySQLContainer<>("mysql:8.0.36")
            .withDatabaseName("demo_db")
            .withUsername("demo")
            .withPassword("demo123")
            // 启用容器复用(需要本地环境允许)
            .withReuse(true)
            .withInitScript("sql/init.sql");
}

复用的利弊

优点:

  • 启动更快;
  • 调试体验更好;
  • 数据能保留。

缺点:

  • 状态不再绝对干净;
  • 容器清理要更谨慎;
  • 团队成员对环境一致性要求更高时不一定适合。

经验建议

  • 教学文章、快速 demo:可以不开复用,保证干净;
  • 个人高频调试:可以考虑打开复用;
  • 团队统一开发基线:最好明确规定是否允许复用。

18.4 初始化脚本不要太重

本地开发环境的目标是“快速进入业务开发”,不是“完整恢复一套生产数据”。

因此:

  • SQL 初始化脚本应尽量精简;
  • 不要导入海量数据;
  • Kafka 主题初始化也尽量轻量;
  • Redis 不要预加载过多内容。

你真正要的是“功能可验证”,而不是“本地环境像生产一样重”。

19. 常见问题排查:启动失败、端口冲突、镜像拉取慢怎么办

这一节是实战里最常被收藏的部分。我们集中把常见问题讲透。

19.1 问题一:Docker 未启动或不可用

典型表现

  • 应用启动时报无法连接 Docker;
  • Testcontainers 初始化失败;
  • 日志提示找不到容器运行时。

排查思路

  1. 先确认 Docker Desktop 或 Docker Engine 已启动;
  2. 命令行执行 docker version
  3. 执行 docker ps 看是否可用;
  4. 确认当前用户有权限访问 Docker;
  5. macOS / Linux 下检查 socket 权限。

核心理解

Testcontainers 不是魔法,它本质上要驱动底层容器运行时。所以只要 Docker 不可用,Testcontainers 就不可能工作。

19.2 问题二:镜像拉取很慢

典型原因

  • 网络环境导致拉取国外镜像慢;
  • 首次拉取大镜像;
  • Kafka 镜像体积较大;
  • 镜像源不可达。

处理建议

  • 提前准备团队统一镜像缓存;
  • 在公司内网构建镜像代理;
  • 选择更合适的镜像版本;
  • 首次启动给开发者明确预期。

经验提醒

不要因为“第一次拉镜像慢”就否定整个方案。镜像一旦到本机,后续体验通常会好很多。

19.3 问题三:端口冲突

为什么 Testcontainers 反而更少冲突?

因为它通常采用动态端口映射,而不是强制占用本机固定端口。Spring Boot 再通过实际映射值建立连接。

这比传统写死 localhost:3306 更灵活。

但为什么有时仍然会报冲突?

可能是:

  • 你手动指定了固定端口;
  • 应用本身端口和其他程序冲突;
  • Docker Desktop 内部网络异常;
  • 复用容器时旧实例占用状态不一致。

建议

本地开发容器化尽量遵循“容器端口固定、宿主端口动态”的思路,不要过早追求本机固定映射。

19.4 问题四:应用启动太慢

排查顺序

  1. 看是否首次拉镜像;
  2. 看数据库初始化脚本是否过重;
  3. 看 Kafka 是否占主要启动时间;
  4. 看应用自身自动配置是否太多;
  5. 考虑是否需要容器复用。

经验建议

对初学者项目,建议先只接 MySQL 和 Redis,Kafka 可以后加。先让读者建立正确心智,再上复杂依赖。

19.5 问题五:容器起来了,但 Spring Boot 还是连不上

可能原因

  • @ServiceConnection 没加;
  • 容器类型无法被自动识别;
  • 使用 GenericContainer 却没显式指定服务名;
  • profile 没激活;
  • 配置文件里又写了错误的固定地址覆盖了自动注入。

排查建议

  • 打开应用日志;
  • 打印容器实际地址;
  • 查看 envbeans 端点;
  • 临时关闭手工配置,观察自动注入是否生效。

这也是为什么我前面专门写了 StartupRunner。调试期尽量让信息透明,排障成本会大幅降低。

20. 与 Docker Compose、Dev Services、传统本地安装对比

技术选型不能只讲自己方案的优点,还要把边界讲清楚。下面我们做一个系统对比。

20.1 与传统本地安装对比

传统安装的优点

  • 对单一长期项目来说,启动速度快;
  • 不需要容器基础设施;
  • 某些老项目团队习惯了。

传统安装的缺点

  • 污染本机环境;
  • 版本难统一;
  • 多项目冲突严重;
  • 新成员接入成本高;
  • 卸载、升级和迁移麻烦。

Testcontainers 的优势

  • 依赖环境代码化;
  • 可复现;
  • 可隔离;
  • 更接近真实运行服务;
  • 更适合多项目切换。

20.2 与 Docker Compose 对比

Docker Compose 是很多团队的第一选择,它确实很有价值。但它与 Testcontainers 并不是谁绝对替代谁,而是关注点不同。

Docker Compose 更适合

  • 团队统一维护一套外部环境编排;
  • 跨语言项目共享依赖服务;
  • 独立于应用代码运行;
  • 有专门运维或平台工程习惯。

Testcontainers 更适合

  • Java / Spring Boot 项目内聚式管理依赖;
  • 想让环境声明跟随代码走;
  • 希望容器启动与 Spring 生命周期打通;
  • 希望在测试与本地开发之间复用思路;
  • 教学、demo、快速启动场景。

一句话对比

  • Docker Compose 更像项目外部环境清单;
  • Testcontainers 更像应用内部可编程依赖基础设施。

20.3 与某些框架的 Dev Services 对比

有些现代框架也提供“开发时自动拉起依赖服务”的能力,本质理念和本文很接近。

但在 Spring Boot 世界里,Testcontainers 的优势在于:

  • 生态成熟;
  • 细粒度控制强;
  • 可以跨测试、开发、演示多场景复用;
  • 与 Spring Boot 3.x 集成后体验足够自然。

也就是说,Spring Boot 并没有缺少这种能力,只是它通过 Testcontainers 的方式来实现。

21. 团队协作价值:为什么这不是个人技巧,而是工程能力

到这里,你应该已经看出来了:本文讲的不是一个“炫技小技巧”,而是一种团队工程能力建设方式。

21.1 新同学接入成本显著下降

最常见的团队痛点就是新人第一天无法把项目跑起来。原因通常不是 Java 不会,而是环境太复杂:

  • 数据库要装;
  • 缓存要装;
  • 消息队列要装;
  • 版本还得对;
  • 某些配置要改;
  • 还要手工初始化数据。

如果把这些依赖服务都做成开发容器 Bean,那么新人上手路径会明显缩短:

  1. 安装 JDK;
  2. 安装 Docker;
  3. 拉代码;
  4. 启动 local-dev profile;
  5. 开始开发。

从团队生产力角度看,这价值非常大。

21.2 降低“环境差异导致的问题”

很多本地 bug 不是代码 bug,而是环境 bug。

例如:

  • 某人 Redis 配置了密码,另一个人没配;
  • 某人 Kafka 版本不同,协议不兼容;
  • 某人 MySQL 字符集不同;
  • 某人机器上数据残留影响了结果。

本地开发容器化的价值就在于尽量把这些差异压缩到最小。

21.3 文档从“安装手册”变成“使用说明”

一份成熟项目文档不应该有一半篇幅都在教人手工装中间件。

如果依赖环境已经代码化,文档就能更专注于:

  • 项目结构;
  • 业务模块;
  • 开发规范;
  • 启动方式;
  • 排障说明。

这会显著提高文档质量。

21.4 测试与开发思路统一

这也是本文特别值得强调的一点。

很多团队把“测试环境容器化”和“开发环境搭建”当成两套完全不同的事情。其实它们底层是可以共用理念的:

  • 都是在代码里声明依赖服务;
  • 都是在需要时自动拉起;
  • 都是在结束时可清理;
  • 都强调可复现和隔离。

换句话说,Testcontainers 把测试工程与开发工程拉到了同一条现代化思路上。

22. 最佳实践清单

下面给出一份可直接用于团队落地的最佳实践清单。

22.1 只在特定 Profile 启用

不要让本地开发容器默认侵入所有环境。推荐用 local-dev 单独隔离。

22.2 把每类依赖服务单独封装

建议按服务拆配置类,例如:

  • MysqlContainerConfig
  • RedisContainerConfig
  • KafkaContainerConfig

或者小项目统一写在一个聚合配置类中,但逻辑要清楚。

22.3 优先使用 @ServiceConnection

只要 Spring Boot 3.x 与对应容器类型支持,优先使用它,而不是手工写一堆属性绑定。

22.4 初始化脚本轻量化

本地开发要的是快速启动,不是完整恢复生产环境。脚本越轻越好。

22.5 业务代码不要感知容器存在

业务层应该依赖标准 Spring 抽象,而不是直接到处拿容器对象。容器信息最多只在调试或基础设施层暴露。

22.6 明确是否允许复用

是否启用 .withReuse(true) 必须有团队约定。不要每个人各自一套。

22.7 提供清理指南

项目 README 至少要告诉开发者:

  • 如何启动;
  • 如何停止;
  • 如何清理残留资源;
  • 常见故障如何排查。

22.8 不要过度容器化

并不是所有依赖都适合在本地用 Testcontainers 拉起。比如某些超重型组件、需要复杂集群拓扑的组件,就要谨慎评估。

23. 适用边界:什么场景适合,什么场景不适合

一篇高质量文章必须告诉读者:什么时候该用,什么时候别硬用。

23.1 很适合的场景

场景一:教学与专栏示例

这几乎是最适合的场景。读者不需要先装半天环境,直接就能跑代码、看结果、学知识。

场景二:中小型 Spring Boot 项目本地开发

尤其是依赖服务数量有限(2~5 个左右)且容器化成熟的项目。

场景三:团队快速 onboarding

新成员入门时,环境统一收益非常大。

场景四:Demo 与 PoC

快速原型验证时,不值得为短期项目手工维护整套本机依赖。

23.2 不太适合的场景

场景一:超重型依赖或复杂集群依赖

例如某些需要复杂多节点模拟的系统,本地用 Testcontainers 可能成本较高。

场景二:对本地数据持续性要求极强

如果开发依赖环境需要长期积累大量本地数据,且容器重建代价高,就要谨慎评估是否采用复用或转向其他方案。

场景三:团队根本没有容器基础设施能力

如果团队成员连 Docker 基础都不具备,贸然推行可能反而制造新的学习门槛。这时应先补齐容器基础教育。

场景四:跨语言统一依赖更适合 Compose

如果多个不同技术栈项目要共享同一套开发依赖,Docker Compose 可能更利于统一维护。

结论

Testcontainers 本地开发容器化不是银弹,但它在 Spring Boot 3.x 项目里的适用面已经足够广,而且价值非常现实。

24. 总结:Testcontainers 作为本地开发基础设施的未来价值

让我们回到文章标题:

Testcontainers 不止用于测试:本地开发容器化

这句话真正要传达的,不是“你可以这么玩”,而是“你应该重新看待开发环境的组织方式”。

在 Spring Boot 3.x 时代,我们完全可以把本地开发基础设施做得像业务代码一样:

  • 有明确声明;
  • 有清晰边界;
  • 有自动装配;
  • 有生命周期;
  • 有资源清理;
  • 有环境隔离;
  • 有团队可复用性。

Testcontainers 之所以重要,不只是因为它能起一个 MySQL、一个 Redis、一个 Kafka;而是因为它让我们有机会把“本地环境搭建”从低价值重复劳动,升级为可编程、可维护、可协作的工程资产。

从更长远的角度看,这种思想和现代软件工程的发展方向是高度一致的:

  • 基础设施即代码;
  • 环境即代码;
  • 开发体验工程化;
  • 测试与开发基础设施统一化;
  • 尽量降低隐式状态和人工步骤。

所以,如果你是 Spring Boot 3.x 开发者,请不要把 Testcontainers 只停留在“集成测试工具”的认知层面。它完全可以成为你本地开发体系中的重要一环。

尤其当你的项目同时依赖 MySQL、Redis、Kafka 这些经典组件时,这种价值会非常直观。

一句话收尾:

真正优秀的开发环境,不是靠每个人手工把依赖装对,而是靠项目本身把依赖环境声明清楚、启动清楚、使用清楚、清理清楚。

而在 Spring Boot 3.x 中,Testcontainers 正是实现这件事的一把非常趁手的工具。

25. 附录:完整可运行示例代码

为了方便你直接整理发布,这里把核心代码按文件再完整汇总一次。

25.1 DemoApplication.java

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 应用启动类
 */
@SpringBootApplication
public class DemoApplication {

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

25.2 LocalDevelopmentContainersConfiguration.java

package com.example.demo.config;

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.kafka.KafkaContainer;
import org.testcontainers.utility.DockerImageName;

/**
 * 本地开发容器聚合配置类
 */
@Configuration
@Profile("local-dev")
public class LocalDevelopmentContainersConfiguration {

    /**
     * MySQL 容器 Bean
     */
    @Bean
    @ServiceConnection
    public MySQLContainer<?> mysqlContainer() {
        return new MySQLContainer<>("mysql:8.0.36")
                .withDatabaseName("demo_db")
                .withUsername("demo")
                .withPassword("demo123")
                .withInitScript("sql/init.sql");
    }

    /**
     * Redis 容器 Bean
     */
    @Bean
    @ServiceConnection(name = "redis")
    public GenericContainer<?> redisContainer() {
        return new GenericContainer<>(DockerImageName.parse("redis:7.2"))
                .withExposedPorts(6379)
                .withCommand("redis-server", "--appendonly", "no");
    }

    /**
     * Kafka 容器 Bean
     */
    @Bean
    @ServiceConnection
    public KafkaContainer kafkaContainer() {
        return new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));
    }
}

25.3 Order.java

package com.example.demo.order;

import jakarta.persistence.*;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 订单实体类
 */
@Entity
@Table(name = "t_order")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /** 订单编号 */
    @Column(name = "order_no", nullable = false, length = 64)
    private String orderNo;

    /** 商品名称 */
    @Column(name = "product_name", nullable = false, length = 128)
    private String productName;

    /** 金额 */
    @Column(name = "amount", nullable = false)
    private BigDecimal amount;

    /** 状态 */
    @Column(name = "status", nullable = false, length = 32)
    private String status;

    /** 创建时间 */
    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public String getProductName() {
        return productName;
    }

    public void setProductName(String productName) {
        this.productName = productName;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public void setAmount(BigDecimal amount) {
        this.amount = amount;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }
}

25.4 OrderRepository.java

package com.example.demo.order;

import org.springframework.data.jpa.repository.JpaRepository;

/**
 * 订单仓储接口
 */
public interface OrderRepository extends JpaRepository<Order, Long> {
}

25.5 CreateOrderRequest.java

package com.example.demo.order;

import java.math.BigDecimal;

/**
 * 创建订单请求对象
 */
public class CreateOrderRequest {

    /** 商品名称 */
    private String productName;

    /** 金额 */
    private BigDecimal amount;

    public String getProductName() {
        return productName;
    }

    public void setProductName(String productName) {
        this.productName = productName;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public void setAmount(BigDecimal amount) {
        this.amount = amount;
    }
}

25.6 OrderCacheService.java

package com.example.demo.order;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Optional;

/**
 * 订单缓存服务
 */
@Service
public class OrderCacheService {

    private final StringRedisTemplate stringRedisTemplate;

    public OrderCacheService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 缓存订单状态
     */
    public void cacheOrderStatus(String orderNo, String status) {
        String key = "order:status:" + orderNo;
        stringRedisTemplate.opsForValue().set(key, status, Duration.ofMinutes(10));
    }

    /**
     * 查询缓存中的订单状态
     */
    public Optional<String> getOrderStatus(String orderNo) {
        String key = "order:status:" + orderNo;
        return Optional.ofNullable(stringRedisTemplate.opsForValue().get(key));
    }
}

25.7 OrderEventProducer.java

package com.example.demo.order;

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

/**
 * 订单事件生产者
 */
@Service
public class OrderEventProducer {

    private final KafkaTemplate<String, String> kafkaTemplate;

    public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    /**
     * 发送订单创建事件
     */
    public void sendOrderCreatedEvent(String orderNo) {
        kafkaTemplate.send("order-created", orderNo, "订单已创建:" + orderNo);
    }
}

25.8 OrderEventConsumer.java

package com.example.demo.order;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

/**
 * 订单事件消费者
 */
@Component
public class OrderEventConsumer {

    private static final Logger log = LoggerFactory.getLogger(OrderEventConsumer.class);

    /**
     * 监听订单创建主题
     */
    @KafkaListener(topics = "order-created", groupId = "order-local-group")
    public void onOrderCreated(String message) {
        log.info("收到 Kafka 消息:{}", message);
    }
}

25.9 OrderService.java

package com.example.demo.order;

import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

/**
 * 订单服务类
 */
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderCacheService orderCacheService;
    private final OrderEventProducer orderEventProducer;

    public OrderService(OrderRepository orderRepository,
                        OrderCacheService orderCacheService,
                        OrderEventProducer orderEventProducer) {
        this.orderRepository = orderRepository;
        this.orderCacheService = orderCacheService;
        this.orderEventProducer = orderEventProducer;
    }

    /**
     * 创建订单
     */
    public Order createOrder(String productName, BigDecimal amount) {
        Order order = new Order();
        order.setOrderNo("ORD-" + UUID.randomUUID().toString().replace("-", ""));
        order.setProductName(productName);
        order.setAmount(amount);
        order.setStatus("CREATED");
        order.setCreatedAt(LocalDateTime.now());

        Order saved = orderRepository.save(order);
        orderCacheService.cacheOrderStatus(saved.getOrderNo(), saved.getStatus());
        orderEventProducer.sendOrderCreatedEvent(saved.getOrderNo());
        return saved;
    }

    /**
     * 查询所有订单
     */
    public List<Order> findAll() {
        return orderRepository.findAll();
    }
}

25.10 OrderController.java

package com.example.demo.order;

import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * 订单控制器
 */
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;
    private final OrderCacheService orderCacheService;

    public OrderController(OrderService orderService,
                           OrderCacheService orderCacheService) {
        this.orderService = orderService;
        this.orderCacheService = orderCacheService;
    }

    /**
     * 创建订单
     */
    @PostMapping
    public Order create(@RequestBody CreateOrderRequest request) {
        return orderService.createOrder(request.getProductName(), request.getAmount());
    }

    /**
     * 查询所有订单
     */
    @GetMapping
    public List<Order> list() {
        return orderService.findAll();
    }

    /**
     * 查询订单状态缓存
     */
    @GetMapping("/{orderNo}/status-cache")
    public Map<String, Object> statusCache(@PathVariable String orderNo) {
        return Map.of(
                "orderNo", orderNo,
                "status", orderCacheService.getOrderStatus(orderNo).orElse("NOT_FOUND")
        );
    }
}

25.11 StartupRunner.java

package com.example.demo.runner;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.kafka.KafkaContainer;

/**
 * 应用启动后打印容器信息
 */
@Component
@Profile("local-dev")
public class StartupRunner implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(StartupRunner.class);

    private final MySQLContainer<?> mysqlContainer;
    private final GenericContainer<?> redisContainer;
    private final KafkaContainer kafkaContainer;

    public StartupRunner(MySQLContainer<?> mysqlContainer,
                         GenericContainer<?> redisContainer,
                         KafkaContainer kafkaContainer) {
        this.mysqlContainer = mysqlContainer;
        this.redisContainer = redisContainer;
        this.kafkaContainer = kafkaContainer;
    }

    @Override
    public void run(ApplicationArguments args) {
        log.info("===== 本地开发容器已就绪 =====");
        log.info("MySQL JDBC URL: {}", mysqlContainer.getJdbcUrl());
        log.info("MySQL Username: {}", mysqlContainer.getUsername());
        log.info("Redis Host: {}", redisContainer.getHost());
        log.info("Redis Port: {}", redisContainer.getMappedPort(6379));
        log.info("Kafka Bootstrap Servers: {}", kafkaContainer.getBootstrapServers());
    }
}

25.12 application.yml

spring:
  application:
    name: boot-testcontainers-dev
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: true
    properties:
      hibernate:
        format_sql: true

management:
  endpoints:
    web:
      exposure:
        include: health,info,beans,env

25.13 application-local-dev.yml

spring:
  config:
    activate:
      on-profile: local-dev
  kafka:
    consumer:
      group-id: order-local-group
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

25.14 init.sql

CREATE TABLE IF NOT EXISTS t_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL,
    product_name VARCHAR(128) NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL
);

25.15 启动命令

mvn spring-boot:run -Dspring-boot.run.profiles=local-dev

25.16 测试接口示例

创建订单

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "productName": "Spring Boot 3 实战课程",
    "amount": 199.00
  }'

查询订单列表

curl http://localhost:8080/api/orders

查询缓存中的订单状态

curl http://localhost:8080/api/orders/你的订单编号/status-cache

ok,同学们,本节课就上到这儿,下课~

🧧 学习福利 · 限时开放 🧧

当然,无论你是计算机专业在读学生,还是对编程充满兴趣的入门者,都强烈建议系统学习SpringBoot全体系专栏:👉 「滚雪球学 Spring Boot」;涵盖SpringBoot所有教学内容。

该专栏以“循序渐进 + 实战驱动”为核心理念,从基础到进阶到就业到架构师逐层展开,帮助你快速建立完整的 Spring Boot 技术体系,带你玩转SpringBoot框架。

📌 学习承诺:
通过该专栏,你将能够:

  • 快速掌握 Spring Boot 核心开发能力
  • 构建完整的后端项目认知体系
  • 实现从“入门”到“独立开发”的跃迁

就像“滚雪球”一样,知识不断积累、能力持续放大,实现指数级成长 🚀

最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。

同时欢迎大家关注技术号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。

ps:本文涉及所有源代码,均已上传至Gitee开源,供同学们直接对照学习 Gitee传送门,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗

🫵 Who am I?

我是 bug菌:

更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看 👈️

硬核技术号 「猿圈奇妙屋」 期待你的加入,一起进阶、一起打怪升级。

- End -

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐