25、注解

内容来自王争 Java 编程之美
注解 API

对于 Java 程序员来说,注解应该一点都不陌生,一些注解我们天天都在用,比如:@Override、@Deprecated、@SuppressWarning 等
除了这些 Java 内建注解(built-in anotation)之外,我们还可以定义注解,比如:Spring 框架就自定义了 @Service、@Controller 等很多注解,我们可以使用注解替代 XML 配置文件来提供配置
本节我们就详细讲解一下注解,在开始之前,给你留一个思考题:相对于 XML 配置文件的配置方式,使用注解来做配置有何优缺点?带着这个问题,我们开始今天的学习吧

1、定义注解

在讲解异常时,我们提到,尽管 Java 提供了许多内建异常,但是在平时的开发中,我们还需要经常根据业务需求自定义异常,注解跟异常有相似之处,也有不同的地方
相似之处是,异常和注解都提供了内建和自定义两种类型
不同的地方在于,在项目开发中,我们经常会自定义异常,但很少自定义注解
跟反射的应用场景类似,自定义注解的应用场景更多的是框架开发,比如刚刚提到 Spring 框架就定义了很多注解

1.1、示例

注解的定义方式比较简单,如下所示,其中一个 Java 内建注解,另一个是自定义注解

// Java 内建注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    public enum TimeUnit {SECOND, MINUTE, HOUR, DAY, MONTH}

    String apiName();

    int limitCount();

    TimeUnit timeUnit() default TimeUnit.SECOND;
}

从上述代码,我们发现,定义一个 Java 注解,仍然需要用到注解,@Target、@Retention 等这些用于定义注解的注解,叫做元注解
接下来,我们依次来看下,这些元注解都是干什么用的

1.2、@Target

@Target 用来描述注解的使用范围,它有以下取值,我们一一罗列并解释

ElementType.TYPE:              类、接口、枚举
ElementType.METHOD:            用于方法
ElementType.CONSTRUCTOR:       用于构造器
ElementType.FIELD:             用于成员变量
ElementType.LOCAL_VARIABLE:    用于局部变量
ElementType.PARAMETER:         用于参数
ElementType.PACKAGE:           用于包

一般来讲,注解最常用于类、方法、成员变量,你可能对注解用于包、参数、局部变量,感到比较诧异
实际上注解只不过是起标识作用,就相当于给代码元素打了一个 tag,任何编译器或者应用程序通过反射可以访问的代码元素,我们都可以用注解去标识
关于最后这句话,我们待会再解释

一个注解可以有多个使用范围,如下所示,如果在定义注解时,我们不使用 @Target 标记使用范围,那么注解可以用于任何范围

@Target({ElementType.PARAMETER, ElementType.LOCAL_VARIABLE})

1.3、@Retention

@Retention 用来描述注解的可见范围(或叫生命周期),它有以下取值

RetentionPolicy.SOURCE   源码阶段
RetentionPolicy.CLASS    源码阶段 + 字节码阶段
RetentionPolicy.RUNTIME  源码阶段 + 字节码阶段 + 运行阶段

SOURCE 表示注解仅在源码中可见,当编译器将源码编译成字节码后,注解信息将被丢弃,不过编译器可以读取到可见范围为 SOURCE 的注解
比如 @Override 的可见范围就是 SOURCE,编译器在编译代码的时候,发现函数上标有 @Override 注解,就会去检查对应的函数有没有在父类中定义,如果没有就提示编译错误
CLASS 表示注解在源码、字节码中均可见,但在运行时是不可见的,我们无法在程序运行时,利用反射获取到代码(类、方法等)的这类注解信息
RUNTIME 表示注解在源码、字节码、运行时均可见,其生命周期最长,我们可以在程序运行时,利用反射来获取代码的这类注解信息

1.4、@Documented

@Documented 用来表示注解信息会输出到 Javadoc 文档中
当我们根据源码生成 Javadoc 文档时,类或方法上的用 @Documented 标记的注解也会跟随输出到 Javadoc 文档中

1.5、@interface

class、interface、enum、@interface 这四者是平级关系,class 用来定义类,interface 用来定义接口,enum 用来定义枚举,@interface 用来定义注解
在注解中,我们还可以定义一些变量,变量的定义比较特殊,跟普通类中的变量定义方式不同,注解使用方法来定义的变量,示例代码如下所示
对于只有一个变量的注解,我们可以将其定义为 value,这样在使用时,我们可以不指定变量的名称

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Author {
    String value();
}

