SpringBoot 使用 AOP 方式高效的记录操作日志

SpringBoot 使用 AOP 方式高效的记录操作日志

文章目录

  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。

系统环境:

  • JAVA JDK 版本: openjdk 17
  • SpringBoot 版本: 3.3.2

示例地址:

一、引言

随着软件系统的复杂度增加,操作日志对于系统维护、故障排查和安全性审计变得至关重要。然而,传统的日志记录方式往往直接在业务代码中插入日志语句,这不仅增加了代码的耦合度,还使得日志需求变化时难以维护。而面向切面编程 (AOP) 提供了一种更灵活的方式来处理这类横切关注点,如日志记录、事务管理等。

本文将介绍如何在 Spring Boot 项目中结合 AOP 实现一套高效且易于维护的操作日志记录机制,从而提高系统的可维护性和可扩展性。

二、操作日志的重要性

操作日志是系统运行过程中记录的关键信息,对于维护系统的稳定运行和进行有效的故障排查至关重要。以下是操作日志的主要作用:

  • 问题诊断: 快速定位和解决系统异常。
  • 责任追溯: 明确操作执行者,为问责提供依据。
  • 数据恢复: 在系统出现问题时,辅助进行数据恢复。
  • 安全审计: 追踪潜在威胁,确保系统符合安全和法规要求。
  • 回溯历史: 重现用户操作流程,帮助理解问题发生的背景和上下文。

所以,通过合理利用操作日志,我们可以显著提升系统的可靠性、安全性和可维护性,同时为持续改进提供有力支持。

三、实现操作日志记录需要注意的问题

在实现操作日志记录时,需要注意以下关键问题:

  • 数据存储: 构建合理的数据生命周期管理策略,避免无限期存储大量日志数据。
  • 访问权限: 需要对操作日志进行访问权限控制,防止未经授权的用户访问和修改。
  • 性能影响: 控制日志记录频率,可以考虑使用异步的方式记录操作日志,以减少性能开销。
  • 数据安全: 对敏感信息进行脱敏处理(如手机号、身份证号等),或者对日志内容进行加密存储。
  • 异常处理: 日志记录本身的异常不应影响主业务流程,需要对记录日志时的异常情况进行妥善处理。
  • 日志内容: 确保记录的信息足够详细,包括操作类型、时间戳、IP地址等,并且避免记录过多无用信息。
  • 设置级别: 根据业务需求设置日志级别,如 INFO、ERROR 等,合理配置可以有效过滤无关紧要的日志输出。
  • 可搜索性: 使用结构化日志格式(如JSON),便于后续检索和分析,并且为关键字段建立索引,提高查询效率。
  • 可扩展性: 需要对日志记录的扩展性进行考虑,以便应对未来的业务需求变化,支持日志系统的平滑升级和扩展。

只有通过认真考虑这些问题,我们才可以构建一个更加健壮、高效和安全的操作日志记录系统。

四、SpringBoot 使用 AOP 记录操作日志示例

4.1 数据库创建操作日志表

这里给出在 MySQL 中建表的 SQL 语句,用于创建操作日志表,如下:

 1CREATE TABLE `sys_oper_log`
 2(
 3    `id`              bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志主键',
 4    `module`          varchar(50)         DEFAULT NULL COMMENT '系统模块',
 5    `oper_desc`       varchar(200)        DEFAULT NULL COMMENT '操作描述',
 6    `request_uri`     varchar(255)        DEFAULT NULL COMMENT '请求URL',
 7    `request_ip`      varchar(50)         DEFAULT NULL COMMENT '请求IP',
 8    `request_method`  varchar(20)         DEFAULT NULL COMMENT '请求方式',
 9    `request_param`   varchar(2000)       DEFAULT NULL COMMENT '请求参数',
10    `response_result` varchar(2000)       DEFAULT NULL COMMENT '响应结果',
11    `oper_type`       tinyint(2)          DEFAULT '0' COMMENT '业务类型 (0查询 1新增 2修改 3删除 4上传 5下载)',
12    `oper_status`     tinyint(2)          DEFAULT NULL COMMENT '操作状态 (0失败 1成功)',
13    `error_info`      varchar(2000)       DEFAULT NULL COMMENT '错误信息',
14    `operator`        varchar(50)         DEFAULT NULL COMMENT '操作人',
15    `oper_time`       datetime   NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
16    PRIMARY KEY (`id`) USING BTREE
17) ENGINE = InnoDB
18  DEFAULT CHARSET = utf8mb4 COMMENT ='系统操作日志';

4.2 Maven 引入相关依赖

在 Maven 的 pom.xml 文件中引入相关依赖:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 3  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
 4  <modelVersion>4.0.0</modelVersion>
 5
 6  <parent>
 7    <groupId>org.springframework.boot</groupId>
 8        <artifactId>spring-boot-starter-parent</artifactId>
 9        <version>3.3.2</version>
