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

一、方案背景与核心设计

1.1 为什么选择AOP?

传统日志实现的痛点:

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

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

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

1.2 核心架构设计

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

Aspect
(切面)

Pointcut
(切点)

Advice
(处理)

Weaving
(织入)

Target
(目标对象)

joinPoint
(连接点)

execution
(路径表达式)

annotation
(注解)

系统注解

自定义注解

处理时机

处理内容

Before
(前置处理)

After
(后置处理)

Around
(环绕处理)

AfterReturning
(后置返回通知)

AfterThrowing
(异常抛出通知)

各组件职责:

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

二、环境准备

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

xml

<!-- 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公共模块)。

java

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模块)。

java

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模块)。

java

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模块)。

java

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实体对应,添加索引优化查询(如按时间、用户查询)。

sql

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模块依赖业务)。

java

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接口

java

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

<?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注解,即可自动记录日志,无需额外编写日志代码。

java

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类型默认不兼容,会报类型转换错误。

Logo

欢迎加入我们的广州开发者社区,与优秀的开发者共同成长!

更多推荐