同事问我:为什么加个依赖就能用了
8 月中旬,刚入职的同事跑来问我一个他觉得"很魔幻"的事:他只在 pom 里加了 spring-boot-starter-data-redis,然后 @Autowired private StringRedisTemplate 就能用了,中间没写任何配置类。他问我 Spring 是怎么知道要创建这个 Bean 的。
我给他讲了一遍,顺便把我们自己封装 starter 的活接了下来——当时有三个项目各写各的短信发送代码,该统一了。这篇把原理和实践都记一下。
起点是 @SpringBootApplication
每个启动类上都有这个注解,它是个组合注解:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootConfiguration
@EnableAutoConfiguration // 关键在这
@ComponentScan
public @interface SpringBootApplication {
@AliasFor(annotation = EnableAutoConfiguration.class, attribute = "exclude")
Class<?>[] exclude() default {};
}
@EnableAutoConfiguration 用 @Import 导入了一个选择器:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
}
AutoConfigurationImportSelector 实现了 DeferredImportSelector,核心方法是 selectImports,它在 Spring Boot 2.1 里最终会调到 SpringFactoriesLoader.loadFactoryNames:
protected List<String> getCandidateConfigurations(AnnotationMetadata metadata,
AnnotationAttributes attributes) {
List<String> configurations = SpringFactoriesLoader.loadFactoryNames(
getSpringFactoriesLoaderFactoryClass(), // EnableAutoConfiguration.class
getBeanClassLoader());
Assert.notEmpty(configurations,
"No auto configuration classes found in META-INF/spring.factories. "
+ "If you are using a custom packaging, make sure that file is correct.");
return configurations;
}
SpringFactoriesLoader 干的事很朴素:扫描 classpath 下所有 jar 包里的 META-INF/spring.factories 文件,读出 key 为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的那些类名。
看一眼 spring-boot-autoconfigure 2.1.6 里的这个文件,有 130 行左右:
# spring-boot-autoconfigure-2.1.6.RELEASE.jar!/META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,\
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration,\
...
这些 XXXAutoConfiguration 类全部会被加载成配置类。同事那个 StringRedisTemplate 就来自 RedisAutoConfiguration:
@Configuration
@ConditionalOnClass(RedisOperations.class) // classpath 里有 Jedis/ Lettuce 才生效
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate") // 你自己定义了就用你的
public RedisTemplate<Object, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Bean
@ConditionalOnMissingBean
public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
这就是全部秘密:没有魔法,只是"预先写好的一堆 @Configuration 类,按条件决定生不生效"。
@Conditional 家族
自动配置能不能生效,全靠 @Conditional 系列注解控制。常用的几个:
| 注解 | 生效条件 | 典型场景 |
|---|---|---|
@ConditionalOnClass | classpath 存在指定类 | 判断依赖有没有引入 |
@ConditionalOnMissingBean | 容器里没有指定类型的 Bean | 让用户自定义覆盖默认 |
@ConditionalOnProperty | 配置属性满足要求 | 功能开关 |
@ConditionalOnBean | 容器里存在指定 Bean | 依赖另一个自动配置 |
@ConditionalOnWebApplication | 当前是 Web 应用 | 区分 Web / 非 Web |
@ConditionalOnExpression | SpEL 表达式为真 | 复杂条件组合 |
@ConditionalOnMissingBean 是最能体现设计意图的一个。它的语义是"用户没配我才配",保证了自动配置永远是兜底方案而不是强制方案。这也解释了一个常见现象:你在自己的 @Configuration 里定义一个 RedisTemplate,自动配置里那个就自动不生效了。
但这个"不生效"是有前提的:你的 Bean 定义必须比自动配置的先被扫描到。自动配置类用 DeferredImportSelector 延迟加载,排在最后,所以正常情况没问题。如果你的配置类里用了 @AutoConfigureBefore / @AutoConfigureAfter 去调整顺序,就要小心了,这两个注解只对自动配置类之间的排序有效。
想看自己项目里哪些自动配置生效了、哪些没生效,加这个配置:
debug: true
启动时会打印一份报告:
============================
CONDITIONS EVALUATION REPORT
============================
Positive matches:
-----------------
RedisAutoConfiguration matched:
- @ConditionalOnClass found required class 'org.springframework.data.redis.core.RedisOperations' (OnClassCondition)
Negative matches:
-----------------
DataSourceAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType' (OnClassCondition)
Exclusions:
-----------
None
我排查自动配置不生效的问题,第一件事就是开这个。Spring Boot 2.1 里还可以在 actuator 的 /actuator/conditions 端点看到同样内容,不用重启。
动手写一个 Starter
我们的需求:三个项目都用阿里云短信,各写各的 SmsClient 初始化代码,accessKey 的读取方式都不一样。封装成 starter 之后,使用方只需要:
<dependency>
<groupId>com.xxx</groupId>
<artifactId>sms-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
sms:
access-key-id: LTAIxxxxxx
access-key-secret: xxxxxx
sign-name: 某某科技
connect-timeout: 3000
@Service
public class NotifyService {
@Autowired
private SmsTemplate smsTemplate;
public void sendCode(String phone, String code) {
smsTemplate.send(phone, "SMS_12345678", "{\"code\":\"" + code + "\"}");
}
}
项目结构。官方的规范是拆成两个模块,starter 模块只做依赖聚合,真正的自动配置代码放在 autoconfigure 模块:
sms-spring-boot-starter/ <-- 只有 pom.xml
pom.xml
sms-spring-boot-autoconfigure/
pom.xml
src/main/java/com/xxx/sms/
SmsProperties.java
SmsTemplate.java
SmsAutoConfiguration.java
src/main/resources/META-INF/
spring.factories
additional-spring-configuration-metadata.json
这么拆是因为:starter 模块可以引入一批可选依赖,autoconfigure 模块只依赖 API 相关的。用户如果只想用自动配置、不想接受我们选的依赖,可以直接依赖 autoconfigure。
配置属性类。@ConfigurationProperties 的 prefix 要和配置里的前缀对上:
@ConfigurationProperties(prefix = "sms")
public class SmsProperties {
private String accessKeyId;
private String accessKeySecret;
private String signName;
/** 连接超时,毫秒 */
private int connectTimeout = 3000;
/** 读超时,毫秒 */
private int readTimeout = 5000;
/** 发送失败重试次数 */
private int maxRetry = 2;
// getter / setter 省略
}
自动配置类。这里是整个 starter 的核心:
@Configuration
@ConditionalOnClass(SmsTemplate.class)
@EnableConfigurationProperties(SmsProperties.class)
@ConditionalOnProperty(prefix = "sms", name = "access-key-id")
public class SmsAutoConfiguration {
private final SmsProperties properties;
public SmsAutoConfiguration(SmsProperties properties) {
this.properties = properties;
}
@Bean
@ConditionalOnMissingBean
public SmsTemplate smsTemplate() {
return new SmsTemplate(properties);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "sms", name = "metrics-enabled", havingValue = "true",
matchIfMissing = true)
public SmsMetrics smsMetrics() {
return new SmsMetrics();
}
}
几个决策说明:
- 类上的
@ConditionalOnProperty(prefix = "sms", name = "access-key-id"):没配 accessKey 就整个不生效。这样没用到短信功能的服务引入了这个 starter 也不会报错。 SmsTemplate上加@ConditionalOnMissingBean:业务方可以自己定义一个SmsTemplate(比如测试环境想用 mock 实现)覆盖掉默认的。matchIfMissing = true表示配置没写时也算匹配,也就是默认开启。
spring.factories。这个文件是让 Spring Boot 找到你的配置类的唯一途径:
# src/main/resources/META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.sms.SmsAutoConfiguration
格式必须严格:\ 后面不能有空格(包括行尾的空白),多个类之间用逗号分隔,最后一行不加逗号。我在这个问题上栽过,行尾多了个空格,Maven 打包时没报错,但 Spring Boot 读出来的类名带个空格,启动时报 ClassNotFoundException,查了半小时。
另外,如果你写的是 Spring Boot 2.1 的 starter,还要注意 spring-configuration-metadata.json。它是给 IDE 用的,配了之后在 application.yml 里写 sms. 就有自动补全和注释提示。加这个依赖让 Maven 自动生成:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<optional>true</optional> 很重要,不然这个编译期处理器会被传递依赖给使用方。
命名规范。官方自己的 starter 叫 spring-boot-starter-xxx,第三方的应该叫 xxx-spring-boot-starter,避免和官方混淆。我们的是 sms-spring-boot-starter。
自动配置的执行顺序
多个自动配置之间有先后依赖时(比如 A 必须在 B 之后加载),用这三个注解控制:
@Configuration
@AutoConfigureAfter(DataSourceAutoConfiguration.class) // 在数据源配置之后
@AutoConfigureBefore(WebMvcAutoConfiguration.class) // 在 WebMVC 配置之前
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10) // 全局排序值,越小越靠前
public class SmsAutoConfiguration { ... }
一个常见误解:以为这三个注解能控制"自动配置类相对于用户 @Component 的顺序"。不能。所有自动配置类统一在用户 Bean 之后加载,这是由 DeferredImportSelector 保证的,改不了。这三个注解只在自动配置类之间排序。
那为什么 @ConditionalOnMissingBean 还能生效?因为 OnBeanCondition 判断的是"此刻容器里有没有这个类型的 Bean 定义",而用户的 Bean 定义已经全部注册完了,所以它看得到。这里比的是 Bean 定义的注册顺序,不是 Bean 的实例化顺序。
调试顺序问题有个好办法,给自动配置类加日志:
@Configuration
public class SmsAutoConfiguration {
public SmsAutoConfiguration() {
log.info("SmsAutoConfiguration loaded by {}",
this.getClass().getClassLoader());
}
}
配合 debug: true 的报告一起看,基本能定位所有"为什么我的 Bean 没生效"的问题。
怎么给 starter 写测试
starter 最容易被忽略的环节是测试。它的特殊性在于:你要测的是"在不同条件下配置类是否生效",而不是普通的业务逻辑。Spring Boot 2.1 提供了 ApplicationContextRunner,专门干这个:
public class SmsAutoConfigurationTest {
private final ApplicationContextRunner contextRunner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(SmsAutoConfiguration.class));
@Test
public void 没配accessKey时不生效() {
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(SmsTemplate.class);
});
}
@Test
public void 配了accessKey时生效() {
contextRunner
.withPropertyValues(
"sms.access-key-id=LTAI_TEST",
"sms.access-key-secret=SECRET",
"sms.sign-name=测试")
.run(context -> {
assertThat(context).hasSingleBean(SmsTemplate.class);
SmsProperties props = context.getBean(SmsProperties.class);
assertThat(props.getConnectTimeout()).isEqualTo(3000);
});
}
@Test
public void 用户自定义Bean优先于自动配置() {
contextRunner
.withPropertyValues("sms.access-key-id=LTAI_TEST")
.withUserConfiguration(CustomSmsConfig.class)
.run(context -> {
assertThat(context).hasSingleBean(SmsTemplate.class);
assertThat(context.getBean(SmsTemplate.class))
.isInstanceOf(MockSmsTemplate.class); // 用的是用户那个
});
}
@Configuration
static class CustomSmsConfig {
@Bean
public SmsTemplate smsTemplate() {
return new MockSmsTemplate();
}
}
}
ApplicationContextRunner 的好处是每个测试都跑在一个全新的、轻量级的容器里,不启动 Web Ubuntu 服务器、不连数据库,单个用例几十毫秒。我们 23 个 starter 测试跑完 1.8 秒,放在 CI 里毫无压力。
如果换成 @SpringBootTest,每个用例都要启动一遍完整容器,几秒钟起步,而且用例之间容易互相干扰。
踩到的两个坑
第一个:@ConfigurationProperties 的 Bean 别用 @Component 注册。我一开始图省事在 SmsProperties 上加了 @Component,结果启动时报 Bean must not be null。原因是用 @EnableConfigurationProperties 注册的和 @Component 扫描进来的冲突了。二选一就行,官方推荐用 @EnableConfigurationProperties。
第二个:自动配置类里不要做组件扫描。@ComponentScan 会扫描当前包及其子包,如果 starter 的包名跟用户项目的包名接近,可能把用户的 Bean 扫进来两遍,报重复定义。正确的做法是在自动配置类里用 @Import 或者显式声明 @Bean,不要扫。
留个问题
关于《Spring Boot 自动配置原理与自定义 Starter》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。