@Retention(RetentionPolicy.SOURCE)
public @interface Description {
    String author();

    String date();
}

@Author("wangzheng")
public class Demo {
    @Description(author = "wangzheng", date = "2020-11-22")
    public void f() {
        // ...
    }
}

除了以上元注解之外,还有其他一些元注解,比如 @Inherited、@Repeatable,因为它们并不常用,所以我们这里就不一一讲解了,感兴趣的话,可以自行查阅

2、标记注解

不管是内建注解,还是自定义注解,使用方法都是一样的,注解用于类、方法等代码元素之上,起到标记作用
比如:Java 内建注解 @Override,用来标记某个函数是对父类的重写
再比如前面定义的 @RateLimit 注解,用于标记需要限流的接口,使用方式如下所示,在使用注解时,我们可以为注解中定义的变量赋值

public class UserController {
    @RateLimit(apiName = "/user/register", limitCount = 1000, timeUnit = RateLimit.TimeUnit.SECOND)
    public UserVo register(String telephone, String password) {
        // ...
    }
}

3、读取注解

大部分情况下,只定义和标记注解还不够,还需要有读取注解并做相应处理的代码逻辑,才能发挥注解的真正作用,注解的定义、标记、读取三者缺一不可
这就相当于,在推荐算法中,我们只定义标签和给数据打标签是没用的,我们还需要设计根据标签分类数据的算法,这样才能发挥标签的作用
对于 Java 内建注解,编译器和 JVM 都可以对其进行读取和处理,比如 @Override 注解,编译器在编译代码时,会读取所有标记了 @Override 的方法,并且检查父类中是否有同名方法,如果没有则编译报错

对于自定义注解,我们需要自己开发相应的读取和处理逻辑,如何来读取代码(类或方法等)中的注解信息呢?这就要用到上一节课讲到的反射语法
因为反射作用于代码运行时,所以从侧面上我们也可以得出,自定义注解的 @Retention 可见范围一般应该设置为 RUNTIME

4、注解应用

注解主要有 3 个应用:替代注释、替代 Marker Interface、替代 XML 配置文件,我们依次来看下这 3 个应用

4.1、替代注释

在编写单元测试代码时,因为访问权限的限制,单元测试一般只能测试 public 和 protected 方法,如果我们希望测试 private 方法,那么我们需要将这个方法的访问权限从 private 变为 protected
为了表明此方法设置为 protected 只是为了测试,以免程序员误解和误用,我们可以使用 Google Guava 提供的 @VisibleForTesting 注解在方法上进行标记
实际上,@VisibleForTesting 注解只是起到注释的作用,并没有实际的作用,并不能限制除了单元测试代码之外的其他代码访问这个方法
尽管这里我们也可以使用注释来替代注解,但是注解相对于注释,更加规范、统一,可读性更好

public class IdGenerator {
    public String generate() {
        // ...
    }

    private String getLastFieldOfHostName() {
        // ...
    }

    @VisibleForTesting
    protected String getLastSubstrSplitByDot(String hostName) {
        // ...
    }

    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
        // ...
    }
}

4.2、替代标记接口

示例 1

Java 中有一种特殊的接口,叫做标记接口(Marker Interface),标记接口中不包含任何方法,跟注解类似,起到标记作用
比如:常见的标记接口有 RandomAccess、Cloneable、Serializable 等,它们的定义如下所示

public interface RandomAccess {} // 随机访问
public interface Cloneable    {} // 克隆
public interface Serializable {} // 序列化

ArrayList 容器实现了这三个标记接口,用于表示 ArrayList 容器支持随机访问、克隆、序列化

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    // ... 省略代码 ...
}

在某些代码逻辑中,我们可以根据标记接口,判断对象是否可以执行某些操作(比如是否可以随机访问、是否可以克隆、是否可以序列化)
如下所示,java.util.Collections 类中的 binarySearch() 函数,会根据不同类型的 List 容器执行不同的二分查找逻辑
对于支持随机访问的 List 容器,也就是实现了 RandomAccess 标记接口的 List 容器,binarySearch() 函数调用 indexedBinarySearch() 函数来实现二分查找

