ARTICLE DETAIL

资讯详情

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

详解Java四大元注解及自定义注解实战

详解Java四大元注解及自定义注解实战 1. 先分清四件事作用域、生命周期、文档、继承1.1 元注解的身份先把概念立住很多人第一次接触 Target、Retention、Documented、Inherited 的时候都被元注解三个字唬住了。说白了元注解就是注解上的注解。普通注解是我们给代码打的标记比如给某个方法加Log给某个类加Component而元注解是打在注解类型本身上的用来告诉编译器、JVM、javadoc、反射机制我这个自定义注解到底该怎么被对待。看一个最普通的例子Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Log { String value() default ; }这里Target和Retention写在interface Log上它们就是 Log 这个注解类型的元注解。你写Log的时候真正生效的其实是这两行元注解组合出来的规则。把这四个元注解放在一起看能解决绝大多数人的疑惑Target管的是注解能贴在哪Retention管的是注解能活多久Documented管的是生成 API 文档时要不要显示注解Inherited管的是子类能不能从父类那边把注解继承过来。四件事分工完全不同互不替代。1.2 一张表看懂四兄弟的分工这四个元注解天天混在一起出现但刚入门时最容易搞混的就是它们各自的边界。我习惯用下面这张表帮人记住你也可以直接收藏参考。元注解控制的内容典型使用场景最容易忽略的点Target注解可以写在哪些位置类、方法、字段、参数等限制一个注解只能用于方法或只能用于类缺少 ANNOTATION_TYPE 时不能拿来注解另一个注解Retention注解保留到源码阶段、class 文件阶段还是运行时阶段运行时反射需要 RUNTIME不写时默认是 CLASS反射拿不到Documentedjavadoc 生成文档时是否展示该注解对外 API 的注解建议加上它不参与运行也不参与编译Inherited子类能否通过反射获得父类上的注解类级别注解的继承只对类生效对方法、字段、接口无效补充一个关键细节Inherited只是告诉 JVM子类查询父类注解时可以把父类的注解当作子类也有但它不改变注解的存储也不改变Target限定的使用位置。换句话说如果某个注解根本没有RUNTIME保留策略那就算加了Inherited反射也照样拿不到。2. Target别把注解的使用范围当成可选项2.1 ElementType 的各个值分别控制什么Target的值是一个ElementType数组可以同时指定多个。下面是日常开发里最常见的几个ElementType含义典型用途TYPE类、接口、枚举、注解类型Component、Entity 这类类级别标记FIELD字段、枚举常量Autowired 直接标字段METHOD方法GetMapping、PostMappingPARAMETER方法参数、构造器参数RequestParamCONSTRUCTOR构造器用于依赖注入或校验构造参数LOCAL_VARIABLE局部变量编译期检查工具ANNOTATION_TYPE注解类型声明给自定义注解再加注解也就是元注解PACKAGE包package-info.java 里的包级注解TYPE_PARAMETER泛型参数声明T extends NotNull ObjectTYPE_USE类型使用处ListNotNull String、类型转换、异常声明等MODULE模块声明Java 9 模块化场景这段看着有点像枚举背诵但实际写代码时非常有用。比如Target(ElementType.METHOD)的注解你硬放到类上面javac 在编译期就会直接报错。这种错误反而是最容易修的因为编译器替你兜底了。需要注意一个注解类型如果没有写Target它在绝大多数声明位置都能使用看起来更方便但代价是控不住边界。比如你写了一个CacheKey本来只想让它用在方法参数上结果同事顺手把它加到类上面编译还通过扫描逻辑就乱了。我个人的习惯是每一个自定义注解都显式声明Target哪怕只允许一种位置。2.2 Target 选错和选宽带来的两种坑选错 Target通常是编译期报错好解决。真正难排查的是 Target 选得过宽。举个例子Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface Auth { String role() default ; }这样一个注解既能用在类上也能用在方法上。你的权限检查器遍历方法时只检查方法上的Auth但有人在类上标了Auth(role admin)你以为类下所有方法都受保护结果没有。Target 太宽导致语义模糊比 Target 太窄更麻烦。如果目标是把一个注解作为其他注解的元注解也就是想写出注解的注解Target(ElementType.ANNOTATION_TYPE)必须带上否则在interface上使用会编译失败。Spring 的Component就是典型例子它本身标注了Target(ElementType.TYPE)和Retention(RetentionPolicy.RUNTIME)所以Service、Repository才能把它们作为元注解组合使用。还有一个容易混淆的点普通业务的运行时注解一般不需要ElementType.TYPE_USE。TYPE_USE是 Java 8 引入的它表示注解可以用在类型出现的位置比如泛型类型实参、数组、类型转换等。这更多是给编译期检查工具如 NullAway、Checker Framework用的不是给 Spring 这类框架用的。你写业务注解先分清我要标记一个声明还是标记一个类型使用再决定用TYPE还是TYPE_USE避免给自己埋坑。3. Retention决定注解到底活到哪一步3.1 SOURCE、CLASS、RUNTIME 三档的区别Retention是四个元注解里最影响运行行为的一个也是注解不生效最常见的元凶。它有三个值RetentionPolicy保留阶段反射能否看到典型场景SOURCE编译时直接丢弃class 文件里没有否Lombok 的 Getter、IDE 的 Deprecated部分CLASS写入 class 文件但 JVM 加载后不保留否字节码工具、部分编译期处理RUNTIME写入 class 文件JVM 加载后仍保留是Spring、Jackson、MyBatis 等框架注解这里有一个决定性的细节如果interface上没有写Retention默认值是RetentionPolicy.CLASS不是 RUNTIME也不是 SOURCE。很多人随手写个自定义注解忘了加Retention(RetentionPolicy.RUNTIME)然后反射拿不到还以为是自己代码写错了其实注解在 class 文件里还在只是 JVM 没有把它加载到运行时内存里供反射使用。看这个例子Retention(RetentionPolicy.CLASS) public interface MyConfig { } MyConfig public class Demo { public static void main(String[] args) { System.out.println(Demo.class.getAnnotation(MyConfig.class)); // null } }如果你第一次跑这段代码可能会很懵但原因就是上面说的MyConfig只保留到 class 文件阶段反射查询时 JVM 不再暴露它。3.2 运行时反射为什么必须用 RUNTIME反射的本质是查看 JVM 运行时的类元数据。注解要被运行时识别必须保留到 RUNTIME 阶段否则getAnnotation、getDeclaredAnnotation、isAnnotationPresent全都拿不到。那 SOURCE 和 CLASS 是不是就没用了不是。SOURCE 最适合只在源码里存在、不进入产物的场景。最典型的是 Lombok它的Getter、Setter都是 SOURCE因为编译阶段就处理完成运行时完全不需要保留。CLASS 适合需要读 class 文件字节码的工具比如 ASM、Gradle 插件、代码生成器它们可以在字节码层面看到注解但反射不需要 RUNTIME。如果你正在设计一个框架核心规则很简单要让使用方通过反射发现注解就用RUNTIME如果只是编译期校验或代码生成优先考虑SOURCE如果既要写进 class 文件又要自己解析字节码才考虑CLASS。3.3 注解处理器的特殊点和反射不是一套逻辑这里要补充一个常见的认知误区很多人以为RetentionPolicy.SOURCE的注解javac 的注解处理器看不到。其实不是。编译期注解处理器运行在编译源码阶段它处理的是源代码模型所以 SOURCE 保留策略的注解也能被处理器看到只是处理完之后不会再写进 class 文件。Lombok 就是靠这一点工作的。所以你在选保留策略时要先问自己一句话这个注解是给编译期工具用还是给运行时代码用给编译期SOURCE 不仅能完成工作还能减少运行期内存占用给运行时就必须 RUNTIME。两者选错都会出现看起来一切正常但实际没效果的诡异问题。4. Documentedjavadoc 里的存在感也值得认真对待4.1 没有 Documented 会怎么样Documented是最容易被忽略的元注解因为它不影响编译不影响运行不影响反射。它的作用只有一个生成 javadoc 时是否把被标注的注解展示到 API 文档中。举个例子Documented Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface ApiOperation { String desc() default ; }如果类里有个方法标注了ApiOperation(desc 查询用户)生成 javadoc 时方法说明里会显示这一行注解信息。如果不加Documented同一个注解虽然真实存在但 javadoc 工具默认不会把它渲染出来。很多团队不生成 javadoc所以觉得Documented无关紧要。但只要你做的是公共 SDK、基础组件或者是要给其他团队看 API 文档的项目Documented的价值就会凸显出来。它可以让阅读文档的人直接看到这个方法被标记了哪些注解比如Deprecated、NotNull、ApiOperation不用再翻源码。4.2 一个注解该不该加 Documented我的判断标准很直接如果这个注解会被使用者当作契约的一部分就应该加Documented。比如标记接口废弃、标记方法权限、标记某个字段必须非空这些都是使用方需要知道的信息。反之如果某个注解只是内部实现细节比如给框架处理器看的标记、给测试框架做临时摆渡的配置那不加Documented反而更干净。javadoc 页面里堆满内部注解正常使用者看见了只会觉得混乱。还有一点Documented本身并不依赖RUNTIME两者是独立的。只是因为大部分需要 javadoc 展示的注解同时也要被反射使用所以经常看到Documented和Retention(RetentionPolicy.RUNTIME)一起出现。别误以为不写RUNTIMEDocumented就没意义如果你不做运行时反射完全可以让 SOURCE Documented 同时存在。5. Inherited继承行为只比想象中少不比想象中多5.1 类继承上的真实表现Inherited是这四个元注解里语义最诱人、但限制也最多的一个。它在类继承场景下的表现是这样的Inherited Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface Entity { String table() default ; } Entity(table parent) class BaseEntity { } class ChildEntity extends BaseEntity { }在这种情况下ChildEntity.class.getAnnotation(Entity.class)可以拿到Entity(table parent)而ChildEntity.class.getDeclaredAnnotation(Entity.class)拿到的是 null。这两个方法的区别很关键getAnnotation()会去查找直接存在和通过继承获得的注解。getDeclaredAnnotation()只看当前类自己声明的注解不处理继承。所以如果你做框架扫描时用的是getDeclaredAnnotation即使父类标了Inherited注解子类依然扫不到。很多人在这一步就开始怀疑Inherited坏了其实没坏只是 API 用错了。5.2 三个不生效的典型场景Inherited最容易踩的坑有三个每个都值得单独说清楚。第一个坑方法、字段上的注解不会继承。父类方法上标了注解子类重写该方法后子类方法上不会自动出现这个注解。就算子类没有重写用getMethod(xxx)拿到的实际上还是父类方法对象注解是直接存在于父类方法上而不是被继承过来的。所以Inherited对字段、构造器、方法参数一律无效它只作用在类上。Inherited Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Trace { } class Parent { Trace public void work() { } } class Child extends Parent { Override public void work() { } }上面这种写法Child.class.getMethod(work).getAnnotation(Trace.class)是 null。想实现方法级注解的多态只能自己写工具沿着方法 override 链一路往父类找。第二个坑接口上的注解不会传播到实现类。就算注解类型标了Inherited你在接口类上标了注解实现类也不会自动获得。Inherited只处理类继承类这条路径不处理类实现接口的路径。第三个坑子类自己声明了同名注解父类注解就不会被返回。也就是说子类标了Entity(table child)父类也标了Entity(table parent)子类查询时拿到的是子类这份父类那份被覆盖了。5.3 框架为什么经常不直接依赖 Inherited因为 Inherited 太局限了真实框架很少只靠 JDK 自带的继承机制来解析注解。Spring 的AnnotatedElementUtils.findMergedAnnotation这类工具会同时检查当前类、父类、父接口、父类方法、接口默认方法还会做元注解查找组合出一套比Inherited强得多的合并规则。如果你自己写组件扫描建议也按这个思路做不要直接getAnnotation而是写一个递归方法沿着getSuperclass()和getInterfaces()向上找并且用SetClass? visited防止循环查找。把继承逻辑握在自己手里比依赖一个只对类生效的元注解安全得多。6. 组合实战仿照 Spring 的 Component 写一个可扫描的标记注解6.1 注解定义和属性设计前面把每个元注解单独讲完现在把它们组合起来看。我们做一个简化版的组件标记注解用来在包扫描时发现需要注册的类型。Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) Documented public interface MyComponent { String name() default ; int order() default 0; }这个注解的设计思路如下Target(ElementType.TYPE)只允许用在类、接口、枚举上防止有人误标到方法上。Retention(RetentionPolicy.RUNTIME)运行时需要反射识别必须保留。Documented这是可对外暴露的组件标记加进 javadoc 对使用者友好。name() default 允许显式指定组件名为空则用类名首字母小写。order() default 0允许排序后续注册时可以用。这里我特意没加Inherited。组件扫描通常应该只扫描显式标记的类如果父类标了MyComponent子类就自动成为组件很容易扩大扫描范围把设计上不想要的东西也注册进去。这也是很多框架不轻易用Inherited的原因。6.2 扫描并注册组件的完整示例接下来用一个极简的包扫描器把这些注解读出来。只扫描一个目录不处理 jar 包和递归子目录但原理和复杂版一致。public static MapString, Class? scanComponents(String basePackage) throws Exception { MapString, Class? registry new HashMap(); String path basePackage.replace(., /); EnumerationURL resources Thread.currentThread() .getContextClassLoader() .getResources(path); while (resources.hasMoreElements()) { URL url resources.nextElement(); File dir new File(url.toURI()); File[] files dir.listFiles(); if (files null) { continue; } for (File file : files) { if (!file.getName().endsWith(.class)) { continue; } String className basePackage . file.getName().replace(.class, ); Class? clazz Class.forName(className); MyComponent annotation clazz.getAnnotation(MyComponent.class); if (annotation ! null) { String beanName annotation.name().isEmpty() ? lowerFirst(clazz.getSimpleName()) : annotation.name(); registry.put(beanName, clazz); } } } return registry; } private static String lowerFirst(String s) { if (s null || s.isEmpty()) { return s; } return Character.toLowerCase(s.charAt(0)) s.substring(1); }这里有几个实战细节值得注意目录里可能存在非 class 文件比如 IDE 缓存文件所以要判断文件名后缀Class.forName会触发静态初始化如果目标类里有复杂静态代码扫描阶段要额外小心如果有内部类和匿名类建议用clazz.isTopLevelClass()或isMemberClass()做过滤避免注册到奇怪的类。6.3 运行结果和可以继续扩展的方向假设包com.example.components下有这些类MyComponent public class UserService { } MyComponent(name orderHandler, order 1) public class OrderHandler { }运行scanComponents(com.example.components)后注册表里会有userService - UserService.class和orderHandler - OrderHandler.class。如果要做排序注册完成后用order()的值再排一次即可。再往深走一点真实框架还会处理「注解放在接口上实现类要不要继承」这类问题也需要处理元注解。比如判断一个类是不是MyComponent时不仅看它上面有没有还要看它有没有被别的注解标记而那个别的注解又有没有标注MyComponent。这其实就是 Spring 里Service继承Component语义的实现思路。明白这四个元注解之后再看这些框架源码会顺畅很多。7. 常见的坑和我总结的排查清单7.1 三次翻车经历我自己踩过的坑基本都逃不开那三个错误。第一次自定义权限注解忘了写Retention默认 CLASS反射传注解类型进去永远返回 null。当时我还在方法上排查了半天最后看了编译后的 class 字节码才发现注解明明在 class 文件里只是反射看不到。第二次做方法级注解继承以为给注解标了Inherited就能让子类重写方法时也带上注解结果发现Inherited只对类生效方法根本不认。后来我把注解上升到类级别配合getAnnotation才把需求绕过去。第三次给一个注解加Target(ElementType.TYPE)后来又想让另一个自定义注解拿它当元注解结果编译器直接报not applicable to annotation type。原因就是缺了ElementType.ANNOTATION_TYPE。这种错误非常快就能发现但容易让人半天反应不过来是 Target 的问题。7.2 注解不生效时按这个顺序排查如果你也遇到写了注解但反射拿不到、框架不认、子类查不到的鬼问题我建议按以下顺序查别一上来就怀疑框架。先查Retention。凡是反射要用的注解必须有RUNTIME没有明确写的话默认是CLASS。再查Target是否覆盖了你使用的位置。如果代码编译通过Target 基本没问题但要注意框架可能只扫描类级别或方法级别你标的位置和扫描器查的位置不一致。再查继承关系。如果查的是子类确认注解类型上有Inherited并且你用的是getAnnotation而不是getDeclaredAnnotation。最后查代理对象。Spring AOP、CGLIB、动态代理场景里方法可能挂在代理对象上注解却在目标类上。必要时要拿到targetClass再查注解。这里有一个额外的提示如果你在 IDE 里搜索某个注解的出现位置发现 Target 里没有RUNTIME但 IDE 的 Inspect Code 能识别并不代表运行时也认。IDE 很多功能跑的是源码分析和运行时反射是两码事。7.3 四句口诀最后分享我记这四个元注解的口诀虽然不是严格定义但足够快速唤起记忆Target决定它贴哪。Retention决定它活多久。Documented决定 javadoc 见不见。Inherited决定子类拿不拿到。以后在任何答疑群里看到自定义注解反射不出来第一反应先问对方写了Retention(RUNTIME)没有十次里有七次是这个问题。剩下的三四次多半是接口继承、代理类或 Target 设置这三类。把这篇里的组合场景自己跑一遍这四个元注解就算是真正吃到脑子里了。
返回列表