在 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 表达式定义在 {{ }}
之间。
{{ @userController.version }}
访问了 IOC 中名为的userController
Bean 的version
属性。{{ #id }}
访问了id
参数的值。{{ #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
如你所见,请求日志成功地被解析了,日志中的各个表达式都获取到了预期的值。