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

引言

先讲一个我亲身经历的线上事故。

三年前,我负责的一个订单服务在凌晨两点突然出现大量超时告警。排查发现,一个刚入职的同事为了让日志链路追踪生效,在 pom.xml 里引入了公司内部的 trace-log-starter,但忘了在 application.yml 里配置 trace.sample-rate。结果这个 Starter 内部默认采样率是 100%,每个请求都往远端日志服务打全量 Trace,把日志服务打挂了,进而拖垮了整个调用链路。

问题不在代码,而在于——这个 Starter 的默认配置和行为,对使用者完全是个黑盒。他不知道自己引入了一个"默认全量采样"的东西。

这件事让我重新思考:Spring Boot 的自动配置(Auto-Configuration)和 Starter 机制,是 Java 生态里最成功的"约定优于配置"实践,也是最容易被误用的地方。绝大多数人会用 @SpringBootApplication,能写 @Configuration,但真正理解"为什么我什么都没配,DataSource 就自动有了"的人,其实不多。

这篇文章,我想把自动配置这件事从源码层面讲透,然后手把手带你写一个生产级可用的 Starter。目标读者是已经写了 3-5 年 Spring Boot、但没深挖过自动配置源码的工程师。


核心概念:从"餐厅后厨"说起

生活类比:自动配置就像一家"预制菜中央厨房"

传统 Spring 开发,就像你开一家餐厅,从买菜、洗菜、切菜、调酱汁全部自己来。每开一家新店,都得把这些流程重走一遍,哪怕两家店卖的是同样的菜。

Spring Boot 的自动配置,相当于引入了一个中央厨房

  • 你只需要告诉中央厨房"我要做川菜"(引入 spring-boot-starter-web);
  • 中央厨房会预判你需要什么(Tomcat、Jackson、DispatcherServlet),把这些"半成品"送到你店里;
  • 如果你自己店里已经有某个半成品(比如你自己配了一个 ObjectMapper),中央厨房送来的那份就自动作废——这叫 @ConditionalOnMissingBean
  • 如果你明确说"我不要某样东西"(@ConditionalOnProperty),它也不会硬塞给你。

这个"预判"的能力,靠的是 Spring Boot 在启动时扫描所有 jar 包里的一个约定文件,然后根据一堆 @Conditional 注解决定"这批货要不要送、送哪几样"。

技术定义

Spring Boot 自动配置的本质是:

  1. 启动时,通过 @EnableAutoConfiguration 触发 AutoConfigurationImportSelector
  2. 该 Selector 从所有依赖 jar 的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 2.7+)文件中,读取出候选配置类全限定名列表;
  3. 经过去重、排除(@SpringBootApplication(exclude=...))、条件过滤后,注册为普通的 @Configuration 类;
  4. 这些配置类内部通过 @Bean + @ConditionalOnXxx 决定每个 Bean 是否真正注册。

一句话:自动配置 = 一堆带条件的 @Configuration 类的批量、按需加载机制。


源码深度剖析:一次启动到底发生了什么

关键入口:@EnableAutoConfiguration

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
    Class<?>[] exclude() default {};
    String[] excludeName() default {};
}

注意 @Import(AutoConfigurationImportSelector.class)——这是整个机制的总开关。它把 AutoConfigurationImportSelector 注册为一个 ImportSelector,在配置类解析阶段被回调。

核心方法:getAutoConfigurationEntry

AutoConfigurationImportSelector 继承自 AutoConfigurationImportSelectorImportSelector。真正干活的是 getAutoConfigurationEntry

