手把手教你如何优雅的使用Aop记录带参数的复杂Web接口日志

前言不久前,因为需求的原因,需要实现一个操作日志。几乎每一个接口被调用后,都要记录一条跟这个参数挂钩的特定的日志到数据库。举个例子,就比如禁言操作,日志中需要记录因为什么禁言,被禁言的人的id和各种信息。方便后期查询。这样的接口有很多个,而且大部分接口的参数都不一样。可能大家很容易想到的一个思路就是,实现一个日志记录的工具类,然后在需要记录日志的接口中,添加一行代码。由这个日志工具类去判断此时应该...

手把手教你如何优雅的使用Aop记录带参数的复杂Web接口日志

前言

不久前,因为需求的原因,需要实现一个操作日志。几乎每一个接口被调用后,都要记录一条跟这个参数挂钩的特定的日志到数据库。举个例子,就比如禁言操作,日志中需要记录因为什么禁言,被禁言的人的id和各种信息。方便后期查询。

这样的接口有很多个,而且大部分接口的参数都不一样。可能大家很容易想到的一个思路就是,实现一个日志记录的工具类,然后在需要记录日志的接口中,添加一行代码。由这个日志工具类去判断此时应该处理哪些参数。

但是这样有很大的问题。如果需要记日志的接口数量非常多,先不讨论这个工具类中需要做多少的类型判断,仅仅是给所有接口添加这样一行代码在我个人看来都是不能接受的行为。首先,这样对代码的侵入性太大。其次,后期万一有改动,维护的人将会十分难受。想象一下,全局搜索相同的代码,再一一进行修改。

所以我放弃了这个略显原始的方法。我最终采用了Aop的方式,采取拦截的请求的方式,来记录日志。但是即使采用这个方法,仍然面临一个问题,那就是如何处理大量的参数。以及如何对应到每一个接口上。

我最终没有拦截所有的controller,而是自定义了一个日志注解。所有打上了这个注解的方法,将会记录日志。同时,注解中会带有类型,来为当前的接口指定特定的日志内容以及参数。

那么如何从众多可能的参数中,为当前的日志指定对应的参数呢。我的解决方案是维护一个参数类,里面列举了所有需要记录在日志中的参数名。然后在拦截请求时,通过反射,获取到该请求的request和response中的所有参数和值,如果该参数存在于我维护的param类中,则将对应的值赋值进去。

然后在请求结束后,将模板中的所有预留的参数全部用赋了值的参数替换掉。这样一来,在不大量的侵入业务的前提下,满足了需求,同时也保证了代码的可维护性。

下面我将会把详细的实现过程列举出来。

开始操作前

文章结尾我会给出这个demo项目的所有源码。所以不想看过程的兄台可移步到末尾,直接看源码。(听说和源码搭配,看文章更美味...)

开始操作

新建项目

大家可以参考我之前写的另一篇文章,手把手教你从零开始搭建SpringBoot后端项目框架。只要能请求简单的接口就可以了。本项目的依赖如下。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.1.1.RELEASE</version></dependency><!-- https://mvnrepository.com/artifact/org.aspectj/aspectjrt --><dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjrt</artifactId> <version>1.9.2</version></dependency><!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver --><dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>1.9.2</version></dependency><dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.2</version></dependency><dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>4.1.14</version></dependency>

新建Aop类

新建LogAspect类。代码如下。

package spring.aop.log.demo.api.util;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.annotation.Pointcut;import org.springframework.stereotype.Component;/** * LogAspect * * @author Lunhao Hu * @date 2019-01-30 16:21 **/@Aspect@Componentpublic class LogAspect { /**  * 定义切入点  */ @Pointcut("@annotation(spring.aop.log.demo.api.util.Log)") public void operationLog() { }  /**  * 新增结果返回后触发  *  * @param point  * @param returnValue  */ @AfterReturning(returning = "returnValue", pointcut = "operationLog() && @annotation(log)") public void doAfterReturning(JoinPoint point, Object returnValue, Log log) {  System.out.println("test"); }}

Pointcut中传入了一个注解,表示凡是打上了这个注解的方法,都会触发由Pointcut修饰的operationLog函数。而AfterReturning则是在请求返回之后触发。

自定义注解

上一步提到了自定义注解,这个自定义注解将打在controller的每个方法上。新建一个annotation的类。代码如下。

package spring.aop.log.demo.api.util;import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;/** * Log * * @author Lunhao Hu * @date 2019-01-30 16:19 **/@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Log { String type() default "";}

TargetRetention都属于元注解。共有4种,分别是@Retention@Target@Document@Inherited

