《Spring Boot 入门》7.2 写一个自定义 Starter

从零手写一个可发布的 acme-spring-boot-starter:完整 pom、配置属性类、客户端、@AutoConfiguration 自动配置类与 .imports 登记文件,配条件装配与排序保证不侵入宿主项目,再讲 mvn install、宿主验证、--debug 报告片段与七个常见错误。

本节目标:从零写出一个完整可发布的自定义 starter,理解每个文件为什么存在,并能把它装进宿主项目验证生效。
适用版本:Spring Boot 4.1.x(Java 21)

7.2 写一个自定义 Starter

假设团队有个内部「Acme」服务,几乎每个项目都要连它。目前的做法是:每个项目复制一份 AcmeClient.java,再抄一段 @Bean,再各自记一遍该写哪几个属性。三个月后,五个项目里出现了五个略有差异的版本。

本节把这个客户端抽成 acme-spring-boot-starter,让宿主项目引一行依赖、写几行配置就能用。整个过程五步,每步都对应 7.1 讲过的一条约定。

7.2.1 需求与目录结构

先定「宿主希望怎么用」,再倒推 starter 提供什么:

# 宿主项目期望的体验
acme:
  enabled: true
  base-url: https://acme.internal/api
  api-key: ${ACME_API_KEY}
  connect-timeout: 3s

对应的目录结构(单模块合并式:依赖聚合与自动配置放同一个 jar):

acme-spring-boot-starter/
├── pom.xml
└── src/main/
    ├── java/com/acme/spring/boot/autoconfigure/
    │   ├── AcmeProperties.java          # 配置属性类
    │   ├── AcmeClient.java              # 真正干活的客户端
    │   └── AcmeAutoConfiguration.java   # 自动配置类
    └── resources/META-INF/spring/
        └── org.springframework.boot.autoconfigure.AutoConfiguration.imports

那个超长的 .imports 文件名不是随便起的——它就是 4.2 节讲过的自动配置登记文件,路径与文件名必须一字不差。

7.2.2 第一步:pom.xml

starter 的 pom 有两个特点:几乎不写版本号(交给父 pom 的 BOM),依赖极简。

<?xml version="1.0" encoding="UTF-8"?>
<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>4.1.1</version>
    <relativePath/>
  </parent>

  <groupId>com.acme</groupId>
  <artifactId>acme-spring-boot-starter</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-configuration-processor</artifactId>
      <optional>true</optional>
    </dependency>
  </dependencies>
</project>

三点说明:用 spring-boot-starter 而非 -webmvc(Acme 客户端不发 HTTP,starter 只引真正需要的东西);configuration-processor 标 <optional>true</optional>(编译期处理器,不该污染宿主);不写 <version>(由 BOM 统一决定)。

7.2.3 第二步:配置属性类 AcmeProperties

package com.acme.spring.boot.autoconfigure;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;

/** Acme 客户端配置,前缀 acme。 */
@ConfigurationProperties(prefix = "acme")
public class AcmeProperties {

    private boolean enabled = true;                              // 默认开启
    private String baseUrl = "https://api.acme.example.com";     // 服务基地址
    private String apiKey;                                       // 调用凭证
    private Duration connectTimeout = Duration.ofSeconds(2);     // 支持 3s / 500ms

    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }

    public String getBaseUrl() { return baseUrl; }
    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }

    public String getApiKey() { return apiKey; }
    public void setApiKey(String apiKey) { this.apiKey = apiKey; }

    public Duration getConnectTimeout() { return connectTimeout; }
    public void setConnectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; }
}

约定与易错点:

要点说明
有无参构造与 getter/setter4.x 默认 JavaBean 绑定,字段不能是 final
Duration 自动转换写 3s / 500ms 即可,无需手工 parse
字段上给默认值宿主不配也能跑
不要标 @Component属性类由 @EnableConfigurationProperties 注册,加 @Component 会绕开条件控制

7.2.4 第三步:客户端 AcmeClient

客户端是 starter 对外提供的能力,这里刻意做极简,便于聚焦 starter 机制:

