# 使用 AOP + 自定义注解实现日志打印

作者:骑猪少年 (opens new window)编程导航星球 (opens new window) 编号 23765

通过日志系统来引出自定义注解的创建方法

在springBoot中,注解是编码时必不可少的,它可以帮助我们更方便快捷的去开发。常见的注解如:@Autowired、@Slf4j、@Data等等。

然而这些注解都是别人已经封装好给我们用的,如果我们想自定义一个拥有特别功能的注解,该怎么操作呢?

看完这篇文章,给你答案~

今天以日志功能为例,灵活的运用自定义注解方便快捷的记录每个接口的日志。

在项目中,有众多的接口,如果接口报错了,该怎么去快速定位代码呢?这个时候就要用到日志了。当接口接收到请求的时候,我希望可以记录下来这个接口的各种信息。比如请求时间、请求参数,结束时间等,还可以在接口开始和结束的时候加一个标记,方便出现报错或者bug的时候可以快速定位到是哪个接口出了问题。


# 不使用注解

我们也可以在接口方法的开头和结尾加一行日志。

public class Mycontroller {
    @GetMapping("/get")
    public String get(String name,int age){
        log.info("Mycontroller**********get********start");
        System.out.println("执行了get方法");
        log.info("Mycontroller**********get********end");
        return name;
    }
}
1
2
3
4
5
6
7
8
9

执行结果:

方法的首尾两行都会有一个日志输出,把这个方法的所有运行包在了日志里面,如果个方法出现了问题,就很容易定位到这里了。

比如我故意写一个报错:int i = 2/0;

    @GetMapping("/get")
    public String get(String name,int age){
        log.info("Mycontroller**********get********start");
        int i = 2/0;
        System.out.println("执行了get方法");
        log.info("Mycontroller**********get********end");
        return name;
    }
1
2
3
4
5
6
7
8

那么输出结果如下:

可以看到报错的上一行日志定位了get方法。我们只需在get方法里面找问题就好了。

每个方法的首尾都要这样写一个日志记录,代码就会大量冗余。想获取入参的话,还得再写一段代码来实现,并且根据每个方法的入参数量、类型的不同,可能代码也要相应的变动。

既然这个是重复性的工作,而且逻辑上都是:在方法开始之前和方法结束之后做一个标记。那么我们能不能把这一部分抽取出来,只写一次代码,就能作用在每一个方法上面呢?

毫无疑问,答案是可以


# 使用自定义注解

在一个事情的开始和结束插入另一个事情,很容易联想到Spring的一个重要特性——AOP

Spring的AOP(Aspect-Oriented Programming,面向切面编程)是Spring框架中的一个重要特性,用于将横切关注点从应用程序的主业务逻辑中分离出来,使得关注点的代码可以被模块化、重用,并且与主业务逻辑解耦。

# 定义注解

使用@interface关键字定义一个注解

public @interface LogInfo {

}
1
2
3

在自定义注解中,根据需要标注元注解,如果没有特定需求的话也可以不标注

一共有以下5个元注解:

  1. @Retention

    (保留策略):

    • RetentionPolicy.SOURCE:注解仅存在于源代码中,在编译时会被丢弃。这种类型的注解通常用于提供编译时的辅助信息,不会对运行时产生影响。
    • RetentionPolicy.CLASS:注解存在于编译后的字节码文件中,但在运行时会被丢弃。这种类型的注解可以在编译时对代码进行一些处理,但不会影响程序运行时的行为。
    • RetentionPolicy.RUNTIME:注解在运行时可以通过反射获取到。这种类型的注解可以在运行时对程序的行为进行动态调整,例如在AOP(面向切面编程)中经常使用。
  2. @Target

    (目标类型):

    • ElementType.METHOD:指定注解可以应用于方法。
    • ElementType.FIELD:指定注解可以应用于字段。
    • ElementType.TYPE:指定注解可以应用于类、接口(包括注解类型)。
    • ElementType.PARAMETER:指定注解可以应用于参数。
    • ElementType.CONSTRUCTOR:指定注解可以应用于构造函数等。
  3. @Documented

    (文档化):

    • 当一个注解被@Documented修饰时,这个注解将会包含在Javadoc生成的文档中,使得注解的信息可以被文档化展示。
  4. @Inherited

    (继承性):

    • 如果一个注解被@Inherited修饰,那么子类会继承父类的该注解。这对于一些需要在继承关系中传递注解的情况非常有用。
  5. @Repeatable

    (可重复性):

    • 允许一个注解在同一个目标上被多次应用,而不需要使用容器注解来包裹多个相同的注解实例。这样可以使代码更加简洁和易读。