10  </parent>
11
12  <groupId>club.mydlq</groupId>
13  <artifactId>spring-boot-log-example</artifactId>
14  <version>0.0.1-SNAPSHOT</version>
15  <name>spring-boot-log-example</name>
16  <description>spring boot log demo</description>
17
18  <properties>
19      <java.version>21</java.version>
20      <maven.compiler.source>21</maven.compiler.source>
21      <maven.compiler.target>21</maven.compiler.target>
22  </properties>
23
24  <dependencies>
25      <!--spring-boot-->
26      <dependency>
27          <groupId>org.springframework.boot</groupId>
28          <artifactId>spring-boot-starter-web</artifactId>
29      </dependency>
30      <!--aop-->
31      <dependency>
32          <groupId>org.springframework.boot</groupId>
33          <artifactId>spring-boot-starter-aop</artifactId>
34      </dependency>
35      <!--mysql-->
36      <dependency>
37          <groupId>mysql</groupId>
38          <artifactId>mysql-connector-java</artifactId>
39          <version>8.0.33</version>
40          <scope>runtime</scope>
41      </dependency>
42      <!--mybatis-->
43      <dependency>
44          <groupId>org.mybatis.spring.boot</groupId>
45          <artifactId>mybatis-spring-boot-starter</artifactId>
46          <version>3.0.3</version>
47      </dependency>
48      <!--test-->
49      <dependency>
50          <groupId>org.springframework.boot</groupId>
51          <artifactId>spring-boot-starter-test</artifactId>
52          <scope>test</scope>
53      </dependency>
54      <!--lombok-->
55      <dependency>
56          <groupId>org.projectlombok</groupId>
57          <artifactId>lombok</artifactId>
58          <optional>true</optional>
59      </dependency>
60  </dependencies>
61
62  <build>
63      <plugins>
64          <plugin>
65              <groupId>org.springframework.boot</groupId>
66              <artifactId>spring-boot-maven-plugin</artifactId>
67          </plugin>
68      </plugins>
69  </build>
70
71</project>

4.3 配置数据库链接参数

在 SpringBoot 的 application.yml 文件中,配置数据库链接参数。配置如下:

 1spring:
 2  application:
 3    name: spring-boot-log-example
 4  datasource:
 5    type: com.zaxxer.hikari.HikariDataSource
 6    driverClassName: com.mysql.cj.jdbc.Driver
 7    url: jdbc:mysql://127.0.0.1:3306/test?serverTimezone=Asia/Shanghai
 8    hikari:
 9      pool-name: DatebookHikariCP
10      minimum-idle: 5
11      maximum-pool-size: 15
12      max-lifetime: 1800000
13      connection-timeout: 30000
14      username: root
15      password: 123456
16
17mybatis:
18  mapper-locations: classpath:mapper/*.xml

4.4 创建自定义线程池

创建一个自定义线程池,用于实现异步记录操作日志时使用。

 1import org.springframework.context.annotation.Bean;
 2import org.springframework.context.annotation.Configuration;
 3import org.springframework.scheduling.annotation.EnableAsync;
 4import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 5import java.util.concurrent.ThreadPoolExecutor;
 6
 7/**
 8 * 自定义线程池
 9 *
10 * @author mydlq
11 */
12@EnableAsync
13@Configuration
14public class ThreadPoolsConfig {
15    
16    @Bean("logAsyncExecutor")
17    public ThreadPoolTaskExecutor getParamTaskExecutor() {
18        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
19        executor.setThreadNamePrefix("LogAsyncExecutor-");
20        // 核心线程数
21        executor.setCorePoolSize(10);
22        // 最大线程数
23        executor.setMaxPoolSize(20);
24        // 队列容量
25        executor.setQueueCapacity(10000);
26        // 等待时间(秒)
27        executor.setAwaitTerminationSeconds(60);
28        // 应用关闭时等待任务完成
29        executor.setWaitForTasksToCompleteOnShutdown(true);
30        // 拒绝策略
31        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
32        return executor;
33    }
34
35}

4.5 创建操作日志实体类

