需求背景

在Web应用开发中,系统日志记录是必不可少的功能。传统的手动日志记录方式存在以下问题:

  1. 代码侵入性强,需要在每个Controller方法中手动添加日志代码
  2. 日志格式不统一,容易遗漏关键信息
  3. 维护成本高,修改日志格式需要修改大量代码
  4. 容易影响主业务逻辑的可读性

为了解决这些问题,我使用Spring AOP技术实现了一套完整的系统日志记录解决方案。

技术原理分析

AOP核心概念

面向切面编程(AOP)是一种编程范式,通过预编译方式和运行期动态代理实现程序功能的统一维护。在Spring框架中,AOP主要基于以下核心概念:

  1. 切面(Aspect): 关注点的模块化,如日志记录、事务管理等
  2. 连接点(Join Point): 程序执行的某个特定位置,如方法调用
  3. 切点(Pointcut): 连接点的集合,定义了在哪些地方应用切面
  4. 通知(Advice): 在特定连接点执行的代码,包括前置、后置、环绕通知等
  5. 织入(Weaving): 将切面应用到目标对象的过程

Spring AOP实现机制

Spring AOP采用动态代理技术实现:

  1. JDK动态代理: 当目标对象实现了接口时,Spring使用JDK动态代理创建代理对象
  2. CGLIB代理: 当目标对象没有实现接口时,Spring使用CGLIB字节码生成技术创建子类代理

执行流程原理

客户端调用 → Spring容器 → 代理对象 → 切面拦截 → 目标方法 → 返回结果

具体执行步骤:

  1. 客户端调用被@SystemLog注解标记的方法
  2. Spring容器识别到该方法需要被代理
  3. 代理对象拦截方法调用
  4. 执行@Around环绕通知的前置逻辑(记录开始时间、收集请求信息)
  5. 调用目标方法执行业务逻辑
  6. 执行@Around环绕通知的后置逻辑(记录响应信息、计算耗时)
  7. 异步保存日志到数据库
  8. 返回业务方法的执行结果

注解元数据处理

Spring在启动时通过反射机制扫描所有类和方法:

  1. 识别带有@SystemLog注解的方法
  2. 解析注解的元数据(operation、saveRequestParam等属性)
  3. 为这些方法创建代理对象
  4. 在运行时通过切面拦截方法调用

技术方案分析

核心技术选型

  • Spring AOP: 实现非侵入式的日志拦截
  • 自定义注解: 标记需要记录日志的方法
  • Jackson: 序列化请求参数和响应数据
  • 异步处理: 避免日志记录影响主业务性能
  • MyBatis-Plus: 数据持久化

架构设计

@SystemLog注解 → AOP切面拦截 → 信息收集 → 异步保存 → 数据库存储
  1. 注解标记: 在需要记录日志的方法上添加@SystemLog注解
  2. AOP拦截: 切面自动拦截带注解的方法
  3. 信息收集: 自动收集请求信息、用户信息、响应信息、性能数据
  4. 异步处理: 使用专用线程池异步保存日志
  5. 数据存储: 保存到MySQL数据库的sys_logs表

代码实现

数据库表设计

CREATE TABLE sys_logs (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
    user_id BIGINT DEFAULT NULL COMMENT '用户ID',
    username VARCHAR(50) DEFAULT NULL COMMENT '用户名',
    operation VARCHAR(50) DEFAULT NULL COMMENT '操作类型',
    method VARCHAR(200) DEFAULT NULL COMMENT '请求方法',
    request_url VARCHAR(255) DEFAULT NULL COMMENT '请求URL',
    request_method VARCHAR(10) DEFAULT NULL COMMENT '请求方式',
    request_params TEXT DEFAULT NULL COMMENT '请求参数',
    request_ip VARCHAR(64) DEFAULT NULL COMMENT '请求IP',
    response_code INT DEFAULT NULL COMMENT '响应状态码',
    response_data TEXT DEFAULT NULL COMMENT '响应数据',
    cost_time BIGINT DEFAULT NULL COMMENT '耗时(毫秒)',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
);

自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SystemLog {
    String operation();
    boolean saveRequestParam() default true;
    boolean saveResponseData() default true;
}

实体类和数据访问层

@Data
@TableName("sys_logs")
public class SysLog {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    private Long userId;
    private String username;
    private String operation;
    private String method;
    private String requestUrl;
    private String requestMethod;
    private String requestParams;
    private String requestIp;
    private Integer responseCode;
    private String responseData;
    private Long costTime;
    private LocalDateTime createdAt;
}

@Mapper
public interface SysLogMapper extends BaseMapper<SysLog> {
    // 继承MyBatis-Plus基础方法
}

日志服务层

@Service
public class SysLogService {
    private final SysLogMapper sysLogMapper;

    @Async("logExecutor")
    public void saveLog(SysLog sysLog) {
        try {
            if (sysLog.getCreatedAt() == null) {
                sysLog.setCreatedAt(LocalDateTime.now());
            }
            truncateLogData(sysLog);
            sysLogMapper.insert(sysLog);
        } catch (Exception e) {
            log.error("保存系统日志失败: {}", e.getMessage(), e);
        }
    }

