基于AOP+Spring实现操作日志记录:从设计到落地全指南

在日常开发中,操作日志是系统不可或缺的一部分——它用于追溯用户行为、排查问题、审计安全操作。但如果在每个业务方法中硬编码日志逻辑,会导致代码耦合度高、重复工作量大、维护困难。本文将基于 AOP(面向切面编程) 思想,结合Spring生态,实现一套“业务与日志解耦、可复用、易扩展”的操作日志方案,附完整代码和关键问题解决方案。

一、方案背景与核心设计

1.1 为什么选择AOP?

传统日志实现的痛点:

  • 日志逻辑与业务逻辑混杂(如每个Service方法都写“记录日志”代码);
  • 新增日志需求时,需修改所有相关业务代码;
  • 日志格式/内容调整时,全局修改成本高。

AOP的优势恰好解决这些问题:

  • 解耦:日志逻辑作为“切面”独立存在,不侵入业务代码;
  • 复用:通过“切点”批量拦截目标方法,统一日志逻辑;
  • 灵活:新增/修改日志规则时,只需调整切面,无需改动业务。

1.2 核心架构设计

本方案采用“注解标记+AOP拦截+接口解耦+数据库存储”的架构,避免模块间循环依赖,同时保证扩展性。架构图如下:

flowchart TD
A[Aspect<br/>(切面)] --> P[Pointcut<br/>(切点)]
A --> Ad[Advice<br/>(处理)]
A --> W[Weaving<br/>(织入)]
W --> T[Target<br/>(目标对象)]
T --> JP[joinPoint<br/>(连接点)]

P --> E[execution<br/>(路径表达式)]
P --> An[annotation<br/>(注解)]
An --> SysA[系统注解]
An --> CusA[自定义注解]

Ad --> Time[处理时机]
Ad --> Content[处理内容]
Time --> Before[Before<br/>(前置处理)]
Time --> After[After<br/>(后置处理)]
Time --> Around[Around<br/>(环绕处理)]
Time --> AfterReturning[AfterReturning<br/>(后置返回通知)]
Time --> AfterThrowing[AfterThrowing<br/>(异常抛出通知)]

各组件职责:

  • @OperationLog注解:标记需要记录日志的业务方法,指定“操作模块”“操作描述”;
  • AOP切面(OperationLogAspect):拦截注解标记的方法,收集请求IP、方法信息、参数、耗时等;
  • LogHandler接口:定义日志保存规范,解耦Common模块与业务模块(避免循环依赖);
  • SysOperationLog实体:封装日志数据,映射数据库表;
  • 数据库表:持久化存储日志数据。

二、环境准备

需引入的核心依赖(Spring Boot项目为例):