创建操作日志实体类,具体包含的属性如下:

 1package club.mydlq.operlog.model;
 2
 3import lombok.Data;
 4import java.util.Date;
 5
 6/**
 7 * 日志类-记录用户操作行为
 8 *
 9 * @author mydlq
10 */
11@Data
12public class OperLogInfo {
13    /**
14     * 主键
15     */
16    private Long id;
17    /**
18     * 系统模块
19     */
20    private String module;
21    /**
22     * 操作描述
23     */
24    private String operDesc;
25    /**
26     * 请求IP
27     */
28    private String requestIp;
29    /**
30     * URI
31     */
32    private String requestUri;
33    /**
34     * 请求方式
35     */
36    private String requestMethod;
37    /**
38     * 请求参数
39     */
40    private String requestParam;
41    /**
42     * 响应结果
43     */
44    private String responseResult;
45    /**
46     * 操作类型
47     */
48    private Integer operType;
49    /**
50     * 操作状态
51     */
52    private Integer operStatus;
53    /**
54     * 错误信息
55     */
56    private String errorInfo;
57    /**
58     * 操作人
59     */
60    private String operator;
61    /**
62     * 操作时间
63     */
64    private Date operTime;
65}

4.6 创建操作日志 Mapper 接口类

创建操作日志的 Mapper 接口类,定义一个插入操作日志的方法。内容如下:

 1import club.mydlq.operlog.model.OperLogInfo;
 2import org.apache.ibatis.annotations.Mapper;
 3
 4/**
 5 * 操作日志 Mapper
 6 *
 7 * @author mydlq
 8 */
 9@Mapper
10public interface OperLogMapper {
11    /**
12     * 插入操作日志
13     *
14     * @param operLogInfo 操作日志
15     * @return 执行结果
16     */
17    int insert(OperLogInfo operLogInfo);
18}

4.7 创建 Mapper 接口对应的 xml 文件

/resources/mapper/ 目录下创建 OperLogMapper.xml 文件,编写插入操作日志的 SQL 语句。内容如下:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 3
 4<mapper namespace="club.mydlq.operlog.mapper.OperLogMapper">
 5
 6    <resultMap id="BaseResultMap" type="club.mydlq.operlog.model.OperLogInfo">
 7        <id property="id" column="id" jdbcType="INTEGER"/>
 8        <result property="module" column="module" jdbcType="VARCHAR"/>
 9        <result property="operDesc" column="oper_desc" jdbcType="VARCHAR"/>
10        <result property="requestUri" column="request_uri" jdbcType="VARCHAR"/>
11        <result property="requestIp" column="request_ip" jdbcType="VARCHAR"/>
12        <result property="requestMethod" column="request_method" jdbcType="VARCHAR"/>
13        <result property="responseResult" column="response_result" jdbcType="VARCHAR"/>
14        <result property="operType" column="oper_type" jdbcType="TINYINT"/>
15        <result property="operStatus" column="oper_status" jdbcType="TINYINT"/>
16        <result property="errorInfo" column="error_info" jdbcType="VARCHAR"/>
17        <result property="operator" column="operator" jdbcType="VARCHAR"/>
18        <result property="operTime" column="oper_time" jdbcType="TIMESTAMP"/>
19    </resultMap>
20
21    <!-- 插入操作日志 -->
22    <insert id="insert" keyColumn="id" keyProperty="id"
23            parameterType="club.mydlq.operlog.model.OperLogInfo"
24            useGeneratedKeys="true">
25        insert into sys_oper_log
26        <trim prefix="(" suffix=")" suffixOverrides=",">
27            <if test="module != null">module,</if>
28            <if test="operDesc != null">oper_desc,</if>
29            <if test="requestUri != null">request_uri,</if>
30            <if test="requestIp != null">request_ip,</if>
31            <if test="requestMethod != null">request_method,</if>
32            <if test="requestParam != null">request_param,</if>
33            <if test="responseResult != null">response_result,</if>
34            <if test="operType != null">oper_type,</if>
35            <if test="operStatus != null">oper_status,</if>
36            <if test="errorInfo != null">error_info,</if>
37            <if test="operator != null">operator,</if>
38            <if test="operTime != null">oper_time,</if>
39        </trim>
40        <trim prefix="values (" suffix=")" suffixOverrides=",">
41            <if test="module != null">#{module,jdbcType=VARCHAR},</if>
42            <if test="operDesc != null">#{operDesc,jdbcType=VARCHAR},</if>
43            <if test="requestUri != null">#{requestUri,jdbcType=VARCHAR},</if>
44            <if test="requestIp != null">#{requestIp,jdbcType=VARCHAR},</if>
45            <if test="requestMethod != null">#{requestMethod,jdbcType=VARCHAR},</if>
46            <if test="requestParam != null">#{requestParam,jdbcType=VARCHAR},</if>
47            <if test="responseResult != null">#{responseResult,jdbcType=VARCHAR},</if>
48            <if test="operType != null">#{operType,jdbcType=TINYINT},</if>
49            <if test="operStatus != null">#{operStatus,jdbcType=TINYINT},</if>
50            <if test="errorInfo != null">#{errorInfo,jdbcType=VARCHAR},</if>
51            <if test="operator != null">#{operator,jdbcType=VARCHAR},</if>
52            <if test="operTime != null">#{operTime,jdbcType=TIMESTAMP},</if>
53        </trim>
54    </insert>
55
56</mapper>

