1. 为什么需要AOP记录操作日志?
在传统的SpringBoot项目中,记录用户操作日志通常采用最直接的方式——在每个业务方法中手动添加日志记录代码。这种方式看似简单,却存在几个明显的痛点:
首先,日志代码与业务逻辑高度耦合。一个典型的Controller方法中,可能有30%的代码都在处理日志记录,严重污染了业务代码的纯净性。当需要修改日志格式或存储方式时,开发者不得不逐个修改所有相关方法,维护成本极高。
其次,日志记录的完整性难以保证。开发人员可能会遗漏某些关键操作的日志记录,或者在异常处理分支中忘记添加日志代码。我曾经参与过一个电商项目审计,就发现因为漏记了几条库存变更日志,导致后续排查资金异常时花了整整两周时间。
而AOP(面向切面编程)的横切特性恰好能完美解决这些问题。通过@Aspect注解定义一个日志切面,我们可以:
- 统一拦截所有需要记录日志的方法
- 在方法执行前后自动记录入参、返回值、异常等信息
- 保持业务代码的纯净性,实现日志逻辑与业务逻辑的解耦
java复制// 传统方式 vs AOP方式对比
// 传统方式
@PostMapping("/update")
public Result updateProduct(@RequestBody Product product) {
log.info("开始更新产品,参数:{}", product); // 日志代码侵入业务逻辑
try {
productService.update(product);
log.info("产品更新成功"); // 必须手动记录成功日志
return Result.success();
} catch (Exception e) {
log.error("产品更新失败", e); // 必须手动记录异常日志
return Result.fail();
}
}
// AOP方式
@PostMapping("/update")
public Result updateProduct(@RequestBody Product product) {
// 纯业务逻辑,没有任何日志代码
productService.update(product);
return Result.success();
}
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现方案设计
2.1 基础环境准备
在开始实现前,需要确保项目中已包含必要的依赖。对于SpringBoot 2.7.x及以上版本,AOP相关组件已经包含在spring-boot-starter-aop中:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
注意:虽然SpringBoot已经自动配置了AOP支持,但为了获得更好的性能,建议在application.properties中显式配置:
properties复制spring.aop.auto=true spring.aop.proxy-target-class=true
2.2 日志注解定义
一个好的实践是先定义一个自定义注解,用于标记需要记录日志的方法。这比拦截所有Controller方法更加灵活精准:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperateLog {
String module() default ""; // 业务模块名称
String type() default ""; // 操作类型:CREATE/UPDATE/DELETE等
String desc() default ""; // 操作描述
}
使用示例:
java复制@OperateLog(module = "产品管理", type = "UPDATE", desc = "更新产品信息")
@PostMapping("/update")
public Result updateProduct(@RequestBody Product product) {
// 业务逻辑
}
2.3 切面类实现
核心切面类需要处理以下几个关键点:
- 方法执行前:记录操作入参、操作人等信息
- 方法执行后:记录返回值、操作耗时
- 方法异常时:记录异常堆栈
- 异步写入:避免日志记录阻塞主流程
java复制@Aspect
@Component
@Slf4j
public class OperateLogAspect {
private static final ThreadLocal<Long> TIME_THREADLOCAL = new ThreadLocal<>();
// 定义切点:所有带有@OperateLog注解的方法
@Pointcut("@annotation(com.example.demo.annotation.OperateLog)")
public void operateLogPointCut() {}
@Around("operateLogPointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
TIME_THREADLOCAL.set(startTime);
// 获取方法签名信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
OperateLog operateLog = method.getAnnotation(OperateLog.class);
// 构建基础日志信息
OperateLogInfo logInfo = new OperateLogInfo();
logInfo.setModule(operateLog.module());
logInfo.setType(operateLog.type());
logInfo.setDesc(operateLog.desc());
logInfo.setMethod(method.getName());
logInfo.setParams(getParams(joinPoint));
logInfo.setOperateUser(getCurrentUser());
try {
Object result = joinPoint.proceed();
logInfo.setResult(result);
return result;
} catch (Exception e) {
logInfo.setException(e.getMessage());
throw e;
} finally {
long endTime = System.currentTimeMillis();
logInfo.setCostTime(endTime - startTime);
TIME_THREADLOCAL.remove();
// 异步保存日志
asyncSaveLog(logInfo);
}
}
private void asyncSaveLog(OperateLogInfo logInfo) {
CompletableFuture.runAsync(() -> {
// 实际存储逻辑,可以存入数据库或发送到日志系统
logService.save(logInfo);
}).exceptionally(ex -> {
log.error("操作日志记录异常", ex);
return null;
});
}
}
3. 高级功能实现与优化
3.1 操作人信息获取方案
获取当前操作人信息是日志系统的关键需求,以下是几种常见方案及其适用场景:
- 基于SecurityContextHolder(Spring Security项目)
java复制private String getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
return authentication.getName();
}
return "anonymous";
}
- 基于ThreadLocal(自定义认证体系)
java复制// 在登录拦截器中设置用户信息
UserContext.setCurrentUser(user);
// 在切面中获取
private String getCurrentUser() {
return UserContext.getCurrentUser().getUsername();
}
- 基于JWT解析(无状态系统)
java复制private String getCurrentUser(HttpServletRequest request) {
String token = request.getHeader("Authorization");
Claims claims = JwtUtil.parseToken(token);
return claims.getSubject();
}
实际项目中,我推荐使用方案2的ThreadLocal方式。相比方案1,它不依赖Spring Security,更加轻量;相比方案3,它避免了每次解析JWT的性能开销。我们在日均百万级请求的系统中采用这种方案,性能损耗几乎可以忽略不计。
3.2 敏感参数脱敏处理
对于包含密码、身份证号等敏感信息的参数,必须进行脱敏处理。可以通过自定义注解+反射的方式实现:
java复制@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sensitive {
SensitiveType type();
}
public enum SensitiveType {
ID_CARD, PHONE, BANK_CARD, PASSWORD
}
// 在切面中处理
private String getParams(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
// 遍历参数对象的所有字段,检查@Sensitive注解
for (Object arg : args) {
if (arg != null) {
Field[] fields = arg.getClass().getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(Sensitive.class)) {
// 根据不同类型进行脱敏
field.setAccessible(true);
Object value = field.get(arg);
String maskedValue = mask(field.getAnnotation(Sensitive.class).type(), value);
field.set(arg, maskedValue);
}
}
}
}
return JsonUtils.toJson(args);
}
3.3 日志内容动态扩展
有时我们需要在业务方法中动态添加日志信息,可以通过在方法参数中添加LogContext对象实现:
java复制public class LogContext {
private Map<String, Object> extInfo = new HashMap<>();
public void addInfo(String key, Object value) {
extInfo.put(key, value);
}
}
// 在Controller中使用
@OperateLog(module = "订单管理", type = "CREATE")
@PostMapping("/create")
public Result createOrder(@RequestBody Order order, LogContext logContext) {
// 业务逻辑
logContext.addInfo("totalAmount", order.getAmount());
logContext.addInfo("productCount", order.getItems().size());
}
// 在切面中处理
private void processExtInfo(ProceedingJoinPoint joinPoint, OperateLogInfo logInfo) {
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg instanceof LogContext) {
logInfo.setExtInfo(((LogContext) arg).getExtInfo());
break;
}
}
}
4. 生产环境中的实践经验
4.1 性能优化方案
在高并发场景下,日志记录可能成为性能瓶颈。以下是几个关键优化点:
- 异步化处理:如前面代码所示,使用CompletableFuture实现异步日志记录。但要注意线程池配置:
java复制@Configuration
public class AsyncConfig {
@Bean("logAsyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("log-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
// 使用时指定线程池
@Async("logAsyncExecutor")
public void asyncSaveLog(OperateLogInfo logInfo) {
// 保存逻辑
}
- 批量插入:对于数据库存储方案,建议采用批量插入而非单条插入。可以结合本地缓存+定时任务实现:
java复制@Component
@Slf4j
public class LogBatchSaver {
private static final BlockingQueue<OperateLogInfo> queue = new LinkedBlockingQueue<>(5000);
private static final int BATCH_SIZE = 100;
private static final long INTERVAL = 5000; // 5秒
@PostConstruct
public void init() {
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(this::batchSave, INTERVAL, INTERVAL, TimeUnit.MILLISECONDS);
}
public void addLog(OperateLogInfo logInfo) {
if (!queue.offer(logInfo)) {
log.warn("操作日志队列已满,丢弃日志:{}", logInfo);
}
}
private void batchSave() {
List<OperateLogInfo> batch = new ArrayList<>(BATCH_SIZE);
queue.drainTo(batch, BATCH_SIZE);
if (!batch.isEmpty()) {
logService.batchInsert(batch);
}
}
}
- 日志精简:对于高频操作,可以采样记录而非全量记录:
java复制// 在切面中添加采样逻辑
private boolean shouldRecord(OperateLog operateLog) {
if ("GET".equals(operateLog.type())) {
// 对查询类操作按10%采样
return ThreadLocalRandom.current().nextInt(100) < 10;
}
return true;
}
4.2 常见问题排查
-
AOP不生效问题:
- 检查切面类是否有@Component或@Aspect注解
- 确保方法调用是通过Spring代理对象而非this直接调用
- 检查是否配置了spring.aop.proxy-target-class=true
-
获取参数为空问题:
- 确保方法没有被其他切面提前拦截并修改了参数
- 对于HttpServletRequest参数,需要从RequestContextHolder获取而非直接从方法参数获取
-
循环依赖问题:
- 避免在切面中注入被切面拦截的Bean
- 可以使用@Lazy延迟加载解决
java复制// 典型错误示例 - 循环依赖
@Aspect
@Component
public class BadAspect {
@Autowired
private UserService userService; // UserService也被这个切面拦截
// 正确做法
@Lazy
@Autowired
private UserService userService;
}
4.3 日志存储方案选型
根据系统规模不同,可以选择不同的存储方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库存储 | 实现简单,查询方便 | 性能压力大,影响业务DB | 低频操作,小型系统 |
| 独立日志库 | 隔离业务DB压力 | 仍需维护数据库 | 中大型系统 |
| ELK体系 | 支持全文检索,扩展性强 | 架构复杂,维护成本高 | 大型分布式系统 |
| 文件存储 | 性能极高 | 查询分析困难 | 需要配合日志收集系统 |
| 消息队列 | 解耦彻底,削峰填谷 | 有数据丢失风险 | 超高并发系统 |
对于大多数中小型SpringBoot项目,我建议采用独立日志库+定时归档的方案。以下是典型表结构设计:
sql复制CREATE TABLE `t_operate_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`module` varchar(50) COMMENT '模块名称',
`type` varchar(20) COMMENT '操作类型',
`description` varchar(200) COMMENT '操作描述',
`method` varchar(100) COMMENT '方法名',
`params` text COMMENT '请求参数',
`result` text COMMENT '返回结果',
`exception` text COMMENT '异常信息',
`operate_user` varchar(50) COMMENT '操作人',
`operate_time` datetime COMMENT '操作时间',
`cost_time` bigint COMMENT '耗时(ms)',
`client_ip` varchar(50) COMMENT '客户端IP',
`ext_info` json COMMENT '扩展信息',
PRIMARY KEY (`id`),
INDEX `idx_module` (`module`),
INDEX `idx_operate_user` (`operate_user`),
INDEX `idx_operate_time` (`operate_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
5. 扩展功能实现
5.1 操作日志审计功能
基于操作日志可以实现强大的审计功能,关键实现点:
- 数据变更对比:通过反射获取对象变更前后的差异
java复制public class DiffUtils {
public static <T> Map<String, Object[]> compare(T oldObj, T newObj) {
Map<String, Object[]> diffMap = new HashMap<>();
Field[] fields = oldObj.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
try {
Object oldValue = field.get(oldObj);
Object newValue = field.get(newObj);
if (!Objects.equals(oldValue, newValue)) {
diffMap.put(field.getName(), new Object[]{oldValue, newValue});
}
} catch (IllegalAccessException e) {
// 处理异常
}
}
return diffMap;
}
}
- 关联操作链追踪:通过TraceID串联整个操作流程
java复制// 在拦截器中生成TraceID
public class TraceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);
return true;
}
}
// 在日志切面中记录
private void processTraceInfo(OperateLogInfo logInfo) {
logInfo.setTraceId(MDC.get("traceId"));
}
5.2 结合SpringBoot Actuator实现日志查询
通过Actuator端点暴露日志查询接口:
java复制@Endpoint(id = "operatelog")
@Component
@RequiredArgsConstructor
public class OperateLogEndpoint {
private final OperateLogService logService;
@ReadOperation
public PageResult<OperateLogInfo> query(
@Selector String module,
@Nullable @DefaultValue("") String operator,
@Nullable @DefaultValue("7") int days) {
LocalDateTime endTime = LocalDateTime.now();
LocalDateTime startTime = endTime.minusDays(days);
return logService.query(module, operator, startTime, endTime);
}
}
配置application.properties启用端点:
properties复制management.endpoints.web.exposure.include=health,info,operatelog
management.endpoint.operatelog.enabled=true
5.3 日志分析预警
通过定时任务分析日志数据,实现异常操作预警:
java复制@Scheduled(cron = "0 0/10 * * * ?")
public void monitorOperation() {
// 统计近10分钟异常操作
Map<String, Long> errorStats = logService.countErrorsLast10Minutes();
errorStats.forEach((module, count) -> {
if (count > THRESHOLD) {
alertService.sendWarn(
String.format("模块[%s]近10分钟出现%d次异常操作", module, count));
}
});
// 检测高频删除操作
long deleteCount = logService.countOperationLastHour("DELETE");
if (deleteCount > DELETE_THRESHOLD) {
alertService.sendWarn(
String.format("系统近1小时出现%d次删除操作", deleteCount));
}
}
在实际项目中,我们基于这种方案发现并阻止了多次误删除和批量异常操作,为系统安全提供了重要保障。
