ARTICLE DETAIL

资讯详情

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

Java自定义注解从入门到实战:元注解、反射解析与常见坑

Java自定义注解从入门到实战:元注解、反射解析与常见坑 上一篇我们聊了注解的基本概念认识了Override、Deprecated这些内置注解。不少朋友在评论区问光会用别人定义好的注解不够过瘾怎么才能自己定义一个注解这篇就是来解决这个问题的。我会从元注解的使用讲起再一步步拆解自定义注解的语法细节最后用一个完整的反射解析示例把注解从写出来变成用起来。整篇文章围绕Java 元注解自定义注解两个核心展开适合已经掌握 Java 基础语法、想深入理解注解机制的朋友。无论你是准备面试还是想在项目里用注解干掉大量重复代码这篇都能给你一个可以直接照搬的思路。1. 元注解初印象注解之上的注解元注解听着玄乎说白了就是用在注解上的注解。它自己不直接作用在业务代码上而是用来声明白定义注解的行为特征。Java 提供了六个元注解Retention、Target、Documented、Inherited、Repeatable。前两个是自定义注解时必须关心的后面三个属于按需使用。我一个个拆开讲。1.1 Retention决定注解的保质期Retention控制注解能存活到哪个阶段这直接决定你有没有办法在运行时读到它。它有且只有一个 value 属性取值来自RetentionPolicy枚举策略源码阶段编译后字节码运行时反射典型例子SOURCE存在不存在不可读Override、SuppressWarningsCLASS存在存在不可读默认策略少用RUNTIME存在存在可读自定义注解大多选它SOURCE的注解在编译期干完活就被丢弃了。比如Override它的作用只是让编译器帮你检查方法签名是否正确字节码里根本没有它的影子运行期更不用说了。CLASS是最容易产生误解的一个。注解会保留在字节码文件里但 JVM 类加载之后并不会在内存中维护这些信息所以运行时通过反射拿不到。RUNTIME才是自定义注解的主战场。只要把注解标成Retention(RetentionPolicy.RUNTIME)JVM 就会在运行期保留它我们可以通过反射 API 读取到注解实例及其属性。我习惯用一个生活化类比帮新同事理解SOURCE像快递上的地址单签收完就丢CLASS像压在抽屉底层的保修卡你知道它存在但平时想不起来看RUNTIME则像手机背面的入网标签任何时候想查都能揭开来看到。一个很关键的经验如果你自己写注解但忘了写Retention它默认是CLASS。于是你在代码里注释标得漂漂亮亮反射代码却怎么都拿不到null这种问题排查起来特别费劲。提示自定义注解如果后续要结合反射或 AOP 读取务必显式加上Retention(RetentionPolicy.RUNTIME)别依赖默认值。1.2 Target决定注解的适用范围Target用来声明注解可以贴在什么地方。它接收ElementType枚举数组常用的取值如下TYPE类、接口、枚举、注解类型上FIELD字段上METHOD方法上PARAMETER方法参数上CONSTRUCTOR构造器上LOCAL_VARIABLE局部变量上ANNOTATION_TYPE注解类型上表示该注解可以修饰其他注解PACKAGE包上TYPE_PARAMETER类型参数上比如泛型TTYPE_USE任何使用类型的地方包括泛型参数、类型转换等TYPE_PARAMETER和TYPE_USE是 Java 8 才加的平时用得不算多。但TYPE_USE有个很有意思的场景比如ListNotNull String list意思就是这个 List 里的元素不允许为 null这个约束可以通过注解处理器或者额外框架来检查。日常自定义注解最常用的组合就是Target({ElementType.METHOD, ElementType.TYPE})表示既能标方法也能标类。如果Target不写呢默认情况下这个注解可以应用在所有非类型声明的位置上实际是啥都能标。但这并不是好事。我见过有人定义了一个ApiLog注解想标方法结果被队友标到了字段上反射解析时一点反应都没有。所以自定义注解最好把目标范围收紧语义明确别人用起来也不会跑偏。这里还有个容易忽略的细节Java 8 之前同一个位置同一个注解只能出现一次。如果你在方法上标了两个同名的自定义注解编译直接报错。Repeatable就是为解决这个问题而生的。1.3 剩下的三个元注解Documented、Inherited、RepeatableDocumented的作用比较简单它只影响 Javadoc 生成。如果一个注解标了Documented那 Javadoc 工具生成文档时就会把带注解的声明上标注的内容展示出来。比如标准库里的Deprecated就标了Deprecated和Documented所以文档里能看到弃用提示。Inherited表示注解的继承性。注意这里只对类上的注解有效方法上的注解、字段上的注解都不具备继承性。比如我定义一个带Inherited的MyAnnotation注解标在父类上子类即使没标也能通过反射getAnnotation读到它。接口的方法实现、类的方法重写都不受Inherited影响。Repeatable是个实用性很强的元注解。它允许在同一个地方重复标注同一个注解。比如定时任务可能有多个调度规则如果一个注解只能写一次那就得拆成两个字段来表示很别扭。用Repeatable需要配套一个容器注解Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) Repeatable(JobSchedules.class) public interface JobSchedule { String cron(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface JobSchedules { JobSchedule[] value(); }容器注解里必须有一个名为 value、类型为原注解数组的属性。使用的时候可以连续写JobSchedule(cron 0 0 6 * * ?) JobSchedule(cron 0 0 18 * * ?) public void pushDailyReport() { }反射读取时可以通过getAnnotationsByType(JobSchedule.class)一次性拿到全部重复注解也可以先拿容器注解再取数组。JDK 8 之后getAnnotationsByType是最推荐的方式。2. 自定义注解实操照着这个模板写就行了解了元注解现在动手定义一个真正可用的注解。以一个最典型的业务场景举例记录操作日志。2.1 注解定义语法拆解注解的定义用interface关键字后面跟着注解名。它看起来像接口但和普通接口有本质区别Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface LogRecord { String module() default ; String action() default ; String level() default INFO; boolean saveResult() default true; }这段代码里要注意几个细节。属性声明方式是方法名 括号括号里不能有任何参数后面跟着类型和默认值。这和我们熟悉的抽象方法很像但语义完全不同这里是在定义注解的成员属性。default关键字指定默认值。如果一个属性没有默认值使用注解时就必须显式赋值否则编译器会报错。反过来如果所有属性都有默认值那用注解时可以不写任何参数。有一个属性名比较特殊value。如果注解只有一个名为value的属性使用时可以省略属性名直接写值public interface SimpleAnnotation { String value(); } // 等价于 SimpleAnnotation(hello) SimpleAnnotation(hello) public void test() {}但如果注解里有多个属性只有 value 能省略名字其他属性必须用key value的形式。这个细节在阅读开源框架源码时非常常见不理解会看着一头雾水。2.2 注解属性类型的限制注解属性的类型有严格限制不是想写什么就写什么。官方规范允许的类型是Java 八种基本类型byte、short、int、long、float、double、boolean、charStringClass 或 Class?枚举类型注解类型以上类型的数组这个限制很底层javac 在编译期就直接拒绝。比如你想用Integer包装类型作为属性类型编译会报错。刚接触时容易踩这个坑下意识以为包装类型兼容其实完全不行。我们项目中尽量用String、int、boolean这些最简单可靠的类型除非有特别强的需求。数组类型属性也有一个细节如果给数组赋一个元素可以省略花括号LogRecord(level ERROR) // 如果 level 是 String[] 类型等价于 level {ERROR}再说 Class 类型属性。它允许你指定某个类的 Class 对象作为元数据比如很多框架喜欢用Class?[] groups()做分组校验。但如果你的业务并不需要不用为了看起来高级加 Class 属性因为 Class 属性在单元测试和序列化场景下往往很麻烦。2.3 一个能用的例子定义我们的 LogRecord基于上面的语法我定义了一个最简版操作日志注解Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) Documented public interface LogRecord { String module() default ; String action() default ; String level() default INFO; }设计意图是这样的module表示业务模块比如用户管理订单管理。只有字符串类型以后存数据库方便统计。action表示具体行为比如新增用户删除订单。level表示日志级别默认 INFO比如异常场景可以标 ERROR。为什么只标方法因为操作日志的核心载体就是方法一个业务动作对应一个方法。把Target设为ElementType.METHOD能防止有人随手把注解标到字段或类上造成混乱。用起来也很自然Service public class UserService { LogRecord(module 用户管理, action 新增用户) public void addUser(User user) { // 业务逻辑 } }到了这一步注解只是写了但没人管的装饰品。它还没有任何实际行为。要想让它真正产生作用必须写对应的注解处理器。3. 注解解析让注解从写到用的关键一步注解本身不干活干活的是解析注解的代码。这是理解注解最重要的心智模型。3.1 反射读取注解的三种姿势Java 反射包提供了java.lang.reflect.AnnotatedElement接口Class、Method、Field、Constructor都实现了它。最常用的方法有这些// 判断是否存在指定注解 boolean isAnnotationPresent(Class? extends Annotation annotationClass) // 获取指定注解实例 T extends Annotation T getAnnotation(ClassT annotationClass) // 获取全部注解 Annotation[] getAnnotations() // 获取直接声明的注解不含继承来的 Annotation[] getDeclaredAnnotations() // 获取指定类型的注解支持 Repeatable T extends Annotation T[] getAnnotationsByType(ClassT annotationClass)getAnnotation和getDeclaredAnnotation的区别很关键。对类来说getAnnotation会往上查找父类里带有Inherited标记的注解而getDeclaredAnnotation只认本类直接标的那一个。对方法和字段来说两者一般情况下结果一致因为方法和字段的注解不涉及继承。但如果一个类实现了接口并且接口方法上标了注解实现类的方法上是拿不到这个注解的。这一点容易与 Spring 里的某些场景混淆后面会再细说。3.2 手写一个注解处理器我写了一个简单的LogRecordProcessor通过反射把模块和操作名打印出来public class LogRecordProcessor { public static void processClass(Class? targetClass) { // 获取目标类所有公开方法 Method[] methods targetClass.getMethods(); for (Method method : methods) { LogRecord record method.getAnnotation(LogRecord.class); if (record ! null) { System.out.printf(方法:%s, 模块:%s, 操作:%s, 级别:%s%n, method.getName(), record.module(), record.action(), record.level()); } } } }调用方式LogRecordProcessor.processClass(UserService.class);这种写法有一个小坑getMethods只返回公有方法并且如果类继承自其他类会包含父类的公有方法。如果只想处理当前类自己声明的方法应该用getDeclaredMethods。更严谨一点的处理器还要过滤掉编译器生成的桥接方法比如带泛型返回值的方法。用method.isBridge()可以判断。大多数业务场景不需要这么做但如果你在做一个通用基础组件这一步不能漏。如果需要读取方法参数上的注解可以用method.getParameterAnnotations()它返回一个二维数组外层对应参数位置内层对应该参数上的注解集合。3.3 生产环境中的注解解析Spring 到底帮你做了什么很多同学会有疑问我自己写注解还得写反射代码但使用Transactional、RequestMapping时也没写过解析器Spring 到底是怎么处理注解的答案是框架基于反射封装了一套完整的解析机制并且在 Bean 初始化阶段就完成了扫描和包装我们只是没有感知而已。Spring 里有AnnotationUtils和AnnotatedElementUtils这几个工具类它们处理了代理类、组合注解、AliasFor属性别名等复杂情况。比如RequestMapping本身是一个复合注解它内部组合了Target、Retention、Documented还被Controller等注解引用Spring 能通过属性别名机制把各个组合属性合并起来表现成一套逻辑属性这套机制如果全部自己写工作量非常大。所以日常开发中我建议如果你是在 Spring 项目里做自定义注解优先考虑结合 AOP 或 Spring 的BeanPostProcessor让框架帮你解析而不是自己裸写反射。但学习阶段用纯反射写一遍处理器对理解底层非常有帮助面试时也能讲得更清楚。4. 实战场景自定义注解能搞定的三类常见需求理解了注解定义和反射解析我们来看看实际项目中怎么用。我挑了三个最常见的场景参数校验、权限控制、日志埋点。4.1 参数校验注解摆脱一堆 if-else后端接口参数校验是刚需。很多老代码里长这样if (user.getName() null || user.getName().isEmpty()) { throw new IllegalArgumentException(用户名为空); } if (user.getAge() 0) { throw new IllegalArgumentException(年龄不合法); }这种代码写多了既啰嗦又容易漏。我们用自定义注解简化它Target({ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) public interface NotBlank { String message() default 字段不能为空; }再写一个校验器public class Validator { public static void validate(Object obj) throws IllegalAccessException { Class? clazz obj.getClass(); Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); NotBlank notBlank field.getAnnotation(NotBlank.class); if (notBlank ! null) { Object value field.get(obj); if (value null || (value instanceof String ((String) value).trim().isEmpty())) { throw new IllegalArgumentException(notBlank.message()); } } } } }注意field.setAccessible(true)能绕过私有访问限制但前提是项目没有启用严格的模块化安全策略。在普通 Java SE 项目里没问题在强模块化或带 SecurityManager 的环境下这里可能抛出异常需要额外做异常处理。使用时public void createUser(User user) { Validator.validate(user); // 业务逻辑 }入口统一校验字段加注解声明规则业务代码清爽不少。当然实际项目直接用 Jakarta Bean Validation 的NotNull、NotBlank也是同理理解自定义注解能让你看懂这些注解底层到底在做什么。4.2 权限控制注解一注解控制接口访问权限控制如果用 if-else 写每个接口都要重复判断角色稍微复杂一点就很容易漏。用注解声明权限是个更好的思路Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequiresPermission { String[] value(); }然后配合一个简单的 AOP 切面Spring 环境Aspect Component public class PermissionAspect { Around(annotation(requiresPermission)) public Object checkPermission(ProceedingJoinPoint joinPoint, RequiresPermission requiresPermission) throws Throwable { // 从 SecurityContext 获取当前用户 // 判断用户权限是否匹配 requiresPermission.value() // 如果不匹配则抛出权限异常 return joinPoint.proceed(); } }使用RequiresPermission({user:add, user:update}) public void updateUser(User user) { }这样权限规则和业务逻辑彻底分离新增接口时只需声明注解不需要关心权限校验实现细节。有几个点需要注意。切面类和方法必须被 Spring 管理也就是注解所在的类要注册为 Bean切面本身也要是Component或通过配置注册。如果直接 new 出来的对象调方法注解不会被切面拦截因为 Spring 的 AOP 基于代理代理只对 Spring 容器管理的对象生效。4.3 日志埋点注解减少重复代码日志埋点也是注解的高频场景。很多团队要求每个核心方法记录操作日志如果手动写日志代码每个方法都要加一段 try-catch-finally代码污染严重。我们用之前定义的LogRecord配合 AOPAspect Component public class LogRecordAspect { private static final Logger log LoggerFactory.getLogger(LogRecordAspect.class); Around(annotation(logRecord)) public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable { long startTime System.currentTimeMillis(); try { Object result joinPoint.proceed(); log.info(模块[{}] 动作[{}] 耗时[{}ms] 返回[{}], logRecord.module(), logRecord.action(), System.currentTimeMillis() - startTime, result); return result; } catch (Exception ex) { log.error(模块[{}] 动作[{}] 异常, logRecord.module(), logRecord.action(), ex); throw ex; } } }切面通知方法里annotation(logRecord)这种写法会把方法上的注解实例自动绑定到参数上非常方便。实际使用中我发现一个细节同一个方法上如果标了多个注解且多个切面分别监听不同的注解它们的执行顺序受Order或Ordered接口控制。如果你希望在权限校验通过后再记录日志就需要给权限切面和日志切面排优先级否则可能出现日志记录了但方法根本没执行的误导信息。5. 实战中容易踩的坑注解失效与排查自定义注解本身不难但实际项目中注解没生效的排查往往很费时间。我把这些年遇到过的问题整理成几类希望能帮你少走弯路。5.1 运行时拿不到注解先检查这两个地方第一个地方是Retention。如果注解没写RUNTIME默认是CLASS反射读取全部返回 null。这一点前面强调过但报错率仍然很高。第二个地方是代理对象。Spring 的默认代理机制是 JDK 动态代理如果你给一个类标了注解但这个类实现的接口上没有标注解而你在注入时用的是接口类型此时目标方法有可能是代理对象上的方法反射获取注解时获取不到。解决方案有两个一是把注解也标到接口方法上二是使用 CGLIB 代理类代理也就是配置spring.aop.proxy-target-classtrue这会让代理对象保留类的真实类型但方法上的注解也可能通过桥接方式暴露具体情况依赖框架版本。第三个地方是权限修饰符。getMethod和getDeclaredMethod对方法的可见性有要求。getMethod只能拿到公有方法getDeclaredMethod可以拿到私有方法。如果业务方法用private修饰又想被 AOP 拦截需要确认切面配置允许操作私有方法很多框架默认只对公有方法做代理。5.2 注解属性的继承与覆盖Inherited只对类级别的注解有效。如果父类上有带Inherited的注解子类可以通过反射读到。但方法重写、接口实现时注解不会跟着方法走。比如interface UserApi { LogRecord(module 用户管理, action 查询用户) User findById(Long id); }实现类重写findById时反射读取实现类方法拿不到接口方法上的LogRecord。Spring 的Transactional也有类似现象如果事务注解标在接口方法上而实现类使用了 CGLIB 代理某些情况下事务标注会丢失。所以我的习惯是注解标在实现方法上而不是接口上避免各种隐藏问题。还有一个容易踩的坑如果子类重写了带注解的方法并自己加了一个注解那么父类注解并不会和子类注解合并。读取子类方法注解时拿到的只有子类自己标的那一个。Spring 的AliasFor可以解决部分合并问题但那属于框架层的高级特性。5.3 反射解析的性能与缓存技巧反射调用本身有一定开销如果每次业务执行都通过反射去获取注解和属性性能会受到影响。尤其在并发量大的接口上重复反射造成的不必要损耗可以被明显感知。解决的思路通常是缓存。项目里可以维护一个本地缓存把方法名或注解类型 - 解析结果存起来。我第一次优化时就用了ConcurrentHashMap后续接入 Caffeine 效果更好。不过要特别注意缓存 key 的设计如果用方法对象作为 key要注意 JVM 的类卸载问题导致的内存泄漏实际项目更推荐用Class 方法名 参数类型列表做 key。// 伪代码 private final ConcurrentHashMapString, LogMeta cache new ConcurrentHashMap(); private LogMeta getLogMeta(Method method) { return cache.computeIfAbsent(cacheKey(method), key - parse(method)); }Spring 内部也是这么做的AnnotationUtils会有findAnnotation缓存目的就是避免重复扫描。自己写解析器时完全可以参考这个思路。5.4 注解处理器和编译期扫描的差异前面讲的都是运行时注解使用反射读取。还有一类是编译期注解最典型的就是 Lombok 的Getter、Setter。它们通过 Java 注解处理器Annotation Processor在javac编译期间生成代码。如果自定义注解想走编译期处理路线需要实现javax.annotation.processing.Processor接口或用AbstractProcessor类并通过 SPI 方式注册到META-INF/services。实际项目编译时还可能要配置maven-compiler-plugin的annotationProcessorPaths否则处理器的依赖在运行时可能漏掉。顺便说一句很多人会混淆 Lombok 和运行期自定义注解。Lombok 是编译期生成 getter/setter完全不依赖运行期反射而我们的LogRecord是运行期注解依赖反射或 AOP。两者的生效时机完全不同排查问题时一定要先搞清楚自己使用的是哪种模式。再提一个很常见的面试问题注解到底能不能被继承答案要分成两层Inherited可以让类注解被子类继承方法注解、字段注解、参数注解都不支持继承哪怕加了Inherited也不生效。如果你在面试时能把这层说清楚面试官通常会比较认可。最后分享一个我个人的习惯设计自定义注解之前先想清楚三个问题。第一这个注解会在哪个阶段读取编译期还是运行期第二谁来解析它AOP 切面、反射工具还是编译器处理器第三如果用反射是否已经考虑缓存和继承边界。把这几个问题想透了再写代码基本一次过。社区里那些看似花哨的注解拆开来看也无非就是标注 解析 元数据映射这三个大脑皮层动作。搞懂这套机制之后再看 Spring 源码、读各种框架的约定式开发都会顺畅很多。
返回列表