protected AutoConfigurationEntry getAutoConfigurationEntry(
        AutoConfigurationMetadata autoConfigurationMetadata,
        AnnotationMetadata annotationMetadata) {
    if (!isEnabled(annotationMetadata)) {
        return EMPTY_ENTRY;
    }
    // 1. 读取 @EnableAutoConfiguration 上的 exclude / excludeName 属性
    AnnotationAttributes attributes = getAttributes(annotationMetadata);
    // 2. 从所有 jar 的 imports 文件里加载候选配置类
    List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
    // 3. 去重(多个 starter 可能引入同一个配置类)
    configurations = removeDuplicates(configurations);
    // 4. 去掉被 exclude 的
    Set<String> exclusions = getExclusions(annotationMetadata, attributes);
    checkExcludedClasses(configurations, exclusions);
    configurations.removeAll(exclusions);
    // 5. 关键:用 AutoConfigurationImportFilter 做粗粒度过滤(OnBeanCondition 等的快速版)
    configurations = filter(configurations, autoConfigurationMetadata);
    // 6. 广播事件,允许扩展
    fireAutoConfigurationImportEvents(configurations, exclusions);
    return new AutoConfigurationEntry(configurations, exclusions);
}

这里面最容易被忽略的是第 5 步 filter。Spring Boot 用 AutoConfigurationImportFilter(实现类有 OnBeanConditionOnClassConditionOnWebApplicationCondition)做了一次"预筛"。这一步不加载类,只读取配置类在 imports 文件旁生成的 .imports 元数据(编译期通过注解处理器生成),所以非常快。

为什么要有这一步? 因为一个大型项目可能引入几百个候选自动配置类,如果每个都真正加载字节码、解析注解,启动会非常慢。预筛把明显不满足条件的(比如 @ConditionalOnClass 里要求的类不存在)直接踢掉,剩下的才真正加载。

条件注解的判定时机

真正的条件判定发生在 ConfigurationClassParser 解析配置类时。以 @ConditionalOnClass 为例:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnClassCondition.class)
public @interface ConditionalOnClass {
    Class<?>[] value() default {};
    String[] name() default {};
}

OnClassCondition 继承 SpringBootCondition,其 getMatchOutcome 里用 ClassNameFilter.MISSING 判断类是否在 classpath。注意它优先用 name 字符串而不是 valueClass 对象——因为用 Class 会在注解解析时触发类加载,如果类不存在会直接抛 NoClassDefFoundError。这是 Spring Boot 里非常经典的一个设计细节。

整体流程

graph TD A[SpringApplication.run] --> B[刷新 ApplicationContext] B --> C[解析 @SpringBootApplication] C --> D[遇到 @EnableAutoConfiguration] D --> E[ImportSelector: AutoConfigurationImportSelector] E --> F[加载所有 jar 的 AutoConfiguration.imports] F --> G[去重 + 排除 exclude] G --> H[AutoConfigurationImportFilter 预筛] H --> I[加载剩余配置类字节码] I --> J[ConfigurationClassParser 解析] J --> K{每个 @Bean 条件判定} K -->|匹配| L[注册 BeanDefinition] K -->|不匹配| M[跳过] L --> N[实例化并注入容器]

一个容易被忽视的细节:自动配置的加载顺序

自动配置类之间是有顺序的,靠 @AutoConfigureBefore / @AutoConfigureAfter / @AutoConfigureOrder 控制。比如 DataSourceAutoConfiguration 必须在 MybatisAutoConfiguration 之前,因为后者依赖前者产出的 DataSource

Spring Boot 在 AutoConfigurationSorter 里用拓扑排序处理这些依赖。如果你自己写 Starter,涉及多配置类时一定要显式声明顺序,否则在某些 classpath 组合下会出现"Bean 找不到"的诡异问题。


实战代码:三个完整示例

示例一:手写一个最小可用的 Starter(限流组件)

我们做一个 rate-limit-spring-boot-starter,提供基于令牌桶的接口限流,自动配置限流器并暴露 @RateLimit 注解。

步骤 1:Starter 的模块结构

生产级 Starter 通常拆成两个模块:

  • rate-limit-spring-boot-autoconfigure:放自动配置逻辑;
  • rate-limit-spring-boot-starter:只放依赖,把 autoconfigure 和第三方库都引进来。

这是 Spring 官方推荐的"autoconfigure + starter"分离模式,好处是使用者可以只引 autoconfigure,自己控制依赖版本。

步骤 2:配置属性类

