文章目录
-
- [0. AOP 操作日志设计思路](#0. AOP 操作日志设计思路)
- [1. AOP原理](#1. AOP原理)
-
- [1.1 核心概念](#1.1 核心概念)
- [1.2 切点表达式](#1.2 切点表达式)
- [1.3 通知类型](#1.3 通知类型)
- [1.4 代理机制](#1.4 代理机制)
- [1.5 织入时机](#1.5 织入时机)
- [2. AOP操作日志实现](#2. AOP操作日志实现)
-
- [2.0 实现关键考量:异步写入与安全上下文传递](#2.0 实现关键考量:异步写入与安全上下文传递)
- [2.1 MySQL建表语句](#2.1 MySQL建表语句)
- [2.2 AOP依赖](#2.2 AOP依赖)
- [2.3 操作日志注解](#2.3 操作日志注解)
- [2.4 操作日志实体类](#2.4 操作日志实体类)
- [2.5 操作日志Mapper(持久层)](#2.5 操作日志Mapper(持久层))
- [2.6 操作日志Service接口(业务层)](#2.6 操作日志Service接口(业务层))
- [2.7 操作日志Service实现类(业务层)](#2.7 操作日志Service实现类(业务层))
- [2.8 操作日志切面类](#2.8 操作日志切面类)
- [3. AOP操作日志注解使用](#3. AOP操作日志注解使用)
-
- [3.1 操作类型常量类](#3.1 操作类型常量类)
- [3.2 在业务层实现类中使用](#3.2 在业务层实现类中使用)
- [4. AOP在Spring中的其他典型应用](#4. AOP在Spring中的其他典型应用)
这篇写了我一整天,终于写完了。和 AI 边讨论边做的,除了操作日志框架的完整代码,正文还内附了很多个注意事项和为什么要这么做。今天学到了很多新东西,谢谢 Qwen。
2025/12/24 0:29
0. AOP 操作日志设计思路
通过 AOP 自动记录用户在系统中的关键业务操作(如增删改),包括操作人、操作内容、时间、结果等,用于后续审计与排查。
1. AOP原理
AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范式,用于将横切关注点 (如日志、事务、安全等)与核心业务逻辑解耦。Spring AOP 是其在 Spring 框架中的实现。
1.1 核心概念
| 术语 | 说明 |
|---|---|
| Aspect(切面) | 横切关注点的模块化,通常是一个带有 @Aspect 注解的类,包含通知和切点。 |
| Join Point(连接点) | 程序执行过程中的某个特定点,如方法调用、异常抛出。Spring AOP 中仅支持方法级别的连接点。 |
| Pointcut(切点) | 匹配连接点的表达式,用于指定哪些方法需要被增强。 |
| Advice(通知) | 在切点处执行的增强逻辑,按执行时机分为多种类型(见下文)。 |
| Weaving(织入) | 将切面应用到目标对象并创建代理对象的过程。Spring AOP 在运行时通过代理完成织入。 |
1.2 切点表达式
切点表达式用于指定哪些方法要被增强。Spring AOP 支持多种写法,比较常用的是:
-
推荐方式:基于注解 (解耦、显式、安全)
java@Around("@annotation(org.example.framework.logging.annotation.OperationLog)")- 匹配所有标注了
@OperationLog的方法; - 不依赖包路径或方法名,重构友好。
- 匹配所有标注了
-
备选方式:基于方法签名(
execution)java// execution(返回类型 包.类.方法名(参数)) // 匹配 service 包下所有以 create 开头、任意参数、任意返回值的方法 execution(* org.example.service.*.create*(..)) -
何时使用
@Pointcut?
@Pointcut不是必须的,仅在以下情况使用:- 表达式需要被多个通知复用;
- 需要组合多个条件 (如"带注解 + 在
Service层")。
java@Pointcut("@annotation(OperationLog)") private void logAnnotated() {} @Pointcut("within(org.example.service..*)") private void inServiceLayer() {} @Around("logAnnotated() && inServiceLayer()") public Object around(ProceedingJoinPoint jp) { ... }
1.3 通知类型
| 类型 | 注解 | 执行时机 |
|---|---|---|
| 前置通知 | @Before |
目标方法执行前 |
| 后置通知 | @After |
目标方法执行后(无论是否异常) |
| 返回通知 | @AfterReturning |
目标方法成功返回后 |
| 异常通知 | @AfterThrowing |
目标方法抛出异常后 |
| 环绕通知 | @Around |
包围整个方法,可控制是否执行、修改参数/返回值(最强大) |
实际项目中,@Around 最常用(如操作日志、性能监控),因为它能统一处理成功/异常,并获取方法元信息。
1.4 代理机制
Spring AOP 基于代理实现,分两种情况:
| 目标对象 | 代理方式 | 特点 |
|---|---|---|
| 实现了接口 | JDK 动态代理 | 生成 $Proxy 接口代理类 |
| 未实现接口(或强制使用) | CGLIB 代理 | 生成目标类的子类作为代理 |
这里先不管这两种代理是什么。
注意:同一个类内部方法调用不会触发 AOP(因为绕过了代理对象),这是常见陷阱。即外部调用时 AOP 生效,内部调用时 AOP 失效。
Spring 为了让我(业务层接口,
IBrandService)专注核心业务逻辑 (解耦,非侵入 ),自动给我配了一个"助手"(代理对象)。.
当别人调用我 时,Spring 先把请求交给"助手"------助手先做 AOP 的事情(比如记日志、开事务),然后再通知真正的我去执行业务逻辑 。
.
但当我自己调用自己的方法 (比如
this.methodB())时,我根本不知道有"助手"存在(因为this就是我自己),所以直接执行了,完全绕过了助手。
- 这个"助手"是 Spring 在启动时偷偷创建的,我们写的类本身没有任何变化;
- 注入的 Bean(比如
Controller里@Autowired BrandService)其实拿到的是助手,不是我们自己;- 但在类内部用
this,永远指向原始的自己,不是助手。
1.5 织入时机
- Spring AOP 是运行时织入(Runtime Weaving);
- 在 Bean 初始化时,由
BeanPostProcessor创建代理对象; - 与 AspectJ 的编译时/加载时织入不同,功能较弱但更轻量。
小结:Spring AOP 通过动态代理 + 切点表达式 + 通知,在运行时将横切逻辑织入目标方法。
2. AOP操作日志实现
2.0 实现关键考量:异步写入与安全上下文传递
为了不影响主线程的业务响应速度 ,使用 AOP 切面类 OperationLogAspect.java 后调用操作日志服务 IOperationService,用 @Async 独立线程异步实现记录操作日志。
在这个独立线程中,就没有办法拿到 userId了,因为 Spring Security 的用户上下文 SecurityContext 是绑定在主线程的 ThreadLocal 中的,不会自动传递到异步线程 ,故 MP 的自动填充处理器也无法正确填充 createdBy(和 createdAt)字段。
所以,为了正确写入操作日志记录,只能在触发独立线程前,在切面类里就把这俩字段给填充了。这样,异步线程只需"傻瓜式"地把已填充好的日志写入数据库,不依赖任何上下文。
原则:敏感上下文(用户、时间点)在主线程捕获,异步线程只做无状态写入。
2.1 MySQL建表语句
sql
CREATE TABLE `operation_log` (
`id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`operator_id` bigint(0) NOT NULL COMMENT '操作人ID',
`description` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作描述,如"删除品牌"',
`operation_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作类型,如 BRAND_DELETE, DRINK_RECORD',
`request_method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '请求方法(GET/POST/PUT/DELETE)',
`request_url` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '请求路径',
`request_params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '请求参数(JSON,已脱敏)',
`client_ip` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端IP(支持IPv6)',
`success` tinyint(1) NOT NULL COMMENT '是否成功(1=成功,0=失败)',
`error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '异常信息(失败时记录,建议前端截断)',
`cost_time` bigint(0) NOT NULL COMMENT '耗时(毫秒)',
`created_at` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '操作时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_create_time`(`created_at`) USING BTREE,
INDEX `idx_operation_type`(`operation_type`) USING BTREE,
INDEX `idx_success`(`success`) USING BTREE,
INDEX `idx_url`(`request_url`(100)) USING BTREE,
INDEX `idx_operator_id`(`operator_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统操作日志表(审计日志)' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
2.2 AOP依赖
xml
<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2.3 操作日志注解
java
package org.example.framework.logging.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD) // 该注解只能用在"方法"上
@Retention(RetentionPolicy.RUNTIME) // 让 Spring 在运行时能通过反射读取到该注解,从而触发日志记录等逻辑
public @interface OperationLog {
/**
* 操作描述,例如 "删除用户"
*/
String value() default "";
/**
* 操作类型(可选,如:CREATE, UPDATE, DELETE, QUERY)
*/
String type() default "OPERATE";
}
-
当注解只有一个
value字段时,使用时可省略字段名,直接写值。如:java@Operation("删除商品") // 可省略 @Operation(value = "删除商品") -
当注解只有一个字段且字段名不为
value,或有多个字段时,使用时必须显式写出字段名。如:java@Operation(description = "删除商品") @Operation(value = "删除商品", type = "DELETE")
2.4 操作日志实体类
java
package org.example.framework.logging.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
@TableName("operation_log")
public class OperationLogEntity {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 操作人ID
*/
private Long operatorId;
/**
* 操作描述,如"删除用户"
*/
private String description;
/**
* 操作类型
*/
private String operationType;
/**
* GET/POST/PUT/DELETE
*/
private String requestMethod;
/**
* 请求路径
*/
private String requestUrl;
/**
* 请求参数(JSON),在AOP切面中需脱敏处理
*/
private String requestParams;
/**
* 客户端IP
*/
private String clientIp;
/**
* 是否成功
*/
private Boolean success;
/**
* 异常信息(失败时)
*/
private String errorMessage;
/**
* 耗时(毫秒),用于性能监控
*/
private Long costTime;
/**
* 操作时间:
* @Async 异步线程中 SecurityUtils.getCurrentUserId() 会失效!
* 故此处无法触发 mp 的自动填充处理器,需要手动填充。
*/
private LocalDateTime createdAt;
}
我还有一个 BaseEntity.java 的通用基类(id,createdBy,createdAt,updatedBy,updatedAt,deleted),但这里不直接继承它:操作日志是只写不改的审计记录,不需要更新审计字段和逻辑删除字段。
2.5 操作日志Mapper(持久层)
java
package org.example.framework.logging.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.example.framework.logging.entity.OperationLogEntity;
@Mapper
public interface OperationLogMapper extends BaseMapper<OperationLogEntity> {
}
这里要注意一下启动类里指定的 mapper 扫描路径:原来的路径 @MapperScan("org.example.*.mapper") 改成 @MapperScan("org.example.**.mapper")匹配任意层级子包。
2.6 操作日志Service接口(业务层)
java
package org.example.framework.logging.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.example.framework.logging.entity.OperationLogEntity;
import org.springframework.stereotype.Service;
@Service
public interface IOperationLogService extends IService<OperationLogEntity> {
/**
* 异步保存操作日志
* @param entity
*/
void createOperationLog(OperationLogEntity entity);
}
2.7 操作日志Service实现类(业务层)
java
package org.example.framework.logging.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.example.framework.logging.entity.OperationLogEntity;
import org.example.framework.logging.mapper.OperationLogMapper;
import org.example.framework.logging.service.IOperationLogService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class OperationLogServiceImpl extends ServiceImpl<OperationLogMapper, OperationLogEntity> implements IOperationLogService {
/**
* 异步保存操作日志
* @param entity
*/
@Async // 独立线程,不影响主线程业务
@Override
public void createOperationLog(OperationLogEntity entity) {
// @Async 异步线程无法获取 SecurityContext(绑定在主线程 ThreadLocal),
// 导致 MP 自动填充拿不到 userId,故需提前手动设置。
this.save(entity);
}
}
2.8 操作日志切面类
java
package org.example.framework.logging.aspect;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.example.framework.logging.annotation.OperationLog;
import org.example.framework.logging.entity.OperationLogEntity;
import org.example.framework.logging.service.IOperationLogService;
import org.example.framework.security.util.SecurityUtils;
import org.springframework.stereotype.Component;
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;
/**
* 日志 AOP 切面类
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private final IOperationLogService operationLogService;
private final ObjectMapper objectMapper; // 把方法参数(Java 对象)转成 JSON 字符串,存入 requestParams 字段
/**
* 环绕通知:记录带 @OperationLog 注解的方法操作日志
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("@annotation(org.example.framework.logging.annotation.OperationLog)") // 切点表达式
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 获取方法签名和注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
OperationLog anno = method.getAnnotation(OperationLog.class);
// 2. 初始化日志实体
OperationLogEntity entity = OperationLogEntity.builder()
.description(anno.value())
.operationType(anno.type())
.success(true) // 默认成功,异常时改为 false
.build();
// 3. 设置请求基本信息
// 获取当前 HTTP 请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
entity.setRequestMethod(request.getMethod()); // 请求方法
entity.setRequestUrl(request.getRequestURI()); // 请求路径
entity.setClientIp(getClientIp(request)); // 客户端 IP
}
// 4. 设置操作人(雪花ID)
Long userId = SecurityUtils.getCurrentUserId();
entity.setOperatorId(userId);
// 5. 记录请求参数(安全脱敏)
try {
String params = objectMapper.writeValueAsString(joinPoint.getArgs());
// 简单脱敏:移除常见敏感字段
params = params.replaceAll("\"password\":\"[^\"]*\"", "\"password\":\"******\""); // 目前只有管理员会使用到 password 字段,为此脱敏
entity.setRequestParams(params);
} catch (JsonProcessingException e) {
entity.setRequestParams("[参数序列化失败]");
log.warn("参数序列化失败: ", e);
}
// 6. 执行方法并记录结果
long start = System.currentTimeMillis();
Object result;
try {
result = joinPoint.proceed(); // 执行原方法
} catch (Exception e) {
entity.setSuccess(false); // 标记失败
entity.setErrorMessage(StringUtils.abbreviate(e.toString(), 1000)); // 截断错误信息
throw e; // 重新抛出,不影响业务。@Async + 异常隔离,独立线程。此处如果抛业务异常会导致业务终止
} finally {
// 记录耗时和创建时间
long cost = System.currentTimeMillis() - start;
entity.setCostTime(cost);
entity.setCreatedAt(LocalDateTime.now());
// 异步保存日志
operationLogService.createOperationLog(entity);
}
return result;
}
/**
* 获取客户端真实 IP(支持代理)
* @param request
* @return
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 处理多IP情况(如 X-Forwarded-For: ip1, ip2, ip3)
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}
这里为了避免
@Autowired字段注入的隐患,推荐使用@RequiredArgsConstructor+
final构造器注入,原因如下:
- 字段注入在切面中不可靠 :
@Aspect由 Spring AOP 和代理机制特殊处理,@Autowired字段可能因初始化顺序问题为null,导致空指针异常。- 构造器注入保证依赖非空 :Spring 在创建 Bean 时通过构造器注入依赖,确保切面实例化后所有
final字段已就绪,避免运行时错误。- 符合 Spring 官方最佳实践:构造器注入更利于不可变性、单元测试和依赖显式化,官方推荐优先使用。
3. AOP操作日志注解使用
3.1 操作类型常量类
这里为了避免硬编码,定义一个操作类型的常量类。我有 common.constants 包,但现在还是放 framework.logging.constants 下吧。
java
package org.example.framework.logging.constants;
/**
* 操作类型常量类
*/
public class OperationLogType {
public static final String CREATE = "CREATE";
public static final String UPDATE = "UPDATE";
public static final String DELETE = "DELETE";
public static final String QUERY = "QUERY";
public static final String OPERATE = "OPERATE"; // 默认值
}
3.2 在业务层实现类中使用
java
package org.example.business.service.impl;
...
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.example.business.dto.BrandDTO;
...
import org.example.business.entity.Brand;
import org.example.business.mapper.BrandMapper;
import org.example.business.service.IBrandService;
...
import org.example.business.vo.BrandAdminVO;
...
import org.example.framework.logging.annotation.OperationLog;
import org.example.framework.logging.constants.OperationLogType;
...
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
public class BrandServiceImpl extends ServiceImpl<BrandMapper, Brand> implements IBrandService {
...
/**
* 新增一个品牌
* @param dto
* @return
*/
@OperationLog(value = "新增品牌", type = OperationLogType.CREATE)
@Transactional(rollbackFor = Exception.class) // 先查后插,涉及多步操作,应开启事务管理
@Override
public BrandAdminVO createBrand(BrandDTO dto) {
...
}
...
}
关于操作日志应该打在 Service 层,而不是 Controller 层:
- 日志要反映真实业务操作,而业务逻辑在
Service; - 可保证与事务一致(失败不记日志);
- 能被所有调用方(Web、定时任务等)统一覆盖。
其他注意事项:
- 避免冗余控制台日志:
- 控制器层不应打印请求参数或用户信息到控制台 (如
log.info()/log.debug()),因为操作日志已通过 AOP 统一记录操作人、操作内容及方法参数。 - 业务层仅保留必要的
warn级别日志 ;info级日志应改为debug并注释掉(或删除),保持生产环境日志干净。
- 控制器层不应打印请求参数或用户信息到控制台 (如
- 防止级联操作重复记录
- 操作日志应仅标注在顶层业务入口方法 (即
Controller直接调用的Service方法); - 内部调用的方法(如级联删除商品)不得添加
@OperationLog,避免因 Spring AOP 代理机制触发多次日志记录,导致审计信息冗余或失真。
- 操作日志应仅标注在顶层业务入口方法 (即
4. AOP在Spring中的其他典型应用
Spring AOP(面向切面编程)是实现横切关注点(如日志、安全、事务等)的核心机制。以下是一些常见且重要的应用:
- 声明式事务管理(
@Transactional)- 最经典的应用:通过 AOP 自动开启/提交/回滚数据库事务;
- 开发者无需手动写
beginTransaction()/commit()。
- MyBatis-Plus 自动填充(
MetaObjectHandler)- 虽然 MP 的自动填充主要靠 MyBatis 插件,但部分扩展逻辑 (如结合用户上下文填充
createBy)常配合 AOP 使用; - 例如:在
Service方法执行前,从SecurityContext获取当前用户ID并注入实体。
- 虽然 MP 的自动填充主要靠 MyBatis 插件,但部分扩展逻辑 (如结合用户上下文填充
- Spring Security 方法级安全控制
- 注解如
@PreAuthorize("hasRole('ADMIN')")、@PostAuthorize、@Secured等; - 底层通过 AOP 拦截方法调用,在执行前校验权限,拒绝非法访问。
- 注解如
- Spring Boot Actuator 健康检查 & 指标收集
- 部分指标(如方法调用次数、耗时)通过 AOP 切面自动采集;
- 例如
@Timed(Micrometer)注解可记录方法性能。
- 缓存管理(
@Cacheable,@CacheEvict,@CachePut)- Spring Cache 抽象基于 AOP 实现;
- 方法调用时自动查缓存、更新缓存或清除缓存,业务代码无感知。
- 异步方法执行(
@Async)- 标记方法为异步执行,底层通过 AOP 创建代理,将方法体提交到线程池;
- 注意:
SecurityContext、Transaction等ThreadLocal上下文默认不会传递(需额外处理)。
- 重试机制(Spring Retry)
- 使用
@Retryable注解的方法,失败后自动重试; - 由 AOP 切面拦截异常并控制重试逻辑。
- 使用
总结:AOP 是 Spring 实现"非侵入式增强"的关键技术。
凡是需要在不修改业务代码的前提下,统一添加通用逻辑的地方,几乎都用到了 AOP。