Administrator
发布于 2019-08-20 / 2045 阅读
16

Spring Boot 自动配置原理与自定义 Starter

同事问我:为什么加个依赖就能用了

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 系列注解控制。常用的几个:

注解生效条件典型场景
@ConditionalOnClassclasspath 存在指定类判断依赖有没有引入
@ConditionalOnMissingBean容器里没有指定类型的 Bean让用户自定义覆盖默认
@ConditionalOnProperty配置属性满足要求功能开关
@ConditionalOnBean容器里存在指定 Bean依赖另一个自动配置
@ConditionalOnWebApplication当前是 Web 应用区分 Web / 非 Web
@ConditionalOnExpressionSpEL 表达式为真复杂条件组合

@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》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考