<!-- Spring AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- MyBatis(操作数据库) -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Lombok(简化实体类代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Java 8时间类型支持(LocalDateTime映射MySQL datetime) -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-typehandlers-jsr310</artifactId>
<version>1.0.1</version>
</dependency>

三、分步实现详解

3.1 步骤1:自定义操作日志注解(@OperationLog)

通过注解标记需要记录日志的方法,并携带“操作模块”“操作描述”等元信息(放在Common公共模块)。

import java.lang.annotation.*;

/**
* 自定义操作日志注解:标记需要记录日志的方法
*/
@Target({ElementType.METHOD}) // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时生效(AOP需动态获取注解信息)
@Documented // 生成JavaDoc时包含该注解
public @interface OperationLog {
/** 操作模块(如:用户管理、订单处理) */
String module() default ""; /** 操作描述(如:新增用户、删除订单) */
String description() default "";
}

3.2 步骤2:定义日志实体类(SysOperationLog)

封装日志的所有字段,与数据库表sys_operation_log一一对应(放在Common模块)。

import lombok.Data;
import java.time.LocalDateTime; /**
* 操作日志实体类:映射数据库表sys_operation_log
*/
@Data // Lombok自动生成getter/setter/toString
public class SysOperationLog {
/** 日志ID(自增主键) */
private Long id;
/** 操作用户(用户名/账号) */
private String username;
/** 操作时间 */
private LocalDateTime operationTime;
/** 操作模块(如:用户管理) */
private String module;
/** 操作描述(如:新增用户) */
private String description;
/** 操作方法全路径(如:com.example.service.UserService.addUser) */
private String method;
/** 方法参数(JSON格式) */
private Object params;
/** 操作结果(成功/失败,JSON格式) */
private Object result;
/** 异常信息(失败时记录) */
private String exception;
/** 操作耗时(毫秒) */
private Long costTime;
/** 客户端IP */
private String clientIp;
}

3.3 步骤3:定义日志处理接口(LogHandler)

为避免Common模块直接依赖业务模块(导致循环依赖),通过接口定义日志保存规范,业务模块实现该接口(放在Common模块)。

import com.wuxi.common.log.entity.SysOperationLog;

/**
* 日志处理接口:Common模块定义规范,业务模块实现具体逻辑
* 作用:解耦Common与业务模块,避免循环依赖
*/
public interface LogHandler {
/**
* 保存操作日志
* @param sysOperationLog 日志实体
*/
void saveOperationLog(SysOperationLog sysOperationLog);
}

3.4 步骤4:实现AOP切面核心逻辑(OperationLogAspect)

AOP切面是日志收集的核心,负责拦截注解标记的方法、收集日志信息、调用LogHandler保存日志(放在Common模块)。

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
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.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Arrays; /**
* 操作日志AOP切面:核心日志收集逻辑
*/
@Slf4j // Lombok自动生成日志对象
@Aspect // 标记为AOP切面
@Component // 纳入Spring容器管理
@RequiredArgsConstructor // Lombok自动生成构造函数,注入LogHandler
public class OperationLogAspect { // 注入日志处理接口(业务模块实现),避免依赖具体业务
private final LogHandler logHandler; /**
* 定义切点:拦截所有添加@OperationLog注解的方法
*/
@Pointcut("@annotation(com.wuxi.common.log.annotation.OperationLog)")
public void logPointCut() {} /**
* 环绕通知:在方法执行前后拦截,收集日志信息
* 优势:可获取方法执行前(如开始时间)、执行后(如结果、耗时)、异常信息
*/
@Around("logPointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 记录方法开始时间(用于计算耗时)
long startTime = System.currentTimeMillis(); // 2. 初始化日志实体
SysOperationLog logEntity = new SysOperationLog();
logEntity.setOperationTime(LocalDateTime.now()); // 操作时间 // 3. 获取客户端IP(从请求上下文获取)
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
HttpServletRequest request = requestAttributes.getRequest();
logEntity.setClientIp(request.getRemoteAddr()); // 客户端IP
} // 4. 获取方法信息(全路径、参数)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 方法全路径:包名+类名+方法名
logEntity.setMethod(method.getDeclaringClass().getName() + "." + method.getName());
// 方法参数:数组转字符串(后续需序列化为JSON)
logEntity.setParams(Arrays.toString(joinPoint.getArgs())); // 5. 获取@OperationLog注解信息(模块、描述)
OperationLog operationLog = method.getAnnotation(OperationLog.class);
logEntity.setModule(operationLog.module());
logEntity.setDescription(operationLog.description()); // 6. 获取操作用户(从登录上下文获取,如Spring Security)
logEntity.setUsername(getCurrentUsername()); Object businessResult = null; // 业务方法返回结果
try {
// 执行目标业务方法(核心业务逻辑)
businessResult = joinPoint.proceed();
// 方法执行成功:标记结果
logEntity.setResult("成功");
// 若需记录业务返回结果,可序列化后赋值:logEntity.setResult(JSON.toJSONString(businessResult));
} catch (Exception e) {
// 方法执行失败:记录异常信息
logEntity.setResult("失败");
logEntity.setException(e.getMessage()); // 异常信息(简化,可记录堆栈)
throw e; // 重新抛出异常,不影响原有业务异常处理逻辑
} finally {
// 7. 计算操作耗时(结束时间-开始时间)
logEntity.setCostTime(System.currentTimeMillis() - startTime); // 8. 保存日志(调用业务模块实现的LogHandler)
saveOperationLog(logEntity);
} // 返回业务方法结果,不影响业务流程
return businessResult;
} /**
* 从登录上下文获取当前用户(实际项目需替换为真实逻辑)
* 示例:Spring Security可通过SecurityContextHolder获取
*/
private String getCurrentUsername() {
// 模拟:从自定义UserContext获取(实际项目需实现上下文管理)
String currentUser = UserContext.getUser();
// 若未获取到用户(如系统操作),默认赋值为"system"
return currentUser == null ? "system" : currentUser;
} /**
* 调用LogHandler保存日志,捕获异常避免影响主业务
*/
private void saveOperationLog(SysOperationLog logEntity) {
try {
logHandler.saveOperationLog(logEntity);
} catch (Exception e) {
// 日志保存失败不影响主业务,仅记录日志告警
log.error("记录系统操作日志失败,日志信息:{}", logEntity, e);
// 若日志为核心审计需求,可抛出自定义异常:throw new DbException("记录日志失败", e);
}
}
}

