优秀的编程知识分享平台

网站首页 > 技术文章 正文

springboot-如何使用AOP+注解实现日志记录

nanyue 2024-12-29 04:54:20 技术文章 4 ℃

一、步骤概览

二、步骤说明

1.引入依赖包

<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>cn.hutool</groupId>
  <artifactId>hutool-all</artifactId>
  <version>5.8.22</version>
</dependency>
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
</dependency>
</dependencies>

2.定义注解

定义日志注解,用于明确标记哪些方法需要进行日志记录,便于识别和管理。

  • Log:日志注解
@Target({ElementType.PARAMETER,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
    /** 所属模块名称 **/
    String title() default "";
    /** 操作类型,如 INSERT、UPDATE、SELECT、DELETE等 **/
    BusinessType businessType() default BusinessType.OTHER;
    /** 操作人员类别,如后台用户、手机端用户等 **/
    OperatorType operatorType() default OperatorType.MANAGE;
    /** 是否保存请求参数 **/
    boolean isSaveReqData() default true;
    /** 是否保存响应参数 **/
    boolean isSaveResData() default true;
}

3.实现日志切面

日志切面完成对请求方法的拦截,主要功能包括:

  • 前置通知:在方法执行前记录当前时间,用于计算操作消耗的时间。
  • 后置通知:在方法执行后将请求信息传递给日志服务进行日志处理。
  • 异常通知:在方法抛出异常时,将异常信息传递给日志服务进行日志处理。
@Aspect
@Component
@Slf4j
public class LogAspect {
    public static final ThreadLocal<Long> TIME_THREADLOCAL = new NamedThreadLocal<Long>("Cost Time");
    @Resource
    private LogHandleService logHandleService;
}

①.前置通知

使用 ThreadLocal 记录方法执行前当前时间戳。

  • LogAspect#doBefore
@Before(value = "@annotation(controllerLog)")
public void doBefore(JoinPoint jp, Log controllerLog) {
  TIME_THREADLOCAL.set(DateUtil.current());
}

②.后置通知

在方法执行后,将请求信息委托给日志服务实现日志逻辑处理。

  • LogAspect#doAfterReturning
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonRes")
public void doAfterReturning(JoinPoint jp, Log controllerLog, Object jsonRes) {
  handleLog(jp, controllerLog, null, jsonRes);
}
  • LogAspect#handleLog:日志处理,最终调用日志接口处理具体逻辑,由于我们在前置通知时,使用 ThreadLocal 记录了请求时间戳,因此在处理完需要移除,避免内存泄漏。
protected void handleLog(final JoinPoint jp, Log controllerLog,
                             Exception e, Object jsonRes) {
  try {
    logHandleService.handle(jp,controllerLog,e,jsonRes);
  } catch (Exception exp) {
    log.error("异常信息:{}", exp.getMessage(),e);
  } finally {
    TIME_THREADLOCAL.remove();
  }
}

③.异常通知

在请求发生异常后,将异常信息传递给日志服务进行日志逻辑处理。

  • LogAspect#doAfterThrowing
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint jp, Log controllerLog, Exception e) {
  handleLog(jp, controllerLog, e, null);
}

4.实现日志记录逻辑

单独封装日志记录处理逻辑,避免切面包含太多繁杂的业务代码。日志记录处理逻辑主要负责解析获取日志信息并进行日志打印,当然我们也可以将日志信息保存至数据库中。

①. 日志信息

日志信息主要包含打印和保存的信息。

@Data
public class SysOperLog implements Serializable {
    private static final long serialVersionUID = 1L;
    /** 日志主键 */
    private Long operId;
    /** 操作模块 */
    private String title;
    /** 业务类型(0其它 1新增 2修改 3删除) */
    private Integer businessType;
    /** 业务类型数组 */
    private Integer[] businessTypes;
    /** 请求方法 */
    private String method;
    /** 请求方式 */
    private String requestMethod;
    /** 操作类别(0其它 1后台用户 2手机端用户) */
    private Integer operatorType;
    /** 操作人员 */
    private String operName;
    /** 部门名称 */
    private String deptName;
    /** 请求url */
    private String operUrl;
    /** 操作地址 */
    private String operIp;
    /** 操作地点 */
    private String operLocation;
    /** 请求参数 */
    private String operParam;
    /** 返回参数 */
    private String jsonResult;
    /** 操作状态(0正常 1异常) */
    private Integer status;
    /** 错误消息 */
    private String errorMsg;
    /** 操作时间 */
    private Date operTime;
    /** 消耗时间 */
    private Long costTime;
}

②. 日志逻辑处理

日志逻辑处理,主要实现获取客户端IP、获取用户信息、获取异常信息,解析请求参数等逻辑。

  • LogHandleService#handle:日志处理方法