    private void truncateLogData(SysLog sysLog) {
        // 截断过长字段,防止数据库溢出
        if (sysLog.getRequestParams() != null && sysLog.getRequestParams().length() > 2000) {
            sysLog.setRequestParams(sysLog.getRequestParams().substring(0, 2000) + "...[数据被截断]");
        }
        // 其他字段截断逻辑...
    }
}

AOP切面核心实现

@Slf4j
@Aspect
@Component
public class SystemLogAspect {
    private final SysLogService sysLogService;
    private final UserMapper userMapper;
    private final ObjectMapper objectMapper;

    @Autowired
    public SystemLogAspect(SysLogService sysLogService, UserMapper userMapper) {
        this.sysLogService = sysLogService;
        this.userMapper = userMapper;
        this.objectMapper = new ObjectMapper();
        
        // 配置ObjectMapper
        this.objectMapper.registerModule(new JavaTimeModule());
        this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        this.objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
        
        // 自定义MultipartFile序列化器
        SimpleModule module = new SimpleModule();
        module.addSerializer(MultipartFile.class, new JsonSerializer<MultipartFile>() {
            @Override
            public void serialize(MultipartFile value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
                if (value != null && !value.isEmpty()) {
                    gen.writeString(String.format("文件: %s (%d bytes)", value.getOriginalFilename(), value.getSize()));
                } else {
                    gen.writeNull();
                }
            }
        });
        this.objectMapper.registerModule(module);
    }

    @Around("@annotation(systemLog)")
    public Object around(ProceedingJoinPoint joinPoint, SystemLog systemLog) throws Throwable {
        long startTime = System.currentTimeMillis();
        SysLog sysLog = sysLogService.createNewLog();

        try {
            // 记录请求信息
            setRequestInfo(sysLog, joinPoint, systemLog);
            setUserInfo(sysLog);

            // 执行目标方法
            Object result = joinPoint.proceed();

            // 记录成功响应
            setResponseInfo(sysLog, result, systemLog, 200);
            return result;

        } catch (Exception e) {
            // 记录异常信息
            setResponseInfo(sysLog, null, systemLog, 500);
            sysLog.setResponseData("执行异常:" + e.getMessage());
            throw e;
        } finally {
            // 记录耗时并保存日志
            long costTime = System.currentTimeMillis() - startTime;
            sysLog.setCostTime(costTime);
            
            try {
                sysLogService.saveLog(sysLog);
            } catch (Exception logException) {
                log.error("保存日志失败: {}", logException.getMessage());
            }
        }
    }

    private void setRequestInfo(SysLog sysLog, ProceedingJoinPoint joinPoint, SystemLog systemLog) {
        try {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attributes != null) {
                HttpServletRequest request = attributes.getRequest();
                sysLog.setRequestUrl(request.getRequestURL().toString());
                sysLog.setRequestMethod(request.getMethod());
                sysLog.setRequestIp(getClientIpAddress(request));
                
                if (systemLog.saveRequestParam()) {
                    sysLog.setRequestParams(getRequestParams(joinPoint, request));
                }
            }
            
            sysLog.setOperation(systemLog.operation());
            String className = joinPoint.getTarget().getClass().getName();
            String methodName = joinPoint.getSignature().getName();
            sysLog.setMethod(className + "." + methodName + "()");
        } catch (Exception e) {
            log.warn("设置请求信息失败: {}", e.getMessage());
        }
    }

    private void setUserInfo(SysLog sysLog) {
        try {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication != null && authentication.isAuthenticated() && authentication.getPrincipal() instanceof Long) {
                Long userId = (Long) authentication.getPrincipal();
                sysLog.setUserId(userId);
                
                User user = userMapper.selectById(userId);
                if (user != null) {
                    sysLog.setUsername(user.getUsername());
                }
            }
        } catch (Exception e) {
            log.warn("获取用户信息失败: {}", e.getMessage());
        }
    }

    private String getRequestParams(ProceedingJoinPoint joinPoint, HttpServletRequest request) {
        try {
            Object[] args = joinPoint.getArgs();
            if (args != null && args.length > 0) {
                StringBuilder params = new StringBuilder();
                for (Object arg : args) {
                    if (arg != null && !isFilterType(arg)) {
                        if (params.length() > 0) {
                            params.append(", ");
                        }
                        
                        try {
                            String paramJson = objectMapper.writeValueAsString(arg);
                            paramJson = filterSensitiveData(paramJson);
                            params.append(paramJson);
                        } catch (Exception serializeException) {
                            String fallbackInfo = String.format("对象类型: %s (序列化失败: %s)", 
                                arg.getClass().getSimpleName(), serializeException.getMessage());
                            params.append(fallbackInfo);
                        }
                    }
                }
                return params.toString();
            }
            return request.getQueryString();
        } catch (Exception e) {
            return "获取参数失败:" + e.getMessage();
        }
    }

    private boolean isFilterType(Object arg) {
        return arg instanceof HttpServletRequest 
            || arg instanceof HttpServletResponse
            || arg instanceof MultipartFile;
    }

    private String filterSensitiveData(String jsonData) {
        if (!StringUtils.hasText(jsonData)) {
            return jsonData;
        }
        
        String[] sensitiveFields = {"password", "oldPassword", "newPassword", "token", "secret"};
        String result = jsonData;
        
        for (String field : sensitiveFields) {
            result = result.replaceAll("\"" + field + "\"\\s*:\\s*\"[^\"]*\"", "\"" + field + "\":\"***\"");
        }
        
        return result;
    }
}

