需求背景
在Web应用开发中,系统日志记录是必不可少的功能。传统的手动日志记录方式存在以下问题:
- 代码侵入性强,需要在每个Controller方法中手动添加日志代码
- 日志格式不统一,容易遗漏关键信息
- 维护成本高,修改日志格式需要修改大量代码
- 容易影响主业务逻辑的可读性
为了解决这些问题,我使用Spring AOP技术实现了一套完整的系统日志记录解决方案。
技术原理分析
AOP核心概念
面向切面编程(AOP)是一种编程范式,通过预编译方式和运行期动态代理实现程序功能的统一维护。在Spring框架中,AOP主要基于以下核心概念:
- 切面(Aspect): 关注点的模块化,如日志记录、事务管理等
- 连接点(Join Point): 程序执行的某个特定位置,如方法调用
- 切点(Pointcut): 连接点的集合,定义了在哪些地方应用切面
- 通知(Advice): 在特定连接点执行的代码,包括前置、后置、环绕通知等
- 织入(Weaving): 将切面应用到目标对象的过程
Spring AOP实现机制
Spring AOP采用动态代理技术实现:
- JDK动态代理: 当目标对象实现了接口时,Spring使用JDK动态代理创建代理对象
- CGLIB代理: 当目标对象没有实现接口时,Spring使用CGLIB字节码生成技术创建子类代理
执行流程原理
客户端调用 → Spring容器 → 代理对象 → 切面拦截 → 目标方法 → 返回结果
具体执行步骤:
- 客户端调用被@SystemLog注解标记的方法
- Spring容器识别到该方法需要被代理
- 代理对象拦截方法调用
- 执行@Around环绕通知的前置逻辑(记录开始时间、收集请求信息)
- 调用目标方法执行业务逻辑
- 执行@Around环绕通知的后置逻辑(记录响应信息、计算耗时)
- 异步保存日志到数据库
- 返回业务方法的执行结果
注解元数据处理
Spring在启动时通过反射机制扫描所有类和方法:
- 识别带有@SystemLog注解的方法
- 解析注解的元数据(operation、saveRequestParam等属性)
- 为这些方法创建代理对象
- 在运行时通过切面拦截方法调用
技术方案分析
核心技术选型
- Spring AOP: 实现非侵入式的日志拦截
- 自定义注解: 标记需要记录日志的方法
- Jackson: 序列化请求参数和响应数据
- 异步处理: 避免日志记录影响主业务性能
- MyBatis-Plus: 数据持久化
架构设计
@SystemLog注解 → AOP切面拦截 → 信息收集 → 异步保存 → 数据库存储
- 注解标记: 在需要记录日志的方法上添加@SystemLog注解
- AOP拦截: 切面自动拦截带注解的方法
- 信息收集: 自动收集请求信息、用户信息、响应信息、性能数据
- 异步处理: 使用专用线程池异步保存日志
- 数据存储: 保存到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方法
参数校验失败的执行流程
正常情况(会记录日志):
HTTP请求 → 参数解析成功 → 进入Controller方法 → AOP切面拦截 → 记录日志
参数校验失败(不会记录日志):
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);
}
}
测试验证
通过实际测试验证了以下功能:
- 基础日志记录: 正常请求的完整日志记录
- 参数过滤: 密码等敏感字段自动替换为***
- 文件上传处理: MultipartFile正确序列化为文件信息而非二进制数据
- 异常处理: 业务异常和参数校验失败的日志记录
- 性能记录: 接口响应时间的准确记录
- 用户信息: 登录用户的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
技术要点总结
关键问题解决
- MultipartFile序列化问题: 通过自定义Jackson序列化器,将文件对象转换为可读的文件信息
- 敏感数据保护: 通过正则表达式自动过滤密码等敏感字段
- 参数校验失败: 需要在全局异常处理器中单独处理,因为AOP切面在校验失败时不会执行
- 性能影响: 通过异步处理确保日志记录不影响主业务性能
- 数据截断: 自动截断过长的数据,防止数据库字段溢出
评论区