Loading
Loading...

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

系统环境:

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

示例地址:

一、引言

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

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

二、操作日志的重要性

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

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

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

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

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

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

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

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

这里直接提供一个基于 Spring Boot 框架的示例,演示如何通过 Spring AOP 来记录系统的操作日志。通过这种方式,我们可以更清晰地分离业务逻辑和日志记录功能,使代码结构更清晰、易于维护。

4.1 数据库创建操作日志表

在开始编写代码之前,首先需要在数据库中创建一张操作日志表,用于存储操作日志。本文以 MySQL 数据库为例,创建一个名为 sys_oper_log 的操作日志表,其中的 SQL 建表语句如下:

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

4.2 Maven 引入相关依赖

在 Maven 的 pom.xml 文件中引入一些必要的相关依赖,如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
</parent>
<groupId>club.mydlq</groupId>
<artifactId>spring-boot-log-example</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-boot-log-example</name>
<description>spring boot log demo</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<!--spring-boot-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--aop-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!--test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

4.3 配置数据库链接参数

在 SpringBoot 项目的配置文件 application.yml 中,我们需要配置数据库相关参数。配置如下:

spring:
application:
name: spring-boot-log-example
datasource:
type: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test?serverTimezone=Asia/Shanghai
hikari:
minimum-idle: 5
maximum-pool-size: 15
max-lifetime: 1800000
connection-timeout: 30000
username: root
password: xxxxxx
mybatis:
mapper-locations: classpath:mapper/*.xml

4.4 创建自定义线程池

在实际应用中,如果将操作日志的记录过程同步执行,可能会对系统性能产生一定影响。为了避免这种情况,我们可以在 Spring Boot 中配置一个自定义的异步线程池,用于异步保存操作日志,从而提高系统的响应速度和并发处理能力。

下面是一个创建自定义线程池的配置类,代码如下:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 自定义线程池
*
* @author mydlq
*/
@EnableAsync
@Configuration
public class ThreadPoolsConfig {
@Bean("logAsyncExecutor")
public ThreadPoolTaskExecutor getParamTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("LogAsyncExecutor-");
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(20);
// 队列容量
executor.setQueueCapacity(10000);
// 等待时间(秒)
executor.setAwaitTerminationSeconds(60);
// 应用关闭时等待任务完成
executor.setWaitForTasksToCompleteOnShutdown(true);
// 拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}

4.5 创建操作日志实体类

接下来,我们创建一个用于操作日志的实体类 OperLogInfo,用于映射数据库中 sys_oper_log 表的字段。这个类中的属性与数据库表结构一一对应,用来在 Java 代码中表示一条操作日志记录。

package club.mydlq.operlog.model;
import lombok.Data;
import java.util.Date;
/**
* 操作日志实体类
*
* @author mydlq
*/
@Data
public class OperLogInfo {
/**
* 主键
*/
private Long id;
/**
* 系统模块
*/
private String module;
/**
* 操作描述
*/
private String operDesc;
/**
* 请求IP
*/
private String requestIp;
/**
* URI
*/
private String requestUri;
/**
* 请求方式
*/
private String requestMethod;
/**
* 请求参数
*/
private String requestParam;
/**
* 响应结果
*/
private String responseResult;
/**
* 操作类型
*/
private Integer operType;
/**
* 操作状态
*/
private Integer operStatus;
/**
* 错误信息
*/
private String errorInfo;
/**
* 操作人
*/
private String operator;
/**
* 操作时间
*/
private Date operTime;
}

4.6 创建操作日志 Mapper 接口类

下来,我们创建一个 Mapper 接口类 OperLogMapper,用于定义与数据库操作日志表 sys_oper_log 之间的数据交互方法。

在本例中,我们只定义一个用于记录操作日志的方法,实现对操作日志的持久化存储。代码如下:

import club.mydlq.operlog.model.OperLogInfo;
import org.apache.ibatis.annotations.Mapper;
/**
* 操作日志 Mapper
*
* @author mydlq
*/
@Mapper
public interface OperLogMapper {
/**
* 插入操作日志
*
* @param operLogInfo 操作日志
* @return 执行结果
*/
int insert(OperLogInfo operLogInfo);
}

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

/resources/mapper/ 目录下创建 OperLogMapper.xml 文件,编写记录操作日志的 SQL 语句。代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="club.mydlq.operlog.mapper.OperLogMapper">
<resultMap id="BaseResultMap" type="club.mydlq.operlog.model.OperLogInfo">
<id property="id" column="id" jdbcType="INTEGER"/>
<result property="module" column="module" jdbcType="VARCHAR"/>
<result property="operDesc" column="oper_desc" jdbcType="VARCHAR"/>
<result property="requestUri" column="request_uri" jdbcType="VARCHAR"/>
<result property="requestIp" column="request_ip" jdbcType="VARCHAR"/>
<result property="requestMethod" column="request_method" jdbcType="VARCHAR"/>
<result property="responseResult" column="response_result" jdbcType="VARCHAR"/>
<result property="operType" column="oper_type" jdbcType="TINYINT"/>
<result property="operStatus" column="oper_status" jdbcType="TINYINT"/>
<result property="errorInfo" column="error_info" jdbcType="VARCHAR"/>
<result property="operator" column="operator" jdbcType="VARCHAR"/>
<result property="operTime" column="oper_time" jdbcType="TIMESTAMP"/>
</resultMap>
<!-- 插入操作日志 -->
<insert id="insert" keyColumn="id" keyProperty="id"
parameterType="club.mydlq.operlog.model.OperLogInfo"
useGeneratedKeys="true">
insert into sys_oper_log
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="module != null">module,</if>
<if test="operDesc != null">oper_desc,</if>
<if test="requestUri != null">request_uri,</if>
<if test="requestIp != null">request_ip,</if>
<if test="requestMethod != null">request_method,</if>
<if test="requestParam != null">request_param,</if>
<if test="responseResult != null">response_result,</if>
<if test="operType != null">oper_type,</if>
<if test="operStatus != null">oper_status,</if>
<if test="errorInfo != null">error_info,</if>
<if test="operator != null">operator,</if>
<if test="operTime != null">oper_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="module != null">#{module,jdbcType=VARCHAR},</if>
<if test="operDesc != null">#{operDesc,jdbcType=VARCHAR},</if>
<if test="requestUri != null">#{requestUri,jdbcType=VARCHAR},</if>
<if test="requestIp != null">#{requestIp,jdbcType=VARCHAR},</if>
<if test="requestMethod != null">#{requestMethod,jdbcType=VARCHAR},</if>
<if test="requestParam != null">#{requestParam,jdbcType=VARCHAR},</if>
<if test="responseResult != null">#{responseResult,jdbcType=VARCHAR},</if>
<if test="operType != null">#{operType,jdbcType=TINYINT},</if>
<if test="operStatus != null">#{operStatus,jdbcType=TINYINT},</if>
<if test="errorInfo != null">#{errorInfo,jdbcType=VARCHAR},</if>
<if test="operator != null">#{operator,jdbcType=VARCHAR},</if>
<if test="operTime != null">#{operTime,jdbcType=TIMESTAMP},</if>
</trim>
</insert>
</mapper>

4.8 创建操作日志 Service 类

接下来创建一个名为 OperLogService 的 Service 类,用于封装操作日志的记录逻辑。代码如下:

import club.mydlq.operlog.model.OperLogInfo;
import club.mydlq.operlog.mapper.OperLogMapper;
import jakarta.annotation.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 操作日志 Service
*
* @author mydlq
*/
@Service
public class OperLogService {
@Resource
private OperLogMapper operLogMapper;
/**
* 使用异步方式记录操作日志 (使用 logAsyncExecutor 线程池)
*/
@Async(value = "logAsyncExecutor")
public void saveLog(OperLogInfo operLogInfo) {
operLogMapper.insert(operLogInfo);
}
}

4.9 创建操作类型枚举

在操作日志记录过程中,不同的业务行为 (如查询、新增、修改、删除等) 需要进行分类标识。为此,我们定义一个枚举类 OperTypeEnum,用于表示操作日志的类型。代码如下:

import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 操作类型枚举
*
* @author mydlq
*/
@Getter
@AllArgsConstructor
public enum OperTypeEnum {
/**
* 查询
*/
QUERY(0, "查询"),
/**
* 新增
*/
INSERT(1, "新增"),
/**
* 修改
*/
UPDATE(2, "修改"),
/**
* 删除
*/
DELETE(3, "删除"),
/**
* 上传
*/
UPLOAD(4, "上传"),
/**
* 下载
*/
DOWNLOAD(5, "下载"),
;
/**
* 状态编号
*/
private final int code;
/**
* 状态描述
*/
private final String name;
}

4.10 创建操作状态枚举

在记录操作日志时,我们需要标识本次操作是 成功 还是 失败。为此,我们定义一个枚举类 OperStatusEnum,用于表示操作日志的执行状态。

import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 操作状态枚举
*
* @author mydlq
*/
@Getter
@AllArgsConstructor
public enum OperStatusEnum {
/**
* 失败
*/
FAIL(0, "失败"),
/**
* 成功
*/
SUCCESS(1, "成功"),
;
/**
* 状态编号
*/
private final int code;
/**
* 状态描述
*/
private final String name;
}

4.11 创建操作日志注解

我们创建一个自定义注解 @OperLog,用于标识哪些 Controller 接口需要记录操作日志。这个注解将作为 AOP 的切入点标记,应用于 Controller 层中的相关方法,用于携带操作日志相关的元信息。

import club.mydlq.operlog.syslog.enums.OperTypeEnum;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 操作日志注解,作为 AOP 中的切入点,应用于 Controller 层
*
* @author mydlq
*/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OperLog {
/**
* 系统模块
*/
String module();
/**
* 接口描述
*/
String description() default "";
/**
* 操作类型
*/
OperTypeEnum operType();
}

4.12 IP地址处理工具

在操作日志记录中,获取客户端的请求 IP 地址是一项重要信息,它可以帮助我们识别操作请求的来源,以便于后续的责任追溯和安全审计。为此,我们创建一个 IP 地址处理工具类 IpUtil,用于从 HTTP 请求对象 (HttpServletRequest) 中提取用户的真实 IP 地址。

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* IP 地址处理工具
*
* @author mydlq
*/
@Slf4j
public class IpUtil {
private IpUtil() {
}
/**
* 本机IP地址
*/
private static final String LOCALHOST_IP = "127.0.0.1";
/**
* 客户端与服务器同为一台机器,获取的 ip 有时候是 IPV6 格式
*/
private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
private static final String SEPARATOR = ",";
private static final String UNKNOWN = "unknown";
private static final int MAX_LEN_LIMIT = 15;
/**
* 根据 HttpServletRequest 获取 IP
*
* @param request 请求对象
* @return 请求IP地址
*/
public static String getIpAddress(HttpServletRequest request) {
if (request == null) {
return UNKNOWN;
}
String ip = request.getHeader("x-forwarded-for");
if (isValid(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (isValid(ip)) {
ip = request.getHeader("X-Forwarded-For");
}
if (isValid(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (isValid(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (isValid(ip)) {
ip = request.getRemoteAddr();
}
if (LOCALHOST_IP.equalsIgnoreCase(ip) || LOCALHOST_IPV6.equalsIgnoreCase(ip)) {
try {
// 根据网卡取本机配置的 IP
InetAddress iNet = InetAddress.getLocalHost();
if (iNet != null) {
ip = iNet.getHostAddress();
}
} catch (UnknownHostException e) {
log.error("", e);
}
}
// 对于通过多个代理的情况,分割出第一个 IP
if (ip != null && ip.length() > MAX_LEN_LIMIT && ip.contains(SEPARATOR)) {
ip = ip.substring(0, ip.indexOf(SEPARATOR));
}
return LOCALHOST_IPV6.equals(ip) ? LOCALHOST_IP : ip;
}
/**
* 判断 IP 是否有效
*
* @param ip IP地址
* @return 是否有效
*/
private static boolean isValid(String ip) {
return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip);
}
}

4.13 请求参数处理工具

在记录操作日志时,我们往往需要将接口的请求参数也一并保存,以便后续排查问题或进行审计。不过,由于不同接口可能使用不同的请求类型(如 URL传参、FORM表单、JSON数据),所以我们需要一个统一的工具类来识别并处理这些参数。

为此,我们创建一个名为 RequestParamUtil 的工具类,用于根据当前请求的 MediaType 类型,来对请求参数进行提取并格式化。

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 请求参数处理工具
*
* @author mydlq
*/
@Slf4j
public class RequestParamUtil {
private RequestParamUtil() {
}
/**
* 处理请求参数 (根据不同的MediaType类型,对不同的参数进行处理)
*
* @param request HttpServletRequest
* @param objectMapper JSON转换工具
* @param proceedingJoinPoint 连接点
* @return 处理后的请求参数
*/
public static String requestParamHandle(HttpServletRequest request,
ObjectMapper objectMapper,
ProceedingJoinPoint proceedingJoinPoint) {
// 获取 RequestMethod 和 ContentType
String contentType = request.getContentType();
// 如果 ContentType 为空,则可能是 URL 传参,直接反 URL 参数
if (!StringUtils.hasText(contentType)) {
return request.getQueryString();
}
// 获取 RequestMethod 和 ContentType 的枚举
MediaType mediaType = MediaType.valueOf(contentType);
// 处理 FROM 表单媒体类型
if (MediaType.APPLICATION_FORM_URLENCODED.equals(mediaType)) {
return fromParamHandle(request, objectMapper);
}
// 处理 JSON 表单媒体类型
if (MediaType.APPLICATION_JSON.equals(mediaType)) {
Object[] args = proceedingJoinPoint.getArgs();
return (args != null && args.length > 0) ? argsArrayToString(args, objectMapper) : "";
}
// 如果以上条件都不符合,则默认反回空串
return "";
}
/**
* FORM 表单参数处理
*
* @param request 请求对象
* @param objectMapper JSON转换工具
* @return 转换后的 JSON 字符串
*/
private static String fromParamHandle(HttpServletRequest request, ObjectMapper objectMapper) {
try {
Map<String, String[]> params = request.getParameterMap();
return objectMapper.writeValueAsString(params);
} catch (JsonProcessingException e) {
log.error("", e);
}
return "";
}
/**
* JSON 参数处理
*/
private static String argsArrayToString(Object[] paramsArray, ObjectMapper objectMapper) {
StringBuilder params = new StringBuilder();
if (paramsArray != null) {
for (Object o : paramsArray) {
// 过滤的对象,如上传这种接口接收的参数类型需要过滤掉
boolean isMultipartFile = o instanceof MultipartFile;
boolean isRequest = o instanceof HttpServletRequest;
boolean isResponse = o instanceof HttpServletResponse;
// 执行过滤
if (isMultipartFile || isRequest || isResponse) {
continue;
}
// 过滤完符合条件的转换为 JSON 字符串存储
try {
String jsonObj = objectMapper.writeValueAsString(o);
params.append(jsonObj).append(" ");
} catch (JsonProcessingException e) {
log.error("", e);
}
}
}
return params.toString().trim();
}
}

4.14 创建操作日志 AOP 切面

我们创建一个 AOP 切面类 SystemLogAspect,用于自动拦截所有标注了 @OperLog 注解的 Controller 方法。该切面在方法执行前后进行拦截,自动收集操作日志需要记录的信息 (如请求 IP、操作人、操作模块、请求参数、响应结果等),并最终将完整的操作日志保存到数据库中。

import club.mydlq.operlog.model.OperLogInfo;
import club.mydlq.operlog.syslog.enums.OperStatusEnum;
import club.mydlq.operlog.service.OperLogService;
import club.mydlq.operlog.syslog.annotation.OperLog;
import club.mydlq.operlog.syslog.utils.IpUtil;
import club.mydlq.operlog.syslog.utils.RequestParamUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Date;
/**
* 操作日志 AOP 切面
*
* @author mydlq
*/
@Slf4j
@Aspect
@Component
public class SystemLogAspect {
private final HttpServletRequest request;
private final OperLogService operLogService;
private final ObjectMapper objectMapper;
@Autowired
public SystemLogAspect(HttpServletRequest request,
ObjectMapper objectMapper,
OperLogService operLogService) {
this.request = request;
this.objectMapper = objectMapper;
this.operLogService = operLogService;
}
/**
* 根据 @SystemControllerLog 主键进行切
*/
@Pointcut("@annotation(club.mydlq.operlog.syslog.annotation.OperLog)")
public void controllerAspect() {
}
/**
* 环绕 Advice - 用于拦截 Controller 层设添加了 @OperLog 注解的方法
*
* @param proceedingJoinPoint 切点
*/
@Around("controllerAspect()")
public Object doAfter(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
// 创建接收响应结果的对象、操作日志对象
Object responseResult;
OperLogInfo operLogInfo = new OperLogInfo();
try {
// --- 前置处理: 拼接操作日志信息 ---
setOperLog(operLogInfo, proceedingJoinPoint);
// 使前置 Advice 通过并获取响应结果
responseResult = proceedingJoinPoint.proceed();
// --- 后置处理: 设置响应结果、操作状态 ---
// 将响应结果转换为JSON串,并且设置操作状态为成功
operLogInfo.setResponseResult(responseResult == null ? "" : objectMapper.writeValueAsString(responseResult));
operLogInfo.setOperStatus(OperStatusEnum.SUCCESS.getCode());
// 返回响应结果
return responseResult;
} catch (Throwable e) {
// 设置操作状态为失败,并且记录错误信息
operLogInfo.setOperStatus(OperStatusEnum.FAIL.getCode());
operLogInfo.setErrorInfo(e.toString());
throw e;
} finally {
// 记录操作日志到数据库
operLogService.saveLog(operLogInfo);
}
}
/**
* 获取操作日志数据
*
* @param operLogInfo 操作日志对象
* @param proceedingJoinPoint 连接点
*/
private void setOperLog(OperLogInfo operLogInfo, ProceedingJoinPoint proceedingJoinPoint) {
// 获取注解信息
OperLog annotationLog = getAnnotationLog(proceedingJoinPoint);
if (annotationLog == null) {
return;
}
// 设置请求参数
setRequestParamValue(operLogInfo, proceedingJoinPoint);
// 设置操作描述
operLogInfo.setOperDesc(annotationLog.description());
// 设置操作模块
operLogInfo.setModule(annotationLog.module());
// 设置操作类型
operLogInfo.setOperType(annotationLog.operType().getCode());
// 设置操作人
operLogInfo.setOperator(this.getOperator());
// 设置请求的IP
operLogInfo.setRequestIp(IpUtil.getIpAddress(request));
// 设置操作时间
operLogInfo.setOperTime(new Date());
// 请求的方法类型(get/post/put)
operLogInfo.setRequestMethod(request.getMethod());
// 设置请求地址
operLogInfo.setRequestUri(request.getRequestURI());
}
/**
* 获取操作人 (这里应当根据鉴权系统获取用户信息,然后填入操作用户)
*
* @return 操作人
*/
private String getOperator() {
// 设置一个假用户名test
return "test";
}
/**
* 获取日志注解
*
* @param proceedingJoinPoint 切入点
* @return 执行结果
*/
private OperLog getAnnotationLog(ProceedingJoinPoint proceedingJoinPoint) {
Signature signature = proceedingJoinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
if (method != null) {
return method.getAnnotation(OperLog.class);
}
return null;
}
/**
* 设置请求参数,放到 operLog 对象中
* (注意请求参数可能涉及到数据安全问题,所以这里可以根据业务场景决定,是否进行数据脱敏或者加密处理)
*
* @param operLogInfo 操作日志对象
* @param proceedingJoinPoint 切点
*/
private void setRequestParamValue(OperLogInfo operLogInfo, ProceedingJoinPoint proceedingJoinPoint) {
// 处理请求参数
String requestParam = RequestParamUtil.requestParamHandle(request, objectMapper, proceedingJoinPoint);
// 将请求参数添加到操作日志对象
operLogInfo.setRequestParam(requestParam);
}
}

4.15 创建查询条件类

在前面的步骤中,我们已经基于 AOP 和注解实现了一套统一的操作日志记录机制。不过,为了便于后续测试接口调用时能正确触发日志记录流程,我们需要创建一个简单的查询条件类 QueryParam,用于作为 Controller 接口中方法的参数示例。

import lombok.Data;
import lombok.ToString;
/**
* 查询条件
*
* @author mydlq
*/
@Data
@ToString
public class QueryParam {
/**
* 参数1
*/
private String param1;
/**
* 参数2
*/
private String param2;
}

4.16 创建测试 Controller 类

在前面的步骤中,我们已经搭建好了操作日志记录的核心机制。为了验证这套机制是否能正确记录不同类型的接口请求,我们需要创建一个测试用的 Controller 类,并在其中定义几个典型的接口方法。

该测试 Controller 类中,包含 3 种常见参数形式的接口和 1 个用于模拟异常的接口,分别对应:

  • (1)、URL 参数 (如 ?param1=xxx&param2=xxx)
  • (2)、FORM 表单提交
  • (3)、JSON 请求体传参 (如 POST JSON 数据)
  • (4)、模拟发生异常的接口 (验证错误信息是否被正确记录)

通过这些接口,我们可以全面地测试 AOP 日志切面是否能够正确获取请求参数、响应结果、IP 地址等信息,并完整地记录到操作日志表中。代码如下:

import club.mydlq.operlog.syslog.enums.OperTypeEnum;
import club.mydlq.operlog.model.QueryParam;
import club.mydlq.operlog.syslog.annotation.OperLog;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 测试 Controller
*
* @author mydlq
*/
@RestController
@RequestMapping("/test")
public class TestController {
/**
* URL入参的接口
*/
@GetMapping("/query")
@OperLog(module = "模块1", description = "测试 Url 传参", operType = OperTypeEnum.QUERY)
public ResponseEntity<Object> queryTest(@RequestParam("param1") String param1,
@RequestParam("param2") String param2) {
return ResponseEntity.ok("ok");
}
/**
* FORM表单入参的接口
* @param queryParam 错误信息
* @return 执行结果
*/
@PostMapping("/form")
@OperLog(module = "模块2", description = "测试 Form 表单", operType = OperTypeEnum.INSERT)
public ResponseEntity<Object> formTest(QueryParam queryParam) {
return ResponseEntity.ok("ok");
}
/**
* JSON入参的接口
* @param queryParam 错误信息
* @return 执行结果
*/
@PostMapping("/json")
@OperLog(module = "模块3", description = "测试 JSON 传参", operType = OperTypeEnum.UPDATE)
public ResponseEntity<Object> jsonTest(@RequestBody QueryParam queryParam) {
return ResponseEntity.ok("ok");
}
/**
* 测试错误信息是否记录的接口
* @return 执行结果
*/
@GetMapping("/error")
@OperLog(module = "模块4", description = "测试发生错误情况", operType = OperTypeEnum.QUERY)
public ResponseEntity<Object> errorTest() {
// 造成一个异常
if (true) {
throw new RuntimeException("发生错误");
}
return ResponseEntity.ok("ok");
}
}

4.17 创建全局异常处理类

在接口执行过程中,如果发生异常,我们需要统一处理并返回处理后的错误信息,同时确保操作日志也能正确记录异常详情。为此,我们创建一个全局异常处理类 GlobalExceptionHandler,它通过 Spring 的 @RestControllerAdvice 注解实现对整个应用中控 Controller 层抛出的异常进行捕获和统一响应。代码如下:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理
*
* @author mydlq
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理通用异常
*
* @param ex 捕获的异常
* @return 响应实体
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception ex) {
// 默认返回异常信息
return ResponseEntity.ok(ex.getMessage());
}
}

4.18 创建 SpringBoot 启动类

创建 SpringBoot 启动类。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* SpringBoot 启动类
*
* @author mydlq
*/
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

五、创建测试类进行测试

接下来创建一个单元测试类,通过调用测试 Controller 类中的接口,测试是否能正确记录操作日志。

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

我们创建一个单元测试类 OperLogInfoControllerTest,用于模拟调用 Controller 层接口,并验证操作日志是否能被正确记录到数据库中。

该测试类通过 Spring 提供的 MockMvc 模拟 HTTP 请求,依次调用标注了 @OperLog 注解的接口,确保 AOP 切面能够正常拦截并记录操作日志。无论接口执行成功还是抛出异常,都能触发日志记录逻辑,从而验证日志记录机制的完整性和健壮性。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* 测试操作日志
*
* @author mydlq
*/
@AutoConfigureMockMvc
@SpringBootTest(classes = Application.class)
public class OperLogInfoControllerTest {
@Autowired
private MockMvc mockMvc;
/**
* 调用不同的接口,测试是否记录操作日志
*/
@Test
public void testOperLog() throws Exception {
// 1 - 访问URL入参的接口进行测试
mockMvc.perform(get("/test/query")
.param("param1", "1")
.param("param2", "2"));
// 2 - 访问FORM表单入参的接口
mockMvc.perform(post("/test/form")
.param("param1", "1")
.param("param2", "2"));
// 3 - 访问JSON入参的接口
mockMvc.perform(post("/test/json")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"param1\":1,\"param2\":2}"));
// 4 - 访问测试错误信息是否记录的接口(验证操作日志中是否会记录错误信息)
mockMvc.perform(get("/test/error"));
}
}

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

启动单元测试类进行单元测试,等待单元测试执行完成后,正常来说所有标注了 @OperLog 注解的接口都会被 AOP 拦截,操作日志会异步记录到数据库表 sys_oper_log 中。如果测试执行无误,你应该能在数据库中看到四条对应的操作日志记录。

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

如上图所示,是测试完成后数据库中记录的日志数据,验证了我们的操作日志功能在各种请求方式下均能正确运行并持久化存储日志信息。

---END---
如果本文对你有帮助,可以关注我的公众号 "小豆丁技术栈" 了解最新动态,顺便也请帮忙 Github 点颗星哦,感谢~

本文作者:超级小豆丁 @ 小豆丁技术栈

本文链接:http://www.mydlq.club/article/142/

本文标题:SpringBoot 使用 AOP 方式高效的记录操作日志

本文版权:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!