前阵子在 Spring 技术群里看到一个特别典型的提问:"Spring 中每一个注解都需要有一个对应的解析的类吗?"
问这个问题的人,多半是被网上那些源码解析文章搞怕了——今天剖析@Autowired的内部处理器,明天拆解@Configuration的后置处理器,看起来好像每个注解背后都站着一个专属的"解析天团",于是忍不住怀疑是不是漏看了某个注解的解析类,导致自己的代码不生效。但实际接触过 Spring 源码的人都知道,这里面的真实情况和直觉不太一样。
先说结论:没有必要给每个注解都配一个专属解析类,Spring 内部也几乎不存在"一个注解一个解析类"的一一对应关系。真正重要的是搞清楚这个注解挂在容器生命周期的哪个环节、由哪段通用逻辑去解释她的语义。这篇文章我就把这件事彻底讲透:什么时候确实有"对应解析类",为什么多数注解没有,以及遇到自定义注解时,你应该怎么设计它的解析路径。
1. 这个问题的正确问法:先分清"注解本身的解析"和"业务动作的执行"
1.1 注解在 JVM 层面到底是什么
要回答这个问题,必须先回到最底层:注解的本质是什么?
在 JVM 看来,注解不过是一段元数据(metadata),附着在类、方法、字段、参数、包等程序元素上。注解本身不携带任何行为,它不会"主动做"任何事。真正让它产生效果的是某个外部读取者,通过反射拿到注解对象后,再根据注解里的值去执行相应的逻辑。
这里要注意区分两个层面的"解析":
- 编译期解析(APT):像 Lombok 的
@Getter、@Slf4j,确实有对应的AbstractProcessor实现类,在javac编译过程中扫描注解并生成代码。这种模式接近"一个注解一个处理类"的直觉。 - 运行时解析(反射):Spring 的容器注解走的是这一条路。它不做代码生成,而是在容器启动、Bean 实例化、方法调用等阶段,通过反射读取注解的值,然后走通用逻辑。
Spring 的绝大多数注解走的是运行时解析这条路。@Transactional的注解值是在代理拦截时才被读取的,@Autowired是在 Bean 属性填充阶段被反射发现的,@RequestMapping是在容器启动后的映射注册阶段被扫描到的。它们都没有像 APT 那样"一个注解对应一个编译器处理类"的结构。
所以,正确的问法不是"每个注解都要有一个对应的解析类吗",而应该是:这个注解的语义,该由谁在哪个阶段解释,解释完之后要去触发什么扩展动作?
1.2 为什么你会有"一对一解析"的错觉
这种错觉不是凭空来的。Spring 早期确实存在"一个 XML 标签对应一个解析器"的阶段,比如<context:annotation-config>有AnnotationConfigBeanDefinitionParser,<mvc:annotation-driven>有MvcNamespaceHandler。XML 是结构化的,标签之间有父子层级,加上命名空间不同,分开写解析器很合理。
但注解不是 XML。注解没有层级结构,它只是一种扁平标记。如果每发明一个注解就写一个解析类,Spring 现在几百个注解就要写几百个解析类,运维和维护成本完全不可控。Spring 的设计者反而在努力做反方向的收敛:用少量几个核心处理器,同时接纳大量注解的语义。
提示:了解这个概念之后,你再去看源码时就不会被"某个注解没有专属解析器"吓到。比如
@RestController没有叫RestControllerParser的类,它是作为"由@Controller衍生的组合注解"被RequestMappingHandlerMapping认识到的。
2. 那些有"对应解析类"的注解:BeanPostProcessor 阵营的编排逻辑
2.1 确实存在几个知名的"专属处理器"
尽管整体上不是一一对应,但如果你打开 Spring 的容器基础模块,会看到几个名字和注解语义高度绑定的类,这就是"有对应解析类"的那部分:
| 注解 | 核心处理器 | 处理阶段 |
|---|---|---|
@Autowired/@Value/@Inject/@Lookup | AutowiredAnnotationBeanPostProcessor | Bean 实例化后、属性填充阶段 |
@Resource/@PostConstruct/@PreDestroy | CommonAnnotationBeanPostProcessor | 属性填充、初始化前后、销毁前 |
@EventListener | EventListenerMethodProcessor | 容器启动早期收集监听器,事件发布时触发 |
@Scheduled | ScheduledAnnotationBeanPostProcessor | Bean 初始化后注册定时任务 |
@ConfigurationProperties | ConfigurationPropertiesBindingPostProcessor | Bean 初始化后绑定属性 |
这些类确实和注解名字强相关,但注意看,它们的共性都是实现了BeanPostProcessor或其子接口。也就是说:在 Spring 的设计里,"解析注解"这个动作,默认的载体就是 Bean 的后置处理器体系。
2.2 一个处理器处理多个注解,才是常态
以AutowiredAnnotationBeanPostProcessor为例,你在源码里能看到它内部维护了一个AutowiredAnnotationType的集合,构造方法里就把@Autowired、@Value、@Inject(JSR-330)都注册进去了。字段注入、Setter 注入、构造器注入,加上@Value的占位符解析、@Lookup的方法覆盖,全都由这一个类负责。
这意味着什么?如果你带着"一个注解一个解析类"的思维去看源码,很多地方是解释不通的:为什么@Value的处理器不单独叫ValueAnnotationBeanPostProcessor?因为@Value和@Autowired在注入逻辑上是高度一致的,都需要走AutowireCapableBeanFactory做类型匹配、依赖解析、懒加载处理,硬拆成两个类只会造成重复代码和状态同步问题。
CommonAnnotationBeanPostProcessor更是典型,它一口气处理了@Resource、@PostConstruct、@PreDestroy三个注解。这三个注解分别对应依赖注入、初始化回调、销毁回调,阶段完全不同,但 Spring 依然把它们放在一个类里。因为它们的共同点是都来自javax.annotation包,都是"通用注解",统一管理更方便。
注意:这些处理器本身也是 Spring 内部的基础设施 Bean(infrastructure bean),Spring 在
registerBeanPostProcessors阶段会优先注册它们,自定义的BeanPostProcessor排在后面。这个顺序很关键,后面讲排查时会再次提到。
2.3 解析类是可替换、可扩展的,不是硬绑定
还有一个容易忽略的细节:这些处理器和注解之间,不是"注解依赖处理类"的关系,而是"处理类主动声明自己接收哪些注解"的关系。
AutowiredAnnotationBeanPostProcessor提供了一个setAutowiredAnnotationTypes方法,你可以通过修改它的配置来让它识别你自定义的注解。CommonAnnotationBeanPostProcessor的init方法里可以通过setResourceFactory等方式调整行为。也就是说,Spring 只是约定了一些默认的"解析类",你完全可以注册一个全自动的BeanPostProcessor来处理你自己的注解,Spring 根本不在乎你处理的是哪个注解。
所以,如果说"每一个注解都需要有一个对应的解析类",那 Spring 的回答其实是:容器只需要注册一批解析器,至于它们各自认领哪些注解,完全由解析器内部的代码决定。
3. 绝大多数注解没有"专属解析类":它们活在通用扫描器和代理工厂里
3.1@Component一族的处理:扫描器的元注解匹配
先看一个最颠覆直觉的例子:@Component是 Spring 最基础的注解,但它没有一个叫ComponentAnnotationParser的类。
@Component及相关派生注解(@Service、@Repository、@Controller)是在ConfigurationClassPostProcessor的执行链路中被处理的。这个处理器实现了BeanDefinitionRegistryPostProcessor,在容器刷新早期触发。它的核心逻辑是ConfigurationClassParser解析各个配置类,当读到@ComponentScan注解时,调用ClassPathScanningCandidateComponentProvider去指定包下扫描候选类。
扫描器判断一个类是否够资格成为候选 Bean 时,用的是isCandidateComponent方法,内部通过@Component这个元注解去匹配。Spring 提供了一个递归查找元注解的工具,不管你是@Service还是自创的@MyComponent,只要类上有一个注解被@Component标注,或者被标注了@Component的注解再标注,扫描器都能认出来。
这个设计直接论证了"不需要每个注解一个解析类":Spring 用元注解的递归匹配,让一个扫描器通吃一族注解。你新增一个@MyComponent,什么都不用配置,它自动就被扫描器接纳了。
3.2 Web 注解的真相:组合注解与统一映射扫描
再看 Web 层。@GetMapping、@PostMapping、@PutMapping这些注解,在源码里其实都是由@RequestMapping通过@AliasFor派生出来的组合注解。Spring MVC 的RequestMappingHandlerMapping继承自AbstractHandlerMethodMapping,它在afterPropertiesSet阶段会对容器里的所有 Bean 做遍历,逐个检查类的@Controller、@RestController标记和方法上的@RequestMapping前缀。
看源码你会发现,RequestMappingHandlerMapping使用AnnotatedElementUtils.hasAnnotation来判断方法是否具备@RequestMapping语义。这个方法会自动将组合注解拆开,所以@GetMapping会被视为"映射路径=xxx,请求方法=GET"的@RequestMapping。你根本不需要为每个派生注解写一个解析器,它们全都被折叠到@RequestMapping的统一扫描逻辑里了。
顺带一提,@RestController和@Controller的关系也是类似的。RestController被@Controller和@ResponseBody标注,RequestMappingHandlerMapping扫描到@RestController时,本质上是识别到了它的元注解@Controller,从而决定把它纳入 handler 方法的扫描范围。
3.3@Transactional/@Cacheable/@Async:AOP 拦截链上的注解
这类业务语义最重的注解,才是最容易让人产生"必须有解析类"错觉的地方。以@Transactional为例,它的完整生效链路其实涉及五个角色:
ProxyTransactionManagementConfiguration:通过@Import导入的配置类,负责装配事务基础设施。BeanFactoryTransactionAttributeSourceAdvisor:AOP 通知器,判断 Bean 是否需要事务代理。TransactionInterceptor:真正的拦截器,负责开启、提交、回滚事务。AnnotationTransactionAttributeSource:负责解析@Transactional注解上的属性(传播行为、隔离级别、超时等)。InfrastructureAdvisorAutoProxyCreator:一个AbstractAutoProxyCreator,在 Bean 初始化完成后检查所有 Advisor,决定是否创建代理。
这里面出现了一个"解析类"——AnnotationTransactionAttributeSource。但它不是为@Transactional量身定制的。它的构造方法允许你传入一组TransactionAnnotationParser,默认注册了SpringTransactionAnnotationParser(对应@Transactional)和Ejb3TransactionAnnotationParser(对应jakarta.ejb的@TransactionAttribute)。也就是说,它是一套可切换的解析器集合,支持"自定义注解"层面的事务属性解析,直接打破"一个注解一个解析类"的说法。
真正决定"要不要给这个 Bean 做代理"的,是 Advisor 的matches方法,它对所有 Bean 统一做匹配,命中则包装。@Cacheable、@Async的路径也和它几乎一模一样:@EnableCaching导入AutoProxyRegistrar注册基础设施,BeanFactoryCacheOperationSourceAdvisor匹配方法,CacheInterceptor拦截执行。
注意:
@Enable*这一族注解本身就是靠@Import工作的。ConfigurationClassParser在解析配置类时看到@Import,就把导入的类注册成 BeanDefinition,这个机制由ImportBeanDefinitionRegistrar/ImportSelector完成。你自定义一个@EnableMyFeature注解,只需要在注解上标@Import(MyRegistrar.class),Spring 就会在解析配置类时自动调用你的 registrar,不需要任何专属解析类。
4. 注解生效时机决定了解析器设计:从容器生命周期看注解处理的三个阶段
4.1 解析不是一步完成的,而是分布到 refresh() 的多个节点
搞明白上面的问题后,你会意识到一个关键点:注解的解析时机,比解析类本身更重要。同一个注解的注解值可能在不同阶段被读取,同一个 Bean 也会先后经历多轮注解检查。我把 Spring 容器refresh()方法里几个关键节点梳理一下:
| 生命周期节点 | 触发的注解处理 | 核心组件 |
|---|---|---|
| 1. 配置类解析阶段 | @ComponentScan、@Import、@PropertySource、@Enable* | ConfigurationClassPostProcessor、ConfigurationClassParser |
| 2. BeanDefinition 后处理 | 作用域修饰、@Lazy标记等 | ConfigurationClassBeanDefinitionReader、各种BeanDefinitionRegistryPostProcessor |
| 3. Bean 实例化后属性填充 | @Autowired、@Value、@Resource | AutowiredAnnotationBeanPostProcessor、CommonAnnotationBeanPostProcessor |
| 4. 初始化前后回调 | @PostConstruct、@PreDestroy、@Scheduled | 各BeanPostProcessor |
| 5. 初始化后代理包装 | @Transactional、@Async、@Cacheable | AbstractAutoProxyCreator、各种 Advisor |
| 6. 事件发布阶段 | @EventListener | EventListenerMethodProcessor |
阶段 1 发生在容器刚刷新时,它改变的是 Bean 的定义。阶段 3 和 4 发生在 Bean 实例化过程中,它们改变的是 Bean 的实例状态。阶段 5 发生在 Bean 初始化之后,直接决定你要不要返回一个代理对象。
4.2 为什么@Transactional有时在自调用时失效:时机问题的典型案例
理解了阶段分布之后,一个高频面试和排错问题就迎刃而解了:为什么同类内a()方法调用b()方法,b()上的@Transactional不生效?
因为事务代理是在阶段 5 创建的。调用方持有的 Bean 实际上已经是代理对象,拦截器只在"外部调用进入代理方法"时生效。当你在a()方法内部直接写this.b()时,这个this指向的是代理对象的内部目标对象,根本没有经过TransactionInterceptor。这是"代理时机"导致的经典失效场景。
有趣的是,如果你把@Transactional放在@Configuration类里,自调用反而可能被拦截。因为@Configuration类需要ConfigurationClassEnhancer做 CGLIB 增强,保证@Bean方法单例语义,这种增强发生在阶段 1 的解析过程中,CGLIB 子类在@Bean方法调用时会做检查。两个代理机制叠加在一起,才会产生"配置类内部自调用也过拦截器"的行为。这类细节光看"有没有解析类"是永远想不通的。
4.3 三级缓存和注解解析:一次时机上的联动
很多人在面经里看到 Spring 三级缓存原理,不明白它和注解解析有什么关系。实际上关系主要体现在@Autowired的解析时机上:
三级缓存的第三级singletonFactories里存的是ObjectFactory,它的存在目的是让 Bean 在"实例化完成但还没完成属性填充"时就能被提前引用。当 A 依赖 B、B 依赖 A 时,A 先实例化完成,把ObjectFactory放进三级缓存。B 创建时去填充依赖,通过DefaultSingletonBeanRegistry.getSingleton拿到 A 的提前引用。而 B 属性填充阶段对 A 的依赖注入,正是AutowiredAnnotationBeanPostProcessor在阶段 3 干的事。
也就是说:@Autowired的解析发生在 Bean 实例化之后、属性填充阶段,它需要从单例缓存里拿依赖;三级缓存的设计保证了即使某个 Bean 尚未完全初始化,也能先给出一个"半成品引用",让注解解析不卡死在循环依赖上。如果一个解析器想提前处理@Autowired,比如在 BeanDefinition 阶段就去做注入,反而会因为依赖尚未实例化而没有办法拿到目标对象。注解的解析时机选择,不是随意的,而是跟着 Bean 生命周期一步一步走的。
5. 遇到一个注解不知道怎么处理?用三个问题定位它的解析路径
5.1 问题一:这个注解的 Target 是什么
拿到一个注解,先看@Target。注解能标在类上、方法上、字段上,还是参数上,直接决定了它最自然的解析方式:
- 标在 TYPE(类/接口)上:大概率影响 BeanDefinition 或 Bean 的创建方式,走
BeanFactoryPostProcessor、扫描器、@Import那条路最合适。比如自定义@MyRepository,本质就是@Component的别名,交给扫描器即可。 - 标在 METHOD/FIELD 上:影响属性填充、初始化行为,走
BeanPostProcessor最合适。比如自定义@MyConfigValue要在字段上注入配置值,模仿@Value的处理方式,写一个BeanPostProcessor在postProcessProperties里做反射赋值即可。 - 标在 METHOD 上表示要拦截增强:比如
@MyAuditLog要记录方法执行时间,走 AOP 切面最合适,因为被拦截的是"方法调用"这个行为,AOP 天生就是干这个的。
所以第一步就是问:这个注解要贴在哪里?贴的位置暗示了你要处理的程序元素类型,也就暗示了要参与的生命周期阶段。
5.2 问题二:这个注解的语义是改定义、改实例,还是拦截行为
注解的语义大概有三种,对应的解析手段完全不同:
| 语义类型 | 要做的事 | 推荐解析方式 | 典型例子 |
|---|---|---|---|
| 改变 Bean 定义 | 新增 BeanDefinition、修改作用域、注册额外 Bean | BeanDefinitionRegistryPostProcessor+@Import(ImportBeanDefinitionRegistrar) | @ComponentScan、@Enable* |
| 改变 Bean 实例状态 | 给属性赋值、执行初始化方法 | BeanPostProcessor/InstantiationAwareBeanPostProcessor | @Autowired、@PostConstruct |
| 拦截方法调用 | 加日志、加缓存、加事务、加权限 | AOP 切面(@Aspect)或 Advisor +AbstractAutoProxyCreator | @Transactional、@Cacheable |
对着这个表就知道,@Component别想用 AOP 去解析,因为你无法"切入一个正在生成的 BeanDefinition";@Transactional别想用普通的BeanPostProcessor在属性填充阶段去解析,因为事务关心的不是 Bean 的内部状态,而是方法执行时的拦截。
5.3 问题三:解析产物要交给谁
最后一个问题,也是决定"要不要为这个注解单独设计一个解析链"的问题:你的解析产物是什么?
- 如果解析产物是一批新的
BeanDefinition,那应该registerBeanDefinition到容器里,交给后面的getBean流程继续处理。 - 如果解析产物是某个字段的值,那你只需要修改目标 Bean 的实例,不需要通知容器。
- 如果解析产物是"要织入一个拦截逻辑",那你需要创建代理对象,并把代理丢回容器;此时最优雅的做法是直接实现一个
Advisor,而不是自己搞一个ProxyFactory去包。
我自己在开发中判断的标准很简单:如果这个注解的解析结果要被 Spring 容器后续大量使用,比如影响了其他 Bean 的实例化,那么优先走 BeanFactoryPostProcessor 一脉;如果只是局部增强,优先走 AOP,因为 AOP 框架帮你处理了代理的生成、排序、合并,比自己造轮子稳得多。
6. 实战推演:自定义@ApiLog注解的四种解析方案与选型
6.1 需求场景
假设我们要给某个@RestController的接口加一个@ApiLog("根据ID查询订单")注解,作用是自动记录方法入参、返回结果、执行耗时。这是个非常经典的业务需求,一类人把它做成 AOP 切面,另一类人上来就琢磨"要不要写一个 ApiLogAnnotationParser"。
6.2 方案 A:纯反射扫描 + 手动包装(不推荐)
最本能的做法,是在容器启动完成后,通过ApplicationContext.getBeansWithAnnotation或者包扫描把所有带@ApiLog的方法找出来,然后尝试包装。但这个方案在 Spring 环境里实现起来很别扭:你已经拿到的是容器里的 Bean 实例,想拦截调用就必须主动创建代理,还得考虑代理是否和已有的@Transactional代理叠加,代码很快就变成一团乱麻。所以这个方案我只在纯 JavaSE 项目里用,Spring 项目不推荐。
6.3 方案 B:AOP 注解切面(最推荐)
直接在项目里加一个@Aspect类,用切点表达式定位注解:
@Aspect @Component public class ApiLogAspect { @Around("@annotation(apiLog)") public Object around(ProceedingJoinPoint joinPoint, ApiLog apiLog) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; // 记录入参、出参、耗时,apiLog.value() 是注解里填的日志描述 return result; } }配合spring-boot-starter-aop,一切交给 AspectJ 的 advisor 机制处理。为什么这个方案最省心?因为 Spring AOP 框架的三件套(AnnotationMatchingPointcut负责定位注解、AspectJAroundAdvice负责拦截、AnnotationAwareAspectJAutoProxyCreator负责生成代理)已经全部为你搭好了,你只需要关心业务代码。而且@Around的切点表达式@annotation(apiLog)本身就实现了"按注解匹配方法"的能力,你不需要写任何解析类。
6.4 方案 C:BeanPostProcessor + ProxyFactory(可控但繁琐)
如果你想完全掌控代理的创建过程,可以用BeanPostProcessor:
@Component public class ApiLogBeanPostProcessor implements BeanPostProcessor { @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { // 判断 bean 的类中是否有 @ApiLog 标注的方法 if (!hasApiLogMethod(bean.getClass())) { return bean; } ProxyFactory factory = new ProxyFactory(bean); factory.addAdvice(new ApiLogInterceptor()); return factory.getProxy(); } }这个方案能解决"特定 Bean 才做代理"的问题,但坑也不少:你要处理 JDK 代理和 CGLIB 代理的选择、防止多次代理、保证和@Transactional代理的共存(AbstractAutoProxyCreator内部有缓存机制,而你手动创建的代理不会进缓存)。所以我的建议是:除非你确实需要控制代理创建的每一个环节,否则不要重复造轮子,AOP 切面已经足够。
6.5 方案 D:编译期注解处理器 APT(性能至上)
如果你追求零运行时反射开销,可以在编译期写一个AbstractProcessor,扫描@ApiLog并生成对应的切面代码或日志代码。但这意味着注解处理器要能拿到完整的 AST,并且把生成的代码集成到现有工程里,维护成本高。在 Spring 生态里,除非是框架底层做基础设施(比如 MapStruct、Lombok),否则业务项目不要这么干。
6.6 四种方案对比
| 方案 | 运行时开销 | 开发复杂度 | 与 Spring 契合度 | 适用场景 |
|---|---|---|---|---|
| 纯反射扫描 | 高 | 低 | 低 | JavaSE 静态元数据处理 |
| AOP 切面 | 低(方法级代理) | 极低 | 极高 | 日志、缓存、权限等 90% 业务注解 |
| BeanPostProcessor + ProxyFactory | 中等 | 高 | 中 | 需要注入 Bean 实例元数据且不用表达式切点 |
| 编译期 APT | 零(编译期生成) | 极高 | 低 | 框架底层,性能敏感代码 |
看到这里你应该已经明白了,在 Spring 里为一个自定义注解选解析方案,核心并不是"给它写一个解析类",而是选择合适的处理器载体。
提示:如果你只是在手写一个简化版 Spring 的练手项目,最贴近框架原型的顺序是:配置类解析器发现
@ComponentScan-> 扫描器找到@Component-> 实例化时用BeanPostProcessor处理@Autowired和@Value-> 初始化后用切点匹配处理@Transactional。这个顺序本身就是 Spring 注解语义的骨架,不用为每个注解单独造轮子。
7. 排错记录:注解不生效时,我一般按这个顺序排查
既然聊到解析类的问题,最后分享一些我踩坑后沉淀下来的排查顺序。每次有人拿代码过来说"我加了@Async但没异步执行""@Autowired注入进来是 null",我都会让他按下面这个顺序自查:
第一,看注解的Retention和Target。@Retention不是RUNTIME的注解,反射根本读不到,Spring 再强也拿它没办法。自定义注解默认RetentionPolicy.CLASS,很多人栽在这里。Target写错了位置,比如想标方法却写成了 TYPE,扫描器从AnnotatedElement上拿到的注解信息就走不到预期分支。
第二,看处理这个注解的BeanPostProcessor/ Advisor 到底有没有注册进容器。用applicationContext.getBeansOfType(BeanPostProcessor.class)打出来看一眼。很多项目引了spring-boot-starter-aop,AOP 的基础设施才注册;引了@EnableAsync,异步 BeanPostProcessor 才会存在。缺少了注册环节,你用@Async当然没效果。
第三,看 Bean 的实际类型。打断点或者直接打印bean.getClass(),确认它不是$$EnhancerBySpringCGLIB$$或$Proxy开头。如果日志里根本没有代理类,那说明 AOP 这层压根没进。常见原因是切点表达式写错,或者@Aspect类本身没被扫描到——注意,@Aspect类必须也是一个 Spring Bean,且@EnableAspectJAutoProxy已开启。
第四,排查自调用和私有方法。@Transactional、@Async都不能标注在 private 方法上,因为代理只能拦截外面进来的调用。同类内部this调用不走代理,这是老生常谈,但依然每天都在发生。
第五,检查是否有自定义BeanPostProcessor干扰。如果你自己注册了一个和内置处理器同类的BeanPostProcessor,或者往AutowiredAnnotationBeanPostProcessor里塞了额外的注解类型,可能导致原有注解的行为发生偏移。这种情况调试起来最磨人,我一般会把自定义处理器逐个注释掉做二分法定位。
排查完这一整套,你就会发现大多数"注解不生效"的问题,根源都落在注册、时机、代理、反射读取这四个环节里,和"有没有对应的解析类"关系不大。
回头再看开头那个问题,我现在的标准回答是:Spring 里的注解解析不是靠"一注解一解析类"堆出来的,而是把少量核心处理器安插在容器的关键生命周期节点上,让它们统一解释各类注解的语义。作为使用者,你真正要做的不是数清楚每个注解背后站着谁,而是想明白一件事——你关心的注解语义,属于容器创建 Bean 的哪个阶段、该交给扫描器、BeanPostProcessor、BeanFactoryPostProcessor,还是 AOP 拦截链。把这套判断练熟了,再看任何 Spring 注解的源码,都会觉得顺理成章。