恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot自动配置内核拆解:从零手写一个防重starter
首页
资讯中心
/
Spring Boot自动配置内核拆解:从零手写一个防重starter
Spring Boot自动配置内核拆解:从零手写一个防重starter
发布时间:2026/10/16 2:57:19
说个很多人没想明白的现象Spring Boot 项目跑起来好像不需要做什么引入一个 starter某个功能就自然“长”出来了。我见过不止一个开发者每天都在写 Controller、调接口但被问到“这个 starter 到底是怎么生效的”时只能答出“大概是自动配置吧”。有一次我在一个内部项目里做接口防重复提交组件顺手把整条自动配置链路摸了一遍才发现所谓“深度解析”其实没有多神秘就是把几个点串起来而已。这篇文章我就顺着这次手写 starter 的全过程把 Spring Boot 自动配置的内核讲透再带你把一个可用的防重 starter 从零写出来。文章主要解决两类人的痛点一类是天天用 Spring Boot 但想搞懂底层原理的开发者另一类是准备做内部组件封装、想把手写 starter 变成标准动作的人。看完之后你能自己复刻一套完整的 starter 工程同时理解为什么它一引进来就能被 Spring Boot 拾取、为什么有的 Bean 会被覆盖、为什么明明配置了却不起作用。1. 自动配置的内核一个注解如何触发整条链路1.1 SpringBootApplication一注解背后藏着三件事我们对着主类看的这个注解其实是个复合注解。它的核心不是“Spring Boot 给 Spring 加了什么魔法”而是从三个角度把 Spring 家族的机制拼在一起。先看注解源码的常用组合SpringBootConfiguration EnableAutoConfiguration ComponentScan(excludeFilters { ... }) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }SpringBootConfiguration本质就是Configuration标记这是一个配置类。ComponentScan负责扫描当前包和子包下的组件所以主类不能随便乱放如果放在某个深包子类里扫描范围就不对了。这个大家都能理解。真正值得钻进去的是EnableAutoConfiguration。它不是一个简单的开关而是一个引入了一大堆复杂逻辑的“总入口”。它内部通过Import(AutoConfigurationImportSelector.class)引入了选择器这个选择器负责决定“哪些自动配置类应该被激活”。也就是说自动配置并不是“启动时扫描所有 starter”而是由这个选择器从类路径下若干个固定资源文件中读取候选配置类的全限定名再进行条件过滤。这里有一个很容易误解的点很多人以为 Spring Boot 启动时会把所有依赖里的类都扫一遍。事实上它的做法要轻量得多只读取固定的注册表文件候选类名是预先写好的。扫描和过滤的工作量远远小于“全量扫描类路径”。1.2 AutoConfigurationImportSelector自动配置的“总调度”AutoConfigurationImportSelector实现了DeferredImportSelector而DeferredImportSelector意味着它的处理时机要晚于普通Configuration和Bean的解析。这里其实藏了一个设计意图Spring Boot 希望先处理业务代码中用户自己声明的 Bean然后再处理自动配置里的 Bean这样用户自定义的 Bean 天然就可能覆盖自动配置中的默认实现。选择器的核心逻辑在process和getAutoConfigurationEntry方法里。简化之后大致流程如下从ClassLoader的getResources拿到注册表文件资源逐行解析类名组成候选集合读取EnableAutoConfiguration注解上的exclude和excludeName属性把显式排除的类剔除把剩余候选交给条件评估器ConditionEvaluator按类上面标注的ConditionalOnXxx逐个做匹配过滤过滤之后剩下的类通过selectImports返回最终被注册为容器内的配置类。这里面最关键的是注册表文件。老的 Spring Boot 版本用META-INF/spring.factories里面有一个固定的 keyorg.springframework.boot.autoconfigure.EnableAutoConfiguration后面的值是多个自动配置类全限定名。新版则把自动配置类单独拆到了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里每行一个类名。无论哪种方式本质都是“类名注册表”。我自己在学习时画过一张简化流程图把整个链路记成三步定位注册表文件读取并解析候选类名对每个候选做条件评估过滤后注册。理解了这三步你就理解了为什么 Spring Boot 官方给的 starter 数量众多但启动时只装配满足条件的少数几个。1.3 从 spring.factories 到 AutoConfiguration.imports注册表演进我在本地同时开过 Spring Boot 2.7 和 3.2 的源码做对比感受还挺明显。2.7 版本开始就引入AutoConfiguration.imports文件并推荐新的 starter 都用这种形式3.0 之后则彻底不再从spring.factories加载自动配置类了。这个演进背后有几个实际原因。老的spring.factories里塞了各种 key比如EnableAutoConfiguration、ApplicationContextInitializer、ApplicationListener一行一个类名解析时要把所有 key 的资源都找出来还得按 key 分类归档读取效率不高。而且对 IDE 和工具不友好想找出某个自动配置类的来源要靠人工搜索。新用AutoConfiguration.imports文件定义后注册表结构更简单、职责更单一也有利于并行加载。所以你在网上零零散散看到的教程如果它还在让你只写spring.factories那大概率是没升级过版本。这里我建议老项目兼容一下新工程直接用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports一行一个类名简洁明了。手写 starter 时这也是我最推荐的写法。2. starter 的原理拆解类路径、条件与配置绑定三件套2.1 一个 starter 的解剖图命名、依赖与模块划分先看一个典型的 starter 长什么样。官方命名分两种官方包的命名是spring-boot-starter-xxx比如spring-boot-starter-web第三方自定义包则是xxx-spring-boot-starter比如mybatis-spring-boot-starter。为什么这个名字因为它纯粹是为了“让使用者一眼识别”而存在的约定不算强制规则。实际工程上一个成熟的 starter 往往拆成两个模块xxx-spring-boot-starter最外层的壳里面几乎可以不放什么代码只负责把核心模块和第三方依赖聚合起来xxx-spring-boot-autoconfigure真正的自动配置代码所在的地方。这样拆的好处是使用者如果不想用自动配置可以只引核心模块想深度集成再把自动配置模块引进来。从依赖隔离的角度看也比所有代码堆在一个包里清晰得多。但说到底你引入一个 starter 之后Spring Boot 是怎么“发现”它并把能力注入进来的答案回到第一章依赖决定了类路径上存在哪些类存在哪些类又决定了条件注解的判断结果。比如ConditionalOnClass(SomeClass.class)当类路径上出现了SomeClass条件为真自动装配对应的类否则就跳过。这就是“starter 一引入就能用”的底层逻辑它不是靠约定式扫描而是靠条件注解对类路径状态做判断。2.2 ConditionalOnClass 等条件注解像开关一样控制装配要理解自动配置就必须理解条件注解家族。用得最多的几个注解生效条件ConditionalOnClass类路径中存在指定类ConditionalOnMissingClass类路径中不存在指定类ConditionalOnBean容器中存在指定 BeanConditionalOnMissingBean容器中不存在指定 BeanConditionalOnProperty配置项满足指定值ConditionalOnWebApplication当前环境是 Web 应用ConditionalOnExpressionSpEL 表达式为真我的通俗类比是每个自动配置类都是一个“预埋了线的插座底盒”而条件注解就是开关。底盒装好了开关没闭合是不会通电的。引入 starter 相当于把“插头”怼进去但真正决定通电的是这些开关是否被合上。其中有一点要特别强调ConditionalOnMissingBean是用来自动配置提供“默认实现”的它最常见的坑就是被滥用。比如在业务代码的普通Component类上标注ConditionalOnMissingBean这在语义上是混乱的。因为自动配置有专门的评估时机和顺序而普通组件是在组件扫描阶段注册的评估顺序和条件语义都不一样。官方文档也强调过条件注解主要设计给自动配置类用业务类上请保持克制。另外条件注解还有一个不直观的性质ConditionalOnClass判断类路径时如果指定的类本身在当前类加载器里不存在条件直接不通过。官方还提到如果类不在条件注解类自身的类加载器里可能会导致条件处理异常所以很多自动配置类会在声明条件时使用name字符串而非Class字面量ConditionalOnClass(name jakarta.servlet.ServletRequest)这个细节可以解释很多“为什么我这个 starter 在别的环境里不生效”的问题也让我对手写 starter 时的依赖范围更加敏感。2.3 ConfigurationProperties把配置变成强类型属性一个 starter 不只是自动装配几个 Bean它还要暴露可调参数给使用者。Spring Boot 的做法是通过ConfigurationProperties绑定配置前缀。举个简单例子ConfigurationProperties(prefix demo.idempotent) public class IdempotentProperties { private boolean enabled true; private long defaultExpireMillis 30000L; // getter / setter 略 }配合EnableConfigurationProperties(IdempotentProperties.class)配置类只要在application.yml里写demo: idempotent: enabled: true default-expire-millis: 5000Spring Boot 就会自动把demo.idempotent.default-expire-millis绑定到defaultExpireMillis字段上。注意这里有 kebab-case 和 camelCase 的自动转换因为 Spring Boot 推荐属性名用中划线风格而 Java 字段用驼峰。绑定机制本身不难但想让开发者用得舒服还要额外生成配置元数据让 IDE 有补全和提示。这部分我会在第 4.2 节展开。总之一套完整的 starter 由三件套组成注册表文件负责“被找到”条件注解负责“该不该装配”配置绑定负责“怎么调参数”。三者结合才是真正意义上的开箱即用。3. 手写一个接口防重 starter从需求到核心代码3.1 先想清楚需求防重组件要解决哪些问题写代码之前我先说说为什么选“接口防重复提交”这个场景。它足够小又足够通用。用户手抖连点两次提交、支付回调重复推送、消息队列重复消费这些场景都需要防重而且非常适合用 AOP 切面加注解做成通用能力。我在模拟项目里总结出的核心需求有三个使用方只需要在接口方法上加一个注解不需要感知底层实现同一个请求参数只允许在指定时间窗口内执行一次窗口期后可以重试支持自定义锁实现默认提供本地内存锁但允许用户替换成 Redis 分布式锁。另外补一个硬性要求如果锁获取失败直接抛异常并返回业务提示不能在 Controller 里写任何 try-catch。这样一来业务代码只保留自己的逻辑职责划分非常干净。于是我把组件拆成两层一层是面向使用者的注解与切面另一层是面向扩展者的锁执行器接口。这里的“面向扩展者”是我刻意留的口子它保证了组件不是封闭的一个类而是可替换的骨架。3.2 核心代码注解、锁接口、切面与自动配置类先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface Idempotent { /** * 自定义 key留空时自动生成比如类名 方法名 参数名 参数值。 */ String key() default ; /** * 锁的存活时间单位毫秒。 */ long expireMillis() default 30000L; /** * 重复提交时返回的提示信息。 */ String message() default 重复提交请稍后重试; }锁执行器接口public interface LockExecutor { /** * 尝试获取锁。 * * return true 表示本线程本次获得了锁 */ boolean tryLock(String lockKey, long expireMillis); /** * 释放锁。 */ void unlock(String lockKey); }默认的本地实现public class LocalLockExecutor implements LockExecutor { private static final ConcurrentHashMapString, LockEntry LOCK_BUCKET new ConcurrentHashMap(); Override public boolean tryLock(String lockKey, long expireMillis) { long now System.currentTimeMillis(); LockEntry newEntry LockEntry.of(now expireMillis); LockEntry oldEntry LOCK_BUCKET.putIfAbsent(lockKey, newEntry); if (oldEntry null) { return true; } // 已过期则允许抢占 if (oldEntry.isExpired(now)) { return LOCK_BUCKET.replace(lockKey, oldEntry, newEntry); } return false; } Override public void unlock(String lockKey) { LOCK_BUCKET.remove(lockKey); } private static class LockEntry { private final long expireAt; private LockEntry(long expireAt) { this.expireAt expireAt; } static LockEntry of(long expireAt) { return new LockEntry(expireAt); } boolean isExpired(long now) { return this.expireAt now; } } }这个实现是刻意简化的实际用在生产环境我会建议加一层“锁拥有者标识”防止误删别人的锁不过作为 starter 演示已经能把核心思路讲清楚。切面部分Aspect public class IdempotentAspect { private final LockExecutor lockExecutor; public IdempotentAspect(LockExecutor lockExecutor) { this.lockExecutor lockExecutor; } Around(annotation(idempotent)) public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { String lockKey buildLockKey(joinPoint, idempotent); boolean locked lockExecutor.tryLock(lockKey, idempotent.expireMillis()); if (!locked) { throw new IllegalStateException(idempotent.message()); } try { return joinPoint.proceed(); } finally { lockExecutor.unlock(lockKey); } } private String buildLockKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) { if (!idempotent.key().isBlank()) { return idempotent.key(); } MethodSignature signature (MethodSignature) joinPoint.getSignature(); String className joinPoint.getTarget().getClass().getName(); String methodName signature.getName(); Object[] args joinPoint.getArgs(); return className # methodName : Arrays.toString(args); } }最后是自动配置类AutoConfiguration ConditionalOnClass(IdempotentAspect.class) ConditionalOnProperty(prefix demo.idempotent, name enabled, havingValue true, matchIfMissing true) EnableConfigurationProperties(IdempotentProperties.class) Import(AopAutoConfiguration.class) public class IdempotentAutoConfiguration { Bean ConditionalOnMissingBean(LockExecutor.class) public LockExecutor localLockExecutor() { return new LocalLockExecutor(); } Bean public IdempotentAspect idempotentAspect(LockExecutor lockExecutor) { return new IdempotentAspect(lockExecutor); } }也许你会疑惑切面为什么还要单独靠ConditionalOnClass(IdempotentAspect.class)判断其实这个条件本身就是恒真的因为我们自己的类一定在类路径上。写它主要是为了跟其他自动配置类的风格保持一致。真正有价值的条件其实是ConditionalOnProperty用户可以把demo.idempotent.enabled设为false一键关闭整个功能。这就是配置开关也是 starter 必须具备的容错兜底。3.3 设计取舍为什么默认本地锁而不是强上 Redis我在这个 starter 里故意不引入 Redis 依赖。原因很简单如果自动配置强制要求 Redis那么所有使用方项目中都得有 Redis哪怕只是一个单体小应用。这违背了 starter 的轻量原则。锁的实现应该是可插拔的。我用LockExecutor接口隔离了锁来源默认本地实现足够单机防护如果用户的项目已经用了 Redis只需要在自己配置类里定义LockExecutor类型的 Bean由于自动配置里有ConditionalOnMissingBean默认的本地锁就不会注册切面会自动使用用户提供的实现。这个替换过程用户完全无感。这也是第一次写 starter 和写业务代码最大的区别你要时刻思考“别人会怎么替换我的实现”而不是只关心“我这个功能能不能跑”。4. starter 工程化打磨注册表、配置提示与Bean覆盖细节4.1 正确注册自动配置新老版本都要兼容切面和自动配置类写好之后最关键的一步是把自动配置类“登记”到注册表里。这是我最初写 starter 时最容易被忽略的一步忘掉它整份代码在引用方项目里就是一堆没有灵魂的 class 文件。新版做法是在src/main/resources/META-INF/spring目录下新建文件org.springframework.boot.autoconfigure.AutoConfiguration.imports内容是com.example.idempotent.autoconfigure.IdempotentAutoConfiguration如果你需要兼容 Spring Boot 2.7 之前的老版本可以同时保留META-INF/spring.factoriesorg.springframework.boot.autoconfigure.EnableAutoConfiguration\ com.example.idempotent.autoconfigure.IdempotentAutoConfiguration但我提醒一句Spring Boot 3.x 之后已经不再处理 spring.factories 里的自动配置 key 了所以新工程不要再以 spring.factories 为主。两个文件共存也没有问题高版本优先读 imports低版本读 factories。注册成功后我们可以用--debug启动应用在日志里看到条件评估报告。如果看到类似Positive matches: IdempotentAutoConfiguration matched: - ConditionalOnProperty ...就说明自动配置类被正确加载了。4.2 让 IDE 认识你的配置项元数据文件的作用第二次写 starter 时我遇到一个很影响体验的问题使用者写application.yml时配置项完全没有补全提示。原因是我没生成配置元数据。Spring Boot 官方提供了spring-boot-configuration-processor它会在编译时自动扫描ConfigurationProperties注解并生成META-INF/spring-configuration-metadata.json。我们只需要在自动配置模块的 POM 中加入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency设置了optional这个依赖就不会传递给使用方属于编译期的注解处理器。引入之后IDE 就能识别demo.idempotent.enabled等配置项。这个细节看起来小但组件的“开箱即用”体验很大程度上取决于它。没有元数据的时候使用者每次都要翻你的源码才能知道有哪些配置可调体验会差一大截。4.3 Bean 覆盖与自动配置顺序最容易翻车的两个点手写 starter 有三个高频问题Bean 没生效、Bean 被覆盖、配置类被重复加载。前两个都跟条件注解和顺序有关。先说 Bean 覆盖。自动配置类里用ConditionalOnMissingBean声明默认 Bean它的本意是“如果容器里没有才注册”。但要注意这个判断是在自动配置阶段执行的。如果你在业务代码里用Component声明了同名 Bean通常业务 Bean 会先于自动配置被处理所以自动配置会跳过默认实现。这正是预期效果。反过来如果自动配置先注册了 Bean业务代码里又通过Bean声明了同类型 Bean启动阶段就会因为类型冲突或者 Bean 名称冲突报错。所以写自动配置时Bean 的方法名最好有区分度比如我就用localLockExecutor()作为方法名避免无意抢占了通用名lockExecutor。再说自动配置顺序。多个自动配置类之间可能有依赖关系。比如我的防重 starter 需要在 AOP 自动配置之后生效所以我显式Import(AopAutoConfiguration.class)。如果你的组件依赖别的自动配置类可以用AutoConfigureBefore和AutoConfigureAfter声明顺序。别小看这个顺序注解Spring Boot 官方源码里大量使用它来保证启动链路稳定。最后说重复加载。如果主应用把自动配置类所在包放在了ComponentScan的扫描范围内自动配置类就会被当普通配置类扫描一次导致注册两次。解决办法是自动配置模块的业务包不要放在使用方主类的同级包或子包下。这也是为什么很多公司内部 starter 的包名都特意跟主工程包区分开。5. 实战联调与踩坑复盘让 starter 真正跑起来5.1 本地模拟项目的完整验证清单starter 写完光靠单元测试不够必须放到一个模拟应用里真实启动验证。我建议按以下清单逐项检查新建一个普通 Spring Boot 项目主类包名和 starter 自动配置类包名放在不同层级引入xxx-spring-boot-starter依赖如果还没发布到仓库先mvn install到本地仓库在 Controller 中写两个接口一个加了Idempotent一个没加用同一个请求参数连续调用两次观察第二次是否抛异常打开--debug确认条件评估报告中的 Positive matches / Negative matches修改配置项demo.idempotent.expire-millis确认默认值能被覆盖。我第一次验证时发现一个奇怪现象切面不生效。排查了依赖、切点表达式最后发现问题出在切面类虽然被自动配置注册了但spring-boot-starter-aop没有被间接传递引入。解决方式是在 starter 的 POM 中显式加入该依赖。这也提醒我autoconfigure 模块自身的依赖千万不要依赖使用方“恰好有”必须显式声明。5.2 排查链路切面失效、Bean 缺失、配置不管用我把联调过程中最容易碰到的三类问题整理成了排查对照表方便直接对着查。症状可能原因排查动作接口可以重复提交切面没拦截starter 没有传递引入 spring-boot-starter-aop注解写错位置目标方法私有目标方法被同类内部调用检查依赖树确认注解在 public 方法上避免自调用启动报NoSuchBeanDefinitionException自动配置类没被加载条件注解不满足注册表文件路径不对打开 --debug看 Negative matches 的 why 输出检查 resources 目录下文件是否编译进入 jar配置项改了没反应prefix 写错属性名大小写不一致没有引入 configuration-processor使用方没刷新配置检查 yml 缩进确认字段 setter 存在重新编译自动配置模块Bean 冲突导致启动失败自动配置默认 Bean 和业务 Bean 重名用唯一方法名业务侧优先注册自定义 Bean这里有一个非常有用的技巧用--debug启动时Spring Boot 会输出一张“自动配置报告”里面清楚列出每个候选配置类为什么通过、为什么不通过。绝大多数“我的 starter 没生效”的问题都能在这张报告里直接看到原因。我甚至建议在写 starter 的过程中把报告的 Positive matches 和 Negative matches 理解为“组件装配是否成功”的运行自检而不是可有可无的调试信息。5.3 实战体会组件化的边界在哪里经历了这次从零写 starter 的过程我最大的体会是组件化的边界不是靠代码数量定义的而是靠“可替换性”定义的。防重逻辑本身不难难的是把“本地锁”和“分布式锁”之间、把“默认实现”和“用户实现”之间、把“自动配置”和“业务扫描”之间的边界切干净。这个 starter 后续还可以继续扩展比如把默认 key 的生成规则改成支持 SpEL 表达式让用户直接在注解里用#request.id提取参数也可以在LockExecutor实现中加入业务线程标识防止锁误释放甚至可以提供指标监控器统计锁冲突率和重复提交次数。这些扩展方向都依赖于最开始搭的接口骨架而非堆代码的方式。而我最后想多说一句的是手写 starter 最大的收获不是输出一个可复用的组件而是逼迫你把 Spring Boot 的自动配置、条件装配、配置绑定、AOP 代理这些原本零散的知识点全部串一遍。这个串联过程比组件本身更值钱。