Target注解说明了该Annotation所修饰的范围。可以传入很多类型,参数为ElementType。例如TYPE,用于描述类、接口或者枚举类;FIELD用于描述属性;METHOD用于描述方法;PARAMETER用于描述参数;CONSTRUCTOR用于描述构造函数;LOCAL_VARIABLE用于描述局部变量;ANNOTATION_TYPE用于描述注解;PACKAGE用于描述包等。

Retention注解定义了该Annotation被保留的时间长短。参数为RetentionPolicy。例如SOURCE表示只在源码中存在,不会在编译后的class文件存在;CLASS是该注解的默认选项。 即存在于源码,也存在于编译后的class文件,但不会被加载到虚拟机中去;RUNTIME存在于源码、class文件以及虚拟机中,通俗一点讲就是可以在运行的时候通过反射获取到。

加上普通注解

给需要记录日志的接口加上Log注解。

package spring.aop.log.demo.api.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RestController;import spring.aop.log.demo.api.util.Log;/** * HelloController * * @author Lunhao Hu * @date 2019-01-30 15:52 **/@RestControllerpublic class HelloController { @Log @GetMapping("test/{id}") public String test(@PathVariable(name = "id") Integer id) {  return "Hello"id; }}

加上之后,每一次调用test/{id}这个接口,都会触发拦截器中的doAfterReturning方法中的代码。

加上带类型注解

上面介绍了记录普通日志的方法,接下来要介绍记录特定日志的方法。什么特定日志呢,就是每个接口要记录的信息不同。为了实现这个,我们需要实现一个操作类型的枚举类。代码如下。

操作类型模板枚举

新建一个枚举类Type。代码如下。

package spring.aop.log.demo.api.util;/** * Type * * @author Lunhao Hu * @date 2019-01-30 17:12 **/public enum Type { /**  * 操作类型  */ WARNING("警告", "因被其他玩家举报,警告玩家"); /**  * 类型  */ private String type; /**  * 执行操作  */ private String operation; Type(String type, String operation) {  this.type = type;  this.operation = operation; } public String getType() { return type; } public String getOperation() { return operation; }}

给注解加上类型

给上面的controller中的注解加上type。代码如下。

package spring.aop.log.demo.api.controller;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RestController;import spring.aop.log.demo.api.util.Log;/** * HelloController * * @author Lunhao Hu * @date 2019-01-30 15:52 **/@RestControllerpublic class HelloController { @Log(type = "WARNING") @GetMapping("test/{id}") public String test(@PathVariable(name = "id") Integer id) {  return "Hello"id; }}

修改aop类

将aop类中的doAfterReturning为如下。

@AfterReturning(returning = "returnValue", pointcut = "operationLog() && @annotation(log)")public void doAfterReturning(JoinPoint point, Object returnValue, Log log) { // 注解中的类型 String enumKey = log.type(); System.out.println(Type.valueOf(enumKey).getOperation());}

加上之后,每一次调用加了@Log(type = "WARNING")这个注解的接口,都会打印这个接口所指定的日志。例如上述代码就会打印出如下代码。

因被其他玩家举报,警告玩家

获取aop拦截的请求参数

为每个接口指定一个日志并不困难,只需要为每个接口指定一个类型即可。但是大家应该也注意到了,一个接口日志,只记录因被其他玩家举报,警告玩家这样的信息没有任何意义。

记录日志的人倒不觉得,而最后去查看日志的人就要吾日三省吾身了,被谁举报了?因为什么举报了?我警告的谁?

这样的日志做了太多的无用功,根本没有办法在出现问题之后溯源。所以我们下一步的操作就是给每个接口加上特定的参数。那么大家可能会有问题,如果每个接口的参数几乎都不一样,那这个工具类岂不是要传入很多参数,要怎么实现呢,甚至还要组织参数,这样会大量的侵入业务代码,并且会大量的增加冗余代码。

大家可能会想到,实现一个记录日志的方法,在要记日志的接口中调用,把参数传进去。如果类型很多的话,参数也会随之增多,每个接口的参数都不一样。处理起来十分麻烦,而且对业务的侵入性太高。几乎每个地方都要嵌入日志相关代码。一旦涉及到修改,将会变得十分难维护。

所以我直接利用反射获取aop拦截到的请求中的所有参数,如果我的参数类(所有要记录的参数)里面有请求中的参数,那么我就将参数的值写入参数类中。最后将日志模版中参数预留字段替换成请求中的参数。

流程图如下所示。

新建参数类

新建一个类Param,其中包含所有在操作日志中,可能会出现的参数。为什么要这么做?因为每个接口需要的参数都有可能完全不一样,与其去维护大量的判断逻辑,还不如贪心一点,直接传入所有的可能参数

源文地址:https://www.guoxiongfei.cn/cntech/9851.html