# 引AOP依赖

要实现AOP自定义注解,第一步先引入AOP的依赖:

    <!--AOP-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
1
2
3
4
5

# 编写 AOP 程序

新建一个AOP类,针对于特定方法根据业务需要进行编程 (加 @Aspect 注解声明为 AOP 类)

这个类中,我们要实现自定义注解的功能,比如在方法开始之前,做一个标记,记录该方法的入参,方法结束之后再做一个标记。

新建一个AOP类:

@Aspect
@Component
@Slf4j
public class LogAOP {
}
1
2
3
4
5

@Aspect注解:标记该类为切面类,Spring AOP会自动识别带有@Aspect注解的类,并将其视为切面,然后根据定义的通知和切点来实现横切逻辑。

@Component:用来表示一个受Spring容器管理的组件的注解。可以让Spring自动扫描并识别被注解的类,然后将其实例化并加入到Spring容器中管理。

写一个在接口执行之前要执行的逻辑方法:

@Before注解标注,里面的@annotation是用于定义切点表达式的一种特殊用法,

下列代码中@Before("@annotation(LogInfo)")表示在执行被自定义注解标记的方法前执行logBefore方法

@Aspect
@Component
@Slf4j
public class LogAOP {
    @Before("@annotation(LogInfo)")
    public void logBefore(JoinPoint joinPoint){
        // 获取方法所在类的名称
        String fullClassName = joinPoint.getSignature().getDeclaringTypeName();

        // 获取方法名称
        String methodName = joinPoint.getSignature().getName();

        // 提取类名的最后一部分
        // 比如:com.pidanxia.aop.LogAOP,只拿LogAOP
        String[] classNameParts = fullClassName.split("\\.");
        String className = classNameParts[classNameParts.length - 1];

        // 在方法执行前记录日志
        log.info(className + "****************" + methodName + "****************start");

        // 获取参数列表
        Object[] args = joinPoint.getArgs();
        // 入参集合
        Map<String, Object> map = new HashMap<>();
        // 获取方法参数名称
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        String[] parameterNames = methodSignature.getParameterNames();
        // 输出入参值
        if (parameterNames != null) {
            for (int i = 0; i < args.length; i++) {
                if (parameterNames.length > i) {
                    String paramName = parameterNames[i];
                    Object paramValue = args[i];
                    map.put(paramName, paramValue);
                }
            }
        }
        JSONObject json = new JSONObject(map);
        log.info("\n入参:" + json);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

之前有了,理应也要有一个之后的。写一个在接口执行之后要执行的逻辑方法:

@After注解标注

    @After("@annotation(LogInfo)")
    public void logAfter(JoinPoint joinPoint){
        // 获取方法所在类的名称
        String fullClassName = joinPoint.getSignature().getDeclaringTypeName();

        // 获取方法名称
        String methodName = joinPoint.getSignature().getName();

        // 提取类名的最后一部分
        String[] classNameParts = fullClassName.split("\\.");
        String className = classNameParts[classNameParts.length - 1];

        // 在方法执行后记录日志
        log.info(className + "****************" + methodName + "****************end");
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 使用自定义注解

在接口处使用自定义注解标记:

    @LogInfo
    @GetMapping("/get")
    public String get(String name,int age){
        System.out.println("执行了get方法");
        return name;
    }
1
2
3
4
5
6

执行结果如下:

即使我们没有在接口方法中写任何的日志逻辑,只要标记了注解,就会自动调用注解方法!

# 整合成@Around注解

有了之前,有了之后,还会有一个包围的注解!

上面的@Before@After可以合并为一个注解:@Around

一般开发中都是使用@Around注解比较多,因为这样只用写一个注解方法就可以了。

使用方法也很简单,就是用Object result = point.proceed();来隔开之前和之后执行的两部分。

Object result = point.proceed();语句就是执行接口方法的意思,执行完这条语句,接口方法就执行完了。

特别注意:用@Around注解标注的方法入参必须是:`ProceedingJoinPoint类型的,因为proceed()方法是在ProceedingJoinPoint接口中定义的,JoinPoint`接口中没有定义。

把之前的logBefore方法logAfter方法都注释掉,然后写一个新的logAround方法

    @Around("@annotation(LogInfo)")
    public void logAround(ProceedingJoinPoint joinPoint) throws Throwable{
		
		……	//这里代表logBefore方法的代码,一模一样拷贝过来即可
		
        // 执行原方法
        Object result = joinPoint.proceed();

        // 在方法执行后记录日志
        log.info(className + "****************" + methodName + "****************end");
        
    }
1
2
3
4
5
6
7
8
9
10
11
12

然后再来请求一下接口,看看控制台输出:

image-20240314182424697

可以看到效果是跟之前的。


# 使用拦截器

其实自定义注解并不适用于系统日志,而是更适用于一些特定的场景,举个简单的例子:审计日志

审计日志记录系统的操作审计信息,包括用户的操作行为、权限变更等,用于合规性和追踪用户操作。

而我们上面实现的是系统日志,记录系统的运行状态和事件,如系统启动、关闭、重启等,用于系统监控和故障排查。

审计日志与系统日志最大的区别是,系统日志中一般会记录着大部分甚至是所有的接口执行信息,而审计日志只需记录用户操作的关键步骤。

例如:

老师A创建了学生B。这个过程中依次调用了list、add这两个接口。 系统日志会记录这两个接口的所有信息。 而审计日志会记录:老师A创建了学生B。

那么我们就可以在add这个接口上标注我们的自定义注解,这样既节省了日志空间,也解决了代码冗余的问题!

之前我们说过自定义代码不适用于系统日志,因为我们发现:有了自定义注解,代码比之前简洁了,其实我们会发现,每个方法头上都要顶一个自定义注解,这其实也是代码冗余。

那么有没有一种办法,既能像自定义注解那样方便,而且也不用在每个方法头上分别标注呢?

答案依然是肯定的!

我们可以使用拦截器来代替自定义注解的操作.

拦截器与自定义注解的实现步骤相类似,除了拦截器不用定义注解,其他步骤跟上面是一样的。只需把元注解里的切点表达式换成路径的形式即可:

    @Around("execution(* com.pidanxia.sqlboy.controller.*.*(..))")
    public void logInterceptor(ProceedingJoinPoint joinPoint) throws Throwable{
		…… //内容与logAround方法一样
    }
@Around`里的切点表达式变成了`"execution(* com.pidanxia.sqlboy.controller.*.*(..))"
1
2
3
4
5

意思是:

  • execution: 表示匹配方法执行的连接点。
  • *: 第一个*表示匹配任意返回类型的方法。
  • com.pidanxia.sqlboy.controller.*: 表示匹配com.pidanxia.sqlboy.controller包下的任意类。
  • *: 第二个*表示匹配任意方法名。
  • (..): 表示匹配任意参数列表。

这段代码的含义是:拦截com.pidanxia.sqlboy.controller包下所有类的所有方法,并在这些方法执行之前和之后执行自定义的逻辑。

为了看出区别,我把之前的自定义注解注释掉,并且定义一个新的自定义注解:LogInfo2,下面是新注解的执行方法:

    @Around("@annotation(LogInfo2)")
    public void LogInfo2Around(ProceedingJoinPoint joinPoint) throws Throwable{
        // 获取方法名称
        String methodName = joinPoint.getSignature().getName();
        log.info("我是LogInfo2注解,我记录了执行了" + methodName + "方法~~~");
        // 执行原方法
        Object result = joinPoint.proceed();
    }
1
2
3
4
5
6
7
8

然后我定义了两个接口:get和postget方法没有加自定义注解,而post方法加了新的自定义注解

    @GetMapping("/get")
    public String get(String name,int age){
        System.out.println("执行了get方法");
        return name;
    }

    @LogInfo2
    @PostMapping("/post")
    public Num post(@RequestBody Num num){
        System.out.println("执行了post方法");
        return num;
    }
1
2
3
4
5
6
7
8
9
10
11
12

分别请求两个注解看看有什么不一样的地方:

可以看到虽然get方法中没有标注任何的注解,但是也记录了日志。而post方法中,还打印出了自定义注解的“审计日志”。

总结:

  • 拦截器适合用于跨越多个请求处理器的通用操作,如日志记录、权限检查等。更适用于大范围的通用的操作。
  • 自定义注解适合用于标记特定的类、方法或字段,并根据标记执行相应的逻辑,可以更灵活地定义特定行为。
最近更新: 3/24/2024, 1:43:50 PM
编程导航   |