4.8 创建操作日志 Service 类

创建一个 OperLogService 类,用于记录操作日志,代码如下:

 1import club.mydlq.operlog.model.OperLogInfo;
 2import club.mydlq.operlog.mapper.OperLogMapper;
 3import jakarta.annotation.Resource;
 4import org.springframework.scheduling.annotation.Async;
 5import org.springframework.stereotype.Service;
 6
 7/**
 8 * 操作日志 Service
 9 *
10 * @author mydlq
11 */
12@Service
13public class OperLogService {
14
15    @Resource
16    private OperLogMapper operLogMapper;
17
18    /**
19     * 使用异步方式记录操作日志
20     */
21    @Async("logAsyncExecutor")
22    public void saveLog(OperLogInfo operLogInfo) {
23        operLogMapper.insert(operLogInfo);
24    }
25
26}

4.9 创建操作类型枚举

定义一个用于表示操作日志类型的枚举,代码如下:

 1import lombok.AllArgsConstructor;
 2import lombok.Getter;
 3
 4/**
 5 * 操作类型枚举
 6 *
 7 * @author mydlq
 8 */
 9@Getter
10@AllArgsConstructor
11public enum OperTypeEnum {
12    /**
13     * 查询
14     */
15    QUERY(0, "查询"),
16    /**
17     * 新增
18     */
19    INSERT(1, "新增"),
20    /**
21     * 修改
22     */
23    UPDATE(2, "修改"),
24    /**
25     * 删除
26     */
27    DELETE(3, "删除"),
28    /**
29     * 上传
30     */
31    UPLOAD(4, "上传"),
32    /**
33     * 下载
34     */
35    DOWNLOAD(5, "下载"),
36    ;
37
38    /**
39     * 状态编号
40     */
41    private final int code;
42    /**
43     * 状态描述
44     */
45    private final String name;
46
47}

4.10 创建操作状态枚举

定义一个用于表示操作日志状态的枚举,代码如下:

 1import lombok.AllArgsConstructor;
 2import lombok.Getter;
 3
 4/**
 5 * 操作状态枚举
 6 *
 7 * @author mydlq
 8 */
 9@Getter
10@AllArgsConstructor
11public enum OperStatusEnum {
12    /**
13     * 失败
14     */
15    FAIL(0, "失败"),
16    /**
17     * 成功
18     */
19    SUCCESS(1, "成功"),
20    ;
21
22    /**
23     * 状态编号
24     */
25    private final int code;
26    /**
27     * 状态描述
28     */
29    private final String name;
30
31    /**
32     * 根据 code 查找操作状态枚举名称
33     *
34     * @param code 状态编号
35     * @return 操作状态枚举
36     */
37    public static String of(Integer code){
38        for (OperStatusEnum operStatus : OperStatusEnum.values()) {
39            if (operStatus.getCode() == code) {
40                return operStatus.getName();
41            }
42        }
43        return "";
44    }
45
46}

4.11 创建操作日志注解

创建一个操作日志注解 @OperLog,后续会将该注解作为 AOP 中的切入点,应用于 Controller 层。

 1import club.mydlq.operlog.syslog.enums.OperTypeEnum;
 2import java.lang.annotation.ElementType;
 3import java.lang.annotation.Retention;
 4import java.lang.annotation.RetentionPolicy;
 5import java.lang.annotation.Target;
 6
 7/**
 8 * 操作日志注解,作为 AOP 中的切入点,应用于 Controller 层
 9 *
10 * @author mydlq
11 */
12@Target({ElementType.PARAMETER, ElementType.METHOD})
13@Retention(RetentionPolicy.RUNTIME)
14public @interface OperLog {
15    /**
16     * 系统模块
17     */
18    String module();
19    /**
20     * 接口描述
21     */
22    String description() default "";
23    /**
24     * 操作类型
25     */
26    OperTypeEnum operType();
27}

4.12 IP地址处理工具

