稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Java Annotations Made Easy

在Java编程语言中,注解Annotations是一种元数据,能提供代码以外的信息,是元编程的一种体现形式。注解的引入极大的增强了Java语言的元编程能力,能在编译时生成代码,大大减少了重复代码,让Java具有了相当高的动态特性,让一些高级技术如依赖注入,AOP等成为可能。今天将从基础使用,核心概念理解和创建自定义注解三个角度来彻底学会注解,并学会使用注解来提升开发效率。

基础知识

注解在代码中使用是非常常见的,相信只要有编程经验的同学都对注解非常的熟悉。

什么是注解

Java 注解(Annotation)是JDK5.0及以后版本引入的,它可以用于创建文档,代码分析,编译检查以及编译时生成代码。Java 注解是接口的一种特殊实现,程序可以通过反射来获取指定程序元素的Annotion对象,然后使用该对象来获取注解里面的元数据。

注解的用法

注解的使用是非常简洁明了的,Java 注解的基本语法是使用“@”符号来定义一个注解,然后在这个符号后面跟上注解的名字,并在这个名字的后面添加一个括号,括号中是这个注解所需要的参数列表。Java 注解是接口的一种特殊实现,因此注解的定义方式类似于接口的定义方式。Java 注解可以分为三种类型:标记注解、单值注解和完整注解。标记注解没有成员变量,只有一个标记作用;单值注解有一个成员变量;完整注解有多个成员变量。

内置注解

Java内置了一些注解,相信写过代码或者看过代码的人都对此非常的了解,因为在代码中是非常非常的常见的。

  • @Override - 用于类的方法上,标记该方法要覆写基类(包括接口)的方法。编译器会对标记的方法作签名检查是否符合覆写规则。
  • @Deprecated - 可以标记类,成员变量和成员方法为过时的方法,编译器会对调用这些类,成员或者方法给出警告(Compile warnings)。
  • @SuppressWarnings - 可以用在类和方法上,强制忽略编译警告,即阻止编译器发出编译警告。后面需要加括号,里面传入字符串或者字符串数组代表要忽略的警告类型。
  • @FunctionalInterface - 这是在Java 8版本中引入的,用在接口上,标记接口是一个函数式接口(即只有一个方法的接口,可以直接用一个lambda来作为接口的实例)。
  • @SafeVarargs - 用于方法和构造方法上,断言varargs参数(即可变长参数)会被安全地使用。比如涉及泛型的可变长参数会有『unchecked』警告,加了@SafeVarargs时编译器不会再给出『unchecked』警告。

通过这些内置注解可以了解注解的类型和特点,并掌握注解的使用方法,这是学习自定义注解,即注解高级玩法的基础。

理解注解

可以发现注解并不直接对其修饰的代码产生影响,它是为代码提供额外的信息,它是代码的元数据,注解与代码一起构成了编译器的完整输入,编译器借助注解可以生成并得到最终完整的代码。

注解本身无论是使用还是定义都相对直观和简洁,非常容易理解,因为注解本身就是一种元数据,提供一种标记或者额外的数据。重点在于注解的处理,这是注解功能发挥作用的地方也就是注解功能逻辑实现的地方。

元编程

注解是程序的元数据,所以这属于元编程范畴。元编程Metaprogramming也即是以代码为操作目标和目标输出的编程范式,元编程是生产力工具,可以减少重复代码,大大的提高代码开发效率。大多数通用编程语言都支持元编程,像C/C++语言中的宏,Java中的注解,反射和动态代理,大Python中的装饰器(Decorators装饰器是高阶函数对函数进行操作)和元类(Metaclasses,对类进行操作可理解为类的模板)等等都是元编程。

优秀的框架(Spring)和领域驱动开发(DDD)都是元编程的典型应用。

关于Java的元编程,推荐这两篇文章:

注解的分类

注解是向编译器提供额外信息的一种元编程机制,那么依据机制的简单到复杂,可以把注解分为5个类型:

标记注解(Marker Annotations)

最简单的注解,对于某个声明进行标记,编译器会对被标记的声明进行检查和处理。如@Override和@Deprecated。

单值注解(Single Value Annotations)

需要给注解传递一个参数且只有一个参数,如@SuppressWarnings(“unchecked”)。

全值注解(Full Annotations)

需要给注解传递很多参数(多个键值对),如:

1
2
3
4
@Test(owner="Paul", values="Class Greeks")
public void testSomeMethod() {
    // ...
}

类型注解(Type Annotations)

可以用在类型被声明的地方,比如方法返回值,方法的参数声明等,如:

1
public @NonNull String transform(@Nullable String source) { ... }

重复注解(Repeating Annotations)

