本节目标:从零写出一个完整可发布的自定义 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/setter | 4.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);
}
}
逐行看:
| 注解 | 作用 |
|---|---|
@AutoConfiguration | 4.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 自动配置的调试与排查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。