创建一个IP地址处理工具类,用于获取请求的 IP 地址。

 1import jakarta.servlet.http.HttpServletRequest;
 2import lombok.extern.slf4j.Slf4j;
 3import org.springframework.util.StringUtils;
 4import java.net.InetAddress;
 5import java.net.UnknownHostException;
 6
 7/**
 8 * IP 地址处理工具
 9 *
10 * @author mydlq
11 */
12@Slf4j
13public class IpUtil {
14
15    private IpUtil() {
16    }
17
18    /**
19     * 本机IP地址
20     */
21    private static final String LOCALHOST_IP = "127.0.0.1";
22    /**
23     * 客户端与服务器同为一台机器,获取的 ip 有时候是 IPV6 格式
24     */
25    private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
26    private static final String SEPARATOR = ",";
27    private static final String UNKNOWN = "unknown";
28    private static final int MAX_LEN_LIMIT = 15;
29
30    /**
31     * 根据 HttpServletRequest 获取 IP
32     *
33     * @param request 请求对象
34     * @return 请求IP地址
35     */
36    public static String getIpAddress(HttpServletRequest request) {
37        if (request == null) {
38            return UNKNOWN;
39        }
40        String ip = request.getHeader("x-forwarded-for");
41        if (isValid(ip)) {
42            ip = request.getHeader("Proxy-Client-IP");
43        }
44        if (isValid(ip)) {
45            ip = request.getHeader("X-Forwarded-For");
46        }
47        if (isValid(ip)) {
48            ip = request.getHeader("WL-Proxy-Client-IP");
49        }
50        if (isValid(ip)) {
51            ip = request.getHeader("X-Real-IP");
52        }
53        if (isValid(ip)) {
54            ip = request.getRemoteAddr();
55        }
56        if (LOCALHOST_IP.equalsIgnoreCase(ip) || LOCALHOST_IPV6.equalsIgnoreCase(ip)) {
57            try {
58                // 根据网卡取本机配置的 IP
59                InetAddress iNet = InetAddress.getLocalHost();
60                if (iNet != null) {
61                    ip = iNet.getHostAddress();
62                }
63            } catch (UnknownHostException e) {
64                log.error("", e);
65            }
66        }
67
68        // 对于通过多个代理的情况,分割出第一个 IP
69        if (ip != null && ip.length() > MAX_LEN_LIMIT && ip.contains(SEPARATOR)) {
70            ip = ip.substring(0, ip.indexOf(SEPARATOR));
71        }
72        return LOCALHOST_IPV6.equals(ip) ? LOCALHOST_IP : ip;
73    }
74
75    /**
76     * 判断 IP 是否有效
77     * 
78     * @param ip IP地址
79     * @return 是否有效
80     */
81    private static boolean isValid(String ip) {
82        return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip);
83    }
84
85}

4.13 请求参数处理工具

创建一个用于处理请求参数的工具类,该工具类可以根据不同的 MediaType 类型,对不同的参数进行处理,并且返回处理后的请求参数。

  1import com.fasterxml.jackson.core.JsonProcessingException;
  2import com.fasterxml.jackson.databind.ObjectMapper;
  3import jakarta.servlet.http.HttpServletRequest;
  4import jakarta.servlet.http.HttpServletResponse;
  5import lombok.extern.slf4j.Slf4j;
  6import org.aspectj.lang.ProceedingJoinPoint;
  7import org.springframework.http.MediaType;
  8import org.springframework.util.StringUtils;
  9import org.springframework.web.multipart.MultipartFile;
 10import java.util.Map;
 11
 12/**
 13 * 请求参数处理工具
 14 *
 15 * @author mydlq
 16 */
 17@Slf4j
 18public class RequestParamUtil {
 19
 20    private RequestParamUtil() {
 21    }
 22
 23    /**
 24     * 处理请求参数 (根据不同的MediaType类型,对不同的参数进行处理)
 25     *
 26     * @param request HttpServletRequest
 27     * @param objectMapper JSON转换工具
 28     * @param proceedingJoinPoint 连接点
 29     * @return 处理后的请求参数
 30     */
 31    public static String requestParamHandle(HttpServletRequest request,
 32                                            ObjectMapper objectMapper,
 33                                            ProceedingJoinPoint proceedingJoinPoint) {
 34        // 获取 RequestMethod 和 ContentType
 35        String contentType = request.getContentType();
 36
 37        // 如果 ContentType 为空,则可能是 URL 传参,直接反 URL 参数
 38        if (!StringUtils.hasText(contentType)) {
 39            return request.getQueryString();
 40        }
 41
 42        // 获取 RequestMethod 和 ContentType 的枚举
 43        MediaType mediaType = MediaType.valueOf(contentType);
 44
 45        // 处理 FROM 表单媒体类型
 46        if (MediaType.APPLICATION_FORM_URLENCODED.equals(mediaType)) {
 47            return fromParamHandle(request, objectMapper);
 48        }
 49        // 处理 JSON 表单媒体类型
 50        if (MediaType.APPLICATION_JSON.equals(mediaType)) {
 51            Object[] args = proceedingJoinPoint.getArgs();
 52            return (args != null && args.length > 0) ? argsArrayToString(args, objectMapper) : "";
 53        }
 54
 55        // 如果以上条件都不符合,则默认反回空串
 56        return "";
 57    }
 58
 59    /**
 60     * FORM 表单参数处理
 61     *
 62     * @param request      请求对象
 63     * @param objectMapper JSON转换工具
 64     * @return 转换后的 JSON 字符串
 65     */
 66    private static String fromParamHandle(HttpServletRequest request, ObjectMapper objectMapper) {
 67        try {
 68            Map<String, String[]> params = request.getParameterMap();
 69            return objectMapper.writeValueAsString(params);
 70        } catch (JsonProcessingException e) {
 71            log.error("", e);
 72        }
 73        return "";
 74    }
 75
 76    /**
 77     * JSON 参数处理
 78     */
 79    private static String argsArrayToString(Object[] paramsArray, ObjectMapper objectMapper) {
 80        StringBuilder params = new StringBuilder();
 81        if (paramsArray != null) {
 82            for (Object o : paramsArray) {
 83                // 过滤的对象,如上传这种接口接收的参数类型需要过滤掉
 84                boolean isMultipartFile = o instanceof MultipartFile;
 85                boolean isRequest = o instanceof HttpServletRequest;
 86                boolean isResponse = o instanceof HttpServletResponse;
 87                // 执行过滤
 88                if (isMultipartFile || isRequest || isResponse) {
 89                    continue;
 90                }
 91                // 过滤完符合条件的转换为 JSON 字符串存储
 92                try {
 93                    String jsonObj = objectMapper.writeValueAsString(o);
 94                    params.append(jsonObj).append(" ");
 95                } catch (JsonProcessingException e) {
 96                    log.error("", e);
 97                }
 98            }
 99        }
100        return params.toString().trim();
101    }
102
103}