package com.example.ratelimit.autoconfigure;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * 限流配置属性。
 * 前缀为 rate-limit,对应 application.yml 中的 rate-limit.*
 * 使用构造器绑定(Spring Boot 2.2+ 推荐),保证不可变性和线程安全。
 */
@ConfigurationProperties(prefix = "rate-limit")
public class RateLimitProperties {

    /** 是否启用限流,默认开启。对应 rate-limit.enabled */
    private boolean enabled = true;

    /** 默认每秒允许的请求数(令牌桶容量) */
    private int defaultPermitsPerSecond = 100;

    /** 获取令牌的超时时间(毫秒),超时则拒绝请求 */
    private long timeoutMillis = 0;

    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
    public int getDefaultPermitsPerSecond() { return defaultPermitsPerSecond; }
    public void setDefaultPermitsPerSecond(int v) { this.defaultPermitsPerSecond = v; }
    public long getTimeoutMillis() { return timeoutMillis; }
    public void setTimeoutMillis(long timeoutMillis) { this.timeoutMillis = timeoutMillis; }
}

步骤 3:核心限流器

package com.example.ratelimit.autoconfigure;

import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 基于 Guava RateLimiter 的限流器。
 * 每个资源名(key)维护一个独立的令牌桶。
 */
public class RateLimitExecutor {

    private final RateLimitProperties properties;
    private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();

    public RateLimitExecutor(RateLimitProperties properties) {
        this.properties = properties;
    }

    /**
     * 尝试获取令牌。
     * @param key 资源标识,通常是 "类名#方法名"
     * @return true 表示放行,false 表示被限流
     */
    public boolean tryAcquire(String key) {
        RateLimiter limiter = limiters.computeIfAbsent(key,
                k -> RateLimiter.create(properties.getDefaultPermitsPerSecond()));
        // timeoutMillis=0 表示不等待,拿不到令牌立即返回 false
        return limiter.tryAcquire(properties.getTimeoutMillis(),
                java.util.concurrent.TimeUnit.MILLISECONDS);
    }
}

步骤 4:自动配置类

package com.example.ratelimit.autoconfigure;

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;
import org.springframework.context.annotation.Configuration;

/**
 * 限流自动配置。
 * 加载条件:
 *  1. classpath 上存在 Guava RateLimiter(@ConditionalOnClass)
 *  2. 配置 rate-limit.enabled 不为 false(@ConditionalOnProperty)
 */
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RateLimiter.class)
@ConditionalOnProperty(prefix = "rate-limit", name = "enabled",
        havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(RateLimitProperties.class)
public class RateLimitAutoConfiguration {

    /**
     * 只有当容器里没有用户自定义的 RateLimitExecutor 时才注册。
     * 这就是"用户配置优先于自动配置"的体现。
     */
    @Bean
    @ConditionalOnMissingBean
    public RateLimitExecutor rateLimitExecutor(RateLimitProperties properties) {
        return new RateLimitExecutor(properties);
    }
}

步骤 5:注册自动配置类

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

com.example.ratelimit.autoconfigure.RateLimitAutoConfiguration

注意:Spring Boot 2.7 之前用的是 META-INF/spring.factories 里的 org.springframework.boot.autoconfigure.EnableAutoConfiguration=...。2.7 引入新文件并标记 spring.factories 方式为 deprecated,3.0 完全移除。写新 Starter 请一律用 .imports 文件。

步骤 6:AOP 切面(可选,让注解生效)

package com.example.ratelimit.autoconfigure;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;

@Aspect
public class RateLimitAspect {

    private final RateLimitExecutor executor;

    public RateLimitAspect(RateLimitExecutor executor) {
        this.executor = executor;
    }

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        String key = sig.getDeclaringTypeName() + "#" + sig.getName();
        if (!executor.tryAcquire(key)) {
            throw new RateLimitExceededException("请求过于频繁,请稍后重试");
        }
        return pjp.proceed();
    }
}

配置类里再补一个 @Bean @ConditionalOnMissingBean 注册切面,并加 @ConditionalOnClass(Aspect.class)。这样当用户没引 AOP 依赖时,整个切面自动不生效——这就是条件装配的威力。