3.5 步骤5:数据库表设计(sys_operation_log)

创建日志存储表,字段与SysOperationLog实体对应,添加索引优化查询(如按时间、用户查询)。

CREATE TABLE `sys_operation_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID(自增主键)',
`username` varchar(50) NOT NULL COMMENT '操作用户',
`operation_time` datetime NOT NULL COMMENT '操作时间',
`module` varchar(100) NOT NULL COMMENT '操作模块(如:用户管理)',
`description` varchar(255) DEFAULT NULL COMMENT '操作描述(如:新增用户)',
`method` varchar(255) NOT NULL COMMENT '操作方法全路径',
`params` text COMMENT '方法参数(JSON格式)',
`result` text COMMENT '操作结果(成功/失败,JSON格式)',
`exception` text COMMENT '异常信息(失败时记录)',
`cost_time` bigint DEFAULT NULL COMMENT '操作耗时(毫秒)',
`client_ip` varchar(50) DEFAULT NULL COMMENT '客户端IP',
PRIMARY KEY (`id`),
KEY `idx_operation_time` (`operation_time`) COMMENT '按操作时间查询索引',
KEY `idx_username` (`username`) COMMENT '按用户查询索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='系统操作日志表';

3.6 步骤6:实现LogHandler接口(业务模块)

在业务模块(如用户服务、订单服务)中实现LogHandler接口,调用MyBatis将日志插入数据库(避免Common模块依赖业务)。

import com.wuxi.common.log.entity.SysOperationLog;
import com.wuxi.common.log.handler.LogHandler;
import com.wuxi.user.mapper.SysOperationLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson.JSON; // 需引入FastJSON依赖 /**
* 日志处理实现类:业务模块实现,负责将日志插入数据库
*/
@Component
@RequiredArgsConstructor
public class LogHandlerImpl implements LogHandler { // 注入MyBatis Mapper(操作数据库)
private final SysOperationLogMapper sysOperationLogMapper; @Override
public void saveOperationLog(SysOperationLog sysOperationLog) {
// 关键:将Object类型的params/result序列化为JSON字符串(适配数据库text类型)
if (sysOperationLog.getParams() != null) {
sysOperationLog.setParams(JSON.toJSONString(sysOperationLog.getParams()));
}
if (sysOperationLog.getResult() != null) {
sysOperationLog.setResult(JSON.toJSONString(sysOperationLog.getResult()));
} // 调用MyBatis Mapper插入数据库
sysOperationLogMapper.insert(sysOperationLog);
}
}

3.7 步骤7:MyBatis映射(插入日志)

编写MyBatis Mapper接口和XML映射文件,实现日志插入逻辑。

7.1 Mapper接口

import com.wuxi.common.log.entity.SysOperationLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options; /**
* 操作日志MyBatis Mapper
*/
public interface SysOperationLogMapper { /**
* 插入操作日志
* @Options:自增主键回写(将数据库生成的id赋值给实体类的id字段)
*/
@Insert("INSERT INTO sys_operation_log (" +
"username, operation_time, module, description, method, " +
"params, result, exception, cost_time, client_ip" +
") VALUES (" +
"#{username}, #{operationTime}, #{module}, #{description}, #{method}, " +
"#{params}, #{result}, #{exception}, #{costTime}, #{clientIp}" +
")")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(SysOperationLog sysOperationLog);
}

7.2 (可选)XML映射文件

若偏好XML配置,可替换为以下方式(SysOperationLogMapper.xml):

<?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="com.wuxi.user.mapper.SysOperationLogMapper"> <!-- 插入操作日志 -->
<insert id="insert" parameterType="com.wuxi.common.log.entity.SysOperationLog"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO sys_operation_log (
username, operation_time, module, description, method,
params, result, exception, cost_time, client_ip
) VALUES (
#{username}, #{operationTime}, #{module}, #{description}, #{method},
#{params}, #{result}, #{exception}, #{costTime}, #{clientIp}
)
</insert> </mapper>

四、实际使用示例

在业务方法上添加@OperationLog注解,即可自动记录日志,无需额外编写日志代码。

import com.wuxi.common.log.annotation.OperationLog;
import com.wuxi.user.entity.User;
import com.wuxi.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; @RestController
@RequiredArgsConstructor
public class UserController { private final UserService userService; /**
* 新增用户接口:添加@OperationLog注解,自动记录日志
*/
@PostMapping("/user/add")
@OperationLog(module = "用户管理", description = "新增用户")
public String addUser(@RequestBody User user) {
userService.saveUser(user);
return "新增用户成功";
} /**
* 删除用户接口:记录日志
*/
@PostMapping("/user/delete")
@OperationLog(module = "用户管理", description = "删除用户")
public String deleteUser(Long userId) {
userService.deleteUser(userId);
return "删除用户成功";
}
}

五、关键问题与解决方案

5.1 如何避免循环依赖?

问题:Common模块需调用业务模块的日志保存逻辑,若Common直接依赖业务模块,会形成“Common→业务→Common”的循环依赖。

解决方案:接口解耦

  • Common模块定义LogHandler接口,不依赖业务;
  • 业务模块实现LogHandler接口,依赖Common模块;
  • 最终依赖链:业务模块→Common模块(单向依赖,无循环)。

5.2 Object类型参数/结果如何存储?

问题:SysOperationLog的paramsresult是Object类型,数据库是text类型,直接存储会报错。

解决方案:JSON序列化

使用FastJSON/Jackson将Object序列化为JSON字符串,存储到数据库(如LogHandlerImpl中JSON.toJSONString())。

5.3 LocalDateTime与MySQL datetime映射问题?

问题:Java 8的LocalDateTime与MySQL的datetime类型默认不兼容,会报类型转换错误。

解决方案:

  1. 引入mybatis-typehandlers-jsr310依赖(已在环境准备中添加);
  2. MyBatis自动识别该类型处理器,无需额外配置。

5.4 日志保存失败影响主业务?

问题:若数据库异常导致日志保存失败,不能阻断核心业务流程。

解决方案:异常隔离

在AOP的saveOperationLog方法中捕获异常,仅记录告警日志,不抛出异常(除非是核心审计日志,需强制记录)。

六、方案优化方向

  1. 异步保存日志:通过@Async注解异步执行日志保存,避免日志操作阻塞主业务(需开启Spring异步支持@EnableAsync);
  2. 日志脱敏:对敏感参数(如密码、手机号)进行脱敏处理后再存储(如用***替换中间字符);
  3. 日志分表:日志数据量较大时,按时间分表(如每月一张表),提升查询性能;
  4. 分布式日志:微服务场景下,可将日志发送到ELK(Elasticsearch+Logstash+Kibana),实现日志集中查询与分析。

七、总结

本文基于AOP+Spring实现的操作日志方案,核心优势在于:

  • 解耦:日志逻辑与业务逻辑完全分离,无侵入;
  • 可扩展:新增日志字段或修改保存逻辑,只需调整切面或LogHandler实现;
  • 易用性:业务方法只需添加注解,即可自动记录日志。

该方案适用于单体应用和微服务架构,可根据实际需求扩展异步、脱敏、分表等功能,是企业级系统操作日志的最佳实践之一。

SpringBoot使用AOP优雅的实现系统操作日志的持久化!的更多相关文章

  1. asp.net mvc 系统操作日志设计

    第一步.系统登录日志 通过signalr来管理用户的登录情况,并保存用户的登录记录. 第二步 通过mvc过滤器,来横切路由访问记录. 保存方式:通过httpclient异步请求webapi 数据通过m ...

  2. springboot—spring aop 实现系统操作日志记录存储到数据库

    原文:https://www.jianshu.com/p/d0bbdf1974bd 采用方案: 使用spring 的 aop 技术切到自定义注解上,针对不同注解标志进行参数解析,记录日志 缺点是要针对 ...

  3. Springboot使用AOP实现统一处理Web请求日志

    1.要使我们自定义的记录日志能够打印出来,我们需要先排除springboot默认的记录日志,添加如下的设置 2.新建 resources/log4j.properties 我的设置为: # LOG4J ...

  4. Spring AOP使用注解记录用户操作日志

    最后一个方法:核心的日志记录方法 package com.migu.cm.aspect; import com.alibaba.fastjson.JSON; import com.migu.cm.do ...

  5. 后台管理系统之系统操作日志开发(Java实现)

    一,功能点 实现管理员操作数据的记录.效果如下 二,代码实现 基于注解的Aop日志记录 1.Log实体类 package com.ideal.manage.guest.bean.log; import ...

  6. 开发一个二方包,优雅地为系统接入ELK(elasticsearch+logstash+kibana)

    去年公司由于不断发展,内部自研系统越来越多,所以后来搭建了一个日志收集平台,并将日志收集功能以二方包形式引入各个自研系统,避免每个自研系统都要建立一套自己的日志模块,节约了开发时间,管理起来也更加容易 ...

  7. spring-boot-route(十七)使用aop记录操作日志

    在上一章内容中--使用logback管理日志,我们详细讲述了如何将日志生成文件进行存储.但是在实际开发中,使用文件存储日志用来快速查询问题并不是最方便的,一个优秀系统除了日志文件还需要将操作日志进行持 ...

  8. 【开源】OSharp3.0框架解说系列(6.2):操作日志与数据日志

    OSharp是什么? OSharp是个快速开发框架,但不是一个大而全的包罗万象的框架,严格的说,OSharp中什么都没有实现.与其他大而全的框架最大的不同点,就是OSharp只做抽象封装,不做实现.依 ...

  9. Struts2拦截器记录系统操作日志

    前言 最近开发了一个项目,由于项目在整个开发过程中处于赶时间状态(每个项目都差不多如此)所以项目在收尾阶段发现缺少记录系统日志功能,以前系统都是直接写在每个模块的代码中,然后存入表单,在页面可以查看部 ...

  10. 基于SqlSugar的开发框架循序渐进介绍(8)-- 在基类函数封装实现用户操作日志记录

    在我们对数据进行重要修改调整的时候,往往需要跟踪记录好用户操作日志.一般来说,如对重要表记录的插入.修改.删除都需要记录下来,由于用户操作日志会带来一定的额外消耗,因此我们通过配置的方式来决定记录那些 ...

随机推荐

  1. java 获取访问的真实ip

    request 是 javax.servlet.http.HttpServletRequest 获取其他机器访问自己服务时的真实ip public String getIP(HttpServletRe ...

  2. StarRocks 物化视图创建与刷新全流程解析

    最近在为 StarRocks 的物化视图增加多表达式支持的能力,于是便把物化视图(MV)的创建刷新流程完成的捋了一遍. 之前也写过一篇:StarRocks 物化视图刷新流程和原理,主要分析了刷新的流程 ...

  3. LB 终面 与 智能家电 的浅析

    今天 就简单的谈了一下薪资 6k  一个月  很纠结要不要去 我知道鱼和熊掌 不可兼得  可是 如果能宽限几天就好了 今天  稍微 看了一下  老板的简介 少老板  带队   隔这么远  我都能看到 ...

  4. SciTech-BigDataAIML-LLM-Generative model

    https://statproofbook.github.io/D/gm Definition: Generative model Index: The Book of Statistical Pro ...

  5. Trae开发uni-app+Vue3+TS项目飘红踩坑

    前情 Trae IDE上线后我是第一时间去使用体验的,但是因为一直排队问题不得转战cursor,等到Trae出付费模式的时候,我已经办了Cursor的会员,本来是想等会员过期了再转战Trae的,但是最 ...

  6. 校验ChatGPT 4真实性的三个经典问题:提供免费测试网站快速区分 GPT3.5 与 GPT4

    现在已经有很多 ChatGPT 的套壳网站,以下分享验明 GPT-4 真身的三个经典问题,帮助你快速区分套壳网站背后到底用的是 GPT-3.5 还是 GPT-4. 大家可以在这个网站测试:https: ...

  7. 2023年11月最新全国省市区县和乡镇街道行政区划矢量边界坐标经纬度地图数据 shp geojson json

    发现个可以免费下载全国 geojson 数据的网站,推荐一下.支持全国.省级.市级.区/县级.街道/乡镇级以及各级的联动数据,支持导入矢量地图渲染框架中使用,例如:D3.Echarts等 geojso ...

  8. 通过bat启动jar的命令

    title sso java ^ -Dspring.datasource.url=jdbc:mysql://172.18.25.12:3307/working-drawing-1.0.0^?chara ...

  9. .NET周刊【7月第4期 2025-07-27】

    国内文章 记一次.NET MAUI项目中绑定Android库实现硬件控制的开发经历 https://www.cnblogs.com/GreenShade/p/18998698 本文介绍了基于.NET ...

  10. MAVEN构建分离依赖JAR

    MAVEN构建分离依赖JAR 1. 背景说明 在Springboot项目中,项目构建时,默认打包成一个可以执行的jar包.导致单一jar过大.项目部署过程中,需要把依赖的jar包和配置文件都单独存放到 ...