public void handle(JoinPoint jp, Log log, Exception e, Object jsonRes) {
  // 1. 获取客户端IP和访问接口地址
  SysOperLog operLog = new SysOperLog();
  operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
  operLog.setOperIp(IpUtils.getIpAddr());
  operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());

  // 2. 根据实际情况获取用户信息
  // operLog.setOperName(loginUser.getUsername());

  // 3. 若是存在异常,则记录异常信息
  if (ObjectUtil.isNotNull(e)) {
    operLog.setStatus(BusinessStatus.FAIL.ordinal());
    operLog.setErrorMsg(StrUtil.sub(e.getMessage(), 0, 2000));
  }

  // 4.设置方法名称
  String className = jp.getTarget().getClass().getName();
  String methodName = jp.getSignature().getName();
  operLog.setMethod(className + "." + methodName + "()");
  // 5.设置请求方式
  operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
  // 6.处理设置注解上的参数
  getControllerMethodDescription(jp, log, operLog, jsonRes);
  // 6.设置消耗时间
  operLog.setCostTime(DateUtil.current() - LogAspect.TIME_THREADLOCAL.get());
  // 7.打印日志
  logger.info(String.format("operLog : [%s]", JSONUtil.toJsonStr(operLog)));
}
  • LogHandleService#getControllerMethodDescription:获取Controller 方法描述信息
public void getControllerMethodDescription(JoinPoint jp, Log log, SysOperLog operLog, Object jsonRes) {
  // 设置 action 动作
  operLog.setBusinessType(log.businessType().ordinal());
  // 设置标题
  operLog.setTitle(log.title());
  // 设置操作人类别
  operLog.setOperatorType(log.operatorType().ordinal());
  // 是否需要保存 request,参数和值
  if (log.isSaveReqData()) {
    operLog.setOperParam(getReqValue(jp, operLog.getMethod()));
  }
  // 是否需要保存 response,参数和值
  if (log.isSaveResData() && Objects.nonNull(jsonRes)) {
    String trimRes = StrUtil.sub(JSONUtil.toJsonStr(jsonRes), 0, 2000);
    operLog.setJsonResult(trimRes);
  }
}
  • LogHandleService#getReqValue:获取请求参数,将其拼接成字符串。
private String getReqValue(JoinPoint joinPoint, String reqMethodName) {
  Map<?, ?> paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest());
  boolean isParamEmpty = MapUtil.isEmpty(paramsMap);
  boolean isPutOrPost = HttpMethod.PUT.name().equals(reqMethodName) ||
    HttpMethod.POST.name().equals(reqMethodName);
  String params;
  if (isParamEmpty && isPutOrPost) {
    params = argsArrayToString(joinPoint.getArgs());
  } else {
    params = JSONUtil.toJsonStr(paramsMap);
  }
  return StrUtil.sub(params, 0, 2000);
}
  • LogHandleService#argsArrayToString:数组参数转为字符串
private String argsArrayToString(Object[] paramsArray) {
  String params = "";
  if (ArrayUtil.isEmpty(paramsArray)) {
    return params;
  }
  for (Object o : paramsArray) {
    if (Objects.nonNull(o) && !isFilterObject(o)) {
      try {
        params += JSONUtil.toJsonStr(o) + " ";
      } catch (Exception e) {
      }
    }
  }
  return params.trim();
}
  • LogHandleService#isFilterObject:判断参数对象是否需要过滤掉,这边主要实现过滤掉 MultipartFile、HttpServletRequest、HttpServletResponse、BindingResult对象
public boolean isFilterObject(Object o) {
  Class<?> clazz = o.getClass();
  if (clazz.isArray()) {
    // 如果是数组类型且数组元素的类型是 MultipartFile 的子类或子接口,则需要过滤
    return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
  } else if (Collection.class.isAssignableFrom(clazz)) {
    // 如果是集合类型,且存在 MultipartFile元素,如果是则返回 true
    Collection collection = (Collection) o;
    for (Object value : collection) {
      return value instanceof MultipartFile;
    }
  } else if (Map.class.isAssignableFrom(clazz)) {
    // 如果是Map类型,且存在MultipartFile 的元素值,则返回 true。
    Map map = (Map) o;
    for (Object value : map.entrySet()) {
      Map.Entry entry = (Map.Entry) value;
      return entry.getValue() instanceof MultipartFile;
    }
  }
  
  return o instanceof MultipartFile || o instanceof HttpServletRequest
  || o instanceof HttpServletResponse
  || o instanceof BindingResult;
}

三、代码测试

1.测试代码

  • UserController:测试控制层,在 addUser 方法上添加日志标记注解
@RestController
@RequestMapping("/user")
public class UserController {

    @PostMapping(value = "/add")
    @Log(title = "用户管理模块", businessType = BusinessType.INSERT)
    public GenericResponse<UserDTO> addUser(@RequestBody UserDTO user) {
        // 数据库插入操作。。。
        return GenericResponse.success(user);
    }
}
  • UserDTO:测试入参实体
@Getter
@Setter
public class UserDTO implements Serializable {
    private static final long serialVersionUID = 1L;
    private String userName;
    // 用户昵称
    private String nickName;
    // 用户邮箱
    private String email;
    // 手机号码
    private String phonenumber;
    // 用户性别
    private String sex;
}

2.测试结果

  • postman 请求
  • 后台日志
最近发表
标签列表