SpringBoot + AOP + 注解:实现无侵入式数据变更追踪
前言
在日常开发中,数据变更追踪(如操作日志、数据版本记录)是高频需求 —— 比如用户信息修改、订单状态变更、配置项调整等场景,都需要记录 “谁改了什么、改之前是什么、改之后是什么、什么时候改的”。传统方式是在业务代码中手动编写日志记录逻辑,不仅冗余繁琐,还容易遗漏或出错,严重违反 “单一职责原则”。
本文基于 SpringBoot + AOP + 自定义注解,提供一套无侵入式数据变更追踪方案:无需修改业务代码,仅通过注解即可自动记录变更日志,支持字段级对比、异步存储、敏感数据脱敏、字段忽略等实用功能,开箱即用。
一、核心技术选型
- 基础框架:SpringBoot 2.x/3.x
- 切面编程:Spring AOP(无需额外引入,SpringBoot Starter 自带)
- 序列化:Jackson(处理对象转 JSON 对比)
- ORM 框架:Spring Data JPA(也可替换为 MyBatis,下文附适配方案)
- 数据库:MySQL(存储变更日志)
- 核心思想:通过自定义注解标记目标方法,AOP 拦截方法执行,对比方法入参 / 返回值与数据库原始数据的差异,自动生成变更日志。
二、核心代码
1. 第一步:引入依赖(Maven/Gradle)
Maven 依赖
<!-- SpringBoot核心依赖 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.15</version> <!-- 3.x版本可替换为3.1.4 -->
<relativePath/>
</parent>
<!-- 核心依赖 -->
<<dependencies>
<!-- SpringBoot Web(按需引入) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Jackson(序列化) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok(简化代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</</dependencies>
Gradle 依赖
plugins {
id 'org.springframework.boot' version '2.7.15'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'java'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'com.fasterxml.jackson.core:jackson-databind'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
2. 第二步:定义核心注解
2.1 变更追踪主注解(@DataChangeTrack)
标记需要追踪的业务方法,支持配置业务类型、业务 ID、忽略字段等。
import java.lang.annotation.*;
/**
* 数据变更追踪注解:标记需要记录变更日志的方法
*/
@Target(ElementType.METHOD) // 仅作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
@Documented
public @interface DataChangeTrack {
/**
* 业务类型(如:USER=用户管理、ORDER=订单管理、CONFIG=配置管理)
*/
String businessType();
/**
* 业务ID字段名(从方法入参中提取,如:userId、orderNo)
* 若入参是对象,填对象的字段名;若入参是单个值(如Long userId),填""即可
*/
String businessIdField() default "";
/**
* 操作类型(默认自动判断:新增/修改/删除)
* 也可手动指定:CREATE/UPDATE/DELETE
*/
OperateType operateType() default OperateType.AUTO;
/**
* 需要忽略的字段(如:密码、创建时间等无需追踪的字段)
*/
String[] ignoreFields() default {};
/**
* 是否记录变更详情(默认true,字段级对比)
*/
boolean recordDetail() default true;
/**
* 操作人字段名(从方法入参中提取,若从上下文获取,可填"")
*/
String operatorField() default "";
// 操作类型枚举
enum OperateType {
AUTO, CREATE, UPDATE, DELETE
}
}
2.2 字段忽略注解(@IgnoreChange)
用于实体类字段,标记无需追踪的字段(优先级高于 @DataChangeTrack 的 ignoreFields)。
import java.lang.annotation.*;
/**
* 字段忽略注解:标记无需追踪变更的字段
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IgnoreChange {
}
3. 第三步:定义变更日志实体(DataChangeLog)
存储变更日志的核心实体,对应数据库表。
import lombok.Data;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 数据变更日志表
*/
@Data
@Entity
@Table(name = "t_data_change_log") // 表名可自定义
@DynamicInsert
@DynamicUpdate
@EntityListeners(AuditingEntityListener.class)
public class DataChangeLog {
/**
* 主键ID(自增)
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 业务类型(如:USER、ORDER、CONFIG)
*/
@Column(name = "business_type", nullable = false)
private String businessType;
/**
* 业务ID(如:用户ID、订单号)
*/
@Column(name = "business_id", nullable = false)
private String businessId;
/**
* 操作类型(CREATE/UPDATE/DELETE)
*/
@Column(name = "operate_type", nullable = false)
private String operateType;
/**
* 操作人(用户名/工号)
*/
@Column(name = "operator")
private String operator;
/**
* 变更前数据(JSON格式)
*/
@Column(name = "before_data", columnDefinition = "TEXT")
private String beforeData;
/**
* 变更后数据(JSON格式)
*/
@Column(name = "after_data", columnDefinition = "TEXT")
private String afterData;
/**
* 变更详情(字段级对比,JSON格式)
* 示例:{"userName": {"old": "张三", "new": "李四"}, "age": {"old": 20, "new": 22}}
*/
@Column(name = "change_detail", columnDefinition = "TEXT")
private String changeDetail;
/**
* 操作时间(自动填充)
*/
@CreatedDate
@Column(name = "operate_time", nullable = false, updatable = false)
private LocalDateTime operateTime;
/**
* 备注(可选)
*/
@Column(name = "remark")
private String remark;
}
4. 第四步:实现 AOP 切面(核心逻辑)
拦截 @DataChangeTrack 标记的方法,对比数据变更,生成日志。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
import java.util.stream.Collectors;
/**
* 数据变更追踪切面:核心逻辑实现
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DataChangeTrackerAspect {
private final ObjectMapper objectMapper;
private final DataChangeLogService changeLogService;
// 切入点:拦截所有带@DataChangeTrack注解的方法
@Pointcut("@annotation(com.example.track.annotation.DataChangeTrack)")
public void dataChangePointcut() {}
// 方法执行成功后触发(AfterReturning:确保业务逻辑执行成功再记录日志)
@AfterReturning(pointcut = "dataChangePointcut()", returning = "result")
public void trackDataChange(JoinPoint joinPoint, Object result) {
try {
// 1. 获取注解信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DataChangeTrack annotation = method.getAnnotation(DataChangeTrack.class);
// 2. 提取核心参数(业务ID、操作人、操作类型)
String businessId = extractBusinessId(joinPoint, annotation);
String operator = extractOperator(joinPoint, annotation);
String operateType = determineOperateType(annotation, result);
// 3. 获取变更前后数据(beforeData:数据库原始数据;afterData:方法入参/返回值)
Object beforeData = getOriginalData(annotation.businessType(), businessId);
Object afterData = getAfterData(joinPoint, result, annotation.operateType());
// 4. 生成变更详情(字段级对比)
String changeDetail = generateChangeDetail(beforeData, afterData, annotation);
// 5. 构建日志实体并保存(异步保存,不影响业务性能)
DataChangeLog changeLog = buildChangeLog(annotation, businessId, operator, operateType, beforeData, afterData, changeDetail);
changeLogService.saveAsync(changeLog);
} catch (Exception e) {
log.error("数据变更追踪失败", e);
// 日志记录失败不影响业务逻辑,仅打印异常
}
}
/**
* 提取业务ID
*/
private String extractBusinessId(JoinPoint joinPoint, DataChangeTrack annotation) {
String businessIdField = annotation.businessIdField();
Object[] args = joinPoint.getArgs();
if (args.length == 0) {
throw new IllegalArgumentException("方法无入参,无法提取业务ID");
}
// 若入参是单个值(如Long userId),直接返回
if (StringUtils.isEmpty(businessIdField)) {
return args[0].toString();
}
// 若入参是对象,通过反射获取businessIdField对应的字段值
Object arg = args[0];
Field field = ReflectionUtils.findField(arg.getClass(), businessIdField);
if (field == null) {
throw new IllegalArgumentException("入参对象无字段:" + businessIdField);
}
ReflectionUtils.makeAccessible(field);
return Objects.toString(ReflectionUtils.getField(field, arg), "");
}
/**
* 提取操作人(可扩展:从Spring Security上下文、Token中获取)
*/
private String extractOperator(JoinPoint joinPoint, DataChangeTrack annotation) {
String operatorField = annotation.operatorField();
if (StringUtils.isEmpty(operatorField)) {
// 默认从上下文获取(示例:假设已实现UserContext工具类)
return UserContext.getCurrentUser() == null ? "匿名用户" : UserContext.getCurrentUser().getUsername();
}
// 从方法入参提取操作人
Object[] args = joinPoint.getArgs();
Object arg = args[0];
Field field = ReflectionUtils.findField(arg.getClass(), operatorField);
if (field == null) {
return "未知用户";
}
ReflectionUtils.makeAccessible(field);
return Objects.toString(ReflectionUtils.getField(field, arg), "未知用户");
}
/**
* 确定操作类型(自动判断/手动指定)
*/
private String determineOperateType(DataChangeTrack annotation, Object result) {
if (annotation.operateType() != DataChangeTrack.OperateType.AUTO) {
return annotation.operateType().name();
}
// 简单逻辑:result为null可能是删除,beforeData为null可能是新增,否则是修改
// 可根据业务自定义判断逻辑(如:根据方法名包含saveOrUpdate、delete等关键词)
return result == null ? "DELETE" : (getOriginalData(annotation.businessType(), extractBusinessId(null, annotation)) == null ? "CREATE" : "UPDATE");
}
/**
* 获取数据库原始数据(需根据业务类型和业务ID查询,这里是示例逻辑)
*/
private Object getOriginalData(String businessType, String businessId) {
// 实际场景:根据businessType路由到对应的Repository查询
// 示例:if ("USER".equals(businessType)) return userRepository.findById(Long.valueOf(businessId)).orElse(null);
return null; // 替换为真实查询逻辑
}
/**
* 获取变更后数据(入参/返回值)
*/
private Object getAfterData(JoinPoint joinPoint, Object result, DataChangeTrack.OperateType operateType) {
// 删除操作返回null,新增/修改返回入参或返回值
if (operateType == DataChangeTrack.OperateType.DELETE) {
return null;
}
return result != null ? result : joinPoint.getArgs()[0];
}
/**
* 生成字段级变更详情
*/
private String generateChangeDetail(Object beforeData, Object afterData, DataChangeTrack annotation) throws JsonProcessingException {
if (!annotation.recordDetail()) {
return "未记录详细变更";
}
// 新增/删除无需字段对比
if (beforeData == null || afterData == null) {
return "新增数据" + (beforeData == null ? "" : "删除数据");
}
// 转换为Map便于对比
Map<String, Object> beforeMap = objectMapper.convertValue(beforeData, Map.class);
Map<String, Object> afterMap = objectMapper.convertValue(afterData, Map.class);
// 过滤忽略字段(注解配置 + @IgnoreChange标记)
Set<String> ignoreFields = new HashSet<>(Arrays.asList(annotation.ignoreFields()));
ignoreFields.addAll(getIgnoreFieldsFromAnnotation(beforeData.getClass()));
// 对比差异
Map<String, Map<String, Object>> changeDetail = new HashMap<>();
for (Map.Entry<String, Object> entry : afterMap.entrySet()) {
String fieldName = entry.getKey();
if (ignoreFields.contains(fieldName)) {
continue;
}
Object oldVal = beforeMap.get(fieldName);
Object newVal = entry.getValue();
// 忽略空值对比(可自定义逻辑)
if (!Objects.equals(oldVal, newVal) && (oldVal != null || newVal != null)) {
Map<String, Object> diff = new HashMap<>();
diff.put("old", desensitizeField(fieldName, oldVal)); // 脱敏处理
diff.put("new", desensitizeField(fieldName, newVal));
changeDetail.put(fieldName, diff);
}
}
return objectMapper.writeValueAsString(changeDetail);
}
/**
* 获取实体类中带@IgnoreChange注解的字段
*/
private Set<String> getIgnoreFieldsFromAnnotation(Class<?> clazz) {
return Arrays.stream(clazz.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(IgnoreChange.class))
.map(Field::getName)
.collect(Collectors.toSet());
}
/**
* 敏感字段脱敏(可扩展:手机号、身份证、密码等)
*/
private Object desensitizeField(String fieldName, Object value) {
if (value == null) {
return null;
}
String val = value.toString();
// 示例:手机号脱敏(138****1234)
if ("phone".equals(fieldName) && val.length() == 11) {
return val.replaceAll("(\d{3})\d{4}(\d{4})", "$1****$2");
}
// 密码直接隐藏
if ("password".equals(fieldName) || "pwd".equals(fieldName)) {
return "******";
}
// 身份证脱敏(110****1234)
if ("idCard".equals(fieldName) && val.length() == 18) {
return val.replaceAll("(\d{3})\d{11}(\d{4})", "$1****$2");
}
return value;
}
/**
* 构建变更日志实体
*/
private DataChangeLog buildChangeLog(DataChangeTrack annotation, String businessId, String operator, String operateType, Object beforeData, Object afterData, String changeDetail) throws JsonProcessingException {
DataChangeLog changeLog = new DataChangeLog();
changeLog.setBusinessType(annotation.businessType());
changeLog.setBusinessId(businessId);
changeLog.setOperateType(operateType);
changeLog.setOperator(operator);
changeLog.setBeforeData(beforeData == null ? null : objectMapper.writeValueAsString(beforeData));
changeLog.setAfterData(afterData == null ? null : objectMapper.writeValueAsString(afterData));
changeLog.setChangeDetail(changeDetail);
changeLog.setRemark("自动记录数据变更");
return changeLog;
}
}
5. 第五步:日志服务层(异步保存 + 批量处理)
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 变更日志服务:异步保存+批量处理优化
*/
@Service
@RequiredArgsConstructor
public class DataChangeLogService {
private final DataChangeLogRepository changeLogRepository;
// 批量保存缓存(可配置容量,如100条批量提交)
private final List<DataChangeLog> batchCache = new CopyOnWriteArrayList<>();
private static final int BATCH_SIZE = 100;
/**
* 异步保存日志(不阻塞业务线程)
*/
@Async("changeLogAsyncPool") // 指定线程池
public void saveAsync(DataChangeLog changeLog) {
batchCache.add(changeLog);
// 达到批量阈值,提交保存
if (batchCache.size() >= BATCH_SIZE) {
synchronized (this) {
if (batchCache.size() >= BATCH_SIZE) {
changeLogRepository.saveAll(batchCache);
batchCache.clear();
}
}
}
}
/**
* 定时批量保存(防止缓存堆积,如每5分钟执行一次)
*/
@Transactional
public void batchSave() {
if (!batchCache.isEmpty()) {
changeLogRepository.saveAll(batchCache);
batchCache.clear();
}
}
// 其他查询方法(如:按业务类型+时间范围查询日志)
public List<DataChangeLog> queryByBusinessTypeAndTimeRange(String businessType, String startTime, String endTime) {
// 实现查询逻辑(JPA/MyBatis)
return changeLogRepository.findByBusinessTypeAndOperateTimeBetween(businessType, parseTime(startTime), parseTime(endTime));
}
// 时间解析工具方法(略)
private LocalDateTime parseTime(String timeStr) {
// 实现字符串转LocalDateTime逻辑
return null;
}
}
6. 第六步:Repository 层(JPA/MyBatis 适配)
JPA 实现
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface DataChangeLogRepository extends JpaRepository<DataChangeLog, Long> {
// 按业务类型+时间范围查询
List<DataChangeLog> findByBusinessTypeAndOperateTimeBetween(String businessType, LocalDateTime startTime, LocalDateTime endTime);
}
MyBatis 适配(可选)
若项目使用 MyBatis,替换 Repository 为 Mapper 接口 + XML 即可,核心是实现saveAll和findByBusinessTypeAndOperateTimeBetween方法,无需修改其他逻辑。
7. 第七步:配置类(异步线程池 + 定时任务)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步线程池+定时任务配置
*/
@Configuration
@EnableAsync // 启用异步
@EnableScheduling // 启用定时任务
@RequiredArgsConstructor
public class AsyncScheduledConfig {
private final DataChangeLogService changeLogService;
/**
* 变更日志异步线程池
*/
@Bean("changeLogAsyncPool")
public Executor changeLogAsyncPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 核心线程数
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(1000); // 队列容量
executor.setKeepAliveSeconds(60); // 空闲线程存活时间
executor.setThreadNamePrefix("change-log-async-"); // 线程名前缀
// 拒绝策略:队列满时,由调用线程执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
/**
* 定时批量保存日志(每5分钟执行一次)
*/
@Scheduled(cron = "0 0/5 * * * ?")
public void scheduledBatchSaveChangeLog() {
changeLogService.batchSave();
}
}
三、使用示例(无侵入式)
1. 实体类(标记忽略字段)
import com.example.track.annotation.IgnoreChange;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "t_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 业务ID
private String userName; // 用户名(需追踪)
private Integer age; // 年龄(需追踪)
@IgnoreChange // 忽略密码字段
private String password;
@IgnoreChange // 忽略创建时间
private LocalDateTime createTime;
}
2. 业务 Service(添加注解即可)
import com.example.track.annotation.DataChangeTrack;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
/**
* 新增用户:自动记录CREATE类型日志
*/
@DataChangeTrack(businessType = "USER", businessIdField = "id", operatorField = "createBy")
public User addUser(User user) {
return userRepository.save(user);
}
/**
* 修改用户:自动记录UPDATE类型日志,忽略age字段
*/
@DataChangeTrack(businessType = "USER", businessIdField = "id", ignoreFields = {"age"})
public User updateUser(User user) {
User oldUser = userRepository.findById(user.getId()).orElseThrow(() -> new RuntimeException("用户不存在"));
oldUser.setUserName(user.getUserName());
oldUser.setAge(user.getAge());
return userRepository.save(oldUser);
}
/**
* 删除用户:手动指定DELETE类型
*/
@DataChangeTrack(businessType = "USER", businessType = "id", operateType = DataChangeTrack.OperateType.DELETE)
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
}
四、性能优化与扩展建议
1. 性能优化
- 异步保存:通过
@Async将日志记录与业务逻辑解耦,避免阻塞业务线程。 - 批量提交:设置缓存阈值(如 100 条)+ 定时任务(如 5 分钟),减少数据库 IO。
- 字段过滤:仅追踪必要字段,忽略密码、创建时间等无需变更的字段。
- 索引优化:给
business_type、business_id、operate_time字段建立联合索引,提升查询效率。
2. 功能扩展
- 敏感数据脱敏:在
desensitizeField方法中扩展更多字段类型(如银行卡号、邮箱)。 - 多数据源支持:若日志量较大,可将变更日志存储到单独的数据库(如 MongoDB),避免占用业务库资源。
- 日志查询 UI:开发日志查询页面,支持按业务类型、时间范围、操作人筛选,展示变更详情。
- 异常重试:给批量保存添加重试机制(如 Spring Retry),防止因数据库临时不可用导致日志丢失。
五、注意事项
- 业务 ID 必须唯一:确保
businessId能唯一标识一条业务数据,否则无法正确查询原始数据。 - 实体类序列化:被追踪的实体类需支持 Jackson 序列化(避免循环引用,可使用
@JsonIgnore)。 - 事务一致性:日志记录使用异步 + 批量,若业务事务回滚,可能会产生无效日志,需在切面中判断事务状态(可通过
TransactionSynchronizationManager实现)。 - 大数据量场景:若日均日志量超 10 万,建议分表存储(按时间分表,如
t_data_change_log_202401)。
总结
本文提供的方案通过 SpringBoot + AOP + 注解,实现了无侵入式数据变更追踪,核心优势:
- 低侵入:无需修改业务代码,仅通过注解标记即可生效。
- 高灵活:支持字段忽略、脱敏、异步、批量等自定义配置。
- 易复用:代码模板可直接复制到项目,仅需修改数据库查询逻辑和业务适配。
该方案适用于大多数中小规模系统的变更追踪需求,若需应对超大规模日志存储,可进一步结合 ELK 栈或时序数据库优化。