// 位于 java.util.Collections 类中
public static <T> int binarySearch(List<? extends T> list, T key, Comparator<? super T> c) {
    if (c == null)
        return binarySearch((List<? extends Comparable<? super T>>) list, key);

    if (list instanceof RandomAccess || list.size() < BINARYSEARCH_THRESHOLD)
        return Collections.indexedBinarySearch(list, key, c);
    else
        return Collections.iteratorBinarySearch(list, key, c);
}

示例 2

标记接口只是起到标记作用,注解也可以起到标记的作用,因此我们可以使用注解来替代标记接口
比如:我们可以将 RandomAccess 标记接口替换为如下注解

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RandomAccess {
}

在 ArrayList 类中,我们使用 @RandomAccess 注解来表示 ArrayList 容器支持随机访问,如下所示

@RandomAccess
public class ArrayList<E> extends AbstractList<E> implements List<E>, Cloneable, java.io.Serializable {
    // .... 省略代码 ...
}

RandomAccess 标记接口改为 @RandomAccess 注解之后,我们需要重新实现 Collections 中的 binarySearch() 函数
重新实现之后的代码如下所示,binarySearch() 函数通过读取 list 对象对应的类上的注解信息,来判断 list 容器是否支持随机访问

public static <T> int binarySearch(List<? extends T> list, T key, Comparator<? super T> c) {
    if (c == null)
        return binarySearch((List<? extends Comparable<? super T>>) list, key);

    Class<?> clazz = list.getClass();
    if (clazz.isAnnotationPresent(RandomAccess.class) || list.size() < BINARYSEARCH_THRESHOLD)
        return Collections.indexedBinarySearch(list, key, c);
    else
        return Collections.iteratorBinarySearch(list, key, c);
}

4.3、替代配置文件

示例 1 XML

在上一节中我们讲到,Spring IOC 容器需要读取应用程序的配置文件,解析出需要创建的对象,然后使用反射来创建对象
Spring 中配置文件的加载方式和配置文件如下示例代码所示

// 配置文件 beans.xml
<beans>
    <bean id="redisCounter" class="com.xzg.redisCounter" scope="singleton" lazy-init="true">
        <constructor-arg type="String" value="127.0.0.1"/>
        <constructor-arg type="int" value="1234"/>
    </bean>

    <bean id="rateLimiter" class="com.xzg.RateLimiter">
        <constructor-arg ref="redisCounter"/>
    </bean>
</beans>
public class Demo {
    public static void main(String[] args) {
        ApplicationContext applicationContext = new ClassPathXmlApplicationContext("beans.xml");
        RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");
        rateLimiter.test();
        // ...
    }
}

示例 2 注解集中式

除了支持配置文件的配置方式之外,Spring 还支持基于注解的配置方式,我们将以上 XML 配置文件替换为 Java 注解,如下所示

@Configuration
public class AppConfig {
    @Bean
    public RateLimiter rateLimiter() {
        return new RateLimiter(redisCounter());
    }

    @Bean(name = "redisCnt")
    public RedisCounter redisCounter() {
        return new RedisCounter("127.0.0.1", 1234);
    }
}

public class Demo {
    public static void main(String[] args) {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");
        rateLimiter.test();
    }
}

解释 2 注解集中式

程序在启动时,Spring IOC 容器利用反射获取到 AppConfig 上的注解,发现包含 @Configuration 注解
便确定这个类为配置类,然后利用反射获取标记有 @Bean 注解的方法,利用反射执行方法并将创建的对象放置于 BeanFactory 中
BeanFactory 维护了一个 Map 结构,Map 中的键为对象名称,值为对象本身,之后我们便可以使用 getBean("rateLimiter") 这种方式从 BeanFactory 中获取对象了
对于 @Bean 标注的方法,默认使用方法名作为对象名称(比如 rateLimiter),当然也可以通过注解中的 value 变量来指定对象名称(比如 rediCnt)

从上述示例我们发现,跟 XML 配置文件的配置方式类似,基于 Java 注解的配置方式,也是集中式的配置方式
所有要创建对象都集中在 AppConfig 类中,AppConfig 类就等同于 XML 配置文件,只不过形式不同而已
有些 Java 程序员偏爱于 Java 代码做配置,有些 Java 程序员偏爱于 XML 文件做配置
两者没有绝对的优势,不过基于 Java 注解,不仅可以实现集中式配置,还可以将配置分散在各个类中