4.14 创建操作日志 AOP 切面

创建一个操作日志 AOP 切面类,用于拦截使用 @OperLog 注解的方法,聚合操作日志中需要记录的信息,然后将构建完成的操作日志记录到库中。

  1import club.mydlq.operlog.model.OperLogInfo;
  2import club.mydlq.operlog.syslog.enums.OperStatusEnum;
  3import club.mydlq.operlog.service.OperLogService;
  4import club.mydlq.operlog.syslog.annotation.OperLog;
  5import club.mydlq.operlog.syslog.utils.IpUtil;
  6import club.mydlq.operlog.syslog.utils.RequestParamUtil;
  7import com.fasterxml.jackson.databind.ObjectMapper;
  8import jakarta.servlet.http.HttpServletRequest;
  9import lombok.extern.slf4j.Slf4j;
 10import org.aspectj.lang.ProceedingJoinPoint;
 11import org.aspectj.lang.Signature;
 12import org.aspectj.lang.annotation.Around;
 13import org.aspectj.lang.annotation.Aspect;
 14import org.aspectj.lang.annotation.Pointcut;
 15import org.aspectj.lang.reflect.MethodSignature;
 16import org.springframework.beans.factory.annotation.Autowired;
 17import org.springframework.stereotype.Component;
 18import java.lang.reflect.Method;
 19import java.util.Date;
 20
 21/**
 22 * 操作日志 AOP 切面
 23 *
 24 * @author mydlq
 25 */
 26@Slf4j
 27@Aspect
 28@Component
 29public class SystemLogAspect {
 30
 31    private final HttpServletRequest request;
 32    private final OperLogService operLogService;
 33    private final ObjectMapper objectMapper;
 34
 35    @Autowired
 36    public SystemLogAspect(HttpServletRequest request,
 37                           ObjectMapper objectMapper,
 38                           OperLogService operLogService) {
 39        this.request = request;
 40        this.objectMapper = objectMapper;
 41        this.operLogService = operLogService;
 42    }
 43
 44    /**
 45     * 根据 @SystemControllerLog 主键进行切
 46     */
 47    @Pointcut("@annotation(club.mydlq.operlog.syslog.annotation.OperLog)")
 48    public void controllerAspect() {
 49    }
 50
 51    /**
 52     * 环绕 Advice - 用于拦截 Controller 层设添加了 @OperLog 注解的方法
 53     *
 54     * @param proceedingJoinPoint 切点
 55     */
 56    @Around("controllerAspect()")
 57    public Object doAfter(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
 58        // 创建接收响应结果的对象、操作日志对象
 59        Object responseResult;
 60        OperLogInfo operLogInfo = new OperLogInfo();
 61        try {
 62            // --- 前置处理: 拼接操作日志信息 ---
 63            setOperLog(operLogInfo, proceedingJoinPoint);
 64
 65            // 使前置 Advice 通过并获取响应结果
 66            responseResult = proceedingJoinPoint.proceed();
 67
 68            // --- 后置处理: 设置响应结果、操作状态 ---
 69            // 将响应结果转换为JSON串,并且设置操作状态为成功
 70            operLogInfo.setResponseResult(responseResult == null ? "" : objectMapper.writeValueAsString(responseResult));
 71            operLogInfo.setOperStatus(OperStatusEnum.SUCCESS.getCode());
 72
 73            // 返回响应结果
 74            return responseResult;
 75        } catch (Throwable e) {
 76            // 设置操作状态为失败,并且记录错误信息
 77            operLogInfo.setOperStatus(OperStatusEnum.FAIL.getCode());
 78            operLogInfo.setErrorInfo(e.toString());
 79            throw e;
 80        } finally {
 81            // 记录操作日志到数据库
 82            operLogService.saveLog(operLogInfo);
 83        }
 84    }
 85
 86    /**
 87     * 获取操作日志数据
 88     *
 89     * @param operLogInfo             操作日志对象
 90     * @param proceedingJoinPoint 连接点
 91     */
 92    private void setOperLog(OperLogInfo operLogInfo, ProceedingJoinPoint proceedingJoinPoint) {
 93        // 获取注解信息
 94        OperLog annotationLog = getAnnotationLog(proceedingJoinPoint);
 95        if (annotationLog == null) {
 96            return;
 97        }
 98        // 设置请求参数
 99        setRequestParamValue(operLogInfo, proceedingJoinPoint);
100        // 设置操作描述
101        operLogInfo.setOperDesc(annotationLog.description());
102        // 设置操作模块
103        operLogInfo.setModule(annotationLog.module());
104        // 设置操作类型
105        operLogInfo.setOperType(annotationLog.operType().getCode());
106        // 设置操作人
107        operLogInfo.setOperator(this.getOperator());
108        // 设置请求的IP
109        operLogInfo.setRequestIp(IpUtil.getIpAddress(request));
110        // 设置操作时间
111        operLogInfo.setOperTime(new Date());
112        // 请求的方法类型(get/post/put)
113        operLogInfo.setRequestMethod(request.getMethod());
114        // 设置请求地址
115        operLogInfo.setRequestUri(request.getRequestURI());
116    }
117
118    /**
119     * 获取操作人 (这里应当根据鉴权系统获取用户信息,然后填入操作用户)
120     *
121     * @return 操作人
122     */
123    private String getOperator() {
124        // 设置一个假用户名test
125        return "test";
126    }
127
128    /**
129     * 获取日志注解
130     *
131     * @param proceedingJoinPoint 切入点
132     * @return 执行结果
133     */
134    private OperLog getAnnotationLog(ProceedingJoinPoint proceedingJoinPoint) {
135        Signature signature = proceedingJoinPoint.getSignature();
136        MethodSignature methodSignature = (MethodSignature) signature;
137        Method method = methodSignature.getMethod();
138
139        if (method != null) {
140            return method.getAnnotation(OperLog.class);
141        }
142        return null;
143    }
144
145    /**
146     * 设置请求参数,放到 operLog 对象中
147     * (注意请求参数可能涉及到数据安全问题,所以这里可以根据业务场景决定,是否进行数据脱敏或者加密处理)
148     *
149     * @param operLogInfo             操作日志对象
150     * @param proceedingJoinPoint 切点
151     */
152    private void setRequestParamValue(OperLogInfo operLogInfo, ProceedingJoinPoint proceedingJoinPoint) {
153        // 处理请求参数
154        String requestParam = RequestParamUtil.requestParamHandle(request, objectMapper, proceedingJoinPoint);
155        // 将请求参数添加到操作日志对象
156        operLogInfo.setRequestParam(requestParam);
157    }
158
159}

