首页 > 应用开发 > IT综合 > 手写一个 Spring Boot Starter
SPRING BOOT · 可复用组件 · 实战长文
从自动配置到条件装配的完整实践
这是 Spring Boot 系列的第二篇。上一篇讲清楚了自动装配"是怎么发生的",这一篇把它用起来:
从零写一个能发布、能被别人依赖的 Starter
。
全文约 1.6 万字,包含一套完整可运行的代码,建议边读边敲。
上一篇的结尾我留了一句话: 自动装配没有魔法,它只是在 refresh 之前,有条件地往容器里塞了一批 BeanDefinition 。这句话如果只停在"理解"层面,价值有限;真正让它变成能力的,是自己动手写一个 Starter。
原因很实际:写得出来,才说明你真懂了那几个约束为什么存在。 为什么配置类不能用 @Component?为什么 @ConditionalOnClass 必须写在方法上?为什么 @ConditionalOnMissingBean 有时候不生效? ——这些问题只有在你踩过一遍之后,才会变成直觉。
本文用一个真实可用的例子贯穿全文:一个 注解式限流 Starter 。它足够简单(令牌桶 + AOP + 一组配置),又覆盖了 Starter 该有的全部要素(自动配置、条件装配、配置属性、扩展点)。写完它,你就掌握了 90% 的 Starter 写法。
目录
一、Starter 到底是什么:定位与演进
二、技术分析:一个 Starter 的四个组成部分
三、条件装配:七种条件与它们的执行顺序
四、核心流程:从一个 jar 到一堆 Bean
五、核心源码:五个决定成败的方法
六、完整实战:手写限流 Starter
七、七个别踩的坑与调试手段
八、学习路线与十二道面试题
九、一页速记
版本基线: Spring Boot 3.x (Spring Framework 6 / JDK 17 / Jakarta)。所有代码在 3.2 与 3.3 上验证可运行,2.7 的差异在对应位置标注。
一、Starter 到底是什么:定位与演进
先用一句话把边界划清楚: Starter 不是一种技术,而是一种打包约定 。它本身几乎不含代码——真正的逻辑在自动配置模块里,Starter 只是"把该引的依赖都引好,让你加一个坐标就能用"。
1.1 它解决的是"配置搬运"问题
在没有 Starter 的年代,接入一个中间件要做四件事:加依赖、写配置类、配连接参数、在每套环境里重复一遍。四步都不难,但乘以十个中间件、乘以二十个项目,就是持续的重复劳动与不一致。
Starter 把这四步压缩成了一步: 加一个依赖坐标,剩下的按约定自动发生 。注意"按约定"这三个字——不是"自动猜测",而是"如果你没反对,我就按默认来"。这个语义差别,正是后面 @ConditionalOnMissingBean 存在的理由。
没有 Starter :加依赖 → 写 @Configuration → 逐项 new → 每个项目复制一遍
有了 Starter :加依赖 → 写几行 yml(甚至不写)→ 用完
本质变化 :配置知识从"每个使用者的项目里"移到了"组件作者的代码里"
1.2 官方的两模块拆分,以及为什么要拆
看官方仓库会发现一个规律:真正干活的叫 spring-boot-autoconfigure ,而 spring-boot-starter-web 这类模块的 pom 里几乎没有代码,只有依赖声明。
spring-boot-autoconfigure
└─ 所有 *AutoConfiguration 都在这
spring-boot-starter-web
└─ pom 只有依赖清单:
starter / json / tomcat / webmvc
拆的理由:
配置集中一处,便于统一演进
Starter 只负责勾选依赖
但如果你只是写一个公司内部的 Starter, 完全可以合成一个模块 ——代码 + 依赖清单放一起。拆两个模块的主要收益是"多个 Starter 共享同一份自动配置",单组件场景下这个收益不存在。
1.3 命名规范:一条容易忽略的约定
官方建议第三方 Starter 用 xxx-spring-boot-starter ,而不要以 spring-boot-starter 开头——那个命名空间属于 Spring 官方,占用会与将来的官方模块冲突。 mybatis-spring-boot-starter 就是标准写法。
官方:spring-boot-starter-web
第三方:mybatis-spring-boot-starter
你的:ratelimit-spring-boot-starter
1.4 2.x 到 3.x:一条必须知道的断层
这是手写 Starter 时最容易翻车的地方。旧的注册方式在 3.0 里 彻底失效 ,而且失效时不一定报错——表现是"依赖加了,Bean 没进来"。
Spring Boot 2.6 及以前:
META-INF/spring.factories
org.springframework.boot.autoconfigure
.EnableAutoConfiguration=\
com.example.FooAutoConfiguration
Spring Boot 2.7 起:
两种都支持(过渡期)
Spring Boot 3.0 起:
只认
META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
spring.factories 里的
EnableAutoConfiguration 键被移除
还有一个配套变化:配置类注解从 @Configuration 换成了 @AutoConfiguration 。后者做了两件事: 强制 proxyBeanMethods=false (避免 CGLIB 代理开销),以及 支持 after / before 声明排序 。
一句判断法: 看到 spring.factories 里写 EnableAutoConfiguration ,就说明这个库还停留在 2.x;升级到 3.x 时必须改文件与路径,否则静默失效。
顺带说清一个常被混淆的点: spring.factories 本身 没有消失 ——它仍然用于 EnvironmentPostProcessor 、 ApplicationContextInitializer 等扩展点。消失的只是"用它注册自动配置"这一种用法。
二、技术分析:一个 Starter 的四个组成部分
不管多复杂的 Starter,骨架都是四件东西。缺任何一件,它就会"能用但不完整"——比如少了元数据文件,功能正常但 IDE 里写 yml 没有提示。
① 自动配置类 : @AutoConfiguration 标注,里面用 @Bean 声明组件
② 条件注解 :决定"什么时候装配、什么时候让位"
③ 配置属性类 : @ConfigurationProperties ,把 yml 绑成对象
④ 注册文件 :告诉 Spring Boot"我这里有自动配置"
2.1 骨架:三十行就能跑
@AutoConfiguration
@ConditionalOnClass(RateLimiter.class)
@EnableConfigurationProperties(
RateLimitProperties.class)
public class RateLimitAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RateLimiter rateLimiter(
RateLimitProperties props) {
return new RateLimiter(
props.getDefaultPermits());
}
}
五行注解,三个动作: 声明自己是自动配置 (@AutoConfiguration)、 声明生效前提 (@ConditionalOnClass)、 声明让位规则 (@ConditionalOnMissingBean)。
值得单独说的是 @ConditionalOnMissingBean :它让"用户自定义优先"成为默认行为。你提供的 RateLimiter 是 兜底实现 ,谁不满意,自己在项目里声明一个同类型 Bean 就覆盖掉了,不需要 @Primary 、不需要改配置、不需要排除自动配置。
2.2 条件注解:七个必须掌握的
条件注解是 Starter 的灵魂。内置几十个,写组件时高频使用的只有七个:
@ConditionalOnClass / @ConditionalOnResource :类路径或资源文件存在才生效,Starter 的第一道门
@ConditionalOnMissingBean / @ConditionalOnSingleCandidate :容器里没有(或唯一存在)该类型才生效,前者用于可覆盖的兜底实现
@ConditionalOnProperty / @ConditionalOnWebApplication :按配置项或应用类型开关,关键在 havingValue 与 matchIfMissing
@ConditionalOnBean :容器中有该 Bean 才生效,但在自动配置里 不可靠 ,原因见 3.3
组合范式: 类上用 OnClass 挡住"依赖不在",方法上用 OnMissingBean 挡住"用户已自定义",再用 OnProperty 给一个总开关 。三层过滤走完,剩下的才是真正要装配的。
2.3 @ConditionalOnClass 为什么能"类不存在也安全"
这里有一个反直觉的点,也是我见过最多人答错的面试题: 如果 @ConditionalOnClass 引用的类压根不在类路径上,JVM 加载这个配置类时为什么不直接抛 NoClassDefFoundError?
答案是: 它根本没有加载那个类 。Spring Boot 用 ASM 直接读取 class 文件的字节码,解析注解元数据,只拿"类名字符串"去 ClassLoader 里做 Class.forName 的存在性判断——不触发配置类本身的加载与链接。
但这里有个陷阱: 只有当引用出现在注解属性里才安全 。如果你写成下面这样,字节码在解析方法签名时就必须加载那个类了:
// 危险:方法直接用了可选依赖的类型
@Bean
public RateLimitAspect aspect(
RateLimiter limiter) { ... }
// 安全:用 Object 或嵌套类隔离
@Bean
@ConditionalOnClass(Aspect.class)
public Object aspect(
RateLimiter limiter) {
return new RateLimitAspect(limiter);
}
实践中的标准做法是 把依赖可选库的 Bean 放进静态内部类 ,在内部类上加条件注解。这样外部类的字节码不引用可选类型,只有内部类被真正处理时才会加载——而这时代码路径上已经确认类存在了。
为什么官方都这么写: 去看 DataSourceAutoConfiguration ,里面全是 static class PooledDataSourceConfiguration 这样的嵌套类,每个都带自己的条件注解。不是为了好看,是为了让"没引 druid 的项目"不会在解析配置类时就崩掉。
2.4 配置属性:@ConfigurationProperties 的三个要点
@ConfigurationProperties(
prefix = "demo.ratelimit")
public class RateLimitProperties {
private boolean enabled = true;
private int defaultPermits = 100;
private Duration defaultWindow
= Duration.ofSeconds(1);
// getter / setter 省略
}
要点一:用 Duration 而不是 int。 Duration 支持 1s / 500ms / PT2S 多种写法,语义自解释;用 int 则必须在字段名里写清单位( windowMillis ),否则三个月后没人记得是秒还是毫秒。
要点二:所有字段必须有默认值。 Starter 的默认值就是"用户什么都不配时候的行为"。如果默认值是 0 或 null,那么"零配置可用"这个承诺就不成立——用户必须去看文档才知道该配什么,Starter 的意义就丢了一半。
要点三:用 @EnableConfigurationProperties 注册,不要用 @Component。 前者会帮你完成绑定,并且保证"即使用户没扫描到这个包"也能用;后者依赖组件扫描,而 Starter 的包名通常在用户项目的扫描范围之外。
2.5 注册文件与元数据
第一个文件是自动配置的 注册清单 ,3.x 里路径固定,一行一个全限定类名,没有 key、没有等号:
路径:
src/main/resources/META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
内容(只有类名):
com.example.ratelimit
.RateLimitAutoConfiguration
第二个文件是 配置元数据 ,它不参与运行,只给 IDE 用——有了它,用户写 yml 时才有自动补全和悬停文档。它由 spring-boot-configuration-processor 在 编译期 生成,从 @ConfigurationProperties 类的 Javadoc 里提取描述。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-configuration-processor
</artifactId>
<optional>true</optional>
</dependency>
optional=true 这四个字很关键:处理器只需要在自己编译时用, 绝不能传递给使用方 。否则用户的编译期也会跑一遍元数据生成,白白拖慢构建。
2.6 pom 的依赖策略:三个"不要"
不要引 spring-boot-starter :引 spring-boot-autoconfigure 就够了。引完整 starter 会把 Tomcat、日志实现、Jackson 全部传递给使用方,可能与人家已有的冲突。
可选依赖必须标 optional :AOP、Redis 这类"只在特定场景需要"的依赖,标了 optional 才不会强行传给使用方,同时本地编译仍能通过。
不要锁死版本 :用 spring-boot-dependencies 的 BOM 管理版本,让使用方的 Boot 版本说了算。
三条是同一个原则: Starter 是客人,不能反客为主 ——加了它,依赖树几乎不该有变化。
三、条件装配:七种条件与它们的执行顺序
会写条件注解不算懂,知道 它们什么时候被判断 才算。这一章要解决三个具体疑问:为什么 @ConditionalOnMissingBean 可靠、为什么 @ConditionalOnBean 不可靠、为什么有时条件加了却不生效。
3.1 条件在两个不同阶段被评估
Spring 内部把条件评估分成两个阶段,这一点是理解所有"条件不生效"问题的钥匙:
PARSE_CONFIGURATION
(解析阶段)
扫描到配置类时立刻判断,此时
还没有 BeanFactory 可用
。只适合"看类路径、看配置项、看环境"这类判断。
REGISTER_BEAN
(注册阶段)
把 @Bean 方法转成 BeanDefinition 时判断,此时
BeanFactory 可用
。凡是需要"看看容器里有没有某个 Bean"的条件,都必须在这一阶段。
对应的实现机制是 Condition #getConfigurationPhase() 。 OnBeanCondition (@ConditionalOnBean 与 @ConditionalOnMissingBean 的父类)在这个方法里返回 REGISTER_BEAN ——意思是"别在解析阶段问我,那时候我看到的容器还不完整"。
三个阶段对照:
① 解析配置类
ConditionEvaluator.shouldSkip(
meta, PARSE_CONFIGURATION)
→ OnClass / OnProperty 在此生效
② 注册 BeanDefinition
Phase = REGISTER_BEAN
→ OnBean / OnMissingBean 在此生效
③ 实例化 Bean
→ 属性绑定、依赖注入
3.2 关键机制:自动配置被"延后"处理
这是整个自动装配里最精巧的一处设计,也是 @ConditionalOnMissingBean 之所以可靠的唯一原因。
自动配置类不是用普通的 @Import 导入的,而是走 DeferredImportSelector 。 "延迟"意味着:等主配置类里所有 @Configuration 都解析、所有 BeanDefinition 都注册完,才轮到我。
① 解析用户配置类
注册 userRateLimiter
② 主配置类全部处理完
③ 才执行 DeferredImportSelector
读 imports → 过滤 → 排序
④ 解析到 RateLimitAutoConfiguration
OnMissingBean 查容器 → 已有
→ 跳过,不注册
如果没有第 ④ 步的"延后",顺序就会反过来:自动配置先注册了 rateLimiter ,用户再声明时就会变成 两个同类型 Bean ,注入时报 NoUniqueBeanDefinitionException。 所以"延后"不是优化,是正确性的前提。
一个可验证的推论: 如果你的自动配置类在用户配置 之前 就被处理了(比如用户在自己的 @Configuration 上 @Import 了你的自动配置类,而不是走 imports 文件),那么 @ConditionalOnMissingBean 就会失效——因为它看到的容器还是空的。
3.3 为什么 @ConditionalOnBean 在自动配置里不可靠
@ConditionalOnMissingBean 可靠,但它的"反义词" @ConditionalOnBean 却是公认的坑。原因是同一个机制的两个面: 自动配置之间的先后顺序,不保证与你想象的依赖关系一致。
假设 A 依赖 B 提供的 Bean:
// A 想依赖 B 的 Bean
@AutoConfiguration
@ConditionalOnBean(BService.class)
public class AAutoConfiguration { ... }
// 若排序时 A 在 B 之前处理
// A 判断时 B 还没注册
// → 条件不满足,A 被跳过
// → 但 B 随后正常注册
// → 结果:A 凭空消失
解决办法不是不用它,而是 显式声明顺序 :
@AutoConfiguration(
after = BAutoConfiguration.class)
@ConditionalOnBean(BService.class)
public class AAutoConfiguration { ... }
after 只保证"B 先处理",但更稳的做法是 干脆不判断,直接靠参数注入 ——如果 B 不存在,Spring 在实例化 A 时会直接报找不到 Bean,报错信息比"默默不生效"清楚一百倍。这是我给所有组件作者的建议: 能用依赖注入表达的依赖,就不要用条件注解表达 。
3.4 条件的评估顺序与短路
同一个类上写多个条件时,按声明顺序依次评估, 任一不满足立即短路 。所以顺序本身就是性能设计:最便宜的 @ConditionalOnProperty 放最前,查类加载器的 OnClass 其次,要遍历 BeanFactory 的 OnBean 放最后。
四、核心流程:从一个 jar 到一堆 Bean
这一章把上两篇讲过的自动装配流程,换成"组件作者视角"重走一遍。差别在于:上一篇关心"框架怎么找到我",这一篇关心" 我在每一步能控制什么 "。
4.1 七个阶段全景
整条链路分七个阶段: 编译打包 → 读取候选 → 去重排除 → 批量预过滤 → 排序 → 注册并评估条件 → 绑定属性并实例化 。前四个阶段决定"哪些类被选中",后三个阶段决定"怎么变成 Bean"。
4.2 阶段一:打包时发生了什么
这一步没有魔法:imports 文件是你 手写 的,放在 resources/META-INF/spring/ 下,构建时随资源进 jar。
正因为它是手写的, 拼错类名是最常见的"静默失败" :类名写错、包名多一层、文件名少个 .imports 后缀——全都不报错,只是 Bean 不出现。所以这一步值得单独检查一次:
验证 jar 里文件是否正确:
unzip -l target/xxx-starter.jar \
| grep META-INF/spring
应看到:
META-INF/spring/
org.springframework.boot
.autoconfigure
.AutoConfiguration.imports
注意:这是完整文件名
不是目录,不能拆行
4.3 阶段二到四:读取、去重、过滤
启动时, AutoConfigurationImportSelector 会去类路径上 扫描所有 jar 里的同名 imports 文件 ,把它们的内容合并成一个候选列表。这一步用 ImportCandidates.load() 完成(3.x 的新 API)。
合并后立刻做两件事:用 LinkedHashSet 去重(顺序保留,因为后面的排序依赖"文件读取顺序"作为兜底),再移除 @SpringBootApplication(exclude=...) 或配置项里指定的排除类。
接下来是性能关键的一步—— 批量预过滤 。 OnClassCondition 实现了 AutoConfigurationImportFilter ,它用 ASM 一次性读取所有候选类的 @ConditionalOnClass 元数据,凡是类路径不满足的直接整批剔除。
一个由此推出的结论: 如果你的自动配置类只在 @Bean 方法上写了条件、类上没有 @ConditionalOnClass,它就 无法被批量预过滤 ,只能进入逐个解析阶段。这就是"有些项目启动慢"的隐藏原因之一——给类加上 @ConditionalOnClass,本身就是启动优化。
4.4 阶段五:排序的三种依据
AutoConfigurationSorter 按三级优先级排序,优先级从高到低:
① @AutoConfigureOrder :显式指定整数值,数字小的先处理
② @AutoConfiguration(after/before) :声明相对顺序,形成有向图后拓扑排序
③ 字母序 :以上都没声明时,按类名字母序——这就是为什么官方类名都遵循 XxxAutoConfiguration 的统一后缀
注意 after 是 相对约束 而非绝对位置: after = X.class 只保证"我在 X 之后",如果 X 本身没被装配(条件不满足),这条约束自动失效,不会报错。这种"宽松约束"设计避免了组件之间的硬耦合。
4.5 阶段六到七:注册、绑定、实例化
排序完成后,每个候选类被当作普通配置类解析:先判类上的条件,通过则把 @Bean 方法注册成 BeanDefinition;然后 ConfigurationPropertiesBindingPostProcessor 在 Bean 初始化前把配置绑定到属性对象;最后实例化, @ConditionalOnMissingBean 在这一刻之前已经判完了。
这里有个细节容易被忽略: 条件判断用的是 BeanDefinition,不是 Bean 实例 。所以 @ConditionalOnMissingBean 判断"有没有"时,对象还没被 new 出来——它看的是"注册信息"。这解释了两件事:一是它不会触发 Bean 的提前实例化(性能好),二是它判断不了"运行时动态注册"的 Bean。
五、核心源码:五个决定成败的方法
自动装配的源码看着多,其实主干就五个方法。读它们的目的不是背流程,而是 在出问题时知道该在哪个环节查 。
5.1 getAutoConfigurationEntry:四步定生死
// AutoConfigurationImportSelector
AutoConfigurationEntry
getAutoConfigurationEntry(
AnnotationMetadata meta,
AnnotationAttributes attrs) {
if (!isEnabled(meta, attrs))
return EMPTY_ENTRY;
// ① 读取候选(跨 jar 扫描)
List<String> candidates =
getCandidateConfigurations(
meta, attrs);
// ② 移除 exclude
Set<String> exclusions =
getExclusions(meta, attrs,
candidates);
candidates.removeAll(exclusions);
// ③ 批量过滤(性能关键)
candidates = getConfigurationClassFilter()
.filter(candidates);
// ④ 排序
return new AutoConfigurationEntry(
candidates, exclusions);
}
四步正对应四类"不生效":① 文件没进 jar、② 被 exclude、③ 预过滤砍掉、④ 排序导致条件判断时依赖没就位。 排查就按这四步走。
5.2 读取:为什么多个 Starter 不冲突
读文件用的是 getResources 而不是 getResource ——前者返回 所有 jar 里的同名资源 ,不只第一个。这正是"多个 Starter 各自声明、互不覆盖"的实现基础;也意味着同名同路径会都被读到,靠去重兜住。
5.3 ConditionEvaluator.shouldSkip:条件怎么被判
public boolean shouldSkip(
AnnotatedTypeMetadata meta,
ConfigurationPhase phase) {
for (Condition c : getConditions(meta)) {
// 阶段不匹配 → 跳过不判
ConfigurationPhase p =
c.getConfigurationPhase();
if (p != null && p != phase)
continue;
// 任一不满足 → 整体跳过
if (!c.matches(context, meta))
return true;
}
return false;
}
这个循环有两处值得记住: 阶段不匹配的条件会被 continue 掉,而不是判失败 ——所以 OnBeanCondition 在解析阶段"不存在",不会被当成 false; 任一条件不满足立即 return true ——这就是短路,也是为什么条件顺序影响启动性能。
5.4 OnBeanCondition:让位的实现
getMatchOutcome 的核心只有三步:取出条件声明的类型与名称 → 调 getMatchingBeans 查容器 → 按条件类型(MISSING 还是存在)决定 match / noMatch。而 getMatchingBeans 内部走的是 getBeanNamesForType() —— 按类型查已注册的 BeanDefinition,不实例化 。这既让它判断很便宜,也决定了它判断不到"运行时动态注册"的 Bean。
5.5 一个被低估的调试入口
所有条件评估的结果都会记录在 ConditionEvaluationReport 里。启动时加上 --debug ,就能看到完整报告:
java -jar app.jar --debug
输出分两部分:
Positive matches:
RateLimitAutoConfiguration matched
- @ConditionalOnClass found
Negative matches:
RedisAutoConfiguration did not match
- @ConditionalOnClass did not find
required class RedisOperations
Unconditional classes:
...
Exclusions:
...(被 exclude 的)
"我的 Starter 不生效"这个问题,80% 的答案就在 Negative matches 里 ——它会明确告诉你差哪一个条件。养成习惯:先看这个报告,再去看代码。
六、完整实战:手写限流 Starter
选限流做例子,是因为它同时具备四个特征: 有真实业务价值、依赖 AOP(正好演示可选依赖)、需要配置(演示属性绑定)、需要开关(演示条件装配) 。写完之后稍作修改,就能变成你们自己的熔断、幂等、审计 Starter。
6.1 目标与目录结构
目标:方法上标一个注解即限流,参数可全局配也可逐方法覆盖,关掉配置项即整体失效。
ratelimit-spring-boot-starter/
pom.xml
src/main/java/com/example/ratelimit/
RateLimit.java
RateLimitProperties.java
RateLimiter.java
RateLimitAspect.java
RateLimitAutoConfiguration.java
src/main/resources/META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
包名 com.example.ratelimit
注意:必须在业务项目的
组件扫描包之外
6.2 pom:三个依赖,两个 optional
依赖一:必备
org.springframework.boot:
spring-boot-autoconfigure
依赖二:编译期生成元数据
org.springframework.boot:
spring-boot-configuration-
processor
optional = true
依赖三:AOP(用户可能已有)
org.springframework.boot:
spring-boot-starter-aop
optional = true
打包建议:
不要用 spring-boot-maven-plugin
的 repackage 目标
Starter 是给别人的依赖
不是可执行 jar
最后一条是新手常犯的错:用 repackage 打出来的 jar 带了一层 BOOT-INF 结构,别的项目引进去后类加载不到,报"找不到符号"或"ClassNotFound"。
6.3 第一件:配置属性类
@ConfigurationProperties(
prefix = "demo.ratelimit")
public class RateLimitProperties {
/** 总开关,默认开 */
private boolean enabled = true;
/** 默认令牌数,默认 100 */
private int defaultPermits = 100;
/** 默认时间窗,默认 1 秒 */
private Duration defaultWindow
= Duration.ofSeconds(1);
// getter / setter 省略
}
三个字段全部带默认值,意味着 用户不写任何配置也能用 。字段上的 Javadoc 会被 configuration-processor 提取成 yml 的悬停提示——这不是可有可无的注释,是交付质量的一部分。
6.4 第二件:注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/** 令牌数,-1 用全局默认 */
int permits() default -1;
/** 时间窗,空用全局默认 */
String window() default "";
/** 限流 key,空则用
方法全名 */
String key() default "";
}
用 -1 和空串表达"用默认值",而不是用 Integer 的 null——注解属性不支持 null,这是 Java 语法的限制,也是设计注解时的常见取舍。
6.5 第三件:令牌桶核心
public class RateLimiter {
private final Map<String, Bucket> buckets
= new ConcurrentHashMap<>();
public boolean tryAcquire(String key,
int permits, Duration window) {
Bucket b = buckets.computeIfAbsent(
key, k -> new Bucket(
permits, window));
return b.tryAcquire();
}
}
桶的实现按"时间比例补充令牌"来做,逻辑很短:
static class Bucket {
final int permits;
final long windowMillis;
long lastRefill;
double tokens;
Bucket(int permits, Duration window) {
this.permits = permits;
this.windowMillis = window.toMillis();
this.lastRefill =
System.currentTimeMillis();
this.tokens = permits;
}
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
long elapsed = now - lastRefill;
if (elapsed > windowMillis) {
tokens = permits; // 整窗重置
} else {
tokens = Math.min(permits,
tokens + (double) permits
* elapsed / windowMillis);
}
lastRefill = now;
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
}
边界要说明: 这是单机内存限流 ,多实例各算各的。要控总量得用 Redis + Lua——而替换 RateLimiter 的实现正是 @ConditionalOnMissingBean 的价值。
6.6 第四件:AOP 切面
@Aspect
public class RateLimitAspect {
private final RateLimiter limiter;
private final RateLimitProperties props;
public RateLimitAspect(RateLimiter limiter,
RateLimitProperties props) {
this.limiter = limiter;
this.props = props;
}
@Around("@annotation(rl)")
public Object around(
ProceedingJoinPoint pjp,
RateLimit rl) throws Throwable {
String key = keyOf(pjp, rl);
int permits = rl.permits() > 0
? rl.permits()
: props.getDefaultPermits();
Duration window = windowOf(rl);
if (!limiter.tryAcquire(
key, permits, window)) {
throw new IllegalStateException(
"too many requests: " + key);
}
return pjp.proceed();
}
}
注意这个类 没有 @Component 。它由自动配置类通过 @Bean 创建——原因在第二章说过:Starter 的包通常不在业务项目的扫描范围里。这也顺带避免了一个坑:加了 @Component 的类如果在扫描范围内,会和 @Bean 声明重复注册。
6.7 第五件:自动配置类(全场核心)
@AutoConfiguration
@ConditionalOnClass({RateLimiter.class,
Aspect.class})
@EnableConfigurationProperties(
RateLimitProperties.class)
public class RateLimitAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RateLimiter rateLimiter() {
return new RateLimiter();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(
prefix = "demo.ratelimit",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public RateLimitAspect rateLimitAspect(
RateLimiter limiter,
RateLimitProperties props) {
return new RateLimitAspect(
limiter, props);
}
}
五行注解,每一行都有明确目的,值得逐条说清:
@AutoConfiguration :声明自动配置 + 关闭 CGLIB 代理。用 @Configuration 也能跑,但会失去 after/before 排序能力。
@ConditionalOnClass :把两个"可选依赖"一起判断。AOP 没引进来,整个配置类直接不装配——所以用户不用为了不用限流而手动排除。
@EnableConfigurationProperties :注册属性类并完成绑定。不写它,注入 Properties 会失败。
@ConditionalOnMissingBean :两处都加。用户可以覆盖限流器实现(换成 Redis),也可以覆盖切面(加日志、改异常)。
@ConditionalOnProperty : matchIfMissing=true 表示"不配就开"。这是 Starter 的默认语义——引了就想用,而不是引了还要再打开。
6.8 第六件:注册文件
文件:
META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
内容(一行,别加注释):
com.example.ratelimit
.RateLimitAutoConfiguration
注意这里显示的是换行,实际文件里必须写在一行 。文件名也是完整的一行,中间不能断。这是最容易出错的地方:文件名里的 . 是文件名的一部分,不是目录分隔符,所以 META-INF/spring/ 下只有一个文件,没有子目录。
6.9 使用方:三行配置
<dependency>
<groupId>com.example</groupId>
<artifactId>
ratelimit-spring-boot-starter
</artifactId>
<version>1.0.0</version>
</dependency>
# application.yml
demo:
ratelimit:
enabled: true
default-permits: 100
default-window: 1s
@RestController
public class OrderController {
@RateLimit(permits = 10, window = "1s")
@PostMapping("/orders")
public String create() {
return "ok";
}
}
三处改动:加依赖、写五行 yml(也可全不写)、标一个注解。 没有 @EnableXxx、没有 @Import、没有配置类 ——这就是 Starter 应该达到的使用体验。
验证方式 :用 @SpringBootTest 断言 RateLimiter 能注入(说明装配成功);再在测试配置里自己声明一个同类型 Bean,断言注入的是自己那个(说明让位生效)。 Starter 的测试重点是装配行为,不是业务逻辑 ——业务逻辑该由单元测试覆盖,装配行为只能靠容器测。
七、七个别踩的坑与调试手段
这一章是我在实际项目里踩过的、以及 code review 时见得最多的七个问题。它们的共同特征是: 不报错、但行为不对 ——所以只能靠事先知道来避免。
坑 1:@ConditionalOnMissingBean 放在类上
// 错误:整个类都让位
@AutoConfiguration
@ConditionalOnMissingBean(Xxx.class)
public class XxxAutoConfiguration {
@Bean Xxx a() { ... }
@Bean Yyy b() { ... }
// 用户只要自定义了 Xxx
// b() 也跟着没了
}
类上的条件作用于整个配置类,一旦不满足,类里所有 @Bean 全部不装配。正确做法是 逐个方法加 。
坑 2:自动配置类被组件扫描到
若 Starter 包名与业务启动类同源,自动配置类会被 @ComponentScan 扫到并 提前注册 ,绕过延迟导入的顺序保证——让位失效、出现重复 Bean。
约定: Starter 的包名一律放在业务包之外,比如 com.yourcompany.middleware.xxx ,并与业务代码分属不同的 artifact。这条约定没有技术强制,但能让上面这类问题从根上不发生。
坑 3:可选类型出现在方法签名里
后果是: 解析方法签名时要加载参数类型 ,可选依赖没引就在启动阶段抛 NoClassDefFoundError ,且堆栈指向框架内部。解法是放进静态内部类。
坑 4:只改了代码,没改注册文件
升级时最常见:代码与依赖都换了,唯独 imports 文件没建。表现是启动正常、日志无异常、 就是不生效 。
坑 5:@Bean 方法互相调用
// proxyBeanMethods=false 时危险
@Bean
public A a() { return new A(); }
@Bean
public B b() {
return new B(a()); // 直接调
}
proxyBeanMethods=false 时 a() 是普通调用, 每次都 new 一个新对象 ,容器里的单例没用上。改成 b(A a) 参数注入。
坑 7:havingValue 的语义写反
// 语义:配置值等于 havingValue
// 时才生效
// 想要"关闭时生效"
@ConditionalOnProperty(
name = "demo.mock.enabled",
havingValue = "false")
// 注意:不是写 !enabled
// 想要"不配就生效"
matchIfMissing = true
这个注解没有"取反"的能力,只能表达"等于某值则生效"。所以"关闭时启用某个降级实现"这类需求,就得写 havingValue="false" ——读代码时很容易理解成"enabled=false 时不生效",方向正好相反。
坑 8:用 repackage 打包
repackage 会把 jar 重排成可执行结构(类进 BOOT-INF/classes )。 Starter 是被依赖的库,必须保持普通 jar ,所以别配这个目标。
一个通用判据: 可以用 java -jar 直接跑起来的,是应用;只能被别的项目依赖的,是库。Starter 属于后者,永远不要把两者混在一起。
7.1 三个调试手段
① --debug 看条件报告
Positive / Negative matches 会直接写出"差哪个条件",是排查首选。
② 检查 jar 里有没有那个文件
unzip -l
看一眼,比读十遍代码快。文件名错一个字符就前功尽弃。
③ 在 getAutoConfigurationEntry 打断点
看
candidates
里有没有你的类名,能快速区分"没读到"还是"读到后被过滤掉"。
如果项目引了 actuator,还有第四个手段: /actuator/conditions 端点,内容与 --debug 的报告一致,但可以在运行时随时查看,不需要重启。
八、学习路线与十二道面试题
8.1 四个阶段
阶段一 会读 :看懂现成 Starter 的骨架,说清每个注解的作用。 毕业条件 :画出 mybatis-spring-boot-starter 的装配链路。
阶段二 会写 :照第六章写出完整 Starter,能被另一个项目依赖。 毕业条件 : --debug 里能看到自己出现在 Positive matches。
阶段三 会设计 :设计出"默认可开箱、处处可覆盖"的扩展点。 毕业条件 :文档里"如何覆盖默认实现"不超过三段。
阶段四 会治理 :多团队共用的版本兼容与废弃策略。 毕业条件 :升一次大版本,使用方零改动。
8.2 读源码的五步
01 ImportCandidates.load
文件怎么被读进来
02 AutoConfigurationImportSelector
.getAutoConfigurationEntry
03 AutoConfigurationImportFilter
.match 怎么批量过滤
04 AutoConfigurationSorter
.getInPriorityOrder
05 ConditionEvaluator.shouldSkip
单个条件怎么判
8.3 十二道面试题
1
|Starter 的原理?
答:打包约定 + 自动配置:imports 文件被 ImportSelector 读取,配置类进容器,条件注解控制装配
2
|为什么第三方不叫 spring-boot-starter-xxx?
答:那是官方的命名空间,占用会与未来官方模块冲突
3
|3.x 注册方式与 2.x 的区别?
答:2.6 前用 spring.factories;3.0 只认 META-INF/spring/...imports
4
|@AutoConfiguration 与 @Configuration 的区别?
答:多了 after/before 排序,并强制 proxyBeanMethods=false
5
|OnClass 引用的类不存在为何不报错?
答:ASM 读字节码拿类名字符串判断存在性,不加载该类
6
|OnMissingBean 为什么可靠?
答:自动配置延迟到最后处理,此时用户的 BeanDefinition 已注册完毕
7
|OnBean 为什么不可靠?
答:自动配置之间的顺序不保证是依赖顺序,可能判断时对方还没注册
8
|条件在哪两个阶段评估?
答:PARSE_CONFIGURATION(无 BeanFactory)与 REGISTER_BEAN(有)
9
|自动配置类为什么不能加 @Component?
答:会被组件扫描提前注册,绕过延迟导入的顺序保证
10
|Starter 的依赖怎么引?
答:引 autoconfigure 而非 starter;可选依赖标 optional
11
|yml 的 IDE 提示从哪来?
答:configuration-processor 在编译期从 Javadoc 生成 metadata.json
12
|Starter 不生效怎么排查?
答:--debug 看 Negative matches → 查 jar 里有没有 imports → 断点看候选列表
九、一页速记
【本质】打包约定 + 自动配置
不是技术,是约定
【四件套】配置类 / 条件 / 属性 / 注册
【注册】META-INF/spring/
xxx.AutoConfiguration.imports
【3.0 断层】spring.factories 里的
EnableAutoConfiguration 已移除
【注解】@AutoConfiguration
强制 proxyBeanMethods=false
【三层条件】OnProperty 开关
OnClass 依赖
OnMissingBean 让位
【OnClass 安全】ASM 读元数据
别写进方法签名
【让位可靠】延迟到最后处理
看得见用户 Bean
【OnBean 不可靠】需 after = Xxx.class
【两阶段】PARSE 无 BeanFactory
REGISTER 有
【依赖】autoconfigure 而非 starter
可选依赖标 optional
【打包】不要 repackage,库不是应用
【排查】--debug 看
Positive / Negative
写 Starter 这件事,技术门槛其实不高——四件套加五条约定,一个下午就能跑通第一个。真正区分水平的是 "别人的项目引入你的 Starter 之后,是否几乎感觉不到它的存在" :依赖树没变、启动没变慢、出问题时有清晰日志、想改的时候有明确的覆盖点。
所以如果只带走一句话,我希望是这句: Starter 的设计目标不是"功能强大",而是"默认合理、可被替换" 。把 @ConditionalOnMissingBean 用对,把 optional 标对,把开关留好,剩下的都是细节。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70019641/viewspace-3187009/ ,如需转载,请注明出处,否则将追究法律责任。