自定义Starter实现指定注解Bean自动扫描与注册

📅 发布时间:2026/10/11 4:35:17
自定义Starter实现指定注解Bean自动扫描与注册
做后端开发的人多少都碰过这种需求希望某个自定义注解标注的类项目一启动就被 Spring 自动扫描并注册成可注入的 Bean。最早我是每个服务里写一个ComponentScan的 includeFilters或者干脆用反射在ApplicationRunner里手动点名加载直到同样一段代码被复制到第四个微服务的时候我决定停下来把它沉淀成一个自定义 starter。所谓“自定义 starter扫描指定注解的 bean”本质上就是做一个可复用的自动配置模块让你定义一种注解用这个注解标记的类会自动完成注册业务方只需要引入依赖、写好自己的业务类剩下全部由 starter 统一处理。这篇文章适合刚接触 Spring Boot 自动配置、想给自己的团队沉淀组件的人。只要你熟悉Configuration和ComponentScan的基础用法跟着这里的代码走一遍就能明白怎么控制 Spring 的类扫描过程也能避开我踩过的几个比较隐蔽的坑。我会从原理、完整实现、问题排查三个阶段展开代码都是可以直接抄走的。1. 什么时候会需要“自定义starter扫描指定注解”1.1 我遇到的实际场景当时团队里有好几个独立的微服务它们都有同一个业务动作把一个标记了某种注解的类在启动时自动收集起来注册成统一的处理策略。比如有一类处理器接口是BizHandler实现类上会打一个BizComponent的注解系统启动后需要把这些标注过的实现类全部变成 Spring Bean并且集中登记到一个注册中心里方便消息路由时按类型分发。一开始每个服务都是复制粘贴同一套扫描代码。代码量倒是不大真正麻烦的是规范很难统一有人把扫描包写死在自己的com.xxx.order下换了服务就要改有人默认扫描整个根包结果把不该加载的工具类也扫了进来还有人忘了加 includeFilters项目日志里静悄悄多出一堆莫名其妙的 Bean。到后来新服务接进来的时候总要人肉提醒一句“记得把那个扫描配置也拷过去”这种状态非常消耗精力。于是我把这块逻辑抽出来做成独立模块对外暴露的方式就是两个动作引入 starter 依赖、给自己想要被扫描的类打上自定义注解。项目启动Spring 自动完成剩余的扫描、校验、注册。这就是“自定义 starter”最典型的用法——把团队约定固化到组件里而不是留在大家的操作习惯里。1.2 Starter和自定义注解组合能解决哪些问题用这种方式能解决几个很实际的问题。第一个是消除重复代码。扫描逻辑只在 starter 里维护一次业务服务里不再需要可见的 ComponentScan 配置。换服务、换团队、换项目依赖引进来就生效。第二个是统一组件的接入标准。以前每个服务里的扫描逻辑各不相同现在对外只暴露一个注解想被扫描到的类就标注一下入口非常清晰。即使是刚入职的同学看到注解也知道这个类代表了什么。第三个是可控性。Starter 里可以加条件开关比如通过配置项控制是否启用扫描也可以加日志项目启动时自动打印这次扫描注册了多少个 Bean这对排查“某个类没生效”的问题非常有帮助。第四个是便于后续扩展。如果以后想在扫描时自动给 Bean 做代理、加缓存或者把符合条件的类信息上报到监控平台改的是组件内部业务服务和依赖方完全不用动。1.3 Spring Boot自动配置的基础规则要实现这个功能先得理解 Spring Boot 的自动配置机制。自动配置类本质上是一个Configuration类Spring Boot 启动时会通过AutoConfigurationImportSelector从约定路径加载一批AutoConfiguration然后按条件装配。在 Spring Boot 3.x 和 2.7 中自动配置候选列表放在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里一行一个自动配置类全限定名。老版本则放在META-INF/spring.factories里key 是org.springframework.boot.autoconfigure.EnableAutoConfiguration。这两个路径搞混是 starter 不生效最常见的根因后面我会专门讲。自动配置类内部一般会用ConditionalOnClass、ConditionalOnMissingBean、ConditionalOnProperty等条件控制装配。我们的自定义 starter 也走这条路判断业务方引用了解析器、判断开关是否打开满足条件才执行扫描注册逻辑。这样既不会污染无关项目也符合“自动配置”的设计习惯。2. Spring容器如何根据注解找到Bean原理拆解2.1ComponentScan的扫描过滤机制先聊聊 Spring 默认的扫描原理。ComponentScan会启动一个类路径扫描器找到符合条件并带有元注解Component包含派生注解Service、Repository、Controller的类把它们注册成 BeanDefinition再由容器实例化。问题在于ComponentScan默认扫描的是“被 Spring 组件注解标记的类”如果我们想让他扫描自定义注解就得在注解上加上Component元注解或者给ComponentScan配置includeFilters。但这两种方式都有一个尴尬点这行ComponentScan需要写在业务方的某个被 Spring 管理的配置类上或者写在启动类上。换到 starter 场景我们希望业务方什么都不写或者只写一个注解扫描动作由组件内部主动完成。另外一个隐患是ComponentScan的includeFilters和普通扫描混在一起容易把大量没有业务意义的内部配置类也扫描进来。如果只是想在固定包里挑选特定注解标注的类编程式扫描器会比ComponentScan精确得多。2.2 编程式扫描ClassPathBeanDefinitionScannerSpring 扫描器核心类是org.springframework.context.annotation.ClassPathBeanDefinitionScanner。它负责扫描指定包路径下的.class文件把符合条件的候选类注册到BeanDefinitionRegistry中。我们平时用的ComponentScan实际上就是通过它实现的。这个类支持自定义过滤器addIncludeFilter(TypeFilter)、addExcludeFilter(TypeFilter)。我们常用的AnnotationTypeFilter就是按注解过滤的类型过滤器只要候选类上带有目标注解就放行。这里有一个关键参数构造ClassPathBeanDefinitionScanner时第二个参数useDefaultFilters决定是否启用默认过滤器。默认过滤器会识别Component及其派生注解。如果我们希望只识别自定义注解就要把useDefaultFilters设为false。否则即使你加了自定义注解的 includeFilterSpring 默认还是会按Component的规则扫描结果就和你预期不一样了。下面是核心扫描代码的骨架ClassPathBeanDefinitionScanner scanner new ClassPathBeanDefinitionScanner(registry, false); scanner.addIncludeFilter(new AnnotationTypeFilter(MyMarker.class)); int beanCount scanner.scan(com.example.biz);注意scanner.scan(String... basePackages)会返回一个整数表示成功注册了多少个 BeanDefinition。如果你只想要“扫描指定注解的 bean”这一行基本就是全部秘密。2.3 注册时机ImportBeanDefinitionRegistrar vs BeanDefinitionRegistryPostProcessor有了扫描器还得选一个合适的执行时机。有两个常见的接口ImportBeanDefinitionRegistrar由Import触发在解析某个配置类时回调能拿到底层注册器BeanDefinitionRegistry可以动态注册 BeanDefinition。BeanDefinitionRegistryPostProcessor在 Spring 容器启动早期执行所有 BeanDefinition 注册完成后立即回调适合对已有注册做补充修改。在自定义 starter 这个场景里我更推荐ImportBeanDefinitionRegistrar。原因有三点它和Import配合更自然业务方显式使用EnableBizBeans时语义清晰它可以读取Import注解或自定义Enable注解上的参数比如指定扫描包路径它的执行时机通常在自动配置类处理阶段我们可以和ConditionalOnProperty等条件注解配合。而BeanDefinitionRegistryPostProcessor虽然更“全局”但优先级太高容易在其他组件还未准备好时就执行反而不够可控。时机接口触发方式可拿参数适用场景ImportBeanDefinitionRegistrarImportEnable注解属性、registry动态注册Bean、自定义扫描BeanDefinitionRegistryPostProcessor容器启动早期registry、BeanFactory全局后处理、修改已有BeanDefinition3. 从零实现完整代码与配置3.1 工程结构和依赖我建议工程结构按照 Spring Boot 官方规范来做一个 starter 模块一个 autoconfigure 模块。starter 模块只放pom.xml和相关spring.factories/AutoConfiguration.imports资源真正的代码放在 autoconfigure 模块。实际开发中有人喜欢合并成一个模块也能用但如果组件未来要发布到公司内部仓库拆开更符合规范依赖关系也更清晰。假设模块名叫做biz-spring-boot-starter和biz-spring-boot-autoconfigure。autoconfigure 模块的依赖大致如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-autoconfigure/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependencystarter 模块则依赖 autoconfigure 模块业务方最终引入的是 starter。3.2 核心注解与自动配置入口先定义扫描目标的注解。这个注解非常简单它只用来标记需要被扫描注册的 Beanpackage com.example.biz.autoconfigure; Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) Documented public interface BizComponent { String value() default ; }然后定义自动配置类。使用ConditionalOnClass判断BizComponent是否存在于 classpath 中再用Import引入注册器。package com.example.biz.autoconfigure; AutoConfiguration ConditionalOnClass(BizComponent.class) EnableConfigurationProperties(BizStarterProperties.class) Import(BizBeanRegistrar.class) public class BizAutoConfiguration { }注意这里写的是AutoConfiguration这是 Spring Boot 2.7 之后推荐的注解等价于Configuration AutoConfigureBefore/After的组合。自动配置类不需要业务方手动Import只要在AutoConfiguration.imports里声明就会被自动加载。3.3 扫描器/注册器核心实现核心注册器实现ImportBeanDefinitionRegistrar在这里面做编程式扫描。完整代码如下package com.example.biz.autoconfigure; public class BizBeanRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware { private Environment environment; Override public void registerBeanDefinitions( AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry, BeanNameGenerator importBeanNameGenerator) { String[] basePackages getBasePackages(importingClassMetadata); if (basePackages.length 0) { return; } ClassPathBeanDefinitionScanner scanner new ClassPathBeanDefinitionScanner(registry, false); scanner.setResourceLoader(new DefaultResourceLoader()); scanner.addIncludeFilter( new AnnotationTypeFilter(BizComponent.class)); int registeredCount scanner.scan(basePackages); if (registeredCount 0) { logger.info(Scanned and registered {} bean(s) annotated by BizComponent, registeredCount); } } private String[] getBasePackages(AnnotationMetadata metadata) { String value environment.getProperty(biz.starter.scan-package); if (StringUtils.hasText(value)) { return new String[]{value}; } // 从启动类所在包取默认值稍后说明 if (registry instanceof DefaultListableBeanFactory) { try { return AutoConfigurationPackages.get((BeanFactory) registry).toArray(new String[0]); } catch (IllegalStateException ignore) { } } return new String[0]; } Override public void setEnvironment(Environment environment) { this.environment environment; } }这里有几个细节值得展开。第一new ClassPathBeanDefinitionScanner(registry, false)的false非常重要它表示不使用默认的组件注解过滤器。如果这里写了true扫描器会同时识别Component整个包下的普通组件都会被扫进来就失去“只扫描指定注解”的意义了。第二scanner.setResourceLoader(new DefaultResourceLoader())不是可有可无。在单元测试或者某些非 Web 容器环境里如果没有设置ResourceLoader扫描可能遇到找不到类路径的问题进而扫描不到任何 Bean。第三getBasePackages里我给出了两种取包路径的方式优先读biz.starter.scan-package配置项没有配置就用启动类所在的默认包。AutoConfigurationPackages 是 Spring Boot 提供的工具它会记录启动类所在的包名。如果你不配置任何扫描路径它就从启动类所在的包往下扫这个逻辑非常贴合一般项目结构。3.4 提供显式Enable注解与配置项自动配置可以做到“无感接入”但对有些团队来说显式写一个EnableBizBeans反而更清晰尤其是需要在注解上直接指定包路径的时候。于是我还提供了这个注解package com.example.biz.autoconfigure; Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Import(BizBeanRegistrar.class) public interface EnableBizBeans { String[] basePackages() default {}; }业务方如果使用自动配置就不需要写任何额外注解如果希望手动控制扫描范围可以在启动类上加EnableBizBeans(basePackages com.example.demo.biz)。配套的配置属性类也需要定义好ConfigurationProperties(prefix biz.starter) public class BizStarterProperties { private boolean enabled true; private String scanPackage; // getter / setter 略 }在自动配置类里再用ConditionalOnProperty(prefix biz.starter, name enabled, matchIfMissing true)控制是否启用扫描。这样组件默认是关不掉的状态除非业务方显式配置biz.starter.enabledfalse。3.5 让扫描出来的Bean真正“有用”只把带BizComponent的类注册成 Bean往往还不够。大多数场景里我们希望这些 Bean 在启动后能统一被消费。常见做法是提供一个 registry 容器在启动阶段收集所有扫描到的 BeanComponent public class BizHandlerRegistry { private final MapString, BizHandler handlers new ConcurrentHashMap(); PostConstruct public void collectHandlers(ApplicationContext context) { MapString, BizHandler beans context.getBeansOfType(BizHandler.class); beans.forEach((name, handler) - handlers.put(name, handler)); } public BizHandler get(String name) { return handlers.get(name); } }这样业务方只需要把自己的类实现BizHandler接口并打上BizComponent注册集中度和路由分发都由组件完成。扫描注册只是第一步后面接什么逻辑完全看组件设计。4. 高频问题与避坑记录4.1 默认扫描包路径怎么取最稳很多 Starter 抄着网上的例子里写死一个basePackage发布到内部仓库后所有业务方只能从那个包下加注解这其实很不合理。我更推荐用AutoConfigurationPackages.get(beanFactory)取启动类所在包作为兜底。但要注意这个 API 在调用时如果还没有设置启动类包会抛IllegalStateException。所以最好包一层 try-catch找不到就返回空数组并用日志提醒业务方配置biz.starter.scan-package。千万别用new Object(){}.getClass().getPackage().getName()这种技巧它取到的是 starter 内部类的包不是业务方的包。4.2 二重注册自定义注解和Component共存有人会在自定义注解上加Component希望 Spring 默认扫描也照顾到。这在我这个方案里是一个很典型的坑如果业务方在启动类上使用SpringBootApplication那么启动类所在包下所有带Component的类都会被 Spring 自动注册一次你在扫描器里再注册一次就会造成重复注册严重时直接报BeanDefinitionStoreException。所以这个 starter 的设计原则是业务方的目标类只打BizComponent不要额外加Component。扫描器里的AnnotationTypeFilter只会匹配BizComponent不会匹配普通组件。另外要注意AnnotationTypeFilter的构造参数。它有considerMetaAnnotations和considerInterfaces两个布尔参数。如果你希望自定义注解能被继承语义覆盖比如子类继承父类上的BizComponent建议使用new AnnotationTypeFilter(BizComponent.class, true, true)。否则只扫描直接标注的类继承场景就会漏。4.3 自动配置不生效spring.factories与AutoConfiguration.imports这是我从 2.x 升级到 3.x 时最痛的一段经历。旧项目里写的是META-INF/spring.factories升级 Spring Boot 之后自动配置越来越难加载调试了很久最后在网上搜到 Spring Boot 2.7 开始已经对新项目强制使用AutoConfiguration.imports了。正确做法是在 autoconfigure 模块的src/main/resources/META-INF/spring/下新建文件org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容很简单一行一个自动配置类com.example.biz.autoconfigure.BizAutoConfiguration如果你既想兼容老版本又支持新版本可以同时保留spring.factories和AutoConfiguration.imports但里面的自动配置类要重复声明一次。发布前最好写一个最小的测试工程验证否则极容易出现“本地 IDEA 跑得起来打包成 jar 给别人用就不生效”的情况。4.4 在Registrar里读取配置的正确姿势ImportBeanDefinitionRegistrar在自动配置解析阶段就会调用这时不要尝试往里注入ConfigurationProperties的 bean。我试过在registerBeanDefinitions方法里直接取BizStarterProperties结果总是 null原因是属性绑定在这个阶段还没有完成。最稳的办法是让注册器实现EnvironmentAware像我上面代码里那样。然后在setEnvironment中拿到Environment通过environment.getProperty(biz.starter.scan-package)读取。这样可以绕开属性绑定的时机问题。如果你的扫描参数比较多也可以统一定义在某个ConfigurationProperties类上但不建议用属性绑定后的实例直接用Environment更简单。4.5 性能与排除项优化如果扫描包太大比如直接扫了整个根包项目启动时会对 classpath 做大量遍历虽然 Spring 做了缓存但类一多依然会有明显启动延迟。建议扫描包尽量控制在业务模块的某个子包下条件允许时允许业务方在注解里指定value把 bean 名显式写出来减少后续解析如果确定某些子包不需要扫描用scanner.addExcludeFilter(new AssignableTypeFilter(AbstractBase.class))之类的方式排除。另外不要天真地以为ClassPathBeanDefinitionScanner会把所有.class都加载进 JVM。它实际通过MetadataReaderFactory读取类元信息对未匹配的类只会读取元数据不会加载完整类。但如果你的业务类引用了某个不存在于 classpath 的类型扫描器在读取元数据时仍可能抛出NoClassDefFoundError。这种情况下在自动配置类上用ConditionalOnClass把强依赖类先做一次判断是比较好的兜底。5. 实测验证与组件沉淀5.1 用临时工程快速验证组件做完一定不要直接发布先拉一个临时工程验证。我一般建一个纯 Spring Boot 应用根包结构如下com.example.demo DemoApplication.java bizlayer OrderBizHandler.java outer OtherComponent.javaorder.biz包下的OrderBizHandler标记BizComponent并实现BizHandler。启动类上不加任何东西只靠自动配置然后在ApplicationRunner里打印applicationContext.getBeansOfType(BizHandler.class)确认orderBizHandler已经被注册。如果是用显式EnableBizBeans的场景就在启动类上加上SpringBootApplication EnableBizBeans(basePackages com.example.demo.biz) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }跑一次日志里能看到Scanned and registered 1 bean(s) annotated by BizComponent这代表扫描流程成功了。如果日志没出现优先检查AutoConfiguration.imports文件路径其次检查ConditionalOnClass是否误写了不存在于 classpath 的类。5.2 团队组件落地的小建议组件能跑通以后要想在团队里沉淀下来还有些细节值得顺手做好。命名上建议使用官方规范name-spring-boot-starter看着清晰后续也好和其他 starter 区分。版本号统一由公司 BOM 管理避免依赖冲突。文档里至少要写清楚依赖怎么引、注解怎么打、扫描包怎么配、开关怎么关、注册不生效怎么排查。不要只给一个没有注释的 jar 包。如果组件需要被其他团队使用我认为日志输出很重要。最好在扫描完成后打一条结构化的 startup log内容包括扫描包、注解类型、注册 Bean 数量、耗时毫秒数。很多“为什么我的类没生效”的疑问通过这条日志基本能一眼定位。最后再说一点个人体会最开始我总觉得ClassPathBeanDefinitionScanner是个很神秘的底层 API真正用下来它其实就是个高度封装好的扫类工具。你要做的只是决定扫什么、什么时候扫、扫完怎么处理。把这三件事想清楚自定义 starter 的核心难度就解决了。后面不管换成扫描方法注解、扫描接口实现类还是给注册的 Bean 加代理都是在这套骨架上继续添砖加瓦。