常规的注解在同一个地方只能出现一次,但重复注解可以在同一个地方出现多次,如:

1
2
3
4
5
@Words(word="Hello", value=1)
@Words(word="World", value=2)
public void method() {
    // ...
}

自定义注解

注解的使用是非常的直观和简洁的,无论是内置注解还是各种框架定义好了的注解,使用起来那是相当的香。但这远远不够,因为注解最大的威力在于元编程,比如代码操作和代码生成,这是减少重复劳动(重复代码)和提供开发效率的大杀器。所以我们必须学会高级玩法,即自定义注解。

元注解

元注解,也即定义注解时所需要的注解。这有点类似于编译器自举,语言本身定义了一个最基础的注解,在其基础之上可以扩展出更多的注解,而注解的处理是通过反射,只要知道一些特殊的标记就可以了,其余的都是逻辑。

@Inherited

默认情况下,在基类中使用的注解是不会被子类继承的,如果注解本身标记有@Inherited,那么注解就会出现在被使用的继承体系中:

1
2
3
4
5
6
7
8
9
10
11
12
@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Company {
    String name() default "ABC";
    String city() default "xyz";
}

@Company
public class Employee { .. }

public class Manager extends Employee { ... }

这个🌰中,如果把@Inherited从注解Company中去掉,那么给类Employee加的注解在其子类Manager中就不会得到继承。大部分时候定义注解时都要加上@Inherited标记。

@Documented

使用了@Documented标记的注解可以出现在文档中(JavaDocs)。

@Repeatable

对应着可重复的注解,指定着可以在哪些标识符上面重复注解。

@Target

指定注解可以作用于何种标识符,如果不指定则可以使用于任何标识符即任何程序元素。可选的选项有:

  • ElementType.ANNOTATION_TYPE - 可以用于其他的注解上面
  • ElementType.CONSTRUCTOR - 可以用于构造方法上面
  • ElementType.FIELD - 可以用于成员变量上面
  • ElementType.LOCAL_VARIABLE - 可以用于方法的本地变量(栈内变量)
  • ElementType.METHOD - 可以用于方法上面
  • ElementType.PACKAGE - 可以用于包(package)上面。
  • ElementType.PARAMETER - 可以用于方法的参数上面。
  • ElementType.TYPE - 可以用于类型声明的地方(即类,接口和枚举的声明)。

可以指定一个@Target(ElementType.METHOD)或者多个目标@Target({ElementType.FIELD, ElementType.LOCAL_VARIABLE})。

@Retention

元注解@Retention用于指定注解保留的生命周期。注解是一种元数据,目标是代码,而代码是有生命周期的:编辑或者说源码时;编译时;运行时。这是程序代码的典型生命周期。而@Retention的作用就是指明注解保留到哪个生命周期。

  • RetentionPolicy.SOURCE - 在源码时保留,编译时就被丢弃,也就是说在编译时并不使用。一般用于编译前的源码处理工具使用,如javadoc,以及代码生成。
  • RetentionPolicy.CLASS - 编译后仍会保留在class文件中,但在运行时(就是JVM加载class时)被丢弃。主要是在编译时使用(比如生成代码)。
  • RetentionPolicy.RUNTIME - 保留到运行时,在运行时可以被使用。

自定义注解

注解可以视为一个特殊的接口,注解的定义就是定义一个接口,而每个接口就是其实现。注解的处理器利用反射获取注解接口的类型信息,再结合注解提供的数据就生成接口的实现代码。这就是注解的工作机制。

用@interface就可以声明一个自定义注解,通用的格式是:

1
2
3
[Access Modifier] @interface <Annotation name> {
    <Type> <Method name>() [default value];
}

可以看到注解本质上是一种接口,但它有一些具体的限制规则:

  • 注解的方法不能有参数和异常签名(throws)
  • 方法的返回值不受限制,可以是任意类型
  • 方法的默认返回值是可选的(即可以有,也可以没有)
  • 定义注解时可以使用元注解,这种套娃机制可以实现更为复杂和更为强大的注解

看一个完整自定义注解的🌰

1
2
3
4
5
6
7
8
9
10
@Documented
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodInfo {
    String author() default "Kevin";
    String date();
    int revision() dfeault 1;
    String comments();
}

运行时注解解析

定义了注解后,就可以在代码中使用了,但这还没完,还需要对注解进行解析和处理。在运行时需要用到反射来解析注解,反射API中有专门用于处理注解的API:

  • AnnotatedElement - 这是反射接口处理注解的核心类型,它是反射类型Method,Field和Constructor的基类,通过它的方法来获取注解Annotation实例。
  • Annotation来处理具体的注解

