前言
操作日志对于我们程序开发者来说重要性不言而喻了,不管是线上环境排查问题还是系统审计,操作日志都是必不可少的模块。此章节,我就记录下在sprinboot
框架中如何利用aop
切面思想进行日志记录
记录操作日志的方式有很多种,比如:数据库触发器,记录日志文件,利用springboot
的aop
切面思想,消息队列等。
先说需求:
我们希望记录日志这个操作是系统自发的,而非手动或者在业务中增加逻辑的方式,并且也需要具备一定的灵活性
解决思路:
- 利用
springboot
的aop
切面思想
我们可以在需要记录日志的地方添加一个自定义注解就能实现记录日志的功能,而做到0侵入,无需改动业务代码
接下来就是具体的实现,这里我们需要掌握aop
切面思想,和自定义注解
的前置知识,关于这2个知识点,不是本文的重点,这里就不再解析了
一、创建自定义注解
我们先定义一个自定义注解,希望我们在添加了这个注解的的接口处能自动记录操作日志,我在写代码的时候只专注业务即可
我们在log
包下面创建一个自定义注解Log
,注意这里的类型是@interface
,由于我们不同的接口可能需要记录不同的信息,比如所属功能模块、描述、业务的类型、操作员类型等信息,我们需要在注解中添加一些可以传递的参数
代码如下:
自定义注解
package com.light.common.log;
import com.light.common.enums.BusinessType;
import com.light.common.enums.OperatorType;
import java.lang.annotation.*;
/**
* 自定义操作日志记录注解
*/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/**
* 模块
*/
String title() default "";
/**
* 操作描述
*/
String description() default "";
/**
* 业务类型
*/
BusinessType businessType() default BusinessType.OTHER;
/**
* 操作员类型
*/
OperatorType operatorType() default OperatorType.OTHER;
/**
* 是否保存请求的参数
*/
public boolean isSaveRequest() default true;
/**
* 是否保存响应的参数
*/
public boolean isSaveResponse() default true;
}
@Target
用于指定另一个注解可以应用的位置,这里应用于方法参数,也可以被应用于方法@Retention
用于指定注解保留的阶段,此处设置为注解在运行时保留,可以通过反射(Reflection)机制读取到注解的信息。
这意味着,即使在编译后的字节码中,注解的信息仍然存在,可以在运行时通过代码读取和使用。@Documented
是一个标记注解,表示任何使用这个注解的元素都会在生成的API文档中显示这个注解的信息
业务类型枚举
package com.light.common.enums;
/**
* 业务操作类型
*/
public enum BusinessType {
/**
* 通用操作类型
*/
CREATE("创建"),
UPDATE("更新"),
DELETE("删除"),
QUERY("查询"),
EXPORT("导出"),
IMPORT("导入"),
LOGIN("登录"),
LOGOUT("注销"),
/**
* 数据同步操作
*/
DATA_SYNC("数据同步"),
DATA_VALIDATE("数据校验"),
/**
* 文件相关操作
*/
FILE_UPLOAD("文件上传"),
FILE_DOWNLOAD("文件下载"),
/**
* 其他
*/
OTHER("其他操作");
private final String description;
BusinessType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
操作员类型枚举
package com.light.common.enums;
/**
* 操作员类型
*/
public enum OperatorType {
/**
* 系统后台人员
*/
ADMIN("后台管理人员"),
/**
* 第三方客户端
*/
THIRD_PARTY("第三方客户端"),
/**
* 移动端应用
*/
APP("移动端用户"),
/**
* H5 页面用户
*/
H5("H5 页面用户"),
/**
* 微信端用户(包括微信小程序、微信客户端)
*/
WECHAT("微信端用户"),
/**
* 网站端用户
*/
WEB("网站端用户"),
/**
* 系统自动化任务
*/
SYSTEM("系统任务"),
/**
* API调用
*/
API("API调用者"),
/**
* 其他未知类型
*/
OTHER("其他类型");
private final String description;
OperatorType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
二、创建操作日志切面类
我们在log
包中定义一个LogAspect
类,用来处理日志切面的逻辑;
此处我这里使用到了hutool
工具包,因为有些工具方法很常用,自己懒得写了,就直接使用hutool
,简单高效,且功能强大
package com.light.common.log;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.light.common.enums.BusinessType;
import com.light.common.enums.OperatorType;
import com.light.common.result.ResultCode;
import com.light.common.utils.NetUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.NamedThreadLocal;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 自定义操作日志记录切面
*/
@Aspect
@Component
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
/**
* 计算操作消耗时间,线程级别变量,保证线程安全的同时,也保证了请求级别的隔离
*/
private static final ThreadLocal<Long> TIME_THREADLOCAL = new NamedThreadLocal<>("Execute Time");
// 定义时间格式化器
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 处理请求前执行
*/
@Before(value = "@annotation(controllerLog)")
public void boBefore(JoinPoint joinPoint, Log controllerLog) {
//记录请求开始将时间戳,方便后续计算请求耗时
TIME_THREADLOCAL.set(System.currentTimeMillis());
}
/**
* 处理完请求后执行
*
* @param joinPoint 切入点
* @param operateLog 注解
* @param result 返回参数
*/
@AfterReturning(pointcut = "@annotation(operateLog)", returning = "result")
public void doAfterReturning(JoinPoint joinPoint, Log operateLog, Object result) {
//执行
handleLog(joinPoint, operateLog, null, result);
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(operateLog)", throwing = "e")
public void doAfterReturning(JoinPoint joinPoint, Log operateLog, Exception e) throws Throwable {
handleLog(joinPoint, operateLog, e, null);
}
/**
* 切面逻辑实现
*/
protected void handleLog(final JoinPoint joinPoint, Log operateLog, final Exception e, Object result) {
try {
//获取当前请求对象
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
//TODO 获取当前的用户,后续通过token来或session来自动获取用户信息 - 待实现
// 请求的ip地址
String ip = NetUtil.getRequestIp(request);
//获取请求类型
String method = request.getMethod();
//获取请求URI
String requestURI = request.getRequestURI();
//获取请求URL
String requestURL = request.getRequestURL().toString();
//获取方法名称
String methodName = joinPoint.getTarget().getClass().getName() + "." + joinPoint.getSignature().getName() + "()";
//获取日志模块名称
String logTitle = operateLog.title();
//获取操作描述
String description = operateLog.description();
//获取业务类型
BusinessType businessType = operateLog.businessType();
//获取操作员类型
OperatorType operatorType = operateLog.operatorType();
//获取请求状态
ResultCode resultCode = ResultCode.SUCCESS;
//记录异常信息
if (e != null) {
resultCode = ResultCode.FAILED;
//异常信息
StrUtil.sub(e.getMessage(), 0, 2000);
}
//获取请求参数
String parameter = getParameter(joinPoint, operateLog);
//获取返回参数
String resultStr = operateLog.isSaveResponse() ? JSONUtil.toJsonStr(result) : "";
//获取方法开始时间
LocalDateTime dateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(TIME_THREADLOCAL.get()), ZoneId.systemDefault());
String startTime = FORMATTER.format(dateTime);
//获取方法执行时间
long executeTime = System.currentTimeMillis() - TIME_THREADLOCAL.get();
// 拼接日志字符串
String logMessage = String.format(
"请求日志: IP=%s, Method=%s, URI=%s, URL=%s, MethodName=%s, LogTitle=%s, Description=%s, BusinessType=%s, OperatorType=%s, Parameters=%s, Result=%s, StartTime=%s, ExecuteTime=%dms",
ip, method, requestURI, requestURL, methodName, logTitle, description, businessType, operatorType, parameter, resultStr, startTime, executeTime
);
//此处输出到控制台
log.info(logMessage);
//TODO 将日志保存到数据库中 - 待实现
} catch (Exception exp) {
// 记录本地异常日志
log.error("异常信息:{}", exp.getMessage());
exp.printStackTrace();
} finally {
TIME_THREADLOCAL.remove();
}
}
/**
* 根据方法和传入的参数获取请求参数
*/
private String getParameter(JoinPoint joinPoint, Log operateLog) {
if (!operateLog.isSaveRequest()) {
return "";
}
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
List<Object> argList = new ArrayList<>();
Parameter[] parameters = method.getParameters();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < parameters.length; i++) {
//将RequestBody注解修饰的参数作为请求参数
RequestBody requestBody = parameters[i].getAnnotation(RequestBody.class);
if (requestBody != null) {
argList.add(args[i]);
}
//将RequestParam注解修饰的参数作为请求参数
RequestParam requestParam = parameters[i].getAnnotation(RequestParam.class);
if (requestParam != null) {
Map<String, Object> map = new HashMap<>();
String key = parameters[i].getName();
if (!StrUtil.isEmpty(requestParam.value())) {
key = requestParam.value();
}
if (args[i] != null) {
map.put(key, args[i]);
argList.add(map);
}
}
}
if (argList.isEmpty()) {
return null;
} else if (argList.size() == 1) {
return JSONUtil.toJsonStr(argList.get(0));
} else {
return JSONUtil.toJsonStr(argList);
}
}
}
-
@Aspect
注解用于定义一个切面类,而@Component
注解用于声明这个类为Spring容器
管理的组件。在Spring AOP
中,这两个注解的组合使得切面类能够被Spring容器
自动检测和管理,从而实现横切关注点的逻辑 -
其中获取操作用户和保存到数据库的操作未完成,此处可以根据实际业务来添加,后续将数据库和用户模块整合进来后再补充此处内容
测试
在控制器中添加我们的自定义注解@Log
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/getMsg")
@Log(title = "测试模块",description = "此模块的描述,巴拉巴拉...",businessType = BusinessType.QUERY,operatorType = OperatorType.ADMIN)
public BaseResult<?> getTest() {
return BaseResult.success("success-ok");
}
}
请求接口,控制台输出如下:
总结
以上便是通过spring boot
的aop切面思想
实现统一日志记录的功能,此文主要是说明思路以及主要示例,实际项目中具体的需求按照此思路再补充即可