基于SpringBoot實(shí)現(xiàn)自動(dòng)數(shù)據(jù)變更追蹤需求
在企業(yè)級(jí)應(yīng)用中,關(guān)鍵配置、業(yè)務(wù)數(shù)據(jù)變更的審計(jì)追蹤是一個(gè)常見(jiàn)需求。無(wú)論是金融系統(tǒng)、電商平臺(tái)還是配置管理,都需要回答幾個(gè)基本問(wèn)題:誰(shuí)改了數(shù)據(jù)、什么時(shí)候改的、改了什么。
背景痛點(diǎn)
傳統(tǒng)手工審計(jì)的問(wèn)題
最直接的實(shí)現(xiàn)方式是在每個(gè)業(yè)務(wù)方法中手動(dòng)記錄審計(jì)日志:
public void updatePrice(Long productId, BigDecimal newPrice) {
Product old = productRepository.findById(productId).get();
productRepository.updatePrice(productId, newPrice);
// 手動(dòng)記錄變更
auditService.save("價(jià)格從 " + old.getPrice() + " 改為 " + newPrice);
}
這種做法在項(xiàng)目初期還能應(yīng)付,但隨著業(yè)務(wù)復(fù)雜度增加,會(huì)暴露出幾個(gè)明顯問(wèn)題:
代碼重復(fù):每個(gè)需要審計(jì)的方法都要寫(xiě)類(lèi)似邏輯
維護(hù)困難:業(yè)務(wù)字段變更時(shí),審計(jì)邏輯需要同步修改
格式不統(tǒng)一:不同開(kāi)發(fā)者寫(xiě)的審計(jì)格式可能不一致
查詢(xún)不便:字符串拼接的日志難以進(jìn)行結(jié)構(gòu)化查詢(xún)
業(yè)務(wù)代碼污染:審計(jì)邏輯與業(yè)務(wù)邏輯耦合在一起
實(shí)際遇到的問(wèn)題
- 產(chǎn)品價(jià)格改錯(cuò)了,查了半天日志才找到是誰(shuí)改的
- 配置被誤刪了,想恢復(fù)時(shí)發(fā)現(xiàn)沒(méi)有詳細(xì)的變更記錄
- 審計(jì)要求越來(lái)越嚴(yán)格,手工記錄的日志格式不規(guī)范
需求分析
基于實(shí)際需求,審計(jì)功能應(yīng)具備以下特性:
核心需求
1. 零侵入性:業(yè)務(wù)代碼不需要關(guān)心審計(jì)邏輯
2. 自動(dòng)化:通過(guò)配置或注解就能啟用審計(jì)功能
3. 精確記錄:字段級(jí)別的變更追蹤
4. 結(jié)構(gòu)化存儲(chǔ):便于查詢(xún)和分析的格式
5. 完整信息:包含操作人、時(shí)間、操作類(lèi)型等元數(shù)據(jù)
技術(shù)選型考慮
本方案選擇使用 Javers 作為核心組件,主要考慮:
- 專(zhuān)業(yè)的對(duì)象差異比對(duì)算法
- Spring Boot 集成簡(jiǎn)單
- 支持多種存儲(chǔ)后端
- JSON 輸出友好
設(shè)計(jì)思路
整體架構(gòu)
我們采用 AOP + 注解的設(shè)計(jì)模式:
┌─────────────────┐
│ Controller │
└─────────┬───────┘
│ AOP 攔截
┌─────────▼───────┐
│ Service │ ← 業(yè)務(wù)邏輯保持不變
└─────────┬───────┘
│
┌─────────▼───────┐
│ AuditAspect │ ← 統(tǒng)一處理審計(jì)邏輯
└─────────┬───────┘
│
┌─────────▼───────┐
│ Javers Core │ ← 對(duì)象差異比對(duì)
└─────────┬───────┘
│
┌─────────▼───────┐
│ Audit Storage │ ← 結(jié)構(gòu)化存儲(chǔ)
└─────────────────┘
核心設(shè)計(jì)
1. 注解驅(qū)動(dòng):通過(guò) @Audit 注解標(biāo)記需要審計(jì)的方法
2. 切面攔截:AOP 自動(dòng)攔截帶注解的方法
3. 差異比對(duì):使用 Javers 比較對(duì)象變更
4. 統(tǒng)一存儲(chǔ):審計(jì)日志統(tǒng)一存儲(chǔ)和查詢(xún)
關(guān)鍵代碼實(shí)現(xiàn)
項(xiàng)目依賴(lài)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.javers</groupId>
<artifactId>javers-core</artifactId>
<version>7.3.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
審計(jì)注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audit {
// ID字段名,用于從實(shí)體中提取ID
String idField() default "id";
// ID參數(shù)名,直接從方法參數(shù)中獲取ID
String idParam() default "";
// 操作類(lèi)型,根據(jù)方法名自動(dòng)推斷
ActionType action() default ActionType.AUTO;
// 操作人參數(shù)名
String actorParam() default "";
// 實(shí)體參數(shù)位置
int entityIndex() default 0;
enum ActionType {
CREATE, UPDATE, DELETE, AUTO
}
}
審計(jì)切面
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
private final Javers javers;
// 內(nèi)存存儲(chǔ)審計(jì)日志(生產(chǎn)環(huán)境建議使用數(shù)據(jù)庫(kù))
private final List<AuditLog> auditTimeline = new CopyOnWriteArrayList<>();
private final Map<String, List<AuditLog>> auditByEntity = new ConcurrentHashMap<>();
private final AtomicLong auditSequence = new AtomicLong(0);
// 數(shù)據(jù)快照存儲(chǔ)
private final Map<String, Object> dataStore = new ConcurrentHashMap<>();
@Around("@annotation(auditAnnotation)")
public Object auditMethod(ProceedingJoinPoint joinPoint, Audit auditAnnotation) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
// 提取實(shí)體ID
String entityId = extractEntityId(args, paramNames, auditAnnotation);
if (entityId == null) {
log.warn("無(wú)法提取實(shí)體ID,跳過(guò)審計(jì): {}", method.getName());
return joinPoint.proceed();
}
// 提取實(shí)體對(duì)象
Object entity = null;
if (auditAnnotation.entityIndex() >= 0 && auditAnnotation.entityIndex() < args.length) {
entity = args[auditAnnotation.entityIndex()];
}
// 提取操作人
String actor = extractActor(args, paramNames, auditAnnotation);
// 確定操作類(lèi)型
Audit.ActionType actionType = determineActionType(auditAnnotation, method.getName());
// 執(zhí)行前快照
Object beforeSnapshot = dataStore.get(buildKey(entityId));
// 執(zhí)行原方法
Object result = joinPoint.proceed();
// 執(zhí)行后快照
Object afterSnapshot = determineAfterSnapshot(entity, actionType);
// 比較差異并記錄審計(jì)日志
Diff diff = javers.compare(beforeSnapshot, afterSnapshot);
if (diff.hasChanges() || beforeSnapshot == null || actionType == Audit.ActionType.DELETE) {
recordAudit(
entity != null ? entity.getClass().getSimpleName() : "Unknown",
entityId,
actionType.name(),
actor,
javers.getJsonConverter().toJson(diff)
);
}
// 更新數(shù)據(jù)存儲(chǔ)
if (actionType != Audit.ActionType.DELETE) {
dataStore.put(buildKey(entityId), afterSnapshot);
} else {
dataStore.remove(buildKey(entityId));
}
return result;
}
// 輔助方法:提取實(shí)體ID
private String extractEntityId(Object[] args, String[] paramNames, Audit audit) {
// 優(yōu)先從方法參數(shù)中獲取ID
if (!audit.idParam().isEmpty() && paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
if (audit.idParam().equals(paramNames[i])) {
Object idValue = args[i];
return idValue != null ? idValue.toString() : null;
}
}
}
return null;
}
// 其他輔助方法...
}
業(yè)務(wù)服務(wù)示例
@Service
public class ProductService {
private final Map<String, Product> products = new ConcurrentHashMap<>();
@Audit(
action = Audit.ActionType.CREATE,
idParam = "id",
actorParam = "actor",
entityIndex = 1
)
public Product create(String id, ProductRequest request, String actor) {
Product newProduct = new Product(id, request.name(), request.price(), request.description());
return products.put(id, newProduct);
}
@Audit(
action = Audit.ActionType.UPDATE,
idParam = "id",
actorParam = "actor",
entityIndex = 1
)
public Product update(String id, ProductRequest request, String actor) {
Product existingProduct = products.get(id);
if (existingProduct == null) {
throw new IllegalArgumentException("產(chǎn)品不存在: " + id);
}
Product updatedProduct = new Product(id, request.name(), request.price(), request.description());
return products.put(id, updatedProduct);
}
@Audit(
action = Audit.ActionType.DELETE,
idParam = "id",
actorParam = "actor"
)
public boolean delete(String id, String actor) {
return products.remove(id) != null;
}
@Audit(
idParam = "id",
actorParam = "actor",
entityIndex = 1
)
public Product upsert(String id, ProductRequest request, String actor) {
Product newProduct = new Product(id, request.name(), request.price(), request.description());
return products.put(id, newProduct);
}
}
審計(jì)日志實(shí)體
public record AuditLog(
String id,
String entityType,
String entityId,
String action,
String actor,
Instant occurredAt,
String diffJson
) {}
Javers 配置
@Configuration
public class JaversConfig {
@Bean
public Javers javers() {
return JaversBuilder.javers()
.withPrettyPrint(true)
.build();
}
}
應(yīng)用場(chǎng)景示例
場(chǎng)景1:產(chǎn)品信息更新審計(jì)
操作請(qǐng)求:
PUT /api/products/prod-001
Content-Type: application/json
X-User: 張三
{
"name": "iPhone 15",
"price": 99.99,
"description": "最新款手機(jī)"
}
審計(jì)日志結(jié)構(gòu):
{
"id": "1",
"entityType": "Product",
"entityId": "prod-001",
"action": "UPDATE",
"actor": "張三",
"occurredAt": "2025-10-12T10:30:00Z",
"diffJson": "{\"changes\":[{\"field\":\"price\",\"oldValue\":100.00,\"newValue\":99.99}]}"
}
diffJson 的具體內(nèi)容:
{
"changes": [
{
"changeType": "ValueChange",
"globalId": {
"valueObject": "com.example.objectversion.dto.ProductRequest"
},
"property": "price",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": 100.00,
"right": 99.99
},
{
"changeType": "ValueChange",
"globalId": {
"valueObject": "com.example.objectversion.dto.ProductRequest"
},
"property": "description",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": null,
"right": "最新款手機(jī)"
}
]
}
場(chǎng)景2:完整操作歷史查詢(xún)
GET /api/products/prod-001/audits
響應(yīng)結(jié)果:
[
{
"id": "1",
"entityType": "Product",
"entityId": "prod-001",
"action": "CREATE",
"actor": "system",
"occurredAt": "2025-10-10T08:00:00Z",
"diffJson": "{\"changes\":[{\"field\":\"name\",\"oldValue\":null,\"newValue\":\"iPhone 15\"},{\"field\":\"price\",\"oldValue\":null,\"newValue\":100.00}]}"
},
{
"id": "2",
"entityType": "Product",
"entityId": "prod-001",
"action": "UPDATE",
"actor": "張三",
"occurredAt": "2025-10-12T10:30:00Z",
"diffJson": "{\"changes\":[{\"field\":\"price\",\"oldValue\":100.00,\"newValue\":99.99}]}"
}
]
場(chǎng)景3:刪除操作審計(jì)
刪除請(qǐng)求:
DELETE /api/products/prod-001 X-User: 李四
審計(jì)日志:
{
"id": "3",
"entityType": "Product",
"entityId": "prod-001",
"action": "DELETE",
"actor": "李四",
"occurredAt": "2025-10-13T15:45:00Z",
"diffJson": "{\"changes\":[]}"
}
場(chǎng)景4:批量操作審計(jì)
創(chuàng)建多個(gè)產(chǎn)品:
// 執(zhí)行多次創(chuàng)建操作
productService.create("prod-002", new ProductRequest("手機(jī)殼", 29.99, "透明保護(hù)殼"), "王五");
productService.create("prod-003", new ProductRequest("充電器", 59.99, "快充充電器"), "王五");
審計(jì)日志:
[
{
"id": "4",
"entityType": "Product",
"entityId": "prod-002",
"action": "CREATE",
"actor": "王五",
"occurredAt": "2025-10-13T16:00:00Z",
"diffJson": "{\"changes\":[{\"field\":\"name\",\"oldValue\":null,\"newValue\":\"手機(jī)殼\"},{\"field\":\"price\",\"oldValue\":null,\"newValue\":29.99}]}"
},
{
"id": "5",
"entityType": "Product",
"entityId": "prod-003",
"action": "CREATE",
"actor": "王五",
"occurredAt": "2025-10-13T16:01:00Z",
"diffJson": "{\"changes\":[{\"field\":\"name\",\"oldValue\":null,\"newValue\":\"充電器\"},{\"field\":\"price\",\"oldValue\":null,\"newValue\":59.99}]}"
}
]
總結(jié)
通過(guò) Javers + AOP + 注解的組合,我們實(shí)現(xiàn)了一個(gè)零侵入的數(shù)據(jù)變更審計(jì)系統(tǒng)。這個(gè)方案的主要優(yōu)勢(shì):
開(kāi)發(fā)效率提升:無(wú)需在每個(gè)業(yè)務(wù)方法中編寫(xiě)審計(jì)邏輯
維護(hù)成本降低:審計(jì)邏輯集中在切面中,便于統(tǒng)一管理
數(shù)據(jù)質(zhì)量改善:結(jié)構(gòu)化的審計(jì)日志便于查詢(xún)和分析
技術(shù)方案沒(méi)有銀彈,需要根據(jù)具體業(yè)務(wù)場(chǎng)景進(jìn)行調(diào)整。如果您的項(xiàng)目也有數(shù)據(jù)審計(jì)需求,這個(gè)方案可以作為參考。
github.com/yuboon/java-examples/tree/master/springboot-object-version
到此這篇關(guān)于基于SpringBoot實(shí)現(xiàn)自動(dòng)數(shù)據(jù)變更追蹤需求的文章就介紹到這了,更多相關(guān)SpringBoot數(shù)據(jù)變更追蹤內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
springBoot靜態(tài)資源加載不到,并且配置了也不生效問(wèn)題及解決
這篇文章總結(jié)了一個(gè)在Spring Boot 2.6.x版本中,由于路徑匹配策略改變導(dǎo)致靜態(tài)資源無(wú)法加載的問(wèn)題,并提供了解決方案:通過(guò)配置類(lèi)或在配置文件中設(shè)置路徑匹配策略為AntPathMatcher,或者直接降級(jí)Spring Boot版本2025-02-02
Java(springboot) 讀取txt文本內(nèi)容代碼實(shí)例
這篇文章主要介紹了Java(springboot) 讀取txt文本內(nèi)容代碼實(shí)例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-02-02
java實(shí)現(xiàn)隨機(jī)驗(yàn)證碼圖片生成
這篇文章主要為大家詳細(xì)介紹了java實(shí)現(xiàn)隨機(jī)驗(yàn)證碼圖片生成,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11
基于ReentrantLock的實(shí)現(xiàn)原理講解
這篇文章主要介紹了ReentrantLock的實(shí)現(xiàn)原理,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-09-09
基于MyBatis的關(guān)聯(lián)查詢(xún)優(yōu)化與應(yīng)用實(shí)踐
在實(shí)際項(xiàng)目開(kāi)發(fā)中,關(guān)聯(lián)查詢(xún)是一種常見(jiàn)的需求,尤其是當(dāng)涉及到多個(gè)表之間的數(shù)據(jù)統(tǒng)計(jì)、關(guān)聯(lián)查詢(xún)以及嵌套對(duì)象的構(gòu)建時(shí),如何確保數(shù)據(jù)的準(zhǔn)確性和實(shí)時(shí)性,是開(kāi)發(fā)者必須面對(duì)的挑戰(zhàn),本文將介紹基于MyBatis的關(guān)聯(lián)查詢(xún)優(yōu)化與應(yīng)用實(shí)踐,需要的朋友可以參考下2024-12-12
SpringBoot項(xiàng)目的五種創(chuàng)建方式
這篇文章主要介紹了SpringBoot項(xiàng)目的五種創(chuàng)建方式,文中通過(guò)圖文結(jié)合的方式講解的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2024-12-12
IntelliJ IDEA使用git初始化倉(cāng)庫(kù)的使用方法
這篇文章主要介紹了IntelliJ IDEA使用git初始化倉(cāng)庫(kù)的使用方法,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-04-04

