在 Spring Boot 中使用 AOP 和 SpEL 记录操作日志

通常,我们在 Spring Boot 应用中都是用过 AOP 和自定义注解的方式来记录请求日志、操作日志等。

这种方式记录到的日志数据,都是固定的模板数据。如:XXX 删除了用户XXX 新增了用户XXX 查询了用户列表 等等。

如果我们想要在日志内容中添加更多的业务信息,如:XXX 删除了用户 ID = xxx 的记录,那么可以通过使用 AOP 和 SpEL 表达式来实现。

SpEL 表达式简介

SpEL(Spring Expression Language) 是 Spring 中的表达式语言,用于在运行时评估和处理表达式。它提供了一种灵活的方式来访问和操作对象的属性、方法和其他表达式。SpEL可以用于配置文件、注解、XML 配置等多种场景,用于实现动态的、可配置的行为。它支持常见的表达式操作,如算术运算、逻辑运算、条件判断、集合操作等,并且可以与 Spring 框架的其他功能整合使用。

通俗理解就是,可以在 Spring 应用中使用 String 定义表达式,在表达式中可以创建、定义对象。以及访问对象的属性、方法,进行逻辑运算等等。最后得到表达式的输出结果!

一个简单的例子如下:

import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class Main {
    public static void main(String[] args) {
        
        // 执行上下文
        EvaluationContext context = new StandardEvaluationContext();
        context.setVariable("param", "World"); // 设置参数到上下文
        
        // 解析器
        ExpressionParser parser = new SpelExpressionParser();
        
        // 使用解析器,解析 SpEL 表达式
        // 该表达式中定义了字符串 'Hello ' 常量,并且调用它的 .concat 方法,参数是一个引用变量
        Expression expression = parser.parseExpression("'Hello '.concat(#param)"); // 在表达式中,使用 #param 访问上下文中的参数
        
        // 在上下文中执行表达式,获取 String 类型的结果
        String result = expression.getValue(context, String.class);
        
        System.out.println(result); // 输出:Hello World
    }
}

对于 SpEL 表达式更完整的用法,请参阅 官方文档

使用 AOP 和 SpEL 记录日志

添加依赖

由于需要使用 AOP,所以除了基本的 spring-boot-starter-web 还需要添加 spring-boot-starter-aop starter 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-aop</artifactId>  
</dependency>  

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

启用 AOP

在启动类上,使用 @EnableAspectJAutoProxy 开启 AOP。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@SpringBootApplication
@EnableAspectJAutoProxy
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

定义日志注解 & Aop 切面类

定义 @RequestLog 注解,用于在 Controller 方法上定义 SpEL 表达式。

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Retention(RUNTIME) // 必须要是 RUNTIME
@Target(METHOD) // 该注解可以用在方法上 
public @interface RequestLog {
    /**
     * SpEL 表达式
     * @return
     */
    String value();
}

定义 RequestLogAop 切面类,在所有 Controller 方法执行前执行,以解析操作日志:

import java.lang.reflect.Method;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.expression.BeanFactoryResolver;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.expression.ExpressionParser;


@Aspect  
@Component 
public class RequestLogAop {

    static final Logger logger = LoggerFactory.getLogger(RequestLogAop.class);

    // 表达式解析模板,在 {{  }} 中的内容,会被当作 SpEL 表达式进行解析
    static final TemplateParserContext TEMPLATE_PARSER_CONTEXT = new TemplateParserContext("{{", "}}");

    static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser();

    private final ApplicationContext applicationContext;

    // 通过构造函数注入 ApplicationContext
    public RequestLogAop(ApplicationContext applicationContext) {
        super();
        this.applicationContext = applicationContext;
    }

    // 拦截所有注解了 @RequestLog 的方法
    @Pointcut(value = "@annotation(cn.springdoc.demo.aop.RequestLog)")
    public void controller() {};

    // 在目标方法执行前执行
    @Before(value = "controller()")
    public void requestLog (JoinPoint joinPoint) throws Throwable {
        
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();

        // 方法的参数
        Object[] args = joinPoint.getArgs();
        
        // 参数的名称
        String[] parameterNames = signature.getParameterNames();

        // 目标方法
        Method targetMethod = signature.getMethod();

        // 方法上的 @RequestLog 注解
        RequestLog log = targetMethod.getAnnotation(RequestLog.class);

        // 获取到 Request 对象
        // HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        // 获取到 Response 对象
        // HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
        
        try {
            /**
             * 创建 EvaluationContext
             */
            StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
            
            // 设置 ApplicationContext 到 Context 中,这样的话,可以通过 @beanname 表达式来访问 IOC 中的 Bean。
            evaluationContext.setBeanResolver(new BeanFactoryResolver(this.applicationContext));
            
            for (int i = 0; i < args.length; i ++) {
                // 把 Controller 方法中的参数都设置到 context 中,使用参数名称作为 key。
                evaluationContext.setVariable(parameterNames[i], args[i]);
            }

            // 解析注解上定义的表达式,获取到结果
            String result = EXPRESSION_PARSER.parseExpression(log.value(), TEMPLATE_PARSER_CONTEXT).getValue(evaluationContext, String.class);
            
            // TODO 异步存储日志
            
            logger.info(result);
        } catch (Exception e) {
            logger.error("操作日志SpEL表达式解析异常: {}", e.getMessage());
        }
    }
}

如上,我们把 Controller 方法中所有的参数、以及 ApplicationContext 都作为 Context 变量放到了 StandardEvaluationContext 中,那么在注解的表达式中我们就可以访问这些参数,以及 IOC 中的 Bean。

创建 Controller

定义一个 Controller 方法,通过 @RequestLog 注解设置访问日志。

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import cn.springdoc.demo.aop.RequestLog;
import jakarta.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/user")
public class UserController {

    // 在 UserController Bean 中定义版本号
    private String version = "v1";

    public String getVersion() {
        return version;
    }

    @PostMapping("/delete")
    @RequestLog("删除用户,Version = {{ @userController.version }},用户ID={{ #id }},UserAgent={{ #request.getHeader('User-Agent') }}")
    public String delete (HttpServletRequest request,
                                @RequestParam("id") Long id) {
        // TODO 根据 id 删除 用户
        return "ok";
    }
}

我们在这个 Controller 中定义了一个 version 属性,并且提供了 getter 方法,这其实没啥实际意义,目的是为了演示在 SpEL 表达式中访问 IOC 中的 Bean。

@RequestLog 注解的日志内容中,有 3 个 SpEL 表达式定义在 {{ }} 之间。

  1. {{ @userController.version }} 访问了 IOC 中名为的 userController Bean 的 version 属性。
  2. {{ #id }} 访问了 id 参数的值。
  3. {{ #request.getHeader('User-Agent') }} 调用了 request 参数的 getHeader 方法,并且传递了字符串参数 User-Agent

测试

启动应用,使用 curl 请求这个接口,传递 id 参数,其值等于 10086。如下:

$ curl  -X POST -d "id=10086"  "http://localhost:8080/user/delete"
ok

响应 “ok”,说明 Controller 方法没问题。接下来看看服务端的日志:

[nio-8080-exec-1] cn.springdoc.demo.aop.RequestLogAop      : 删除用户,Version = v1,用户ID=10086,UserAgent=curl/8.0.1

如你所见,请求日志成功地被解析了,日志中的各个表达式都获取到了预期的值。