示例 3 注解非集中式

如下所示,如果我们需要 Spring IOC 容器帮忙创建和管理某个类的对象
那么我们只需要在这个类上标记上 @Component 注解(当然也可以是 @Controller、@Service、@Repository 等 Spring 可以识别的其他注解)
Spring IOC 容器会为标记了 @Component 注解的类创建一个同名对象,我们也可以在 @Component 注解中指定创建的对象名称

除此之外,如果创建某个类的对象需要依赖其他对象,那么我们可以使用 @Autowired 自动依赖注入注解,标记依赖的成员变量
这样 Spring IOC 容器会从 BeanFactory 中获取依赖的对象,自动赋值给成员变量

@Component
public class RateLimiter {
    @Autowired
    private RedisCounter redisCounter;

    public void test() {
        System.out.println("Hello World!");
    }

    // ...
}

因为被标记为 @Component 的类可散落在项目代码中的各个地方,为了让 Spring IOC 容器能查找到这些类,我们需要告知 Spring IOC 去哪些 package 下扫描查找
告知的方法如下所示,编写 AppConfig 类,并且通过注解 @ComponentScan 指定扫描路径

@Configuration
@ComponentScan("com.xzg")
public class AppConfig {
}

public class Demo {
    public static void main(String[] args) {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");
        rateLimiter.test();
    }
}

程序在启动时,Spring IOC 容器会通过反射读取 AppConfig 类上的注解,发现包含 @Configuration,确认是配置文件
然后再通过反射读取 @ComponentScan 注解中的 value 值,获得扫描范围 com.xzg
接下来,Spring IOC 容器便在 com.xzg 包下,扫描标记有 @Component 注解的类,然后利用反射创建类的对象,并存储到 BeanFactory 中
在创建的过程中,如果某个类中的成员变量标记有 @Autowired 注解,那么 Spring IOC 容器会从 BeanFactory 中,查找已经创建好的对象,自动赋值给这个成员变量

总结

  • 非集中式的配置方式:添加、删除类不需要修改集中的配置文件,并且注解还能在代码中起到注释的作用
    比如:在阅读代码时,如果我们发现某个方法标记了 @Transactional 注解,那么我们可以得知这个方法支持事务
  • 集中式配置也有优点:配置信息跟代码解耦合,方便代码复用,除此之外,使用集中式配置,配置集中于一处,项目中有哪些配置一目了然
    比如:如果我们使用配置文件配置事务,那么通过查看配置文件,我们就可以得知项目中所有支持事务的方法

集中式配置方式(基于 XML 配置文件、基于 Java 注解)和非集中式配置方式(基于 Java 注解)各有利弊,没有哪个具有绝对优势,你可以根据团队的习惯自行选择

5、课后思考题

很多 Java IDE 中都支持 Lombook 插件,借助 Lombook 插件,我们只需要如下所示使用 Lombook 提供的注解,Lombook 便能自动帮我们生成 getter、setter 方法
请简单阐述一下 Lombook 的实现原理

public class Student {
    @Getter
    @Setter
    private String id;
}
从 JDK 6 开始,javac 编译器开始支持 JSR269 规范(Pluggable Annotation Processing API)
我们只需要按照这个规范来开发注解插件(插件包含定义注解、使用注解、注解处理器这三部分内容)
javac 编译器便会在执行前端编译时调用注解插件执行相应的注解处理器代码

我们在开发中经常用到的 Lombook 插件便是按照 JSR269 规范开发的注解插件
在编译代码时,javac 编译器会调用 Lombook 插件的注解处理器,注解处理器根据 @getter、@setter 等注解,为类、成员变量生成 getter、setter 等方法
Lombook 插件中定义的注解大部分都是 SOURCE 级别的,只存在于源码中,当代码编译成字节码之后,这些注解便没有存在的意义了
毕竟 JVM 并不关心 getter、setter 方法来自于程序员亲自编写,还是 Lombook 注解
posted @ 2023-06-10 15:49  lidongdongdong~  阅读(37)  评论(0编辑  收藏  举报