Java注解机制的应用研究
2022-02-20曾水新黄日胜
曾水新 黄日胜
摘要:Java的注解机制在JDK5就已推出,后续发布的新版不断完善,目前注解机制的应用已经很广泛,如用于辅助开发的工具Lombok、AutoValue、Immutables等,主流的开发框架如Spring、MyBatis也大量以注解替代了XML配置文件。大多数开发者仅会使用注解,但不了解其工作原理,该文详细介绍了JDK内置的注解、元注解的作用和用法,分析了注解的工作原理,并以案例演示了如何编写自定义注解,包括声明注解、处理注解、使用注解三个流程,最后介绍了注解的应用场景。
关键词:Java;注解;反射技术;框架技术;编译器
中图分类号:TP311.1 文献标识码:A
文章编号:1009-3044(2022)34-0035-04
1 引言
Java或Android的开发者对注解(Annotation) 机制一定不会陌生,在项目开发过程中,开发者会接触到很多注解,如@Override、@Deprecated、@SuppressWarnings等,如果使用框架,可能会使用到注解@Controller、@Param、@Select等。目前关于注解原理的资料相对比较贫乏,很多开发者会使用注解,但不了解注解的工作原理、运行机制,也不清楚如何编写注解。
2 Java注解机制介绍
2.1 注解的概念
注解是JDK5.0引入的一种标注机制,Oracle官方定义为:“Annotations, a form of metadata, provide data about a program that is not part of the program itself.”[1]。即注解是元数据的一种形式,注解提供了程序的信息但不属于程序的一部分。注解可以对代码添加附加信息,但不会侵入业务代码,也不会影响代码的具体执行过程[2]。开发者可以对包、类、接口、字段、方法参数、局部变量等进行注解,添加特殊标记,在编译期或运行期可以对这些被标记的类、变量、方法或方法参数进行一些特殊的操作。
注解与Javadoc注释容易混淆,两者貌似相同,实则区别很大:
Javadoc是Sun公司提供的一种工具,它可以从程序源代码中抽取类、方法、成员等注释,然后形成一个和源代码配套的API帮助文档,相当于产品说明书,是为了便于开发者调用时了解类、方法和属性的作用、用法而诞生的,Javadoc是特殊的、格式化的注释,本质上还是注释。注释是给开发者看的。
注解则不一样,开发者通过配置,使其在编译时、类加载时、运行时可见,还可通过Java的反射机制获取注解内容,加入自定义的处理逻辑。注解是非侵入性的,不会干涉代码本身的处理流程,而是通过低耦合的“贴标签”形式,向原有代码附加信息,编码辅助工具、部署工具、IDE都可以读取注解,为开发者提供检测代码、自动编码、验证部署的服务,注解是给机器看的。
2.2 Java内置的标准注解
1) @Override:该注解是使用频率较高的一个,标注在方法上,表示该方法是重写父类方法。编译器会进行检测是否符合重写规则,如果不符合,比如父类(包括接口)没有这个方法,则会提示错误。不写@Override其实也不会影响程序运行,这个注解是否就没有存在的意义?不是的,编写代码时,通常开发者很清楚他要重写一个方法,但是可能单词拼写错误,如果没加注解,编译器就发现不了错误。
2) @Deprecated:用于标记类、成员变量、成员方法或者构造方法已废弃,不推荐开发者使用,如果开发者调用了被标记了@Deprecated的方法,编译时会有警告信息,但仍能强制编译。
3) @SuppressWarnings:该注解的作用是指示编译器对被注解的代码元素内部的某些警告保持静默,支持在类、属性、方法、参数、构造方法、本地变量上使用。标注了“@SuppressWarnings”不等于消除了警告内容,只是编译器不显示而已,除非开发者确定该警告的隐患不会影响程序的正常运行,否则不建议使用该注解。
4) @SafeVarargs:JDK7加入的注解,用于取消编译器产生的unchecked警告。在声明一个有泛型的可变参数的构造函数或者方法时,编译器会提示unchecked警告,如果开发者确定该构造函数或方法不会造成不安全的操作时,可使用@SafeVarargs进行修饰,编译器就会忽略unchecked警告。例如定义了一个静态方法如下,添加上@SafeVarargs后,编译器警告消失:
@SafeVarargs
public static
//函数主体
}
5) @FunctionalInterface:JDK8新增了函数式编程[3],相應地加入了函数式接口注解,所谓函数式接口实际上是一个Lambda表达式,它本质上是接口,比普通接口多了一个约束:有且仅有一个抽象方法。标注了@FunctionalInterface注解,即指示编译器检查开发者编写的接口是否符合函数式接口的约束条件,如不符合,编译器会给出错误提示。
2.3 Java内置的元注解
元注解是注解的注解,是JDK的基础注解,它作用在其他注解上面,用于标记和描述注解的基本信息。编写一个注解需要指明其保留的时间和生效的上下文等最基本的信息,JDK原生的元注解则提供了可以用于标注并描述这些信息的注解。JDK提供的元注解有:
1) @Retention:用于设定注解的生命周期,可以取值为:①RetentionPolicy.SOURCE,注解只在源码阶段保留,源码被编译之后就不存在了。②RetentionPolicy.CLASS,注解内容被编译到字节码文件(.class) 里,但JVM读取字节码文件时,并不将其加载,这是@Retention的默认取值。③RetentionPolicy.RUNTIME,注解的生命周期贯穿于源码、.class文件、JVM三个阶段,因此程序在运行时可以获取到它们。
2) @Documented:注解后Javadoc工具可从源代码中抽取出类、方法、成员等注释形成一个配套的API帮助文档,默认情况下,类和方法的注解内容是不会出现在Javadoc中的,使用@Documented修饰后,该注解即可被Javadoc工具提取到API文档。要使@Documented注解生效的前提是:@Retention的值需设置为RetentionPolicy.RUNTIME。
3) @Target:用于指定注解的放置目标。注解可用于修饰包、接口、类、方法、变量等类型,@Target注解确定该注解可以出现在哪个位置,取值范围定义在ElementType枚举里:①TYPE:表示可用于标注类、接口、注解、枚举;②FIELD:表示可用于标注成员变量;③METHOD:表示可用于标注成员方法;④ PARAMETER:表示可用于标注参数;⑤CONSTRUCTOR:表示可用于标注构造器;⑥LOCAL_VARIABLE:表示可用于标注本地变量;⑦ANNOTATION_TYPE:表示可用于标注注解(即元注解);⑧PACKAGE:表示可用于标注包;⑨TYPE_PARAMETER:这是JDK8新增的,表示可用于标注自定义类型参数;⑩TYPE_USE:JDK8新增的,表示可用于标注除class外的任意类型。
@Target可使用单个枚举值,如设定为元注解,代码为:
@Target(ElementType. ANNOTATION_TYPE)
也可使用多个枚举值,需将多个枚举值用大括号“{}”包围,如设定注解可添加到成员方法和成员变量上,代码为:
@Target({ElementType.METHOD, ElementType.FIELD})
4) @Inherited:用于指明父类注解会被子类继承。@Inherited仅针对@Target(ElementType.TYPE)类型的注解有效,并且仅针对class的继承,对interface的继承无效。
5) @Native:JDK8新增的注解,表示被修饰的成员变量可以被本地代码引用,常被代码生成工具使用。
6) @Repeatable:JDK8新增的注解。JDK8之前,同一程序元素前最多只能有一个相同类型的注解。@Repeatable允许在相同的程序元素中重复注解。
3 自定义注解
内置的注解并不多,开发者可以编写自定义注解,主要有三个步骤:一是声明注解,二是处理注解,三是使用注解。
3.1 自定义注解的声明
3.1.1 自定义注解的语法
[[public] @interface 注解名称{
[数据类型 变量名称();]
} ]
关键字“@interface”与标准的接口关键字interface是不一样的,从反编译的代码中,可以看到类似“public interface AnnoDemo extends Annotation {}”的代码,意味着注解继承了Annotation接口(在java.lang.annotation包中),即该注解就是一个Annotation,因此注解本质上是一个特殊的接口(interface) ,接口里可以定义什么,注解里同样也可以定义,它的修饰符与接口一样,也是默认被public abstract修饰。它和普通的接口不一样的地方:
1) 定义普通接口使用interface修饰,但定义注解使用的是@interface。
2) 普通接口使用implements关键字实现接口,注解的实现是由编译器完成。
3) 普通接口可以继承多个接口,注解不能继承其他的注解或接口。
4) 在定义注解时可以定义属性,但是属性必须使用括号“()”,形式上是一个方法。
一个典型的注解声明代码如下:
[@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.ANNOTATION_TYPE,ElementType.TYPE})
public @interface AnnoDemo {
String role() ;
} ]
3.1.2 注解的属性
如前所述,注解的属性与普通类属性不一样,它的属性是抽象方法,注解的属性规则有:
1) 返回值類型必须是以下几种:基本数据类型、String类型、枚举类型、注解、以上类型的数组。
2) 使用时需给属性赋值,如下面代码SomeClass类前面添加了@AnnoDemo注解,并为其role属性赋值为admin。
[@AnnoDemo(role = "admin")
class SomeClass{
} ]
3) 可以使用default关键字设置属性的默认值,有默认值的属性,在使用时可不给属性赋值。
[public @interface AnnoDemo {
String role() default "user";
}
@AnnoDemo
class SomeClass{
} ]
4) 注解如有多个属性,赋值时可以在注解括号中用“,”号隔开分别给对应的属性赋值。
5) 如注解只有一个属性,可将其命名为value,赋值时可省略value直接定义值。
6) 给数组属性赋值时,如数组中只有一个值,则可以省略“{}”数组符号。
3.2 处理注解
注解的处理是自定义注解的核心,生命周期为SORCE、CLASS的注解,需编写注解处理器处理注解,生命周期为RUNTIME的注解,可通过反射获取注解内容,再进行业务处理。
3.2.1 注解处理器
注解处理器(Annotation Processor) 是javac的一个工具,用于编译时扫描和处理注解。开发者如想在编译期处理注解,需要编写一个注解处理器。编写注解处理器需要继承JDK自带的抽象处理器javax.annotation.processing.AbstractProcessor类,AbstractProcessor继承于Processor接口,提供了以下方法:
1) init(ProcessingEnvironment processingEnv):初始化时处理工具会调用init()方法。ProcessingEnviroment对象提供很多有用的工具类Elements, Types和Filer。
2) process(Set<? extends TypeElement> annos, RoundEnvironment roundEnvironment):这是开发者需要实现的方法,需要编写该注解的业务逻辑。RoundEnviroment参数可让你查询包含特定注解的被注解元素。
3) getSupportedAnnotationTypes():获取支持的注解类型,方法的返回值是字符串集合,如果没有支持的类型,则返回空集。该方法也可以用@SupportedAnnotationTypes注解替代。
4) getSupportedSourceVersion():获取支持的源代码版本,一般情况下返回SourceVersion.latestSupported(),如果返回的版本小于当前编译器版本,会有警告提示。
3.2.2 插入式注解处理器原理
插入式注解处理器是JDK6之后提供了一种可以在编译期进行注解读取和处理的能力,开发者可通过实现JDK的API自定义注解处理器实现干涉编译器的行为。编译期的注解,需要插入式注解处理器进行解析,注解处理器在编译过程中是如何工作的?可以从Javac的编译流程去分析,如图1所示:
1) 插入式注解处理器初始化。
2) 解析与填充符号表,包括词法、语法分析;将源代码的字符流转换为标记集合,构造出抽象语法树;填充符号表,产生符号地址和符号信息。
3) 插入式注解处理器对注解的处理,执行后如果产生了新的符号,则返回上一步骤,重新处理这些新符号。
4) 分析与生成字节码。
JDK编译字节码前,会先扫描源代码中的注解,如果注解有对应的注解处理器,则会调用process() 方法处理,因此可能会产生新的源代码、修改原有代码,因此需要进行多轮的注解处理。
3.2.3 处理编译期的注解
生命周期为SOURCE的注解,被编译为.class文件时被抹去,生命周期为CLASS的注解,会被编译到.class里,但运行程序时,不会被加载到JVM中,在运行时是获取不到它的信息的,因此这两类的注解,需要在编译期处理,步骤为:
1) 开发者编写一个继承于AbstractProcessor类的注解处理器,如MyProcessor,在MyProcessor里重写初始化方法init()、重写注解的逻辑实现process()方法,例如我们的注解的功能是为类添加getter、setter方法,就可以在process()先獲取被注解的类,然后在类中插入getter、setter。
2) 在编译的参数中指定MyProcessor,如javac -processer com.zsx.MyProcessor,编译时会自动调用MyProcessor的process()方法。很多IDE如IntelliJ IDEA也支持注解处理器,只需在工具中配置注解处理器的路径即可。
3.2.4 处理运行时的注解
生命周期为RUNTIME的注解,虚拟机加载字节码文件后,依然能读取注解的信息,此类注解可通过Java反射机制获取注解内容,再进行业务逻辑处理。
获取注解的关键是java.lang.reflect.AnnotatedElement接口,它的对象代表了一个被注解的元素,AccessibleObject、Class、Constructor、Executable、Field、Method、Package、Parameter类都实现了这个接口,可获取到AnnotatedElement对象,然后可调用该对象提供以下方法访问注解:
1) isAnnotationPresent(Class<?extends Annotation> annoClass):该方法功能是判断指定类型的注解是否存在,返回一个布尔类型的值,如存在返回true,反之为false。
2) getDeclaredAnnotations():该方法获取此元素上的所有注解,但不包括继承的父类的注解,返回Annotation类型数组,如果该元素不存在注解,则返回长度为0的数组。
3) getAnnotation(Class
4) Annotation[] getAnnotations():该方法获取此元素上的所有注解,并且包括继承的父类的注解,这是与getDeclaredAnnotations()的区别的地方。
例如,我们准备利用注解机制实现日志工具,步骤为:
1) 声明注解
[@Retention(RetentionPolicy.RUNTIME)//声明周期为运行时
@Target(ElementType.METHOD)//允许加在方法上
public @interface LogTool {
String action() default "默认操作";
String description() default "无说明";
} ]
声明一个LogTool注解,设置元素类型为方法、生命周期为运行时,声明两个属性分别为:action记录操作类型、description作为备注。
2) 使用注解
定义一个Dao类,模拟数据库操作类,在其各个方法前加上LogTool注解。
[public class Dao {
@LogTool(action="新增",description="新增一行数据")
public void addUser(){
}
@LogTool(action = "更新")
public void updateUser(){
}
@LogTool(action="删除")
public void delUser(){
}
@LogTool
public void initData(){
}
} ]
3) 处理注解
定义一个注解处理方法parse,传入被注解的类,对该类的所有方法进行遍历,使用isAnnotationPresent()方法判断该方法是否被注解,如果是被注解的方法,通过getDeclaredAnnotation()方法获取注解类的实例,即可获取注解的两个属性“操作类型”及“备注”的值,然后对其进行其他的业务操作。
public class AnnoParser {
public static void parse(Class annoClass) throws Exception{
Method[] array = annoClass.getMethods();
for(Method method : array){
if(method.isAnnotationPresent(LogTool.class)){
String methodName=method.getName();
LogTool methodLog = method.getDeclaredAnnotation(LogTool.class);
String action = String.valueOf(methodLog.action());
String description = String.valueOf(methodLog.description());
System.out.println("方法:"+methodName + " - " + action+"("+description+")");
}
}
}
public static void main(String[] args){
try {
AnnoParser.parse(Dao.class);
}catch(Exception e){
e.printStackTrace();
}
}
}
4 注解的应用场景
注解目前在生产环境已经得到了广泛的应用,给开发者带來了效率的提升:
1) 检测代码。例如内置的@Deprecated、@Override可以帮助开发者减少开发错误、规范代码,企业也可以根据内部的代码规范,编写自定义注解,检测代码的合法性,提高编码质量。
2) 辅助编码。可以帮助开发者自动生成部分烦琐的代码,提高编码效率,如第三方库Lombok、AutoValue等,可以自动插入到编辑器和构建工具中,通过注解生成诸如getter、setter或equals方法等,提高了开发效率。
3) 替代配置文件。例如Servlet现在可以使用注解替代原来的web.xml部署文件,越来越多的框架如Spring、Mybatis等使用了注解进行开发。
4) 测试。例如Junit单元测试框架使用了大量的注解[4]。
5) 面向切面编程应用。在需要非侵入式业务逻辑的面向切面编程(AOP) [5],如权限控制、日志、监控等场景,注解是较好的解决方案。
5 结束语
经过多年的迭代优化后,目前JDK对注解已有了较完备的支持,包括内置注解、元注解、注解处理器、自定义注解四部分。注解机制是非常巧妙的、简约而强大的设计,一方面,它简约:API简洁,对使用者友好、耦合度低;另一方面,它强大:开放、扩展性强,应用面广,极大地推动了Java生态如开发框架、分析工具、开发工具、部署工具等的发展。
参考文献:
[1] Lesson:Annotations[EB/OL].[2021-03-20].https://docs.oracle.com/javase/tutorial/java/annotations.
[2] 刘学玉.JAVA编程语言在计算机软件开发中的应用[J].电子技术与软件工程,2022(1):57-60.
[3] 赵荣彪.JDK1.8新特性与编程性能[J].信息技术与信息化,2021(5):145-146,150.
[4] 刘彦楠.JUnit参数化测试的应用研究[J].信息与电脑(理论版),2021,33(14):30-32.
[5] 迟慧智,孔德智.Java方法增强技术研究[J].电子产品可靠性与环境试验,2022,40(3):75-80.
【通联编辑:谢媛媛】