ITPub博客

首页 > 应用开发 > IT综合 > 手写一个 Spring Boot Starter

手写一个 Spring Boot Starter

投稿 IT综合 作者:Flow_Official 时间:2026-10-29 09:39:46 0 删除 编辑

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/ ,如需转载,请注明出处,否则将追究法律责任。

下一篇: 没有了~
请登录后发表评论 登录
全部评论

注册时间:2022-08-02

  • 博文量
    38
  • 访问量
    43607