ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring注解与解析类并非一对一的处理机制解析

Spring注解与解析类并非一对一的处理机制解析 “Spring里每一个注解都需要有一个对应的解析的类吗”这个问题我大概被问过不下几十次尤其是刚啃Spring源码的新人看完了 TransactionInterceptor、AutowiredAnnotationBeanPostProcessor 这些类之后很容易形成一个印象每个注解都有自己的“专属解析类”。但把 Spring 容器启动到 Bean 实例化结束这一整条链路走通你会发现这个印象错得离谱。注解是 Java 提供的一种元数据标记它只是把语义贴在类、方法、字段上真正让注解起作用的是一套由组件扫描器、Bean工厂后置处理器、Bean后置处理器、AOP代理共同组成的处理管网。在这个管网里同一个注解可以被多个阶段处理一个处理器也可以同时认领几十个注解。所以答案是不需要也不存在一一对应的关系。1. 先说结论注解与解析类根本不是一对一1.1 注解只是“贴纸”干活的全在后面要理解这个问题得先从注解的物理形态说起。你写一个 interface 的时候它本质上就是声明了一个接口编译后照样会生成一个 .class 文件。比如public interface CostTime { long warnMs() default 100; }这个注解被放到方法上后JVM 会把注解引用记录到方法的 RuntimeVisibleAnnotations 属性表里。运行的时候你通过 method.getAnnotation(CostTime.class) 拿到的实例其实是 JVM 根据属性表动态构造出来的对象。整个过程里注解自己一句话都没说它只是安静地记录“这里有这个标记”。真正决定注解有没有起作用完全取决于有没有代码在消费它。比如 Deprecated 的消费者是 javac 编译器编译时看到它就打一条过期警告再比如 Override 的保留策略是 SOURCE运行期反射根本拿不到。同样Spring 里的注解能不能被处理、被谁处理取决于“谁在读它、在什么时候读”。在 Spring 里注解的处理时机基本可以分成四类编译期、容器启动期、Bean 实例化前后、方法调用时的代理拦截。注解和处理者之间是“多对多”的关系而不是“一对一”。1.2 为什么会有“一个注解配一个解析类”的错觉我琢磨过这个错觉的产生原因大概率是学习材料造成的。很多入门教程为了把源码讲薄直接把复杂链路简化成“Autowired 由 AutowiredAnnotationBeanPostProcessor 处理”新人的第一反应就是“注解都有个解析类”。这句话单看没错但它默认了一个不成立的前提一个解析类只对一个注解负责。真正要避的坑是你自己设计自定义注解时如果带着“每个注解都要找解析类”的思路就会去造一个 CostTimeParser 之类的类再把解析逻辑塞进一个和 Spring 无关的类里面结果这个类既没有实现 Spring 的扩展接口也没有被注册成 Bean注解自然永远不会生效。我见过不止一个项目出现这种情况自定义注解写得挺整齐但跑起来没反应最后定位原因就是处理逻辑悬空了没挂到 Spring 的任何一个回调点。正确的理解模型是先想清楚自己的注解要在哪个生命周期生效再挂上对应的钩子。想让容器启动时处理就去实现 BeanFactoryPostProcessor想让 Bean 创建之后处理就去实现 BeanPostProcessor想方法调用时增强就用 AOP。这个“阶段——钩子”的对应关系远比“注解——解析类”有意义得多这是 Spring 框架设计里很核心的一条心智模型。2. Spring 处理注解的四条“生产线”既然注解和解析类不是一对一Spring 到底是怎么拿到注解并消费的我习惯按生效时机把 Spring 的注解处理分成四条生产线编译期、容器启动期、Bean 实例化前后、AOP 方法调用拦截。2.1 编译期spring-context-indexer 这类注解处理器很多人不知道 Spring 其实也有编译期注解处理机制。Spring 官方提供 spring-context-indexer 模块它会作为 javac 的注解处理器自动注册。它的任务是编译项目时扫描所有候选组件注解Component、Service、Repository、Controller、Configuration 这类标注的类把全限定名写进 META-INF/spring.components 索引文件里。应用启动时ClassPathScanningCandidateComponentProvider 可以直接读索引不用每个类都去磁盘扫描项目类多的时候能明显缩短启动时间。实践很简单Maven 项目加一个依赖dependency groupIdorg.springframework/groupId artifactIdspring-context-indexer/artifactId optionaltrue/optional /dependency就算在这个编译期处理器里“解析类”和“注解”也不是一一对应的。CandidateComponentsIndexProcessor 在 process() 方法里处理的是“Spring 候选组件”这么大一个集合它内部维护一组注解的类名清单遇到任何一个就记录下来这是典型的一个处理器认领多个注解。明白这个你以后再看到各种带 Indexer、Compiler、Generator 结尾的类就不会犯迷糊它们都是在某个统一节点批量处理同类标签的加工厂不是某个注解的专属翻译官。生产环境里单独为了省启动时间引入它的场景不算多但它是理解“注解处理不依赖一对一”的好范本。2.2 容器启动期ConfigurationClassPostProcessor 一把梭容器启动期是 Spring 注解处理最繁忙的阶段。你加的 Configuration、Bean、ComponentScan、PropertySource、Import 全在这个阶段被消耗背后真正的主处理器只有一个ConfigurationClassPostProcessor。它实现了 BeanDefinitionRegistryPostProcessor会在所有 BeanDefinition 加载完成后、Bean 实例化开始前执行做的事包括解析配置类上的各种配置注解、把 Bean 方法转成 BeanDefinition、根据 ComponentScan 扫描指定包下的候选组件、解析 Import 导入的配置类或 ImportSelector。理解这条生产线最关键的是记住一个能打的处理器干掉了几十个注解的活。启动类上的 SpringBootApplication 本身组合了 SpringBootConfiguration、EnableAutoConfiguration、ComponentScan哪个单独拎出来都不能说“它有一个解析类”。真正的处理全发生在 ConfigurationClassPostProcessor 的 parse 流程里它通过递归读取元注解一层层剥开 SpringBootApplication才能发现里面还有 ComponentScan 要执行。这也解释了 EnableAsync、EnableScheduling、EnableCaching 这类开关注解的原理它们本质上都是组合注解内部用 Import 引一个配置类。比如 EnableScheduling 的源码就是 Import({SchedulingConfiguration.class})SchedulingConfiguration 里再用 Bean 注册 ScheduledAnnotationBeanPostProcessor。所以开关注解的效果最终是“通过 Import 把配置类拉进来配置类再注册一个后置处理器”实现的而不是某个类专门去读 EnableScheduling 这几个字。Spring AI、Spring Cloud Alibaba 这类新框架的自动配置也完全遵循同一套机制没有发明新解析方法只是通过 Import 和 ConditionalOnXxx 让现有处理器完成接入。2.3 Bean 实例化前后一堆 BeanPostProcessor 逐个过Bean 被创建出原始实例后在正式可用之前Spring 会按顺序回调所有注册的 BeanPostProcessor。Bean 身上贴的绝大多数“行为型注解”都是在这一阶段被认领的AutowiredAnnotationBeanPostProcessor负责 Autowired、Value。它在 postProcessProperties() 里遍历当前 Bean 的字段和方法判定哪些成员需要注入然后执行依赖注入。CommonAnnotationBeanPostProcessor负责 PostConstruct、PreDestroy、Resource 这些 JSR-250 注解。AsyncAnnotationBeanPostProcessor检测类或方法上的 Async有则把 Bean 交给代理机制。ScheduledAnnotationBeanPostProcessor读 Scheduled把标注的方法注册成定时任务。注意这些类命名基本都是“XxxAnnotationBeanPostProcessor”这是 Spring 最接近“注解解析类”的一类存在。但读源码会发现AutowiredAnnotationBeanPostProcessor 内部维护的是一个“已知注解集合”把 Autowired 和 Value 的全限定名放在一个 Set 里处理时拿这个 Set 和成员上的注解做匹配。同一个解析器可以认领多个注解。还要注意这阶段的后置处理器有执行顺序。如果你实现了 BeanPostProcessor 又想控制先后可以实现 Ordered 接口或加 Order。顺序错乱会带来隐蔽问题比如你想在某个后置处理器里读取已注入完成的依赖但你的处理器排在 AutowiredAnnotationBeanPostProcessor 之前读到的就是 null。这种问题不报错但结果不对排查时比较恶心。2.4 AOP 代理期方法真正被调用时才裁决BeanPostProcessor 在初始化时只会做元数据收集、标记或者注入动作但如果你希望注解能拦截“方法调用”本身——Transactional 开事务、Async 切线程、Cacheable 走缓存——那就必须走 AOP 代理机制。链路大致是某个 BeanPostProcessor常见的是 InfrastructureAdvisorAutoProxyCreator 或自定义的 AnnotationAwareAspectJAutoProxyCreator在 Bean 初始化后检查这个 Bean 是否匹配至少一个 Advisor匹配则通过 ProxyFactory 创建代理对象之后外部拿到的是代理而不是原始 Bean。当外部调用被注解标注的方法时调用先进拦截器链拦截器通过反射读取方法上的注解属性决定接下来怎么做。拿 Transactional 举例没有一个类叫 TransactionalAnnotationParser 在 Bean 初始化时开事务。真正拦截到方法调用后的执行者是 TransactionInterceptor它会读方法上的 Transactional 注解找到事务管理器和配置属性再决定传播行为和隔离级别。注解解析被拆成两半创建代理时判断“要不要代理”方法调用时读取“注解具体怎么执行”。所以你在网上搜 Transactional 的解析类会看到好几个不同的类这恰恰说明它没有单一对应关系。很多 Transactional 不生效的经典案例最后都归结为“目标 Bean 没有走代理”解析注解的前提就是代理链路存在。3. 常用注解到底归谁处理一张表看清全部把常用注解按处理者分组整理成速查表遇到“注解不生效”问题时直接对一遍注解处理者 / 处理链路生效阶段Component / Service / Repository / ControllerClassPathScanningCandidateComponentProvider AnnotationConfigUtils容器启动、BeanDefinition 注册期Configuration / Bean / Import / ComponentScan / PropertySourceConfigurationClassPostProcessor容器启动、BeanDefinition 注册期Autowired / ValueAutowiredAnnotationBeanPostProcessorBean 实例化后、初始化前Resource / PostConstruct / PreDestroyCommonAnnotationBeanPostProcessorBean 实例化后、初始化前后TransactionalInfrastructureAdvisorAutoProxyCreator TransactionInterceptorAOP 代理期 方法调用期AsyncAsyncAnnotationBeanPostProcessor AsyncExecutionInterceptorBean 初始化后创建代理ScheduledScheduledAnnotationBeanPostProcessorBean 初始化后注册任务RequestMapping / GetMapping 等RequestMappingHandlerMappingWeb 容器启动期EnableXxx 开关注解通过 Import 引入的配置类再注册对应后置处理器容器启动期JDK 内置标记注解Deprecated 等无JVM/编译器处理编译期3.1 标注型注解为什么没有“专属解析类”表格第一行最容易让人误解。Component、Service、Repository、Controller 在 Spring 里没有对应“解析类”而是被 ClassPathScanningCandidateComponentProvider 统一识别。这个扫描器 scan 包时检查类的注解列表里是否包含候选组件注解。Spring 内部维护 includeFilters默认的 includeFilter 就包含 Component 一个注解而 Service、Repository、Controller 因为类声明上加了 Component 元注解所以也会被识别。这个设计给了很灵活的扩展思路如果你希望自定义注解也能被组件扫描识别最简单就是在自定义注解上加 Component 元注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Component public interface Gateway { }之后任何类标 Gateway扫描器会顺着元注解链路发现 Component自动注册为 Bean。作为技术负责人想让团队统一打自定义标签、又希望自动变成 Spring Bean 时这个技巧就很实用完全没必要去写一个 GatewayParser。3.2 行为型注解每一个都牵动一条处理链行为型注解的处理者通常是一个后置处理器或拦截器但也是一条链。Autowired 的实现链就很典型容器拿到候选 Bean 后AutowiredAnnotationBeanPostProcessor 扫描字段发现 Autowired 后解析出需要的类型再从容器按类型找 Bean候选多时还要走 byType 加 byName 的降级匹配。整个过程涉及 BeanWrapper、TypeConverter、DefaultListableBeanFactory 等类但不会有一个类叫 AutowiredResolver。这里有个高频排查知识当你看到 “No qualifying bean of type xxx available: expected single matching bean but found 2”不要以为是解析类没了而是“一个类型下有多个候选 BeanSpring 无法决定注入哪一个”。解法是把其中一个标 Primary或者在注入点用 Qualifier 指定名字。这个报错非常实用值得记一下。3.3 深挖案例Transactional 的完整链路Transactional 是 Spring 里最容易被误解的注解。假设一个 Service 类的类上标注了 TransactionalSpring 看到类上有事务注解就认为这个 Bean 需要织入事务切面。容器创建 Bean 时InfrastructureAdvisorAutoProxyCreator 检查候选 Advisor发现存在 BeanFactoryTransactionAttributeSourceAdvisor且能匹配当前 Bean于是返回代理对象。之后调 saveOrder 方法时TransactionInterceptor 拦截内部用 TransactionAttributeSource 读取方法或类上的 Transactional 注解取出传播行为、隔离级别、超时、回滚规则交由 TransactionAspectSupport 提交、回滚或挂起事务。这引出一个结论Transactional 的方法优先级高于类。如果你类上写了事务某个方法上又单独写了不同事务解析时方法优先。我见过有人类上定义 REQUIRES_NEW方法上却写默认 REQUIRED结果事务合并和预期完全不同。提醒一句不要在方法上随手贴事务注解先想清楚传播行为是什么。4. 自定义注解的三种处理姿势附完整代码如果把 Spring 现有机制都吃透了这一节回到实践想自己定义一个注解记录耗时、标记幂等、给特定 Bean 天加降级逻辑怎么写才能真正生效。我按场景给三种姿势。4.1 先定义好注解本身无论哪种姿势注解定义的基本功都要过关。完整示例Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface CostTime { long warnMs() default 100; }Target 决定注解能贴在哪你要拦截方法Target 必须包含 METHOD要支持类级默认值就加上 TYPE。Retention 决定生命周期Spring 运行时要反射读取必须选 RUNTIME默认的 CLASS 会让运行时取不到。注解里的成员变量是方法形态比如 long warnMs() default 100使用注解时写 CostTime(warnMs 500)相当于给成员赋值。一个容易被忽略的点是Spring 读注解时不完全靠 JDK 原生的 getAnnotation()而是用自己的 AnnotationUtils 和 AnnotatedElementUtils。这两个工具类支持元注解查找、组合注解合并、类级与方式级覆盖。自己写注解解析器时直接用它们别自己递归写元注解查找逻辑处理 EnableXxx 这种“元注解拼装”尤其重要。4.2 姿势一AOP 注解适合方法级统一增强这是自定义注解最主流的玩法定义一个切面通过切点表达式把目标注解绑定到通知参数上。Aspect Component public class CostTimeAspect { Around(annotation(costTime)) public Object recordCostTime(ProceedingJoinPoint pjp, CostTime costTime) throws Throwable { long start System.nanoTime(); try { return pjp.proceed(); } finally { long cost TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); if (cost costTime.warnMs()) { System.out.println(pjp.getSignature().toShortString() cost cost ms, threshold costTime.warnMs()); } } } }核心是 annotation(costTime)它告诉 Spring AOP拦截所有标注了 CostTime 的方法并把方法上的 CostTime 注解实例直接注入通知参数。连反射都不用自己写注解属性拿来即用。实测这种切面接入监控、限流、日志场景非常稳定。要注意 annotation 切点只针对方法注解如果注解贴在类上想对类里所有方法生效得用类级别切点或 within。这个方案有个典型限制同类内部调用不会触发代理。比如一个 Service 方法里用 this.save() 调用另一个被 CostTime 标注的方法拦截器不生效原因和 Transactional 失效一模一样代理没参与内部调用。想内部也走拦截要么拆出去注入另一个 Bean要么自己拿到代理对象再调用。4.3 姿势二BeanPostProcessor 注解适合类级别的容器逻辑如果你的注解不是拦截某个方法而是影响 Bean 生命周期行为比如“标注了注解的 Bean 在初始化后把某个配置值写进字段”那就实现 BeanPostProcessorComponent public class ConfigInjectorPostProcessor implements BeanPostProcessor { Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { Class? targetClass AopUtils.getTargetClass(bean); if (targetClass.isAnnotationPresent(EnvPrefix.class)) { EnvPrefix prefix targetClass.getAnnotation(EnvPrefix.class); System.out.println(bean beanName has EnvPrefix prefix.value()); } return bean; } }注意postProcessBeforeInitialization 阶段 Bean 还是原始对象依赖未注入postProcessAfterInitialization 阶段依赖已注入但如果有 AOP 代理创建拿到的就是代理对象。这里用 AopUtils.getTargetClass(bean) 尽量拿到真实类避免代理类掩盖注解信息这是很多人踩过的坑。如果你想在 postProcessAfterInitialization 里真正给 Bean 套上拦截方法调用的代理用 ProxyFactory 也做得到但工作量和调试成本都不小。经验法则是方法级拦截优先用 AOPBean 生命周期逻辑才用 BeanPostProcessor别在同一个注解上把两个责任混在一起否则排查难度翻倍。4.4 姿势三编译期 APT追求运行时零开销第三种姿势和前两种不在一个维度它是在编译期处理注解。需要注册一个自定义的 javax.annotation.processing.Processor比如SupportedAnnotationTypes(com.example.Metric) SupportedSourceVersion(SourceVersion.RELEASE_8) public class MetricProcessor extends AbstractProcessor { Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { for (TypeElement annotation : annotations) { for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { // 通过 processingEnv.getFiler() 生成额外的源文件、资源或配置 } } return true; } }编译期处理的好处是运行期零反射开销很多 DTO 转换器、代码生成器都这么做。但缺点也很明显编译期拿不到运行时对象状态只能拿源代码结构不适合做业务拦截更适合做代码生成。我建议业务项目里除非在写基础框架、通用组件否则别轻易上 APT维护成本拉开之后会非常痛苦。Spring 官方的 spring-context-indexer 就是基于这个机制做的但那是框架底层优化。你自己复刻这种机制时一定要想清楚注解的消费动作能不能提前到编译期完成如果不能生成静态产物就别走这条路。4.5 自定义注解时的三条设计原则最后整理三条写注解的硬规矩。第一能组合 Spring 已有注解就别发明新注解。很多场景你想要的不是新注解而是一个 Bean 方法加 ConditionalOnProperty或者一个自定义 BeanPostProcessor。第二确定好注解的“生效期”并在注释里写清楚是编译期还是运行期因为它直接决定选什么处理姿势。第三给注解取名不要带 Parser、Resolver 这种误导性后缀更不要轻易去写一个和 Spring 毫无关联的 Xxx解析类。把处理逻辑挂在 Spring 的钩子上才是正路。5. 注解不生效按这个思路排查准没错前面理论建完最后是故障排查。结合踩过的坑把“注解不生效”的高频原因整理成排查手册。5.1 第一问注解有没有被容器“看到”这听起来像废话但却是最高频的原因。如果是 Component 这类标注型注解无效先检查类所在包是否被 ComponentScan 覆盖Spring Boot 默认扫描启动类所在包及子包类放外面自然扫不到。如果是自定义注解加自定义后置处理器先确认后置处理器本身有没有被注册成 Bean我见过写好了 XxxPostProcessor 但忘了加 Component或者 XML 里没配置整个处理链都没起来。还有一个隐蔽情况用了 spring-context-indexer 后META-INF/spring.components 索引过期会导致新增组件不被发现开发环境少见持续集成环境偶发。5.2 第二问处理和消费发生在哪个阶段时机对不对Spring 对同一个注解往往有多个阶段可以处理但行为大不相同。比如在自定义 BeanPostProcessor 里想给某个 Bean 注入依赖却写在 postProcessBeforeInitialization 里此时 Autowired 还没执行拿到的自然是 null。又比如想在 AOP 拦截器里读注解但后置处理器创建代理时还没把注解信息加载到 Advisor 里切点就匹配不上。我的排查顺序是先确定注解在 Spring 里的哪个生命周期起作用再顺着该生命周期的处理类打断点看它有没有走到读取注解的那行代码。这是最有效的定位方式。5.3 第三问调用的对象是不是代理对象很多方法级注解不生效根因不在注解本身而是目标对象根本是原始对象。最典型是同类内部调用一个类的方法 A 调方法 BB 上有 Transactional 或 Async但 A 通过 this.methodB() 直接调代理没参与B 上的注解全部落空。解决方法有几种把 B 迁移到另一个 Bean 里注入调用或者自己注入 AopContext.currentProxy() 来调用或者用 Spring 4.3 之后的自注入方式。还要检查代理方式JDK 动态代理只拦截接口方法类里的 final 方法无效CGLIB 下 final 类、final 方法也有限制。排障时这些都要一项项过。5.4 第四问注解定义本身有没有问题这个维度最容易被忽视。使用自定义注解时如果 Retention 没设成 RUNTIMESpring 运行时根本拿不到注解因为这种注解在字节码里的可见性只有 CLASSJVM 不生成运行期 Annotation 对象。如果希望子类继承父类上的类级注解要加 Inherited但它只对类生效方法、字段无效。还有一个细节Transactional 支持类级默认值加方法级覆盖但方法级注解的属性如果写成没意义的组合比如在 REQUIRES_NEW 外层又包一层 REQUIRED 的内部传播解析结果可能和直觉完全不同。遇到这类问题把注解属性打印出来对比最省时间。把常见问题和解决动作对应起来现象可能原因排查动作自定义注解无反应处理逻辑没挂在 Spring 钩子上检查是否有 Aspect / BeanPostProcessor / BeanFactoryPostProcessor 被注册Transactional 失效同类内部调用 / 事务管理器未配置拆分 Bean、确认 TransactionManager 存在Async 失效未加 EnableAsync或同类调用确认 EnableAsync、注入代理对象Bean 方法未执行配置类未扫描到或方法不是 public检查启动类扫描路径注入的字段为 nullBeanPostProcessor 执行时机太早改为 postProcessAfterInitialization最后再分享一个小技巧面对任何注解问题时先问自己三句话——这个注解是谁读取的、在哪个阶段读取的、最终消费方拿到的对象是不是代理。这三句话能覆盖我遇到过的绝大多数 Spring 注解故障剩下的小概率情况靠断点日志也基本能定位。这套思路陪我排查过不少线上问题也是我认为这个领域最值得内化的核心经验。老实说想通了“注解是贴纸、处理是管网”这件事之后再回到 Spring 源码里翻各种 PostProcessor你会发现一切都顺了。
返回列表