4.15 创建查询条件类

创建一个查询条件类,便于在 Controller 类中使用,用于后续接口测试。

 1import lombok.Data;
 2import lombok.ToString;
 3
 4/**
 5 * 查询条件
 6 *
 7 * @author mydlq
 8 */
 9@Data
10@ToString
11public class QueryParam {
12    /**
13     * 参数1
14     */
15    private String param1;
16    /**
17     * 参数2
18     */
19    private String param2;
20}

4.16 创建测试 Controller 类

创建一个测试 Controller 类,在类中定义 3 个不同类型参数的接口,以及 1 个模拟发生错误的接口,用于后续进行测试。

 1import club.mydlq.operlog.syslog.enums.OperTypeEnum;
 2import club.mydlq.operlog.model.QueryParam;
 3import club.mydlq.operlog.syslog.annotation.OperLog;
 4import org.springframework.http.ResponseEntity;
 5import org.springframework.web.bind.annotation.*;
 6
 7/**
 8 * 测试 Controller
 9 *
10 * @author mydlq
11 */
12@RestController
13@RequestMapping("/test")
14public class TestController {
15
16    /**
17     *  URL入参的接口
18     */
19    @GetMapping("/query")
20    @OperLog(module = "模块1", description = "测试 Url 传参", operType = OperTypeEnum.QUERY)
21    public ResponseEntity<Object> queryTest(@RequestParam("param1") String param1,
22                                            @RequestParam("param2") String param2) {
23        return ResponseEntity.ok("ok");
24    }
25
26    /**
27     * FORM表单入参的接口
28     * @param queryParam 错误信息
29     * @return 执行结果
30     */
31    @PostMapping("/form")
32    @OperLog(module = "模块2", description = "测试 Form 表单", operType = OperTypeEnum.INSERT)
33    public ResponseEntity<Object> formTest(QueryParam queryParam) {
34        return ResponseEntity.ok("ok");
35    }
36
37    /**
38     * JSON入参的接口
39     * @param queryParam 错误信息
40     * @return 执行结果
41     */
42    @PostMapping("/json")
43    @OperLog(module = "模块3", description = "测试 JSON 传参", operType = OperTypeEnum.UPDATE)
44    public ResponseEntity<Object> jsonTest(@RequestBody QueryParam queryParam) {
45        return ResponseEntity.ok("ok");
46    }
47
48    /**
49     * 测试错误信息是否记录的接口
50     * @return 执行结果
51     */
52    @GetMapping("/error")
53    @OperLog(module = "模块4", description = "测试发生错误情况", operType = OperTypeEnum.QUERY)
54    public ResponseEntity<Object> errorTest() {
55        // 造成一个异常
56        if (true) {
57            throw new RuntimeException("发生错误");
58        }
59        return ResponseEntity.ok("ok");
60    }
61
62}