注意注意,注解的解析和处理用的是反射,所以注解定义时要用RententionPolicy.RUNTIME,否则用反射是拿不到注解信息的,因为反射是在运行时(Runtime)。下面我们会用一个完整的实例来学习如何处理自定义注解。

完整示🌰

至此注解的概念的原理都清楚了,融会贯通一下,用一个完整的🌰来展示自定义注解。

Step 1:定义注解

直接复用前面定义的@MethodInfo。

Step 2:使用注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MethodInfoExample {
    @Override
    @MethodInfo(author = "Alex", comments = "Main method", date = "Mar 29 2024", revision = 2)
    public String toString() {
        return "toString method Overridden";
    }

    @Deprecated
    @MethodInfo(comments = "Deprecated method", date = "Mar 30, 2024")
    public static void oldMethod() {
        System.out.println("Old method out!");
    }

    @SuppressWarnings({"unchecked", "deprecation"})
    @MethodInfo(author = "Paul", comments = "Main method", date = "Mar 31 2024")
    public void genericsMethod() throws FileNotFoundException {
        List list = new ArrayList();
        list.add("Xyz");
        oldMethod();
    }
}

Step 3:解析注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MethodInfoParsing {
    public static void main(String[] args) {
        try {
            Method[] methods = MethodInfoParsing.class
                    .getClassLoader().loadClass("MethodInfoExample").getDeclaredMethods();
            for (Method method : methods) {
                if (!method.isAnnotationPresent(MethodInfo.class)) {
                    continue;
                }
                for (Annotation annotation : method.getDeclaredAnnotations()) {
                    System.out.println("Annotation " + annotation + " on method " + method.getName());
                }
                MethodInfo info = method.getAnnotation(MethodInfo.class);
                if ("Paul".equals(info.author())) {
                    System.out.println("From Pauls: " + method.getName());
                }
            }
        } catch (ClassNotFoundException e) {
        }
    }
}

注解处理器

在运行时解析注解比较简单,较麻烦的是在编译时(Compile time)处理注解,这时的处理又特别的关键,因为像代码生成是在这一阶段做的。编译时处理注解需要用到Annotation Processor

一个典型的Annotation processor实现过程:

  • 实现一个Processor,通常通过继承AbstractProcess
  • 覆写方法process来处理注解,这里面过滤出想要处理的注解,然后用JavaWriter来生成Java文件(或者粗暴的用PrintWriter也可以)。
  • 注册实现好的Processor给编译器:可以通过编译命令javac -processor来指定处理器;也可以把处理器打成jar包然后当成库添加到项目中,因为编译器在开始编译前会自动的去搜索注解和注解处理器。

可以参考如下文章来详细了解Annotation processor的实现过程:

这里是一系列优秀的Annotation processor案例

为什么用注解

注解是非常优雅的元编程方式,可以生成代码(减少重复),降低耦合。比如著名的单元测试框架JUnit,在其4.0时(即JUnit4)就用注解替代了继承。在JUnit3要这样写测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Using JUnit 3.0
public class MyClassTest extends TestCase {
    private MyClass instance;

    @Override
    protected void setup() throws Exception {
        super.setup();
        instance = new MyClass();
    }

    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
    }

    public void testSomeMethod() {
        assertNotNull(instance);
        assertEquals("Hello, world", instance.say());
    }
}

这是类MyClass的一个简单的测试用例。在JUnit4使用了注解后,就可以这样写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Using JUnit 4.0
public class MyClassTest {
    private MyClass instance;

    @Before
    private void setup() {
        instance = new MyClass();
    }

    @After
    private void tearDown() {}

    @Test
    public void testSomeMethod() {
        assertNotNull(instance);
        assertEquals("Hello, world", instance.say());
    }
}

通过注解@Before标记测试前准备和@After测试后清理,用@Test标记测试用例,也不用继承TestCase了,整体测试代码非常的优雅。这就是注解的作用。

什么时候用注解

注解的本质是程序的元数据,为编译器提供的代码以外的额外的数据。注解是优雅的元编程的一种方式,可以减少重复的代码,提升开发效率。所以每当需要减少重复代码,生成代码,提供元数据时就要用注解来实现。特别是特定领域的问题,非常适合大量使用注解,如数据库(Room),网络请求(Retrofit),单元测试(JUnit)等等。并且注解的大部分应用都是在编译时生成代码,也不影响性能,所以可劲造儿,尽可能的使用注解吧。

总结

本文从注解的基础用法出发,再到核心概念的阐述,最后用一个自定义注解的例子展示如何用注解来实现元编程,全方位的阐述了注解。相信通过此文对注解的理解会更上一个层次。

参考资料

Comments