异步配置

@Configuration
@EnableAsync
public class LoggingConfig {
    
    @Bean("logExecutor")
    public Executor logExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("log-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }
}

特殊情况处理

HTTP 400及其他状态码不记录日志的原因

在实践中发现,当客户端发送HTTP 400(参数校验失败)等状态码的请求时,日志表中不会有相应记录。这是由于Spring MVC的请求处理流程和AOP切面的执行时机决定的:

Spring MVC请求处理流程

HTTP请求 → DispatcherServlet → HandlerMapping → HandlerAdapter → 参数解析器 → Controller方法

参数校验失败的执行流程

  1. 正常情况(会记录日志)

    HTTP请求 → 参数解析成功 → 进入Controller方法 → AOP切面拦截 → 记录日志
    
  2. 参数校验失败(不会记录日志)

    HTTP请求 → 参数解析失败 → 抛出MethodArgumentNotValidException → 全局异常处理器
    

关键问题分析

当使用@Valid注解进行参数校验时:

@PostMapping("/login")
@SystemLog(operation = "用户登录")
public ResponseEntity<ApiResponse<LoginResponseDTO>> login(@Valid @RequestBody LoginRequestDTO request) {
    // 如果request中的字段校验失败,这个方法根本不会被调用
}

Spring在进入Controller方法之前就会进行参数校验,如果校验失败:

  • 直接抛出MethodArgumentNotValidException
  • Controller方法不会被执行
  • AOP切面没有机会拦截
  • 因此不会记录日志

解决方案

为了记录参数校验失败的日志,需要在全局异常处理器中手动记录:

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @Autowired
    private SysLogService sysLogService;
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        
        // 手动记录参数校验失败的日志
        SysLog sysLog = new SysLog();
        sysLog.setOperation("参数校验失败");
        sysLog.setRequestUrl(request.getRequestURL().toString());
        sysLog.setRequestMethod(request.getMethod());
        sysLog.setResponseCode(400);
        sysLog.setResponseData("参数校验失败: " + ex.getMessage());
        sysLog.setCreatedAt(LocalDateTime.now());
        
        sysLogService.saveLog(sysLog);
        
        return ResponseEntity.badRequest().body(ApiResponse.fail("参数校验失败"));
    }
}

这种设计既保证了AOP切面的纯净性(只处理正常进入Controller的请求),又通过异常处理器补充了边界情况的日志记录。

应用实践

Controller层使用

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    
    @PostMapping("/login")
    @SystemLog(operation = "用户登录", saveResponseData = false)
    public ResponseEntity<ApiResponse<LoginResponseDTO>> login(@RequestBody LoginRequestDTO request) {
        return userService.login(request);
    }
    
    @PostMapping("/register")
    @SystemLog(operation = "用户注册")
    public ResponseEntity<ApiResponse<UserDTO>> register(@RequestBody UserRegistrationDTO request) {
        return userService.register(request);
    }
    
    @PutMapping("/users/me")
    @SystemLog(operation = "更新个人信息", saveRequestParam = false)
    public ResponseEntity<ApiResponse<UserDTO>> updateProfile(@ModelAttribute UserUpdateRequestDTO request) {
        return userService.updateProfile(request);
    }
}

测试验证

通过实际测试验证了以下功能:

  1. 基础日志记录: 正常请求的完整日志记录
  2. 参数过滤: 密码等敏感字段自动替换为***
  3. 文件上传处理: MultipartFile正确序列化为文件信息而非二进制数据
  4. 异常处理: 业务异常和参数校验失败的日志记录
  5. 性能记录: 接口响应时间的准确记录
  6. 用户信息: 登录用户的ID和用户名自动记录

实际日志示例

用户登录日志:
user_id: 1, username: "testuser", operation: "用户登录"
request_params: {"account":"test@example.com","password":"***"}
response_code: 200, cost_time: 150ms

文件上传日志:
user_id: 1, username: "testuser", operation: "更新个人信息"  
request_params: {"username":null,"bio":"更新简介","avatar":"文件: image.png (1650585 bytes)"}
response_code: 200, cost_time: 10328ms

技术要点总结

关键问题解决

  1. MultipartFile序列化问题: 通过自定义Jackson序列化器,将文件对象转换为可读的文件信息
  2. 敏感数据保护: 通过正则表达式自动过滤密码等敏感字段
  3. 参数校验失败: 需要在全局异常处理器中单独处理,因为AOP切面在校验失败时不会执行
  4. 性能影响: 通过异步处理确保日志记录不影响主业务性能
  5. 数据截断: 自动截断过长的数据,防止数据库字段溢出