示例二:自定义条件注解(判断是否在 Kubernetes 环境)

自动配置的强大之处在于 @Conditional。我们实现一个 @ConditionalOnKubernetes,只有检测到 K8s 环境时才装配某个 Bean。

package com.example.condition;

import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;

/**
 * 判断当前是否运行在 Kubernetes 环境。
 * 依据:存在 KUBERNETES_SERVICE_HOST 环境变量,
 * 或存在 /var/run/secrets/kubernetes.io/serviceaccount 目录。
 */
public class OnKubernetesCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String host = context.getEnvironment().getProperty("KUBERNETES_SERVICE_HOST");
        if (host != null && !host.isEmpty()) {
            return true;
        }
        return new java.io.File("/var/run/secrets/kubernetes.io/serviceaccount").exists();
    }
}
package com.example.condition;

import org.springframework.context.annotation.Conditional;
import java.lang.annotation.*;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnKubernetesCondition.class)
public @interface ConditionalOnKubernetes {
}

使用:

@Configuration(proxyBeanMethods = false)
public class K8sHealthAutoConfiguration {

    @Bean
    @ConditionalOnKubernetes
    public K8sReadinessIndicator readinessIndicator() {
        return new K8sReadinessIndicator();
    }
}

这里有个关键坑Condition 接口的 matches 会被调用非常多次(每个条件、每次刷新),所以实现里绝不能做耗时操作(比如发起网络请求探测)。上面读环境变量和文件是否存在,是最安全的做法。如果你需要缓存结果,用 SpringBootCondition 而不是裸 Condition,它提供了 getMatchOutcome 和日志支持。


示例三:让自动配置可观测——自定义 FailureAnalyzer

生产级 Starter 应该"报错友好"。如果用户配置错了,应该给出清晰的错误提示,而不是一堆堆栈。Spring Boot 提供了 FailureAnalyzer 机制。

package com.example.ratelimit.autoconfigure;

import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;

/**
 * 当限流配置非法(如每秒令牌数 <= 0)时,给出可读的错误提示。
 */
public class InvalidRateLimitConfigFailureAnalyzer
        extends AbstractFailureAnalyzer<IllegalArgumentException> {

    @Override
    protected FailureAnalysis analyze(Throwable rootFailure, IllegalArgumentException cause) {
        if (!cause.getMessage().contains("permitsPerSecond")) {
            return null;
        }
        return new FailureAnalysis(
                "限流配置非法:" + cause.getMessage(),
                "请检查 application.yml 中的 rate-limit.default-permits-per-second,"
                        + "该值必须大于 0。",
                cause
        );
    }
}

META-INF/spring.factories 中注册(FailureAnalyzer 至今仍走 spring.factories,不走 .imports):

org.springframework.boot.diagnostics.FailureAnalyzer=\
com.example.ratelimit.autoconfigure.InvalidRateLimitConfigFailureAnalyzer

这样当用户把 default-permits-per-second 配成 0 时,控制台会打印一段人话提示,而不是 IllegalArgumentException 堆栈。这个细节是区分"能用的 Starter"和"好用的 Starter"的分水岭。


方案对比:自动配置 vs 其他装配方案

