Spring Boot 注解拦截器实现审计日志功能
引言
在业务系统中,审计日志记录至关重要。系统需要记录用户的操作日志,特别是在用户操作数据库修改、查询、删除重要数据时,系统应追踪操作人的身份、操作的对象、操作的时间等关键数据。这不仅对运维、合规性有帮助,同时也能提高系统的可审计性和安全性。
本篇文章将深入讲解如何在 Spring Boot 中通过注解和拦截器实现审计日志功能。通过自定义注解,可以在不同模块、不同操作上灵活地记录审计信息,包括操作模块、操作对象属性、用户信息和 IP 地址。同时,这一方案具有高度的拓展性,可以适配于不同业务场景。
我们将以电商交易系统为案例进行详细说明,提供表结构设计和完整的代码示例。
1. 项目环境与依赖
在实现审计日志功能之前,我们需要确保项目的环境和依赖配置正确。本例使用的技术栈如下:
- Spring Boot 2.x
- Maven
- JDK 8+
- MySQL (用于存储审计日志)
- Lombok (简化 POJO 开发)
1.1 Maven 依赖
首先,在 pom.xml
文件中加入所需的依赖。主要包含 Spring Web、MyBatis 和 Lombok。
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Starter -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring Boot DevTools (for development) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
1.2 数据库配置
在 application.yml
中配置数据库连接信息。
spring:
datasource:
url: jdbc:mysql://localhost:3306/ecommerce_db?useSSL=false&serverTimezone=UTC
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.ecommerce.model
2. 数据库表结构设计
为了记录审计日志,我们需要设计一个用于存储日志信息的数据库表。这里,我们设计一个 audit_logs
表,用于保存操作模块、操作的对象信息、操作用户、IP 地址等审计数据。
2.1 审计日志表 audit_logs
CREATE TABLE `audit_logs` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`module_name` VARCHAR(255) NOT NULL, -- 操作模块
`object_id` VARCHAR(255) NOT NULL, -- 操作对象的ID(例如订单ID、用户ID等)
`object_detail` TEXT, -- 操作对象的详细信息(可选)
`operation` VARCHAR(255) NOT NULL, -- 操作类型,如创建、修改、删除
`user_id` BIGINT NOT NULL, -- 操作用户的ID
`username` VARCHAR(255) NOT NULL, -- 操作用户的名称
`ip_address` VARCHAR(50), -- 用户的IP地址
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 记录时间
);
字段解释:
- module_name:记录操作发生在哪个模块,比如“订单模块”或“用户模块”。
- object_id:记录操作对象的主键 ID,如修改的是订单,记录订单 ID。
- object_detail:操作对象的详细信息,如订单的具体信息,方便后续审计。
- operation:记录用户的操作类型,如创建、修改、删除等。
- user_id 和 username:操作用户的信息。
- ip_address:用户操作时的 IP 地址。
- created_at:记录审计日志创建的时间。
3. 自定义注解 @AuditLog
3.1 注解设计
通过自定义注解 @AuditLog
,我们可以标记在需要记录日志的地方,比如在 Service 层或 Controller 层。注解的参数可以包括操作模块名、需要记录的对象属性等。
package com.example.ecommerce.annotation;
import java.lang.annotation.*;
/**
* 用于记录操作审计日志的自定义注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {
/**
* 操作模块名称(如 "订单模块", "用户模块")
*/
String moduleName();
/**
* 操作类型(如 "创建", "修改", "删除")
*/
String operation();
/**
* 指定操作对象的属性(如 "orderId" 或 "userId")
*/
String objectId() default "id";
}
- moduleName:指定操作的模块名,便于区分日志来源。
- operation:操作类型,如创建、修改、删除等。
- objectId:用于标识操作对象的主键属性。
4. 实现审计日志拦截器
4.1 用户上下文 UserContext
首先我们创建一个用户上下文 UserContext
,用来保存当前用户的登录信息和 IP 地址。在实际应用中,用户登录信息一般是通过 JWT 或 Session 获取的,这里为了简化,假设这些信息已经存在。
package com.example.ecommerce.util;
public class UserContext {
private static final ThreadLocal<Long> userId = new ThreadLocal<>();
private static final ThreadLocal<String> username = new ThreadLocal<>();
private static final ThreadLocal<String> ipAddress = new ThreadLocal<>();
public static void setUserId(Long id) {
userId.set(id);
}
public static Long getUserId() {
return userId.get();
}
public static void setUsername(String name) {
username.set(name);
}
public static String getUsername() {
return username.get();
}
public static void setIpAddress(String ip) {
ipAddress.set(ip);
}
public static String getIpAddress() {
return ipAddress.get();
}
public static void clear() {
userId.remove();
username.remove();
ipAddress.remove();
}
}
4.2 审计日志拦截器
接下来,我们实现一个 Spring 的 HandlerInterceptor
拦截器,用于拦截带有 @AuditLog
注解的方法,并记录日志。
package com.example.ecommerce.interceptor;
import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.service.AuditLogService;
import com.example.ecommerce.util.UserContext;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
/**
* 用于记录审计日志的拦截器
*/
@Aspect
@Component
public class AuditLogInterceptor {
@Autowired
private AuditLogService auditLogService;
@Autowired
private HttpServletRequest request;
@Around("@annotation(com.example.ecommerce.annotation.AuditLog)")
public Object logAudit(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = getTargetMethod(joinPoint);
if (method == null) {
return joinPoint.proceed();
}
AuditLog auditLog = method.getAnnotation(AuditLog.class);
if (auditLog != null) {
// 获取用户信息和 IP 地址
Long userId = UserContext.getUserId();
String username = UserContext.getUsername();
String ipAddress = request.getRemoteAddr();
// 获取操作对象的ID
Object[] args = joinPoint.getArgs();
String objectId = getObjectId(args, auditLog.objectId());
// 执行目标方法
Object result = joinPoint.proceed();
// 创建日志记录
AuditLogRecord record = new AuditLogRecord();
record.setModuleName(auditLog.moduleName());
record.setOperation(auditLog.operation());
record.setUserId(userId);
record.setUsername(username);
record.setIpAddress(ipAddress);
record.setObjectId(objectId);
// 保存日志
auditLogService.saveLog(record);
return result;
}
return joinPoint.proceed();
}
private Method getTargetMethod(ProceedingJoinPoint joinPoint) {
Method method = null;
try {
method = joinPoint.getTarget().getClass().getMethod(joinPoint.getSignature().getName(),
((MethodSignature) joinPoint.getSignature()).getParameterTypes());
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
return method;
}
private String getObjectId(Object[] args, String objectIdField) {
try {
for (Object arg : args) {
Field field = arg.getClass().getDeclaredField(objectIdField);
field.setAccessible(true);
return String.valueOf(field.get(arg));
}
} catch (Exception e) {
// log the error
}
return null;
}
}
4.3 日志服务
我们需要提供一个 AuditLogService
,用来保存日志信息。
package com.example.ecommerce.service;
import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.mapper.AuditLogMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AuditLogService {
@Autowired
private AuditLogMapper auditLogMapper;
public void saveLog(AuditLogRecord record) {
auditLogMapper.insert(record);
}
}
4.4 日志记录实体
package com.example.ecommerce.model;
import lombok.Data;
@Data
public class AuditLogRecord {
private Long id;
private String moduleName;
private String operation;
private String objectId;
private String objectDetail;
private Long userId;
private String username;
private String ipAddress;
private String createdAt;
}
4.5 Mapper 定义
package com.example.ecommerce.mapper;
import com.example.ecommerce.model.AuditLogRecord;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AuditLogMapper {
@Insert("INSERT INTO audit_logs(module_name, operation, object_id, user_id, username, ip_address, created_at) " +
"VALUES (#{moduleName}, #{operation}, #{objectId}, #{userId}, #{username}, #{ipAddress}, NOW())")
void insert(AuditLogRecord logRecord);
}
5. 示例使用
在 OrderService
中,我们可以通过 @AuditLog
注解来记录订单的创建操作。
package com.example.ecommerce.service;
import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.Order;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@AuditLog(moduleName = "订单模块", operation = "创建订单", objectId = "orderId")
public void createOrder(Order order) {
// 订单创建逻辑
}
}
在执行 createOrder
方法时,日志将自动记录到 audit_logs
表中。
6. 注解拦截器实现异步审计日志功能
通过自定义注解 @AuditLog
结合拦截器,实现了审计日志功能,记录用户的操作日志。然而,审计日志功能属于辅助功能,它的执行不应该影响到主流程的性能,尤其是在高并发的场景中,日志记录操作可能会成为性能瓶颈。
进一步优化审计日志的实现,将日志记录功能改为异步处理,从而提高接口的性能和响应速度。
6.1 异步处理的必要性
在实际场景中,审计日志功能仅用于记录用户的操作行为,这类操作通常是写入数据库或记录到日志系统中。虽然日志写入过程本身并不复杂,但如果将日志写入与主业务逻辑串行执行,可能会增加响应时间,特别是在高并发场景下。
通过异步化处理,我们可以将日志的记录放到后台线程中执行,主业务流程无需等待日志记录完成,从而提升接口的性能。
6.2 启用异步支持
首先,在 Spring Boot 的主类上添加 @EnableAsync
注解,启用异步功能。
package com.example.ecommerce;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class EcommerceApplication {
public static void main(String[] args) {
SpringApplication.run(EcommerceApplication.class, args);
}
}
6.3 配置线程池
为了更好地处理异步任务,我们可以自定义一个线程池用于执行异步任务。通过线程池可以更好地控制并发数量以及任务的执行速度。
在 config
包下创建一个 AsyncConfig
类来配置线程池。
package com.example.ecommerce.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "auditLogExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 核心线程数
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(500); // 队列容量
executor.setThreadNamePrefix("AuditLog-"); // 线程名称前缀
executor.initialize();
return executor;
}
}
在上述配置中,ThreadPoolTaskExecutor
用于处理异步任务,auditLogExecutor
线程池负责异步执行日志记录任务。配置中我们设置了核心线程数为 5,最大线程数为 10,队列容量为 500。可以根据实际需求调整这些参数。
6.4 修改日志服务
接下来,我们将之前的 AuditLogService
进行修改,使其能够异步记录日志。只需要在日志保存方法上加上 @Async
注解,并指定执行的线程池。
package com.example.ecommerce.service;
import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.mapper.AuditLogMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class AuditLogService {
@Autowired
private AuditLogMapper auditLogMapper;
/**
* 异步保存审计日志
*/
@Async("auditLogExecutor")
public void saveLog(AuditLogRecord record) {
// 模拟一个较为耗时的日志记录操作
try {
Thread.sleep(200); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
// 保存日志记录到数据库
auditLogMapper.insert(record);
}
}
在 saveLog
方法上,添加了 @Async("auditLogExecutor")
注解,表示该方法会在我们之前配置的 auditLogExecutor
线程池中异步执行。当该方法被调用时,Spring 会将其丢到异步线程中执行,而不会阻塞主线程。
6.5 审计日志拦截器保持不变
我们之前的审计日志拦截器实现并不需要修改,拦截器依旧会在标记有 @AuditLog
注解的方法执行前后进行日志记录操作。唯一的不同是 AuditLogService.saveLog
现在是异步执行的,因此不会阻塞业务方法的执行。
package com.example.ecommerce.interceptor;
import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.AuditLogRecord;
import com.example.ecommerce.service.AuditLogService;
import com.example.ecommerce.util.UserContext;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
@Aspect
@Component
public class AuditLogInterceptor {
@Autowired
private AuditLogService auditLogService;
@Autowired
private HttpServletRequest request;
@Around("@annotation(com.example.ecommerce.annotation.AuditLog)")
public Object logAudit(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = getTargetMethod(joinPoint);
if (method == null) {
return joinPoint.proceed();
}
AuditLog auditLog = method.getAnnotation(AuditLog.class);
if (auditLog != null) {
// 获取用户信息和 IP 地址
Long userId = UserContext.getUserId();
String username = UserContext.getUsername();
String ipAddress = request.getRemoteAddr();
// 获取操作对象的ID
Object[] args = joinPoint.getArgs();
String objectId = getObjectId(args, auditLog.objectId());
// 执行目标方法
Object result = joinPoint.proceed();
// 创建日志记录
AuditLogRecord record = new AuditLogRecord();
record.setModuleName(auditLog.moduleName());
record.setOperation(auditLog.operation());
record.setUserId(userId);
record.setUsername(username);
record.setIpAddress(ipAddress);
record.setObjectId(objectId);
// 异步保存日志
auditLogService.saveLog(record);
return result;
}
return joinPoint.proceed();
}
private Method getTargetMethod(ProceedingJoinPoint joinPoint) {
Method method = null;
try {
method = joinPoint.getTarget().getClass().getMethod(joinPoint.getSignature().getName(),
((MethodSignature) joinPoint.getSignature()).getParameterTypes());
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
return method;
}
private String getObjectId(Object[] args, String objectIdField) {
try {
for (Object arg : args) {
Field field = arg.getClass().getDeclaredField(objectIdField);
field.setAccessible(true);
return String.valueOf(field.get(arg));
}
} catch (Exception e) {
// log the error
}
return null;
}
}
6.6 示例使用
假设我们有一个订单模块的 OrderService
,通过 @AuditLog
注解,我们可以记录订单创建的操作。由于审计日志的记录现在是异步进行的,因此不会影响接口的响应性能。
package com.example.ecommerce.service;
import com.example.ecommerce.annotation.AuditLog;
import com.example.ecommerce.model.Order;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@AuditLog(moduleName = "订单模块", operation = "创建订单", objectId = "orderId")
public void createOrder(Order order) {
// 订单创建逻辑
}
}
在 createOrder
方法执行时,日志记录的操作会被异步提交给后台线程处理,从而确保主业务的执行不受影响。即便日志记录出现一些延迟,也不会影响主流程的性能。
6.7 日志输出
假设 OrderService.createOrder()
方法被调用,并且当前用户的 ID 为 1,用户名为 john_doe
,操作的 IP 地址为 192.168.1.1
,记录的审计日志最终会存储在数据库的 audit_logs
表中。
日志记录的 SQL 如下:
INSERT INTO audit_logs (module_name, operation, object_id, user_id, username, ip_address, created_at)
VALUES ('订单模块', '创建订单', '12345', 1, 'john_doe', '192.168.1.1', NOW());
7. 总结
通过自定义注解和拦截器,我们可以轻松实现审计日志的自动化记录。通过该方案,系统不仅可以动态记录用户的操作,还可以灵活地扩展到不同的模块和业务场景。
通过将审计日志的记录改为异步执行,整个系统的性能得到了显著提升。主流程执行完毕后,无需等待日志写入的