从零开发Spring Boot Starter:自动配置类、条件注解到发布Maven
从零开发Spring Boot Starter:自动配置类、条件注解到发布Maven
适读人群:有Spring Boot基础、想封装公共组件的Java开发者 | 阅读时长:约18分钟
开篇故事
去年我们部门做了一次大重构,把十几个微服务里重复的短信发送、OSS上传、统一日志打点全部抽出来做成公共组件。
最开始的做法很原始:把代码复制到一个叫 common-utils 的模块,然后每个服务用 <dependency> 引进来。结果没过两个月,噩梦开始了——某个服务用了旧版本的 SMS 客户端,发短信的签名格式不对,线上报错了半天才发现根源在 JAR 版本没有统一。
更麻烦的是配置。每个服务的 application.yml 里都要手动加一堆 sms.access-key、sms.secret,有人忘了加,启动直接 NPE。
那时候我就想,Spring Boot 自己的那些组件(比如 Redis、Kafka 的 starter)为什么这么丝滑?引个依赖,配几行 yml,就能直接 @Autowired 用?
花了一个周末把 spring-boot-autoconfigure 的源码翻了一遍,终于搞清楚了整个机制。后来我们把公司内部的短信、OSS、分布式锁全部做成了 starter,所有服务引入后零配置(除了必填的 key),踩坑记录和心得全在这篇文章里。
一、为什么需要自定义 Starter
1.1 "复制粘贴组件"的三大痛点
很多团队的公共组件都经历过这个阶段:
| 痛点 | 具体表现 |
|---|---|
| 版本碎片化 | 各服务依赖不同版本,行为不一致 |
| 配置分散 | 每个服务都要写重复的 @Bean 定义 |
| 缺乏条件控制 | 引了包就必须配置,否则启动报错 |
1.2 Starter 能解决什么
一个合格的 Starter 要做到:
- 引入即可用:不需要手动声明 Bean,自动注册到 Spring 容器
- 配置即生效:通过
application.yml驱动行为,配了才生效,没配给默认值 - 条件隔离:缺少某个依赖或配置时,优雅降级,不影响启动
- 可覆盖:业务方如果想自定义 Bean,能覆盖默认实现
二、自动配置的原理深度解析
2.1 整体工作流程
2.2 SpringFactoriesLoader 加载机制
Spring Boot 在启动时会调用 SpringFactoriesLoader.loadFactoryNames(),扫描 classpath 下所有 JAR 包里的 META-INF/spring.factories 文件(Spring Boot 2.x)或 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件(Spring Boot 3.x)。
关键源码在 AutoConfigurationImportSelector#getAutoConfigurationEntry:
protected AutoConfigurationEntry getAutoConfigurationEntry(
AnnotationMetadata annotationMetadata) {
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
configurations = removeDuplicates(configurations);
Set<String> exclusions = getExclusions(annotationMetadata, attributes);
configurations.removeAll(exclusions);
configurations = getConfigurationClassFilter().filter(configurations);
return new AutoConfigurationEntry(configurations, exclusions);
}2.3 常用条件注解一览
三、完整代码实现
我们以一个「统一短信发送 Starter」为例,手把手走一遍。
3.1 工程结构
laozhang-sms-spring-boot-starter/
├── pom.xml
└── src/main/
├── java/com/laozhang/sms/
│ ├── SmsClient.java # 核心接口
│ ├── AliyunSmsClient.java # 阿里云实现
│ ├── SmsProperties.java # 配置属性类
│ ├── SmsAutoConfiguration.java # 自动配置类
│ └── SmsTemplate.java # 对外暴露的Template
└── resources/
└── META-INF/
├── spring.factories # Spring Boot 2.x
└── spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports # Spring Boot 3.x3.2 pom.xml
<?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
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.laozhang</groupId>
<artifactId>laozhang-sms-spring-boot-starter</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
</properties>
<dependencies>
<!-- 不要引spring-boot-starter,只引autoconfigure -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- 配置元数据处理器,生成IDE提示 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<!-- 阿里云SDK,设为optional,不强制引入 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.0</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>3.3 配置属性类 SmsProperties.java
package com.laozhang.sms;
import org.springframework.boot.context.properties.ConfigurationProperties;
import lombok.Data;
/**
* 短信配置属性
* 对应 application.yml 里的 laozhang.sms 前缀
*/
@Data
@ConfigurationProperties(prefix = "laozhang.sms")
public class SmsProperties {
/**
* 是否启用短信功能,默认 true
*/
private boolean enabled = true;
/**
* 短信服务商:aliyun / tencent
*/
private String provider = "aliyun";
/**
* AccessKey ID
*/
private String accessKeyId;
/**
* AccessKey Secret
*/
private String accessKeySecret;
/**
* 短信签名
*/
private String signName;
/**
* 发送限流:每个手机号每分钟最多发送次数,默认1次
*/
private int rateLimitPerMinute = 1;
/**
* 阿里云专属配置
*/
private Aliyun aliyun = new Aliyun();
@Data
public static class Aliyun {
private String regionId = "cn-hangzhou";
private String endpoint = "dysmsapi.aliyuncs.com";
}
}3.4 核心接口与实现
package com.laozhang.sms;
/**
* 短信客户端接口
* 业务方可以自己实现该接口来覆盖默认行为
*/
public interface SmsClient {
/**
* 发送短信
*
* @param phone 手机号
* @param templateCode 模板Code
* @param params 模板参数(JSON格式)
* @return 是否发送成功
*/
boolean send(String phone, String templateCode, String params);
/**
* 批量发送
*
* @param phones 手机号列表,逗号分隔
* @param templateCode 模板Code
* @param params 模板参数列表(JSON数组)
* @return 成功数量
*/
int sendBatch(String phones, String templateCode, String params);
}package com.laozhang.sms;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.profile.DefaultProfile;
import lombok.extern.slf4j.Slf4j;
/**
* 阿里云短信实现
*/
@Slf4j
public class AliyunSmsClient implements SmsClient {
private final SmsProperties properties;
private final IAcsClient acsClient;
public AliyunSmsClient(SmsProperties properties) {
this.properties = properties;
DefaultProfile profile = DefaultProfile.getProfile(
properties.getAliyun().getRegionId(),
properties.getAccessKeyId(),
properties.getAccessKeySecret()
);
this.acsClient = new DefaultAcsClient(profile);
}
@Override
public boolean send(String phone, String templateCode, String params) {
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers(phone);
request.setSignName(properties.getSignName());
request.setTemplateCode(templateCode);
request.setTemplateParam(params);
try {
SendSmsResponse response = acsClient.getAcsResponse(request);
if ("OK".equals(response.getCode())) {
log.info("[SMS] 发送成功 phone={} template={}", phone, templateCode);
return true;
}
log.error("[SMS] 发送失败 phone={} code={} message={}",
phone, response.getCode(), response.getMessage());
return false;
} catch (Exception e) {
log.error("[SMS] 发送异常 phone={}", phone, e);
return false;
}
}
@Override
public int sendBatch(String phones, String templateCode, String params) {
// 批量发送实现省略,逻辑类似
return 0;
}
}3.5 Template 封装层
package com.laozhang.sms;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 对业务层暴露的 Template,提供更友好的 API
*/
@Slf4j
@RequiredArgsConstructor
public class SmsTemplate {
private final SmsClient smsClient;
private final SmsProperties properties;
/**
* 发送验证码
*
* @param phone 手机号
* @param code 验证码
* @return 是否成功
*/
public boolean sendVerifyCode(String phone, String code) {
// 验证码模板固定,code作为参数传入
String params = String.format("{\"code\":\"%s\"}", code);
return smsClient.send(phone, "SMS_VERIFY_CODE", params);
}
/**
* 发送通知短信
*/
public boolean sendNotification(String phone, String templateCode, Object... args) {
// 将可变参数组装成JSON
StringBuilder sb = new StringBuilder("{");
String[] keys = {"content", "name", "amount", "time"};
for (int i = 0; i < args.length && i < keys.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(keys[i]).append("\":\"").append(args[i]).append("\"");
}
sb.append("}");
return smsClient.send(phone, templateCode, sb.toString());
}
}3.6 核心:自动配置类
package com.laozhang.sms;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
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;
/**
* 短信自动配置类
*
* 条件说明:
* 1. @ConditionalOnClass:classpath有AliyunSmsClient才加载(避免没引阿里云SDK时报错)
* 2. @ConditionalOnProperty:laozhang.sms.enabled=true时才生效(默认true,可设false关闭)
* 3. 每个Bean都加@ConditionalOnMissingBean,允许业务方覆盖
*/
@AutoConfiguration
@ConditionalOnClass(name = "com.aliyuncs.IAcsClient")
@ConditionalOnProperty(prefix = "laozhang.sms", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(SmsProperties.class)
public class SmsAutoConfiguration {
/**
* 注册 SmsClient Bean
* 如果业务方自己定义了 SmsClient,这里就不会再注册,完美支持覆盖
*/
@Bean
@ConditionalOnMissingBean(SmsClient.class)
public SmsClient smsClient(SmsProperties properties) {
String provider = properties.getProvider();
// 这里可以根据provider做策略选择
return new AliyunSmsClient(properties);
}
/**
* 注册 SmsTemplate Bean
*/
@Bean
@ConditionalOnMissingBean(SmsTemplate.class)
public SmsTemplate smsTemplate(SmsClient smsClient, SmsProperties properties) {
return new SmsTemplate(smsClient, properties);
}
}3.7 SPI 注册文件
Spring Boot 2.x — src/main/resources/META-INF/spring.factories:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.laozhang.sms.SmsAutoConfigurationSpring Boot 3.x — src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
com.laozhang.sms.SmsAutoConfiguration3.8 业务方使用示例
业务服务的 pom.xml:
<dependency>
<groupId>com.laozhang</groupId>
<artifactId>laozhang-sms-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>application.yml:
laozhang:
sms:
enabled: true
provider: aliyun
access-key-id: ${SMS_ACCESS_KEY_ID}
access-key-secret: ${SMS_ACCESS_KEY_SECRET}
sign-name: 老张技术
rate-limit-per-minute: 1业务代码:
@Service
@RequiredArgsConstructor
public class UserService {
// 直接注入,无需任何额外配置
private final SmsTemplate smsTemplate;
public void sendLoginCode(String phone) {
String code = RandomStringUtils.randomNumeric(6);
boolean success = smsTemplate.sendVerifyCode(phone, code);
if (!success) {
throw new BusinessException("短信发送失败,请稍后重试");
}
// 将code存入Redis,设置5分钟过期
redisTemplate.opsForValue().set("sms:verify:" + phone, code, 5, TimeUnit.MINUTES);
}
}3.9 发布到私服 Maven
pom.xml 补充 distributionManagement:
<distributionManagement>
<repository>
<id>nexus-releases</id>
<url>http://nexus.your-company.com/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>nexus-snapshots</id>
<url>http://nexus.your-company.com/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>settings.xml 配置认证:
<servers>
<server>
<id>nexus-releases</id>
<username>deployer</username>
<password>your-password</password>
</server>
</servers>发布命令:
# 发布 release 版本
mvn clean deploy -P release
# 发布到本地 .m2(本地测试用)
mvn clean install四、踩坑实录
坑1:引了 spring-boot-starter 导致版本冲突
症状:自定义 Starter 在业务服务里引入后,Tomcat 启动了两次,或者 actuator 端口冲突。
根因:spring-boot-starter 会引入 spring-boot-autoconfigure + spring-boot + spring-core 等一大堆依赖,和业务服务的 parent 里的版本打架。
正确做法:Starter 的 pom.xml 里只引 spring-boot-autoconfigure,不要引 spring-boot-starter。
<!-- 错误写法 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 正确写法 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>坑2:spring.factories 格式写错,自动配置不生效
症状:引了 Starter,但 Bean 注入时报 NoSuchBeanDefinitionException,debug=true 打印的自动配置报告里找不到你的配置类。
常见错误写法:
# 错误:换行没有加反斜杠
org.springframework.boot.autoconfigure.EnableAutoConfiguration=
com.laozhang.sms.SmsAutoConfiguration
# 错误:多个类之间没有逗号
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.laozhang.sms.SmsAutoConfiguration
com.laozhang.sms.OtherAutoConfiguration正确写法:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.laozhang.sms.SmsAutoConfiguration,\
com.laozhang.sms.OtherAutoConfiguration另外,Spring Boot 3.x 如果只有 spring.factories 没有 AutoConfiguration.imports,默认会走新机制,老文件不一定被扫描到。建议两个文件都提供,做到兼容。
坑3:@ConditionalOnProperty 的 matchIfMissing 没设对
症状:业务服务没有配置 laozhang.sms.enabled,结果 Starter 的 Bean 没有注册,注入时报错。
根因:@ConditionalOnProperty 默认 matchIfMissing = false,意思是"配置项不存在时,条件不满足,不加载 Bean"。
对于"默认开启"的功能,一定要加 matchIfMissing = true:
// 错误:没有配置项时Bean不会注册
@ConditionalOnProperty(prefix = "laozhang.sms", name = "enabled", havingValue = "true")
// 正确:没有配置项时默认注册Bean(等价于enabled=true)
@ConditionalOnProperty(prefix = "laozhang.sms", name = "enabled",
havingValue = "true", matchIfMissing = true)坑4:ConfigurationProperties 绑定失败,配置不生效
症状:application.yml 里明明配了 laozhang.sms.access-key-id,但注入进来的 SmsProperties.accessKeyId 是 null。
根因:@ConfigurationProperties 要生效,需要满足以下任一条件:
- 配置类上有
@Component(不推荐,会污染扫描) - 自动配置类上加了
@EnableConfigurationProperties(SmsProperties.class) - 在某个
@Configuration类上加了@EnableConfigurationProperties
另一个子坑:YAML 的 access-key-id 对应 Java 的 accessKeyId(松散绑定),但如果你在 YAML 里写了 accessKeyId(驼峰),Spring Boot 虽然也能识别,但在某些场景下会失效。建议统一用 kebab-case(中划线格式)。
坑5:SNAPSHOT 版本被私服缓存,改了代码没有生效
症状:改了 Starter 代码,重新 mvn deploy,业务服务重新拉取依赖后,旧代码还在。
根因:Maven 本地 .m2 缓存了 SNAPSHOT。
解决办法:
# 强制更新 SNAPSHOT
mvn clean package -U
# 或者删掉本地缓存
rm -rf ~/.m2/repository/com/laozhang/laozhang-sms-spring-boot-starter五、总结与延伸
这篇把自定义 Starter 从工程结构、条件注解原理、完整代码到发布私服全部过了一遍。几个关键点总结:
Starter 命名规范:官方的叫
spring-boot-starter-xxx,第三方的叫xxx-spring-boot-starter,自己的组件也建议遵守这个规范。条件注解是灵魂:
@ConditionalOnClass做依赖隔离,@ConditionalOnMissingBean允许覆盖,@ConditionalOnProperty做开关控制,三者配合能覆盖 99% 的场景。兼容 Boot 2.x 和 3.x:同时提供
spring.factories和AutoConfiguration.imports两个文件,让使用方不管什么版本都能用。configuration-processor不能省:加了这个依赖,IDEA 在写application.yml时会有智能提示,对使用方体验非常好。
延伸阅读方向:
- 下一篇(454)会讲 Maven 多模块工程里如何统一管理这些 Starter 的版本
- 后续会深入
SpringFactoriesLoader的源码,和 Java SPI 做对比