4.17 创建全局异常处理类

创建一个全局异常处理类,用于接口抛出异常后,能正确的返回对应的错误信息。

 1import org.springframework.http.ResponseEntity;
 2import org.springframework.web.bind.annotation.ExceptionHandler;
 3import org.springframework.web.bind.annotation.RestControllerAdvice;
 4
 5/**
 6 * 全局异常处理
 7 *
 8 * @author mydlq
 9 */
10@RestControllerAdvice
11public class GlobalExceptionHandler {
12
13    /**
14     * 处理通用异常
15     * @param ex 捕获的异常
16     * @return 响应实体
17     */
18    @ExceptionHandler(Exception.class)
19    public ResponseEntity<String> handleException(Exception ex) {
20        // 默认返回异常信息
21        return ResponseEntity.ok(ex.getMessage());
22    }
23
24}

4.18 创建 SpringBoot 启动类

创建 SpringBoot 启动类。

 1import org.springframework.boot.SpringApplication;
 2import org.springframework.boot.autoconfigure.SpringBootApplication;
 3
 4/**
 5 * SpringBoot 启动类
 6 *
 7 * @author mydlq
 8 */
 9@SpringBootApplication
10public class Application {
11
12	public static void main(String[] args) {
13		SpringApplication.run(Application.class, args);
14	}
15
16}

五、创建测试类进行测试

5.1 创建单元测试类测试接口

创建一个接口测试类,用于测试接口是否能正确记录操作日志。

 1import org.junit.jupiter.api.Test;
 2import org.springframework.beans.factory.annotation.Autowired;
 3import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
 4import org.springframework.boot.test.context.SpringBootTest;
 5import org.springframework.http.MediaType;
 6import org.springframework.test.web.servlet.MockMvc;
 7import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 8import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 9
10/**
11 * 测试操作日志
12 *
13 * @author mydlq
14 */
15@AutoConfigureMockMvc
16@SpringBootTest(classes = Application.class)
17public class OperLogInfoControllerTest {
18
19    @Autowired
20    private MockMvc mockMvc;
21
22    /**
23     * 调用不同的接口,测试是否记录操作日志
24     */
25    @Test
26    public void testOperLog() throws Exception {
27        // 1 - 访问URL入参的接口进行测试
28        mockMvc.perform(get("/test/query")
29                .param("param1", "1")
30                .param("param2", "2"));
31        // 2 - 访问FORM表单入参的接口
32        mockMvc.perform(post("/test/form")
33                .param("param1", "1")
34                .param("param2", "2"));
35        // 3 - 访问JSON入参的接口
36        mockMvc.perform(post("/test/json")
37                .contentType(MediaType.APPLICATION_JSON)
38                .content("{\"param1\":1,\"param2\":2}"));
39        // 4 - 访问测试错误信息是否记录的接口(验证操作日志中是否会记录错误信息)
40        mockMvc.perform(get("/test/error"));
41    }
42
43}

5.2 运行单元测试类进行测试

启动单元测试类进行单元测试,等待单元测试执行完成后,正常情况应该可以在数据库的操作日志表中查询到记录的四条操作日志信息。如下图所示:

数据库表中记录的操作日志

---END---


  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。