package com.acme.spring.boot.autoconfigure;

public class AcmeClient {

    private final AcmeProperties properties;

    public AcmeClient(AcmeProperties properties) {
        this.properties = properties;
    }

    /** 返回当前生效的基地址,用于自检。 */
    public String ping() {
        return "acme@" + properties.getBaseUrl();
    }
}

注意它没有被 @Component 标注。starter 里的业务 bean 一律由自动配置类用 @Bean 创建,这样才能被条件注解控制。

7.2.5 第四步:自动配置类 AcmeAutoConfiguration

这是整个 starter 的大脑:

package com.acme.spring.boot.autoconfigure;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

@AutoConfiguration
@ConditionalOnProperty(prefix = "acme", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(AcmeProperties.class)
public class AcmeAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public AcmeClient acmeClient(AcmeProperties properties) {
        return new AcmeClient(properties);
    }
}

逐行看:

注解作用
@AutoConfiguration4.x 自动配置类专用标注,是 @Configuration(proxyBeanMethods = false) 的语义化封装,并支持声明顺序
@ConditionalOnProperty(...)仅 acme.enabled=true 时生效;matchIfMissing = true 表示宿主不写该属性也默认开启
@EnableConfigurationProperties把 AcmeProperties 注册成 bean 并完成绑定
@Bean @ConditionalOnMissingBean宿主自定义了 AcmeClient 时 starter 让位,不覆盖用户决定

最关键的是两个「不侵入」设计:matchIfMissing = true 让默认可用,@ConditionalOnMissingBean 让用户可以覆盖。宿主「什么都不做」能用,「想自己接管」也能接管——这正是官方 starter 的一贯作风。

7.2.6 第五步:登记 imports 文件

这是最容易漏、也最容易写错的一步。新建文件,路径与文件名必须完全一致:

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

内容只有一行,写自动配置类的全限定名:

com.acme.spring.boot.autoconfigure.AcmeAutoConfiguration

没有这个文件,AcmeAutoConfiguration 就是「写在 jar 里但没人知道」的普通类,启动时根本不会被加载。多个自动配置类就写多行,一行一个全限定类名。

7.2.7 打包安装到本地仓库

starter 要先能被别的项目解析到。开发阶段最省事的是装进本地仓库:

# 在 acme-spring-boot-starter 目录下
mvn -q clean install

成功后,~/.m2/repository/com/acme/acme-spring-boot-starter/1.0.0/ 下会出现 jar 与 pom。正式团队改用私服发布:

mvn clean deploy -DaltDeploymentRepository=internal::default::https://nexus.internal/repository/releases

deploy 的具体参数取决于私服(Nexus / Artifactory)配置,通常写在 settings.xml 或 pom 的 <distributionManagement> 里。记住一点:宿主能引到 starter 的前提,是它已经进了某个仓库。

7.2.8 在宿主项目里引入并验证生效

新建一个普通 Boot 项目,加一行依赖(配置沿用 7.2.1 里的 acme 段):

<dependency>
  <groupId>com.acme</groupId>
  <artifactId>acme-spring-boot-starter</artifactId>
  <version>1.0.0</version>
</dependency>

再加一个启动自检,把客户端打印出来:

import com.acme.spring.boot.autoconfigure.AcmeClient;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ProbeConfig {

    @Bean
    ApplicationRunner acmeProbe(AcmeClient client) {
        return args -> System.out.println("Acme client ready: " + client.ping());
    }
}

启动后日志里会出现:

2026-09-16T21:03:12.884+08:00  INFO 51230 --- [           main] c.e.demo.DemoApplication : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.12.1 with PID 51230
2026-09-16T21:03:13.412+08:00  INFO 51230 --- [           main] c.e.demo.DemoApplication : Started DemoApplication in 0.912 seconds (process running for 1.307)
Acme client ready: acme@https://acme.internal/api

那行 Acme client ready: 就是最直接的生效证据——AcmeClient 被自动装配进了容器。想看得更细,加上 --debug,在报告里搜 AcmeAutoConfiguration:

