1. 从一次诡异的“注解失效”说起
有次排查线上问题,现象很典型:某个定时任务在测试环境一切正常,上了生产就偶尔不执行。翻代码发现方法上明明加了@Scheduled(cron = "0 0 2 * * ?"),日志里却没有任何调度记录。折腾了半天,最后发现是这方法所在的 Bean 被另一个对象给 new 出来了,压根没进 Spring 容器。注解还在 class 文件里躺着,可 Spring 根本看不见它——因为@Scheduled是由 Spring 容器在创建 Bean 时通过反射扫描注册的,容器都没参与创建,注解自然成了摆设。
这类问题我后来见过太多:@Transactional不生效、自定义注解被切面拦截不到、子类重写方法后注解神秘消失。表面上看是框架“抽风”,根子上都是没吃透 Java 注解的底层机制。注解不是魔法,它只是附着在代码上的元数据,真正“干活”的是运行时那些读取注解、解释注解、根据注解改变行为的处理器。Spring 能做注解驱动开发,是因为它在容器启动阶段写好了各种BeanPostProcessor和切面逻辑;你想让自己的自定义注解生效,也必须自己提供对应的处理机制,或者复用框架现成的扩展点。
这篇就把注解这块彻底说透:从字节码层面的本质、元注解的取舍,到自定义注解的设计套路,再到和 Spring / 事务 / 切面的整合原理,最后附上我在实际项目中排查过的几个典型故障和避坑清单。适合刚接触注解的新手建立完整认知,也适合写了好几年代码但遇到注解失效问题就头大的同学查漏补缺。
2. 注解的底层逻辑:它到底“长”在代码的哪里
2.1 注解的本质是一种“元数据契约”
用大白话说,注解就是给代码贴标签。这个标签不会直接改变程序的执行逻辑,它只是静静地待在类、方法、字段旁边,等某个处理器在特定时机来读它。Java 里注解的官方名字叫 annotation,定义方式很特殊——它不是 class,也不是 interface,而是一个继承了java.lang.annotation.Annotation接口的“注解类型”。你在.java文件里写@Override,编译后字节码的RuntimeVisibleAnnotations属性或者RuntimeInvisibleAnnotations属性里就会多一条记录。
很多人不理解“注解不改变逻辑”这句话的真正含义。其实注解系统是一种典型的数据与行为分离设计:注解是数据,处理器是行为。同一个@Deprecated注解,IDE 用来在编辑器里画删除线,javadoc工具用来生成弃用说明,静态检查工具用来扫代码规范——注解一个字都没改,全靠不同的处理器各取所需。这也是注解和“魔法”最大的区别:它永远是个被动角色,主动逻辑都在处理器代码里。
2.2 生命周期策略 RetentionPolicy:注解能活多久
注解的生命周期由@Retention元注解决定,三个值对应三个层次:
| 策略 | 保留位置 | 典型场景 |
|---|---|---|
SOURCE | 仅源代码,编译后丢弃 | @Override、@SuppressWarnings,只在写代码和编译期有用 |
CLASS | 编译进 class 文件,但 JVM 加载时丢弃 | 字节码增强工具、部分编译期注解处理 |
RUNTIME | 一直保留到运行期,可通过反射读取 | @Transactional、@RequestMapping、自定义业务注解 |
选择策略的行业惯例是这样的:如果注解只给编译器或代码检查工具看,用SOURCE足够;如果要在字节码层面做静态分析或者用 ASM 之类的库做插桩,可以用CLASS;如果处理器跑在运行期、需要靠反射getAnnotation去拿,必须用RUNTIME。我见过不少人把自定义注解一律写成RUNTIME,倒也没大错,只是 class 文件里多带点冗余信息而已。但从严谨角度讲,明确“这个注解到底给谁看”会逼你想清楚处理时机。
@Override和@Transactional的对比特别能说明问题:@Override是SOURCE策略,所以你永远不可能在运行期反射到它,这就是为什么市面上没有任何框架能“运行时读取 @Override 做逻辑增强”——它早被编译器丢弃了。而@Transactional是RUNTIME策略,Spring 的事务拦截器才能在运行时看到它、为它生成代理。
2.3 注解信息的“存储位置”
把一个带注解的类编译成 class 文件,再用javap -v查看字节码,会看到类似这样的片段:
RuntimeVisibleAnnotations: 0: #30(#31=s#32)RuntimeVisibleAnnotations对应RUNTIME策略的注解,RuntimeInvisibleAnnotations对应CLASS策略。这两个属性表就是注解在字节码里的“家”。理解了存储位置,就很容易理解“注解丢失”的几种情况:
SOURCE策略的注解编译后直接没有字节码,自然谈不上运行时可见- 类被代理后,代理类上可能没有继承原方法上的注解(具体见下文的代理陷阱)
- 方法被编译器生成桥接方法时,注解可能会出现在桥接方法上,导致反射时找不到
3. 元注解逐个拆:五个标准决定注解的“行为边界”
3.1 @Retention:决定注解能活多久
前面已经讲了RetentionPolicy三种值。这里补充一个实操经验:如果你在设计一个将来可能会被 AOP 或反射使用的注解,直接选RUNTIME,省得后面为了改生命周期破坏兼容性。但如果是纯粹给 IDE 做提示、给编译器做校验的内部标记,别偷懒,老老实实选SOURCE,这样打进生产包里的字节码更干净,潜在攻击面也更小。
3.2 @Target:限定注解能贴在哪里
@Target控制注解可以用在哪些程序元素上,常见的值包括TYPE(类、接口、枚举)、METHOD(方法)、FIELD(字段)、PARAMETER(参数)、CONSTRUCTOR(构造器)、LOCAL_VARIABLE(局部变量)、ANNOTATION_TYPE(注解类型)。不写@Target时注解几乎哪里都能贴,但强烈建议显式声明——这既是一种文档化约束,也能让编译器在写错位置时立刻报错,而不是等到运行期才莫名其妙地不生效。
举个例子,如果设计一个只能用在方法上的@RateLimit,你写:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { int value() default 100; }那把它贴在类上时编译直接报错,这比运行时踩坑友好一百倍。设计 API 时给自己的注解限定@Target,其实是在给使用方划安全边界。
3.3 @Documented 与 @Inherited:文档和继承的那些坑
@Documented表示注解会进入 javadoc 生成文档,纯粹是文档层面的标记,不影响任何运行逻辑。真正容易踩坑的是@Inherited:它表示如果一个类加了@Inherited注解,那么它的子类会“继承”这个注解,但有几条严格限制:
- 只对类生效,对接口不生效
- 方法、字段上的注解永远不会被继承
- 子类如果显式声明了同名注解,则父类的被覆盖
网上很多教程把@Inherited吹得神乎其神,实际项目里却常常带来认知偏差。比如你在父类方法上加了@Transactional,子类重写这个方法但不加注解,事务不会自动继承到子类的重写方法上。Spring 内部对事务注解的解析规则考虑到了实现接口和父类的情况,但它不会因为@Inherited就万事大吉。我的建议是:不要依赖注解继承来保证业务逻辑,关键行为在子类上显式声明最稳妥。
3.4 @Repeatable:同一个注解贴多次
Java 8 引入了@Repeatable,允许同一个注解在一个位置重复出现。典型例子是@Scheduled的旧版实现方式,以及某些校验注解(比如同一个字段有多个正则校验规则)。定义可重复注解需要两层结构:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Repeatable(Schedules.class) public @interface Schedule { String cron(); } @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Schedules { Schedule[] value(); }处理器端读取时,既可以用getAnnotationsByType(Schedule.class)一次拿全,也可以先拿外层容器再拆。个人经验:能用容器注解不用@Repeatable,因为容器注解方式更直观,而且老代码和某些框架对@Repeatable的兼容性一般。
4. 自定义注解的完整设计:从需求到落地的行业套路
4.1 一个最基础的自定义注解长什么样
新建一个自定义注解非常简单,但“简单”恰恰是很多劣质注解的根源。一个合格的自定义注解应该包含:清晰的语义、合理的@Target和@Retention、有意义的属性、可选的默认值。
下面用一个打印操作日志的注解作为完整示例。需求场景:凡是加上@OperationLog的方法,执行完后自动记录操作人、操作内容、执行时长。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface OperationLog { /** 操作模块 */ String module(); /** 操作动作,比如 新增、删除、审核 */ String action() default "execute"; /** 是否记录执行耗时 */ boolean recordCost() default true; }这段设计里有几个细节值得展开。第一,module没有默认值,强制使用者必须指定模块,这是参数约束意识;第二,action给了默认值,适配那些懒得写具体动作的场景;第三,recordCost这个布尔开关很有用,因为日志记录本身也有性能开销,高并发核心链路可能只想记录关键模块。一个注解的参数设计,背后是产品需求的取舍。
4.2 属性类型与参数设计:别把注解写成类
注解属性支持的类型有限:基本类型、String、Class、枚举、注解、以上类型的数组。不支持List、Map这类集合类型,也不支持包装类型默认值为空之类的花活。这意味着注解本身适合传递“简单的配置元数据”,不适合承载复杂对象。
设计注解属性时,有个行业共识叫“单一职责”。一个注解的属性别超过五六个,属性越多,解析逻辑越复杂,使用方越容易迷茫。如果遇到庞杂的配置需求,通常做法是拆成多个小注解组合使用,或者属性值用字符串包一个 JSON——第二种做法不推荐,等于放弃了编译期类型检查。
命名上有个隐含约定:如果只有一个核心属性,通常命名为value,这样使用时可以直接写@Log("关键字")而不用写@Log(value = "关键字")。属性命名建议用名词短语或动词短语,如operationType、recordCost,避免歧义。
4.3 注解与接口/类的区别,别混为一谈
很多 Java 新手会问:注解能不能定义方法体?答案是不能。注解的属性本质上只是“配置参数”,你不能在注解类型里写任何计算逻辑。如果你发现某个“注解类”里塞了大段代码,那么正确姿势是把逻辑放到处理器里,注解里只留“开关和参数”。
这里有个类比很管用:注解就像是西服上的标签,标明尺码、面料、产地;真正让西服能穿的是裁缝的手艺。你不可能要求标签自己变成一件西服,同理也别指望注解自带行为。
5. 注解怎么“动起来”:反射、AOP 与编译期处理三巨头
5.1 反射式处理:最直接也是性能最敏感的方式
运行时注解的处理绕不开反射 API。核心有三个方法:Class.getAnnotation()、Method.getAnnotation()、Field.getAnnotation(),以及批量版本的getAnnotations()/getDeclaredAnnotations()。注意getAnnotation()和getDeclaredAnnotation()的区别:后者只返回本类上直接声明的,前者还会向上查找父类和接口中的@Inherited注解。
一个最简单的反射处理器长这样:
public class OperationLogProcessor { public void process(Object target, Object[] args, Object result) { Class<?> clazz = target.getClass(); for (Method method : clazz.getDeclaredMethods()) { OperationLog log = method.getAnnotation(OperationLog.class); if (log != null) { System.out.printf("模块:%s, 动作:%s%n", log.module(), log.action()); // 这里可以拼参数、保存数据库、发送消息... } } } }反射处理的问题在性能。虽然 JDK 8 之后反射性能大幅改善,但高并发环境下频繁调用getAnnotation依旧有不可忽略的开销。行业里的成熟做法是“缓存解析结果”:框架启动时扫描一遍类和方法,把注解信息解析成内部结构存在 Map 里,运行时只查缓存。Spring 的MergedAnnotations就是干这个的,自己写处理器的同学可以模仿这个思路。
5.2 AOP 切面处理:框架整合的核心方式
真正生产级别的自定义注解,大多搭配 AOP 使用。AOP 的本质是“不修改原有代码,在方法执行前后织入额外逻辑”,这和注解“被动标记”的特性完美互补。下面直接用 Spring Boot + AOP 实现一个完整的@OperationLog切面:
首先引入依赖(Gradle 写法):
implementation 'org.springframework.boot:spring-boot-starter-aop'然后写切面:
@Aspect @Component public class OperationLogAspect { @Around("@annotation(operationLog)") public Object handle(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start = System.currentTimeMillis(); String methodName = joinPoint.getSignature().getName(); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; if (operationLog.recordCost()) { // 这里有四条信息:模块、动作、目标方法、耗时 // 实际项目中可以封装成日志对象后写入数据库或消息队列 System.out.printf("模块=%s, 动作=%s, 方法=%s, 耗时=%dms%n", operationLog.module(), operationLog.action(), methodName, cost); } return result; } catch (Throwable throwable) { // 异常场景同样可以记日志,但注意别吞异常,要重新抛出 throw throwable; } } }几个关键细节值得写死在心里:
@Around绑定注解参数时,切点表达式里如果传了注解类型,Spring 会把目标方法上的注解对象直接注入处理方法的参数,不需要你再反射拿一遍,这是 Spring AOP 的语法糖。joinPoint.proceed()必须调用,不调用就相当于把原方法掐断了。如果方法有返回值,原样返回,否则调用方拿到 null。- 异常必须继续向上抛。我见过有人为了日志把业务异常吃了,后续调用方永远不知道方法失败,这是个非常隐蔽的生产事故。
5.3 编译期处理 APT:注解处理器与“代码生成”的门道
反射和 AOP 都是运行时处理的路线,但还有一条不少人不熟悉的路线——APT(Annotation Processing Tool)。它在编译阶段扫描注解,然后生成新的 Java 源码或资源文件。市面上大量框架都依赖它,比如:
- Lombok:
@Getter、@Setter通过 APT 在编译期生成方法 - MapStruct:
@Mapper生成类型转换实现类 - Dagger2 / Hilt:依赖注入代码在编译期生成
实现一个 APT 处理器需要继承AbstractProcessor,重写process方法。由于篇幅关系不展开全部代码,但核心流程有三步:通过processingEnv.getElementUtils()获取被注解元素、用javax.lang.modelAPI 遍历注解、用JavaFileObject生成新源文件。
选型时怎么权衡运行期反射和编译期 APT?我的判断标准很朴素:如果注解信息在运行期还必须参与业务判断(比如事务边界、权限开关),走反射+AOP;如果只是编译期为了减少样板代码、提升运行性能,走 APT。反射灵活但慢,APT 编译期生成后运行期几乎零开销,但调试和构建链路更复杂,对 IDE 增量编译的兼容性偶尔会出问题。
6. 框架整合实操:以 Spring 事务注解为例的完整剖析
6.1 @Transactional 到底发生了什么
Spring 的@Transactional让人又爱又恨。爱的是声明式事务太方便,恨的是它经常“悄悄失效”。要搞懂它,先要知道 Spring 处理事务注解的基本原理:
Spring 容器启动时,AnnotationTransactionAttributeSource会扫描 Bean 容器内所有实例的方法,找出标注了@Transactional的方法,收集事务属性(隔离级别、传播行为、回滚规则)。然后通过 AOP 给对应的 Bean 生成代理对象。真正执行时,外部调用的是代理对象,代理对象在方法调用前开启事务、调用真实业务方法、方法结束后决定提交还是回滚。
事务注解的失效场景几乎都能从这个原理中推导出来:
| 典型失效场景 | 原因 |
|---|---|
private方法加@Transactional | 代理只能拦截 public 方法,Spring 不会对 private 方法做事务增强 |
类内部this调用 | 调用的是原始对象而非代理对象,事务切面无从介入 |
异常被try-catch吃掉 | 事务拦截器默认只对RuntimeException和Error回滚,普通异常若被吞掉就永远触发不了回滚 |
@Transactional加在非 Spring 管理的对象上 | 代理根本没生成,注解形同虚设 |
| 传播行为设置不对 | 外层事务已存在时,默认REQUIRED会加入外层事务,内层无法独立回滚 |
第六个细节尤其重要。我在一个项目里遇到“方法 A 调用方法 B,B 上标注@Transactional(propagation = Propagation.REQUIRES_NEW),但 B 的数据变更跟着 A 一起回滚了”。排查到最后发现:A 和 B 在同一个类里,B 的调用走的是this直接调用,不是代理调用,REQUIRES_NEW根本没机会生效。解决方法是把 B 的逻辑拆到另一个 Bean,或者注入自身代理。
6.2 Spring 注解驱动的骨架:ConfigurationClassPostProcessor 与 BeanPostProcessor
除了@Transactional,Spring 里还有一堆注解:@Component、@Autowired、@Configuration、@PropertySource等。它们的处理机制有一个共同点——都靠 Spring 内置的处理器在容器生命周期的特定阶段完成解析。
ConfigurationClassPostProcessor是@Configuration和@ComponentScan的核心处理器。它实现了BeanDefinitionRegistryPostProcessor,容器刷新时先扫描所有@Configuration类,读取@ComponentScan指定的包路径,寻找所有带@Component及其派生注解的类,把它们注册成BeanDefinition。AutowiredAnnotationBeanPostProcessor则处理@Autowired和@Value,在 Bean 实例化后、初始化前把依赖注入进去。
理解这个骨架,你就明白了自定义注解和框架整合的两条路:
- 走“Bean 生命周期整合”:让注解处理逻辑存在于
BeanPostProcessor中,比如实现InstantiationAwareBeanPostProcessor,在 Bean 实例化前后读取注解并做增强。 - 走“AOP 整合”:直接写
@Aspect切面,配合@annotation切点拦截带自定义注解的方法。这条路人书易懂,是大多数业务项目的首选。
6.3 实战:自定义限流注解与 Spring AOP 整合
结合前面讲的思路,我给出一个完整的限流注解示例。需求:某接口每秒最多允许 10 次调用,超过后快速失败。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { /** 每秒允许的最大请求数 */ int qps() default 10; }切面用最简单的计数限流实现(生产环境请用 Guava RateLimiter 或 Redis 令牌桶):
@Aspect @Component public class RateLimitAspect { private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>(); @Around("@annotation(rateLimit)") public Object limit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String method = joinPoint.getSignature().toLongString(); AtomicLong counter = counters.computeIfAbsent(method, k -> new AtomicLong(0)); long current = System.currentTimeMillis() / 1000; long windowKey = current * 1000 + counter.get() / rateLimit.qps(); long count = counter.incrementAndGet(); long currentSecond = System.currentTimeMillis() / 1000; // 简单实现:每秒重建计数。完整版本应该用滑动窗口 if (count > rateLimit.qps()) { throw new RuntimeException("触发限流"); } return joinPoint.proceed(); } }上面这段是演示代码,真放生产会被打爆,原因在于ConcurrentHashMap的高频读写、时间窗切换策略等都有优化空间。但整合方式是对的:注解声明“我要限流”,切面执行“如何限流”,业务代码完全无侵入。
6.4 与第三方框架整合:一次 Dubbo 参数的注解解析经历
有一次跟外部系统联调,遇到一个需要把某字段的动态配置项通过 Dubbo 的RpcContext透传的场景。我们不想在业务代码里到处手动 set 参数,于是想到了注解。
方案是定义一个@PassThrough注解,标在需要透传的参数上,然后在 Dubbo 的Filter里反射读取方法参数上的注解,把指定参数值写入RpcContext。服务端再在 Filter 里取出来放回上下文中。
这个案例的价值在于说明一个规律:框架整合的核心不是“会写注解”,而是“找到框架的扩展点”。Dubbo 允许通过@Activate自定义 Filter 并注入到调用链;Spring 允许通过BeanPostProcessor介入 Bean 生命周期;MyBatis 允许通过Interceptor拦截 SQL 执行。注解只是入口,扩展点才是钥匙。理解了这一点,遇到任何框架都能用同样的思路去设计“自定义注解 + 扩展点 + 解析器”的三层结构。
7. 常见问题与排查技巧实录
标题里提到了一个热词是“class 文件 overrider 注解为什么会丢失”,这其实是好几个经典问题的集合。我在下面把它们拆开,每个都附上排查思路。
7.1 子类重写方法后注解“丢失”的原因
先明确一点:注解没有真正丢,是处理器“看不见”了。拿@Transactional举例,父类方法上有事务注解,子类重写后可能发生两种情况:
- 如果重写方法没保留注解,反射查看子类方法时看不到任何
@Transactional,Spring 自然不增强。 - Java 会为泛型方法或协变返回类型生成桥接方法,桥接方法有时会携带注解而真实方法上没有,反射拿方法时容易拿错。
排查脚本通常长这样:写一个测试类,分别对父类.class.getDeclaredMethod和子类.class.getDeclaredMethod调用getAnnotations(),把输出贴出来对比。如果确认子类方法确实没有注解,那就老老实实在子类重写方法上也加上显式注解。别指望@Inherited——它只对类上的注解有效,对方法无效。
7.2 代理类截断注解:JDK 动态代理与 CGLIB 的差异
Spring 默认对接口使用 JDK 动态代理,对类使用 CGLIB。两种代理方式对注解可见性的影响不同。JDK 动态代理生成的代理类实现了目标接口,但你通过代理对象反射拿到的方法是代理类自己的方法,注解信息不一定原样存在于代理类上。CGLIB 生成的子类重写方法时也未必会复制注解。
常见现象:在自定义拦截器里拿到method.getAnnotation(...)返回 null,但原始类上明明有注解。解决方式有几种:
- 通过
AopUtils.getMostSpecificMethod(joinPoint.getSignature())获取目标类真实方法 - 用
AnnotationUtils.findAnnotation(Spring 提供)替代getAnnotation,它会搜索接口、父类以及桥接方法 - 尽量避免代理机制拦截非 public 方法
Spring 内部实现里,SpringProxy、Advised等标记接口就是专门给代理对象贴标签用的,从侧面验证了“注解 + 代理”这条路水很深,必须借助框架封装好的工具类,别裸用反射。
7.3 @Transactional 不生效的排查流程
我整理了一套万金油排查顺序,照着走能解决大部分事务失效问题:
- 确认注解写在了
public方法上 - 确认方法所在 Bean 是否被 Spring 管理(可注入
ApplicationContext后containsBean验证) - 确认调用链外部是否经过了代理对象(自调用直接排除)
- 确认异常类型是否会被回滚:只 RuntimeException / Error 回滚时,checked 异常需要显式
rollbackFor - 确认有没有在事务方法内手动
try-catch吞异常 - 确认数据源和事务管理器配置正确,如果配置了多数据源,
@Transactional需要指定对应transactionManager - 确认是否用了
@Transactional在非 Spring 管理的定时任务、异步方法里
第 7 条是个大坑。@Async方法和@Transactional同时使用时,事务管理器拿到的连接可能和异步线程上下文对不上。我自己就遇到过开启@EnableAsync后事务莫名失效的情况,最后通过显式指定@Transactional(transactionManager = "xxTransactionManager")并检查线程池配置解决。
7.4 AOP 切面没生效的几个幕后黑手
写好的自定义注解 + 切面,运行时完全不触发,多数逃不出下面几个原因:
- 切面类没加
@Component或没有配进 Spring 容器,@Aspect只是标记,没有注册就没有代理 @Aspect类里用了@Around但切点表达式写错,比如包路径少写一层、方法名拼错- 目标方法被调用时走的是
this引用而非 Bean 引用 - Spring AOP 默认只拦截 public 方法
- 多个切面优先级顺序不对,前面的切面没有
proceed(),后面的自然被掐断 - 切面表达式中同时用
@annotation和execution时,@annotation的参数绑定失败会导致整个切点不匹配
排查时最笨也最有效的方法:在切面方法第一行打日志,只要进了切面,再逐步缩小范围;切面连日志都没有,先检查注册和切点。
7.5 自定义注解 + 反射解析时拿不到泛型参数
还有一个很隐蔽的问题:方法参数上的注解,如果参数类型是泛型,反射获取时要注意Method.getParameters()和Method.getParameterAnnotations()的索引对应关系。泛型类型擦除后,参数上的注解仍然可以取到,但如果你用param.getAnnotation()前没有按索引对齐,很容易拿到错误位置的值。排查技巧是先把所有参数和注解打出来,肉眼核对下标。
8. 把注解用“稳”的几个核心原则
说了这么多,最后提炼几条我这些年实践下来的硬经验。
第一,注解只是声明,处理器才是本体。凡是写自定义注解前,先想清楚“谁来读它、什么时候读、读完做什么”。三句话说不清,说明设计还没成熟。
第二,生命周期和@Target必须显式声明。别写那种“哪都能贴、活到天荒地老”的注解。约束是保护,不是麻烦。
第三,对注解做解析缓存。如果处理器放在运行期且会被高频调用,务必把注解信息缓存起来,别在高并发路径上反复getAnnotation()。Spring 内部的全套注解解析工具可以直接复用,别自己重复造轮子。
第四,理解框架的扩展点比背 API 重要。Spring 的事务注解、AOP 注解、Dubbo 的 Filter、MyBatis 的 Interceptor,本质上都是框架预留的钩子。你设计的自定义注解,只有挂到正确的钩子上才有意义。
第五,遇到“注解失效”问题时先怀疑三个对象:代理对象是否生成、处理器是否读到注解、异常是否被吞掉。九成问题都能归到这三类。
第六,排查时多用 Spring 提供的工具类。AnnotationUtils.findAnnotation、AopUtils.getMostSpecificMethod这些封装帮你绕开了代理和桥接方法的雷区。裸写反射解析代理对象上的注解,纯属给自己找麻烦。
拿前面@OperationLog那个例子再收个尾:注解声明了“记日志”的意图,AOP 切面实现了“怎么记”的过程,业务代码一句额外代码都没写,这就是注解驱动的价值。我在实际项目中感悟最深的一点是,注解看着简单,真正难的是设计取舍——加一个属性背后是一层使用约束,少一个属性可能少了一层灵活性。多在项目里从零写几个自定义注解、多踩几次失效的坑,你才会真正理解“元数据契约”这五个字的分量。
下次再有人跟你说“注解就是写几个 @ 符号”,你可以把这篇甩给他看。