维度 Spring Boot 自动配置 传统 XML/Java Config SPI(ServiceLoader) Spring Cloud 的 Bootstrap
装配时机 容器启动,条件驱动 手动声明 运行时按需 容器启动前(Bootstrap 阶段)
是否可覆盖 是(@ConditionalOnMissingBean 完全手动
启动开销 有预筛,较快 极低 额外一次上下文
适用场景 通用组件、中间件集成 业务配置 插件化 分布式配置
调试难度 高(需 --debug 看报告)

核心结论:自动配置适合"框架/中间件向业务提供默认能力"的场景。业务代码不应该滥用自动配置——你的业务 Service 用 @Component 就够了,别搞个 BizAutoConfiguration,那是杀鸡用牛刀,还会让依赖关系变隐晦。


最佳实践与避坑指南

1. 永远不要用 @ComponentScan 扫自动配置类

自动配置类必须通过 .imports 文件注册,不要把它放在你的业务包下被 @ComponentScan 扫到。否则条件判定时机可能不对,还会导致重复注册。

2. @ConditionalOnMissingBean 要慎用类型

@Bean
@ConditionalOnMissingBean  // 默认按返回类型判断
public RateLimitExecutor rateLimitExecutor(...) { ... }

如果用户定义了 RateLimitExecutor 的子类,@ConditionalOnMissingBean 默认按类型匹配,子类也会被认为"已存在",从而跳过。如果你的意图是"用户没定义任何实现就注册默认的",这没问题;但如果想更精确,用 @ConditionalOnMissingBean(value = RateLimitExecutor.class)

3. 配置类用 @Configuration(proxyBeanMethods = false)

从 Spring Boot 2.2 起官方推荐自动配置类都加 proxyBeanMethods = false。因为自动配置类里的 @Bean 方法之间不应该互相调用(那会绕过容器,破坏单例),关掉 CGLIB 代理能显著减少启动时的类增强开销。

4. spring.factories vs .imports 的迁移

  • Spring Boot ≤ 2.6:META-INF/spring.factories
  • Spring Boot 2.7+:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  • Spring Boot 3.0+:只认 .imports

如果你维护的 Starter 要同时兼容 2.x 和 3.x,可以两个文件都放。但注意 FailureAnalyzerEnvironmentPostProcessor 等非自动配置的扩展点,仍然走 spring.factories,别搞混。

5. 用 --debug 或 Actuator 排查

启动加 --debug 参数,Spring Boot 会打印 ConditionEvaluationReport,清楚列出每个自动配置类为什么生效/没生效:

RateLimitAutoConfiguration matched:
  - @ConditionalOnClass found required class 'com.google.common.util.concurrent.RateLimiter'
  - @ConditionalOnProperty (rate-limit.enabled) matched

生产环境可以用 spring-boot-actuator/actuator/conditions 端点在线查看。

6. 常见坑:配置属性绑定失败静默

@ConfigurationProperties 绑定时,如果类型不匹配(比如 yml 里写了字符串,字段是 int),默认会静默忽略。务必加 @Validated + JSR-303 注解,让非法配置在启动时就 fail-fast。

@ConfigurationProperties(prefix = "rate-limit")
@Validated
public class RateLimitProperties {
    @Min(1)
    private int defaultPermitsPerSecond = 100;
    // ...
}

总结

回到开头的那个事故。如果那个 trace-log-starter 的作者做到了三点,事故就不会发生:

  1. 默认值保守:采样率默认 10% 而不是 100%;
  2. 条件清晰:用 @ConditionalOnProperty 明确要求用户配置采样率;
  3. 可观测:用 FailureAnalyzer 在配置缺失时给出友好提示。

Spring Boot 自动配置的精髓,是用条件装配把"默认行为"和"用户覆盖"优雅地统一起来。它不是什么魔法,本质就是:

启动时扫描 .imports 文件 → 预筛 → 加载 → 条件判定 → 注册 Bean。

理解这五个步骤,你就能:

  • 写出对使用者友好的 Starter;
  • 排查"为什么我的 Bean 没生效"这类问题;
  • 在阅读 Spring Cloud、MyBatis、Dubbo 等框架源码时,一眼看穿它们的装配逻辑。

延伸思考:Spring Boot 3.x 引入了 AOT(Ahead-Of-Time)编译和 GraalVM Native Image 支持。在 AOT 模式下,条件判定发生在构建期而非运行期,这意味着运行时动态变化的条件(比如读环境变量决定是否装配)会失效。这是自动配置机制在云原生时代面临的新挑战,也是理解 Spring Boot 未来演进的关键方向。如果你在做 Native Image 迁移,这块一定要提前踩点。

自动配置写得好,是"润物细无声";写得不好,就是"薛定谔的 Bean"——你不启动看日志,永远不知道它在不在。愿你的 Starter 都能被温柔以待。