Positive matches:
-----------------

   AcmeAutoConfiguration matched:
      - @ConditionalOnProperty (acme.enabled=true) matched (OnPropertyCondition)

   AcmeAutoConfiguration#acmeClient matched:
      - @ConditionalOnMissingBean (types: com.acme...AcmeClient) did not find any beans (OnBeanCondition)

看到这两段,说明属性条件通过、bean 也顺利创建。若把 acme.enabled 改成 false 再启动,这两段会整体挪到 Negative matches,AcmeClient 消失。

7.2.9 条件装配与顺序:让 starter 不侵入

@ConditionalOnClass:依赖可选时才有意义。 给自动配置类加上 @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry"),宿主没引 Micrometer 时,这段配置连类都不会被加载。用 name 字符串而非 .class,可避免硬引用导致 NoClassDefFoundError——这是「可选集成」的标准写法。

@AutoConfiguration(after = ...):声明加载顺序。 默认顺序按类名排序,一旦 bean 有依赖就必须显式声明:

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;

@AutoConfiguration(after = DataSourceAutoConfiguration.class)
public class AcmeAuditAutoConfiguration {
    // 本类的 bean 依赖 DataSource,必须排在它之后
}

如果 Acme 的审计 bean 需要 DataSource,就必须 after = DataSourceAutoConfiguration.class,否则条件评估时数据源还没就绪。对称地,before 表示「我必须在某人之前」。顺序不能靠运气,有依赖就写明。

7.2.10 进阶:拆成「两模块」的官方写法

当 starter 变大,官方会拆成两个模块:acme-spring-boot-autoconfigure(放 @AutoConfiguration、属性类、客户端、.imports)与 acme-spring-boot-starter(空 jar,只依赖前者)。好处是宿主可以只引 autoconfigure(不触发自动装配,手动 @Import),也可以引 starter(开箱即用)。小 starter 用单模块即可,别为了「像官方」而过度拆分。

7.2.11 常见错误清单

starter「引了却没用」,九成栽在下面几条:

症状常见原因排查
自动配置完全没加载.imports 路径/文件名写错,或放到了 src/main/java 下核对是否在 resources/META-INF/spring/,文件名逐字比对
加载了但条件全不满足自动配置类没标 @AutoConfiguration报告里搜不到该类名,或出现在 Exclusions
属性配了不生效属性类没被 @EnableConfigurationProperties 注册,或前缀拼错确认 AcmeProperties 是 bean
宿主报 NoClassDefFoundError@ConditionalOnClass 用了 .class 引用了可选依赖改用 @ConditionalOnClass(name = "全限定名")
宿主自己的 bean 被覆盖漏写 @ConditionalOnMissingBean给 @Bean 方法补上
宿主找不到坐标没 mvn install / 没发私服,或版本写错确认本地仓库或私服里有该版本

还有一条设计层面的红线:不要在自动配置类上加 @ComponentScan(7.1.6 已说明)。starter 只装配自己声明的 bean,绝不扫描宿主的包。

小结

  • 自定义 starter 五步:pom(依赖聚合)→ @ConfigurationProperties 属性类 → 客户端 → @AutoConfiguration 自动配置类 → .imports 登记文件。
  • .imports 路径 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 必须逐字正确,内容是一行一个全限定类名。
  • 「不侵入」靠两个设计:matchIfMissing = true 让默认可用,@ConditionalOnMissingBean 让宿主可覆盖。
  • 有 bean 依赖时用 @AutoConfiguration(after = ...) 显式声明顺序,不要依赖默认字母序。
  • 依赖可选库时用 @ConditionalOnClass(name = "..."),避免硬引用导致 NoClassDefFoundError;starter 里永远不要写 @ComponentScan。

starter 写好了、宿主也引进去了,但真实项目里它未必每次都乖乖生效。下一节把「它为什么不生效」查到底:读懂 --debug 报告、用 Actuator 端点、在 IDE 里打断点,形成一套可复用的排查手法。

阅读导航:上一节:7.1 Starter 的组成 · 下一节:7.3 自动配置的调试与排查 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计