一、前言
注解,我们都并不陌生,如常见的有@Override
、@Deprecated
,@Controller
,@Bean
等,这其中有 JDK 原生的注解,也有第三方框架自定义的注解,通过注解,我们可以快速且便利地表达很多含义,扩展很多功能。在 Java 的整个技术体系中,注解这一技术扮演着举足轻重的角色,真正理解注解的原理以及常用场景,显然是非常重要的。
二、注解的基本概念
Java 自 JDK1.5 版本开始引入注解这一技术/设计,它有点像一种特殊的注释(或者说标签),结合 Java 的反射技术、编译技术,可以让开发人员更简洁方便地去设计/编写/扩展相关代码,以下,笔者对注解相关的基本概念进行简单说明。(本文所述基于 JDK1.8)
为了让读者有一个相对直观的感受,笔者此处先列举几个常见/常用注解的关键源代码:
//1、JDK自带的@Override注解关键源码:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
复制代码
//2、spring中的@Controller注解关键源码:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {
/**
* The value may indicate a suggestion for a logical component name,
* to be turned into a Spring bean in case of an autodetected component.
* @return the suggested component name, if any (or empty String otherwise)
*/
@AliasFor(annotation = Component.class)
String value() default "";
}
复制代码
//3、lombok中@Slf4j注解关键源码:
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Slf4j {
/** @return The category of the constructed Logger. By default, it will use the type where the annotation is placed. */
String topic() default "";
}
复制代码
从以上@Override
、 @Controller
、 @Slf4j
这 3 个注解的源码中,我们可以看到注解的一些基本定义/写法。(不理解没关系,后文会有解释)
各注解详细的定义/说明,基本都可以直接查看 JDK 源码上的注释,非常详细。列清单有点多余,但为了本文整体上的完整性,笔者此处依然简单列举说明一下。
Java 注解本身提供了 5 个基本的注解,列举如下:
同时,为了支持用户自定义注解,Java 提供了 6 个元注解,元注解是用来修饰(定义)注解的一种注解,列举如下:
元注解@Target
的元素类型 ElementType 共有 10 种,其枚举值如下:
元注解@Retention
的保留策略 RetentionPolicy 共有 3 种,其枚举值如下:
说到此处,再回过头来看@Override
、@Controller
、 @Slf4j
这 3 个注解的源代码,是不是就好理解了一些呢?
三、注解的基本原理
上文所述,基本都是注解该如何定义相关,实际上,如果说我们只是定义了注解,而不对注解进行解析、处理的话,那注解和一般的注释也就没有太大的差别了。要真正发挥注解的作用,我们就需要将定义的注解使用在合适的程序单元上,然后对注解进行读取并解析处理,可以说,注解其实就是定义好给编译器/JVM 看的一种特殊标签。
那么,为了实现读取并解析处理注解,Java 提供了一套怎么样的机制呢?
整体来说,Java 注解需要有定义注解、使用注解、读取注解(解析处理)这 3 个步骤,这 3 个步骤构成了注解的一整个生命周期。为便于理解,以下,笔者通过一个简单示例,对这 3 个步骤进行说明:
1、定义注解:
首先,自定义一个最基本的注解@MyDoc
,其关键源代码如下:
/**
* 自定义 MyDoc 文档注解,用于描述类的含义
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyDoc {
/**
* doc描述
*/
String value() default "";
}
复制代码
执行命令javac MyDoc.java
编译源码,生成字节码文件MyDoc.class
,用javap MyDoc.class
命令反编译后,代码如下:
public interface com.laonong.demo.annotation.common.MyDoc extends java.lang.annotation.Annotation {
public abstract java.lang.String value();
}
复制代码
由编译后的 class 文件代码可知,Java 通过@interface
关键字来定义一个注解,而从内容来看,注解其实就是继承了 JDK 内置Annotation
接口的一种特殊接口。
2、使用注解:
定义好注解后,我们就需要在业务代码上使用,示例如下:
/**
* 中华田园犬实体类
*/
@MyDoc(value = "这是中华田园犬类的文档注解使用示例")
public class ChineseDog {
/**
* 犬的名字
*/
private String dogName;
}
复制代码
3、读取注解(解析处理):
使用了注解后,我们需要对注解进行读取,并根据读取到的注解值,做自己所需的业务操作(这也是注解最关键的地方),示例如下:
/**
* 获取类注解示例
*/
public class MyDocTest {
public static void main(String[] args) {
ChineseDog chineseDog = new ChineseDog();
//通过类上的注解,获取类上的描述文案
MyDoc myDoc = chineseDog.getClass().getAnnotation(MyDoc.class);
System.out.println("注解内容:" + myDoc.value());
/**
* 获取到注解内容后,我们就可以根据注解的内容执行所需的业务操作。
* 如记录操作日志、根据不同内容执行不同业务逻辑等。
*/
//TODO 业务逻辑
}
}
复制代码
此处,笔者是通过反射方式读取类注解上的内容,同理,其它如属性、方法上的注解内容读取,都可以通过反射的方式来读取。(关于反射,可参考Java 核心基础——反射)
实际上,根据注解处理方式的不同,Java 注解可以分为运行时注解(即注解策略为 RUNTIME)、编译时注解(即注解策略为 SOURCE、CLASS)两大类。
对于运行时注解,需要结合 java 的反射技术来进行处理,即注解继承了 Annotation 接口,在 Java 进程运行时生成动态的代理类,然后通过反射可以获取到代理对象的属性、方法以及相对应的注解内容。(如有兴趣,此处可阅读下 java.lang.reflect 包下的相关注解处理类)
而编译时注解,则需要结合 Java 的字节码相关技术进行处理,一般在一些编译器/框架组件的场景下使用,如 JIT 编译器、lombok 组件等,而这些要求对 Java 的字节码文件结构、语法树结构、class 文件加载运行机制等较为熟悉,要求相对比较高,通常情况下,我们一般不会做这种级别的自定义注解。
日常工作中,对于需要自定义注解的场景来说,运行时注解方式使用灵活,实现起来也相对简单一些,不需要对 class 类文件结构、jvm 指令等相关技术很熟悉,但缺点是性能稍差,不如编译时注解直接生成 class 源代码的方式,但瑕不掩瑜,所以,运行时注解是日常工作当中,我们见到的、用到的比较多的注解方式。
四、自定义注解
在上一节中,笔者自定义了一个简单的注解,本小节,对自定义注解的关键点进行详细说明:
1、自定义注解通过关键字@interface
来定义,默认含义就是继承 JDK 自带的 java.lang.Annotation
接口;
2、定义一个注解和定义一个类/接口是类似的,但有所不同的是,注解不能再继承其它注解或接口,注解中是没有方法的,只有成员变量(属性),且成员变量可以定义默认值,只是成员变量看起来像个方法;
3、成员变量一般建议设置默认值(方便使用,通过 default 关键字定义),且默认值应当是明确的值,且不能为null
;
4、成员变量只能用 public 或默认的(即不写)两种访问权限修饰;
5、若注解有多个成员变量,使用时必需写成员变量的名称(形式为 key=value);若注解只有一个成员变量,建议将成员变量命名为 value,这样在使用注解的时候,就可以不用写变量名了;
6、自定义注解需要用到元注解,也可以使用其它已存在的自定义注解修饰当前的自定义注解,自定注解可以没有成员变量;
7、成员变量的数据类型可以有多种,如基础数据类型、枚举类型、数组等,示例如下:
/**
* 注解成员变量数据类型示例
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface AnnotationValueTypeDemo {
// 1、8种基本数据类型(byte、char、short、int、long、float、double、boolean)
byte byteValue() default 0;
int intValue() default 666;
boolean booleanValue() default true;
// ...其它省略
//2、String类型 (是我们相对用的最多的成员变类型)
String strValue() default "";
//3、Class类型
Class<?> classValue() default Object.class;
//4、枚举类型
Season seasonValue() default Season.SPRING;
//5、注解类型
MyAnnotationType myAnnotationTypeValue() default @MyAnnotationType("laonong");
//6、数组类型 (以上类型的数组,此处以String、枚举类型为例)
String[] strValueArray() default {"red","green"};
Season[] seasonValueArray() default {Season.SUMMER};
}
/**
* 季节枚举类
*/
enum Season{ SPRING,SUMMER,AUTUMN,WINTER }
/**
* 自定义的注解
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
@interface MyAnnotationType {
String value() default "";
}
复制代码
8、通过反射的方式获取注解内容,主要就是通过java.lang.reflect.AnnotatedElement
接口下的相关方法来调用;
使用示例如下:
/**
* 为方便演示AnnotatedElement接口的用法,笔者此处定义2个实体类:Dog、ChineseDog ,
* 定义了3个注解@DogInfo、@ChineseDogInfo、@DogColor
*/
/**
* 狗实体类
*/
@DogInfo("狗信息注解内容")
public class Dog {
}
/**
* 中华田园犬实体类
*/
@ChineseDogInfo(value = "中华田园犬信息注解内容")
public class ChineseDog extends Dog{
/**
* 犬的名字
*/
private String dogName;
/**
* 犬的颜色
*/
@DogColor("中华田园犬颜色")
private String dogColor;
}
/**
* 狗基本信息注解
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DogInfo {
String value() default "";
}
/**
* 中华田园犬基本信息注解
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ChineseDogInfo {
String value() default "";
}
/**
* 狗的颜色注解
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DogColor {
String value() default "";
}
/**
* 反射获取注解值示例:
*/
public class AnnotatedElementGetValueDemo {
public static void main(String[] args) throws NoSuchFieldException {
ChineseDog chineseDog = new ChineseDog();
//1、判断元素上是否有对应注解修饰
boolean classIsAnnotation = chineseDog.getClass().isAnnotationPresent(ChineseDogInfo.class);
System.out.println("1.1、类上是否有对应注解:" + classIsAnnotation);
boolean fieldIsAnnotation = chineseDog.getClass().getDeclaredField("dogName").isAnnotationPresent(DogColor.class);
System.out.println("1.2、属性上是否有对应注解:" + fieldIsAnnotation);
//2、获取所有的注解(包括从父类继承的)
Annotation[] annotations = chineseDog.getClass().getAnnotations();
System.out.println("2、获取所有的注解(包含继承的):" + Arrays.toString(annotations));
//3、获取所有的注解(不包括继承的)
Annotation[] declaredAnnotations = chineseDog.getClass().getDeclaredAnnotations();
System.out.println("3、获取所有的注解(不含继承的):" + Arrays.toString(declaredAnnotations));
//4、获取指定注解的值(继承的注解值也可获取,前提是自定义注解需被元注解@Inherited修饰)
DogInfo classTypeAnnotation = chineseDog.getClass().getAnnotation(DogInfo.class);
System.out.println("4、获取指定注解的值(继承的也可获取):" + classTypeAnnotation.value());
//5、获取指定注解的值(不可获取继承的注解值)
DogInfo declaredAnnotation = chineseDog.getClass().getDeclaredAnnotation(DogInfo.class);
System.out.println("5、获取指定注解的值(不可获取继承的注解值):" + declaredAnnotation);
//6、根据注解类型获取所有注解值内容(若当前类有指定注解,则取当前注解值,没有则继续从父类取继承的注解值;JDK1.8新增)
DogInfo[] dogInfos = chineseDog.getClass().getAnnotationsByType(DogInfo.class);
System.out.println("6、根据注解类型获取所有注解值内容(继承的也可获取):" + Arrays.toString(dogInfos));
//7、根据注解类型获取所有注解值内容(若当前类有注解,则取当前注解值,没有则返回空数组;JDK1.8新增)
DogInfo[] declaredDogInfos = chineseDog.getClass().getDeclaredAnnotationsByType(DogInfo.class);
System.out.println("7、根据注解类型获取所有注解值内容(不可获取继承的注解值):" + Arrays.toString(declaredDogInfos));
}
}
/**
* 执行输出结果如下:
*/
1.1、类上是否有对应注解:true
1.2、属性上是否有对应注解:false
2、获取所有的注解(包含继承的):[@com.laonong.demo.annotation.common.DogInfo(value=狗信息注解内容), @com.laonong.demo.annotation.common.ChineseDogInfo(value=中华田园犬信息注解内容)]
3、获取所有的注解(不含继承的):[@com.laonong.demo.annotation.common.ChineseDogInfo(value=中华田园犬信息注解内容)]
4、获取指定注解的值(继承的也可获取):狗信息注解内容
5、获取指定注解的值(不可获取继承的注解值):null
6、根据注解类型获取所有注解值内容(继承的也可获取):[@com.laonong.demo.annotation.common.DogInfo(value=狗信息注解内容)]
7、根据注解类型获取所有注解值内容(不可获取继承的注解值):[]
复制代码
以下,笔者通过一个实际业务场景演示自定义注解的定义、使用与解析处理:
业务场景:
统计服务方法的执行耗时。
业务方法代码如下:
/**
* @Description : 用户基础信息服务接口
* @Author : laonong
*/
public interface UserInfoService {
/**
* 更新用户基本信息
* @throws InterruptedException
*/
void updateUserInfo() throws InterruptedException;
}
/**
* @Description : 用户基础信息服务接口实现类
* @Author : laonong
*/
@Service
public class UserInfoServiceImpl implements UserInfoService{
/**
* 更新用户基本信息
* @throws InterruptedException
*/
@Override
public void updateUserInfo() throws InterruptedException {
//TODO 业务逻辑,此处以一个休眠模拟业务方法耗时
TimeUnit.MILLISECONDS.sleep(10);
System.out.println(LocalDateTime.now() + ",执行更新用户基本信息业务方法");
}
}
复制代码
如上所示,如果我们需要统计业务方法updateUserInfo()
的执行耗时,最简单直接的方式就是直接在方法的开始、结束位置记录当前时间,然后计算耗时,简易示例如下:
简易示例:
/**
* @Description : 用户基础信息服务接口实现类
* @Author : laonong
*/
@Service
public class UserInfoServiceImpl implements UserInfoService{
/**
* 更新用户基本信息
* @throws InterruptedException
*/
@Override
public void updateUserInfo() throws InterruptedException {
long start = System.currentTimeMillis();
//TODO 业务逻辑,此处以一个休眠模拟业务方法耗时
TimeUnit.MILLISECONDS.sleep(10);
System.out.println(LocalDateTime.now() + ",执行更新用户基本信息业务方法");
long end = System.currentTimeMillis();
System.out.println(String.format("业务方法 %s 执行完成,耗时 %d ms",Thread.currentThread().getStackTrace()[1].getMethodName(),end - start));
}
}
复制代码
这种方式的优点是逻辑简单清晰、代码实现直接,但缺点也很明显,那就是需要强业务侵入,影响主业务逻辑代码的清晰简洁,如果需要统计的业务方法较多的话,则会带来较大的改动工作量,且改动风险也会变大。因此,我们需要一种修改相对简单的、低业务代码侵入的方式来满足这种场景需求,而自定义注解的方式就是不错的选择。(还有一种方式是,通过中间层代理类的方式来实现,但是当需要统计的方法较多时,需要创建/维护大量的中间代理类,非常繁琐,且本质上,和直接在业务方法前后添加统计耗时代码没有太大区别,此处不再赘述。)
自定义注解的方式,又可分为两种,一是运行时注解、二是编译时注解,以下,笔者通过简单实现示例进行说明:
运行时注解示例:
//步骤1、自定义运行时注解
/**
* 方法耗时注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodTimeCost {
}
//步骤2、自定义服务方法的动态代理类
/**
* 方法耗时注解动态代理类
*/
public class ProxyMethodTimeCostHandler implements InvocationHandler {
private Object targetObject;
@Override
public Object invoke(Object object, Method method, Object[] args) throws Throwable {
/**
* 方法没有被@MethodTimeCost注解修饰,则执行默认逻辑
*/
if(!targetObject.getClass().getMethod(method.getName()).isAnnotationPresent(MethodTimeCost.class)){
return method.invoke(targetObject,args);
}
/**
* 方法被注解@MethodTimeCost修饰,则执行统计方法耗时逻辑
*/
long start = System.currentTimeMillis();
Object result = method.invoke(targetObject,args);
long end = System.currentTimeMillis();
System.out.println(String.format("动态代理中,调用业务方法 %s 执行完成,耗时 %d ms",method.getName(),end - start));
return result;
}
public Object newProxyInstance(Object targetObject){
this.targetObject = targetObject;
return Proxy.newProxyInstance(targetObject.getClass().getClassLoader(),targetObject.getClass().getInterfaces(),this);
}
}
//步骤3、定义获取服务代理对象的工具类
/**
* UserInfoService服务代理工具类
*/
public class ProxyUserInfoService {
/**
* 获取UserInfoService的动态代理对象
* @param userInfoService
* @return
*/
public static UserInfoService of(UserInfoService userInfoService) {
return (UserInfoService) new ProxyMethodTimeCostHandler().newProxyInstance(userInfoService);
}
}
//步骤4、使用自定义@MethodTimeCost注解
/**
* 更新用户基本信息
* @throws InterruptedException
*/
@MethodTimeCost
@Override
public void updateUserInfo() throws InterruptedException {
//TODO 业务逻辑,此处以一个休眠模拟业务方法耗时
TimeUnit.MILLISECONDS.sleep(10);
System.out.println(LocalDateTime.now() + ",执行更新用户基本信息业务方法");
}
//步骤5、修改服务方法调用方式
@Test
public void updateUserInfo_reflectTest() throws Exception {
/**
* 原方式,直接通过服务接口userInfoService调用业务方法
*/
//userInfoService.updateUserInfo();
/**
* 新方式,通过userInfoService的代理类调用业务方法
*/
ProxyUserInfoService.of(userInfoService).updateUserInfo();
}
复制代码
当然,这样实现,还是显得有些繁琐(甚至觉得有些怪异),因为我们还需要在调用服务方法的位置进行服务方法调用方式的改造;不过,通常大家的业务项目都是基于 Springboot 框架搭建的,所以,利用 Springboot 框架的 AOP 机制,我们可以更方便地来实现,而这也是日常工作中最常用的实现方式,示例如下:
首先,我们需要引入 AOP 的依赖包:(AOP 的版本取决于 springboot 的版本)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
复制代码
其次,代码改造步骤如下:
//步骤1、自定义运行时注解
/**
* 方法耗时注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodTimeCost {
}
//步骤2、定义方法执行耗时统计切面
/**
* 方法执行耗时统计切面
*/
@Component
@Aspect
public class MethodTimeCostAspect {
/**
* 方法执行
* @return
*/
@Around("@annotation(methodTimeCost)")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint, MethodTimeCost methodTimeCost) throws Throwable {
long start = System.currentTimeMillis();
//执行业务方法
Object object = proceedingJoinPoint.proceed();
long end = System.currentTimeMillis();
System.out.println(String.format("切面中,调用业务方法 %s 执行完成,耗时 %d ms",proceedingJoinPoint.getSignature().getName(),end - start));
return object;
}
}
//步骤3、使用自定义@MethodTimeCost注解
/**
* 更新用户基本信息
* @throws InterruptedException
*/
@MethodTimeCost
@Override
public void updateUserInfo() throws InterruptedException {
//TODO 业务逻辑,此处以一个休眠模拟业务方法耗时
TimeUnit.MILLISECONDS.sleep(10);
System.out.println(LocalDateTime.now() + ",执行更新用户基本信息业务方法");
}
//步骤4、服务调用
@Test
public void updateUserInfo_reflectTest() throws Exception {
userInfoService.updateUserInfo();
}
复制代码
由以上示例可知,通过引入 springboot 框架的 AOP,我们可以非常便利的实现统计方法的执行耗时,当我们需要统计相关的方法执行耗时时,只需在方法上添加 @MethodTimeCost 注解即可,非常方便快捷。(Spring 框架 AOP 的底层也是动态代理)
当然,我们也可以通过编译时注解的方式,在编译阶段,直接将需要添加的统计方法耗时代码编译到原业务代码中,这种方式在日常业务开发工作中并不常见,此处笔者仅做简单示例。
编译时注解示例:
首先,需引入相关依赖:
<!-- 1、引入aspectjrt的依赖包。 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.7</version>
<scope>runtime</scope>
</dependency>
<!-- 如果是springboot项目,也可直接引入springboot的aop包,该starter包下包含aspectj包中的内容。这两个依赖包任选其一即可 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 2、引入aspectj包的maven编译插件。 -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.11</version>
<configuration>
<complianceLevel>1.8</complianceLevel>
<source>1.8</source>
<target>1.8</target>
<showWeaveInfo>true</showWeaveInfo>
<Xlint>ignore</Xlint>
<encoding>UTF-8</encoding>
<skip>true</skip>
</configuration>
<executions>
<execution>
<configuration>
<skip>false</skip>
</configuration>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
<!--
说明:
1、此处是利用aspectj包中的代码编译功能,在编译时直接将需要添加的代码编译到原业务逻辑代码的字节码文件中。和上文中的切面场景下使用时,虽然都是基于aspectj包,但利用的是其提供的不同的能力。
2、引入aspectj-maven-plugin插件,是为了充分利用现有的编译插件来实现编译时注解的字节码编织,而不需要我们自己去根据Java的APT技术实现一个编译生成工具(比较麻烦,有兴趣可以去了解下相关技术)。
3、如果项目中已经引用了类似lombok之类的编译时注解,则maven的aspectj-maven-plugin插件可能会与相对应注解的编译插件产生冲突,此时,需要根据实际情况处理相关冲突,这块网上基本都有相关文章说明,笔者此处不再赘述。
-->
复制代码
其次,编写相关代码:
//步骤1:自定义编译时注解
/**
* 方法耗时注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface MethodTimeCostPro {
}
//步骤2:定义基于Aspectj的切面,用于添加将用于编译到原业务代码中的耗时统计代码
/**
* 方法执行耗时统计 ,编译时切面
*/
@Component
@Aspect
public class MethodTimeCostProAspect {
//ThreadLocal 记录当前线程开始时间
ThreadLocal<Long> startTimeRecord = new ThreadLocal<>();
@Pointcut("@annotation(com.laonong.demo.annotation.common.MethodTimeCostPro)")
public void jointPoint(){
}
@Before("jointPoint()")
public void doBefore(JoinPoint joinPoint){
//存储方法执行前的时间
startTimeRecord.set(System.currentTimeMillis());
}
@After("jointPoint()")
public void doAfter(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
long endTime = System.currentTimeMillis();
System.out.println(String.format("编译切面中,调用业务方法 %s 执行完成,耗时 %d ms",methodName,endTime - startTimeRecord.get()));
startTimeRecord.remove();
}
}
//步骤3:使用自定义@MethodTimeCostPro注解
/**
* 更新用户基本信息
* @throws InterruptedException
*/
@MethodTimeCostPro
@Override
public void updateUserInfo() throws InterruptedException {
//TODO 业务逻辑,此处以一个休眠模拟业务方法耗时
TimeUnit.MILLISECONDS.sleep(10);
System.out.println(LocalDateTime.now() + ",执行更新用户基本信息业务方法");
}
//步骤4、服务调用(先编译后调用)
@Test
public void updateUserInfo_reflectTest() throws Exception {
userInfoService.updateUserInfo();
}
复制代码
由以上示例可知,编译时注解,和运行时注解,注解/切面代码的实现方式几乎一致,使用方式则完全相同,但是实际执行过程是有所不同的,以下,笔者列出使用自定义@MethodTimeCostPro
注解后,业务方法updateUserInfo()
编译后的字节码文件内容:
/**
* updateUserInfo()方法编译后,字节码class文件反编译后的内容,从编译后的内容可以看出,确实将自定义切面中添加的统计
* 方法耗时逻辑的代码编织到了字节码文件中。
*/
@MethodTimeCostPro
public void updateUserInfo() throws InterruptedException {
JoinPoint var1 = Factory.makeJP(ajc$tjp_0, this, this);
try {
MethodTimeCostProAspect.aspectOf().doBefore(var1);
TimeUnit.MILLISECONDS.sleep(10L);
System.out.println(LocalDateTime.now() + ",执行更新用户基本信息业务方法");
} catch (Throwable var3) {
MethodTimeCostProAspect.aspectOf().doAfter(var1);
throw var3;
}
MethodTimeCostProAspect.aspectOf().doAfter(var1);
}
复制代码
当然,还有一种方式可以实现统计方法执行耗时的,那就是基于 Java agent 探针技术,进行动态注入代码来实现,会更加灵活方便,但日常业务开发一般不会这么做,通常是在一些排查问题的工具中才会使用,如阿里开源的 Arthas 工具,但这不是本文重点,此处不再赘述。
五、自定义注解常见的业务场景
因为注解可以实现在代码的编译、类加载、运行时等各个阶段,进行干预,这实际上也就意味着,注解几乎可以实现所有的功能。通常情况下,注解更多的应用场景是在一些通用的、框架层面的场景下使用,如我们常用@Controller
、@Mapper
等注解其实就是框架提供的自定义注解。对我们来说,对于大多数框架层面的注解,我们只需要了解其基本含义、会使用就可以了,而完全需要我们自己自定义注解的场景并不多,此处,笔者列举几个日常工作当中,常见的可借助自定义注解的方式来实现的业务场景:(由于 springboot 框架的普遍性,以下所有业务场景示例都是基于 springboot 项目来演示。)
场景一:
记录用户操作日志。
通过自定义注解的方式来记录用户操作日志,应该是绝大多数同学在实际工作当中见过(使用过)的业务场景了,此处笔者通过一个实例演示一下相关关键步骤。
步骤 1:自定义操作日志注解
package com.laonong.demo.annotation.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @Description : 自定义操作日志注解
* @Author : laonong
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {
String value() default "";
}
复制代码
步骤 2:编写操作日志切面
package com.laonong.demo.annotation.aop;
import com.laonong.demo.annotation.common.OperateLog;
import com.laonong.demo.annotation.entity.OperateLogInfo;
import com.laonong.demo.annotation.service.OperateLogService;
import com.laonong.demo.annotation.util.LocalContextHandler;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Objects;
/**
* @Description : 系统操作日志切面
* @Author : laonong
*/
@Component
@Aspect
public class OperateLogAspect {
/**
* 操作日志服务
*/
@Autowired
private OperateLogService operateLogService;
@Around("@annotation(operateLog)")
public Object around(ProceedingJoinPoint proceedingJoinPoint, OperateLog operateLog) throws Throwable {
//1、获取请求参数
String methodName = proceedingJoinPoint.getSignature().getName();
Object[] args = proceedingJoinPoint.getArgs();
Object operateId = LocalContextHandler.get("userId");
String operateDesc = operateLog.value().toString();
//2、执行业务方法
Object object = null;
Exception methodException = null;
try {
object = proceedingJoinPoint.proceed();
} catch (Exception e) {
methodException = e;
throw e;
} finally {
//3、添加操作日志
OperateLogInfo operateLogInfo = new OperateLogInfo();
operateLogInfo.setMethodName(methodName);
operateLogInfo.setRequestParam(Arrays.toString(args));
operateLogInfo.setOperateId(Long.valueOf(operateId.toString()));
operateLogInfo.setOperateDesc(operateDesc);
if(Objects.nonNull(methodException)){
//业务方法发生异常,则操作日志标识为操作异常记录,并记录关键异常信息
operateLogInfo.setMethodResult("exception");
operateLogInfo.setExceptionMessage(methodException.toString());
} else {
operateLogInfo.setMethodResult(Objects.nonNull(object) ? object.toString() : null);
}
//插入数据
operateLogService.insertOperateLogInfo(operateLogInfo);
}
//返回
return object;
}
}
复制代码
步骤 3:使用注解(在需要记录操作日志的业务方法上添加自定义的@OperateLog
注解)
/**
* 新增用户信息
*/
@OperateLog("新增用户信息")
@Override
public Boolean insertUserInfo(UserInfo userInfo) {
//TODO 业务代码
}
复制代码
代码说明>>
1、从示例代码可知,通过自定义注解的方式记录操作日志,整体上可以分为自定义注解、编写注解处理切面、使用注解这三大步骤,而这其中最关键的在于注解切面的实现。
2、注解切面,基本上就是利用了 Spring 的 AOP 机制和 Java 的反射机制来实现业务方法参数的获取与解析处理,然后,在切面中添加所需的记录操作日志的相关业务逻辑。
3、示例代码中的OperateLogService
接口就是一个普通直接写 DB 的服务;LocalContextHandler
是笔者自定义的一个简单的从ThreadLocal
中获取参数值的工具类,一般用户的登录信息可以通过这种方式进行传递,赋值操作一般在鉴权拦截器中进行。LocalContextHandler
源码如下:
package com.laonong.demo.annotation.util;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* @Description : 本地上下文内容处理Handler
* @Author : laonong
*/
public class LocalContextHandler {
private static ThreadLocal<Map<String,Object>> threadLocal = new ThreadLocal<>();
public static void set(String key,Object value) {
Map<String,Object> map = threadLocal.get();
if(Objects.isNull(map)){
map = new HashMap<>();
threadLocal.set(map);
}
map.put(key,value);
}
public static Object get(String key) {
Map<String,Object> map = threadLocal.get();
if(Objects.isNull(map)){
map = new HashMap<>();
threadLocal.set(map);
}
return map.get(key);
}
public static void remove() {
threadLocal.remove();
}
}
复制代码
4、记录操作日志的实体具体字段,就需要根据自己实际业务需求进行定义了,以下是笔者定义的操作日志 bean 源码,仅供参考:
package com.laonong.demo.annotation.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* @Description : 业务操作日志实体
* @Author : laonong
*/
@Data
public class OperateLogInfo {
/**
* 主键ID
*/
private Long id;
/**
* 操作者ID
*/
private Long operateId;
/**
* 操作描述
*/
private String operateDesc;
/**
* 请求参数
*/
private String requestParam;
/**
* 业务方法名称
*/
private String methodName;
/**
* 业务方法执行结果
*/
private String methodResult;
/**
* 业务方法异常关键信息(业务方法抛异常时才有值)
*/
private String exceptionMessage;
/**
* 新增时间
*/
private LocalDateTime addTime;
/**
* 最后修改时间
*/
private LocalDateTime updateTime;
}
复制代码
5、本示例只是演示自定义注解记录操作日志场景的一个整体上的步骤,实际工作中需要考虑的点会更多。例如,笔者此处自定义的@OperateLog
注解只有一个描述方法操作的成员变量,实际工作中,操作日志可能还需记录是哪条业务线、哪个项目、哪个模块、哪种类型的操作等更详细的信息,此时,就需要在自定义的注解中添加相关成员变量,以便在使用注解时标识相关内容,满足实际业务所需。
再例如,此处笔者是直接同步插入操作日志到 DB 中,但是如果说插入操作日志的业务场景很多、访问量很大的话,那么插入操作日志这一步骤就可能会成为整个系统的性能瓶颈,这显然是不太合理的,此时就需要考虑将插入操作日志这一步骤做成异步的(本地异步线程、异步队列、消息中间件等),而一旦改成异步的,就又要考虑操作日志记录、与业务操作记录的数据一致性问题。
当然,本示例基本上能够满足大多数的业务场景需求了,即便是需要进行相关的扩展改造,通过自定义注解的方式记录操作日志,在整体上最关键的依然就是这几个步骤。
场景二:
API 参数校验。
服务端 API 参数校验是 web 开发必不可少的组成部分,大多情况下,我们都会选择集成validation-api
来实现,springboot 对它的兼容支持也比较好,通常情况下,validation-api
自带的相关校验注解已经能够满足大多数业务场景了,而对于一些有特定需求的业务场景,validation-api
也提供了一套扩展机制,我们只需自定义注解进行扩展,就可以基本满足我们日常工作所需了。
使用validation-api
做参数校验需引入依赖包:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
复制代码
实例:
例如我们有一个衣服信息的实体 bean 如下:
package com.laonong.demo.annotation.bo;
import lombok.Data;
/**
* @Description : 衣服信息请求参数BO
* @Author : laonong
*/
@Data
public class ClothesBO {
/**
* 名称
*/
private String name;
/**
* 价格
*/
private Integer price;
/**
* 尺寸 (S、M、L、XL、XXL)
*/
private String size;
/**
* 品类 (1、2、3、4、5)
*/
private Integer type;
}
复制代码
我们需要对请求参数ClothesBO
进行参数校验,则需要在ClothesBO
实体的属性上添加相关校验注解,示例如下:
package com.laonong.demo.annotation.bo;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
/**
* @Description : 衣服信息请求参数BO
* @Author : laonong
*/
@Data
public class ClothesBO {
/**
* 名称
*/
@NotBlank(message = "名称不能为空")
@Length(max = 50,message = "名称长度不能超过50个字符")
private String name;
/**
* 价格
*/
@NotNull(message = "价格不能为空")
@Min(value = 0,message = "价格需为非负数数")
private Integer price;
/**
* 尺码 (S、M、L、XL、XXL)
*/
@Pattern(regexp = "^S|M|L|XL|XXL$",message = "尺码错误")
private String size;
/**
* 品类 (1、2、3、4、5)
*/
@Range(min = 1,max = 5,message = "品类错误")
private Integer type;
}
复制代码
一般来说,这些框架自带的注解已经能够满足基本业务场景需求了,但是细想一下,还是有所不足。如@Range
注解标识品类值,是通过数字范围来实现的,如果说业务上某种属性值不是这种连续数字,而是类似 10、20、30 这种,那@Range
注解就无能为力了。再说@Pattern
注解,虽然理论上正则表达式可以匹配大多数校验场景,但是还是不够完善,一是@Pattern
注解只能用于修饰String
类型属性,而对于Integer、Long
等其它数据类型则不行,二是对于数组/集合、对象等业务场景的参数校验也实现不了,三是相对复杂的正则表达式并不是很容易编写,可读性较差,后续维护也比较困难。好在,validation-api
本身提供了扩展机制,我们只要根据其规则,自定义校验注解,就可以自己编写校验逻辑,从而满足个性化的校验需求。
validation-api
的扩展实现,关键点在于@Constraint
注解和ConstraintValidator
接口的使用,示例如下:
步骤 1:自定义校验注解
package com.laonong.demo.annotation.common;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
/**
* @Description : 自定义指定类型值注解
* @Author : laonong
*/
@Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {StateValueValidator.class})
public @interface StateValue {
//默认校验失败消息
String message() default "必须为指定值";
//字符串类型数组
String[] strValues() default {};
//整型类型值数组
int[] intValues() default {};
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
复制代码
步骤 2:编写自定义校验器代码(实现ConstraintValidator
接口中的initialize
、isValid
两个方法)
package com.laonong.demo.annotation.common;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
/**
* @Description : 自定义指定类型值注解校验器
* @Author : laonong
*/
public class StateValueValidator implements ConstraintValidator<StateValue,Object> {
/**
* 使用自定义校验注解@StateValue时,存放注解成员变量上用户所赋的校验值
*/
private String[] strValues;
private int[] intValues;
/**
* 初始传值方法
* @param constraintAnnotation 自定义的注解,可获取使用时成员变量的值
*/
@Override
public void initialize(StateValue constraintAnnotation) {
strValues = constraintAnnotation.strValues();
intValues = constraintAnnotation.intValues();
}
/**
* 校验逻辑方法(每次参数校验,都会调用该方法,这里就是我们可以自己灵活编写自定义校验逻辑的地方)
* @param value 待校验的请求参数
* @param context 校验器上下文变量
* 提供了一套更加灵活的上下文操作,如默认的提示信息更改。有兴趣可查看源码中的注释。
* @return boolean true表示校验通过,false表示不通过
*/
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if(value instanceof String) {
for (String s:strValues) {
if(s.equals(value)){
return true;
}
}
}else if(value instanceof Integer){
for (Integer i:intValues) {
if(i == value){
return true;
}
}
}
/**
* ConstraintValidatorContext 参数如不需要,可以不用管
*/
/**
if(value instanceof String) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("字符串模板提示消息,必须为指定值").addConstraintViolation();
} else {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("整数模板提示消息,必须为指定值").addConstraintViolation();
}*/
return false;
}
}
复制代码
步骤 3:使用自定义的参数校验注解
//......省略重复代码......
/**
* 尺码 (S、M、L、XL、XXL)
*/
@StateValue(strValues = {"S","M","L","XL","XXL"},message = "尺码错误")
private String size;
/**
* 品类 (1、2、3、4、5)
*/
@StateValue(intValues = {1,2,3,4,5},message = "品类错误")
private Integer type;
复制代码
从示例可以看出,使用自定义校验注解@StateValue
后,校验可穷举类型的参数简单清晰了很多,非常方便。而实现一个这样的自定义注解也并不是很复杂,关键点就在于实现ConstraintValidator
接口中的isValid()
方法,理解了这点,我们就可以实现各种复杂的参数校验逻辑。以下,笔者演示一个校验手机号集合的场景:
步骤 1:自定义集合校验注解
package com.laonong.demo.annotation.common;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* @Description : 自定义正则匹配数据集注解
* @Author : laonong
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {PatternItemsValidator.class})
public @interface PatternItems {
String message() default "数据格式错误";
String regexp() default "";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
复制代码
步骤 2:编写自定义集合校验器代码
package com.laonong.demo.annotation.common;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Collection;
import java.util.Iterator;
import java.util.regex.Pattern;
/**
* @Description : 自定义正则匹配数据集注解校验器
* @Author : laonong
*/
public class PatternItemsValidator implements ConstraintValidator<PatternItems, Collection<String>> {
/**
* 手机号校验正则表达式(建议使用这种方式,来预编译正则表达式)
*/
//private static Pattern NUMBER_PATTERN = Pattern.compile("^[1]\\d{10}$");
private String regexp;
@Override
public void initialize(PatternItems constraintAnnotation) {
regexp = constraintAnnotation.regexp();
}
@Override
public boolean isValid(Collection<String> value, ConstraintValidatorContext context) {
Iterator<String> items = value.iterator();
while (items.hasNext()) {
String item = items.next();
Pattern pattern = Pattern.compile(regexp);
if (!pattern.matcher(item).matches()) {
return false;
}
/**
* 此处,每次调用都会编译执行,虽然带来了灵活性,但影响性能,所以,
* 实际业务允许的话,比较好的方式是固定正则表达式写成静态预编译的方式。
*/
/**
if(!NUMBER_PATTERN.matcher(item).matches()){
return false;
}*/
}
return true;
}
}
复制代码
步骤 3:使用
/**
* 手机号集合
*/
@PatternItems(regexp = "^[1]\\d{10}$")
private List<String> phones;
复制代码
通过示例代码可知,@PatternItems
注解可以用于校验各种字符串集合请求参数,只需在注解成员变量regexp
中填写相应正则表达式即可,这对于我们校验前端传递的各种集合类型的参数来说非常方便。
有了这样的校验扩展机制存在,我们就可以在isValid()
方法中实现各种灵活操作,比方说,可以结合配置中心组件,将校验规则配置在配置中心组件中,从而实现校验规则的可动态调整。
场景三:
鉴权标识。
登录鉴权、加密解密、验签等业务场景在日常工作中并不少见,而通常情况下,都是部分接口需要,部分接口不需要,所以在实现相关业务功能时,通常我们都会引入过滤器、拦截器等相关机制来实现,以便达到一定程度上的通用性。
登录鉴权是日常工作中最常见的业务场景,通常情况下的实现思路基本是这样的:先自定义一个登录鉴权拦截器,实现HandlerInterceptor
拦截器接口,在自定义拦截器中实现登录鉴权逻辑;然后添加自定义 webConfig 配置类,实现WebMvcConfigurer
接口,再在重写的addInterceptors()
方法中,添加自定义的登录拦截器,并添加配置好需要拦截的接口 URL 路径。一般来说,这样做是没有什么太大的问题的。不过,还是有所不足:一是后续维护麻烦,当后续新增(或调整)接口时,如果其登录鉴权的必要性和默认的不一样,就需要在统一配置的地方添加这个接口的 URL 配置;二是对于企业级项目来说,一般都会把类似登录鉴权这样的模块作为一个公共基础服务包,以提供给各项目使用,这个时候,如果说想在公共 webConfig 配置的地方添加一个需要处理的接口 URL 就不是很方便了。此时,利用注解的灵活性可以有效地处理这个问题,示例如下:
步骤 1:自定义登录鉴权注解
package com.laonong.demo.annotation.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @Description : 自定义的登录标识注解
* @Author : laonong
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiredLogin {
String message() default "";
//标识是否需要登录 true:需要 false:不需要
boolean required() default true;
}
复制代码
步骤 2:编写登录拦截器代码(写好后,需将拦截器添加到 webConfig 中)
package com.laonong.demo.annotation.intercepter;
import com.laonong.demo.annotation.common.RequiredLogin;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @Description : 登录鉴权拦截器
* @Author : laonong
*/
public class LoginIntercepter implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
boolean requiredLoginTag = handlerMethod.getMethod().isAnnotationPresent(RequiredLogin.class);
if(requiredLoginTag){
//该接口存在@RequiredLogin注解,走登录鉴权逻辑
RequiredLogin requiredLogin = handlerMethod.getMethod().getAnnotation(RequiredLogin.class);
if(requiredLogin.required()){
//TODO 鉴权业务逻辑
//return false;
}
}
return true;
}
}
复制代码
步骤 3:使用
/**
* 查询用户信息
*/
@RequiredLogin(required = true)
@GetMapping("/getUserInfo")
public UserInfoResponseBO getUserInfo(){
//TODO 业务逻辑
}
复制代码
通过结合 springboot 拦截器的使用,有了自定义的@RequiredLogin
注解后,我们可以先设置默认全部接口都不需要登录鉴权(反之同理),当我们新增业务接口时,如果需要登录鉴权,则直接在相应接口上添加@RequiredLogin
注解即可,这样的话,不论将来业务接口如何添加扩展,我们都不需修改最初的基础鉴权模块,非常地灵活方便。
其它:
自定义注解可使用的场景还有很多,理论上来说,只要业务有需要,然后充分发挥你的想象力,就基本可以满足。如常见的还有参数赋值、通用数据 BO 属性补全/格式装换、通用签名验签等等,但总体上的实现思路和以上笔者所列的三个场景示例并无本质上的区别,基本上都是结合语言/框架本身提供的一些机制(反射、切面、拦截器等),然后再扩展实现自定义注解,此处,笔者就不再一一详述。
六、小结
1、日常工作中,我们通常只需要直接使用各种框架/组件所提供的的注解即可,就以为注解本来就应该是这样的,事实上,只是这些框架/组件完成了注解的定义、读取解析过程。久而久之,我们似乎都忘记了注解的完整运行原理了。而理解注解的基本原理,还有利于我们理解各开源框架的代码实现。
2、注解本身并没有什么特殊含义,关键是注解解释器的实现(读取注解,并解析处理),而注解解释器中,反射技术、代码编织技术尤其重要。SOURCE 策略的注解,其实是编译器在进行处理,而不是注解本身在处理。
3、注解的优点之一就是灵活,通过框架的扫描机制,可以随意添加到任意位置,但是,注解的加载使用过于灵活,有时并不是一件好事,尤其是那种项目结构复杂,有多人同时开始/维护时,如果不按照约定的规范来定义/使用注解的话,往往会造成莫名其妙的各种问题,且通常不便于定位问题。
4、在 springboot 的生态中,自定义注解通常可以结合 AOP、拦截器、校验器等机制来使用,从而可以充分满足丰富的业务场景需求。
越学越无知,我是老农,欢迎交流~
评论