JaVers 版本历史功能完整实现指南
本文档基于 FBI 系统的 JaVers 实现,提供从零开始在 Spring Boot + Vue 3 项目中集成实体版本历史功能的完整指导。涵盖依赖配置、事件驱动架构、Entity/DTO 注解、API 设计、前端 HistoryDialog 组件等全部环节。
目录
- 技术选型与依赖
- JaVers 配置
- 核心基础设施(事件驱动架构)
- 实体与 DTO 的 JaVers 注解规范
- 业务层集成(如何发布事件触发快照)
- 后端 API 设计(查询、详情、对比)
- 已有数据初始化快照
- 前端 HistoryDialog 组件
- 前端 API 层
- 页面集成示例
- 完整实施步骤清单
1. 技术选型与依赖
后端(Java)
JaVers 版本:7.9.0
Maven 依赖(在父 POM 的 中声明版本):
<properties>
<javers.version>7.9.0javers.version>
properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.javersgroupId>
<artifactId>javers-spring-boot-starter-sqlartifactId>
<version>${javers.version}version>
dependency>
dependencies>
dependencyManagement>
在 common 模块中引入(所有业务模块依赖 common):
<dependency>
<groupId>org.javersgroupId>
<artifactId>javers-spring-boot-starter-sqlartifactId>
dependency>
说明:使用
javers-spring-boot-starter-sql而非javers-spring-boot-starter-mongo。JaVers 的快照数据存储在应用同一 MySQL 数据库中,无需额外数据库。
前端(Vue 3)
无需额外依赖,使用 Element Plus 的 el-table、el-scrollbar、el-checkbox、el-empty 组件即可。
2. JaVers 配置
application.yml
javers:
spring-data:
enabled: false # 不使用 @JaversSpringDataAuditable 自动审计
sql:
enabled: true
# 显式指定使用应用的 EntityManagerFactory,确保 JaVers 与应用共享事务
entity-manager-bean-name: entityManagerFactory
关键说明:
spring-data.enabled: false:我们不使用@JaversSpringDataAuditable注解。这种自动审计方式会导致每次 Spring Data JPA 保存时都自动提交快照,粒度太粗且无法使用 DTO 包装。sql.enabled: true:使用 SQL 存储(MySQL)。entity-manager-bean-name:必须指定,确保 JaVers 的快照提交在应用的同一事务中。如果 JaVers 提交失败,整个业务操作回滚。
JaVers 自动创建的表
启动应用后,JaVers 会在数据库中自动创建以下表(通过 JPA ddl-auto=update 或 JaVers 内置的 Liquibase 脚本):
| 表名 | 用途 |
|---|---|
javers_commit |
每次快照提交的元数据(作者、时间、提交ID) |
javers_snapshot |
实体在每个版本号下的完整状态(JSON 序列化) |
javers_global_id |
全局对象标识(类型名 + 实体ID 的映射) |
3. 核心基础设施(事件驱动架构)
3.1 设计理念
不使用 JaVers 自带的 @JaversSpringDataAuditable,而是通过自定义 Spring 事件机制手动触发快照提交。这样做的优势:
- 精确控制提交时机:只在业务真正完成时提交,不在中间状态提交
- 支持 DTO 包装:可以提交自定义的 SnapshotDTO(包含关联数据),而非原始 Entity
- 统一事件入口:所有实体的变更通过同一事件通道,方便后续扩展(如通知、日志等)
- 同步事务保证:事件监听器是同步的,快照提交与业务操作在同一事务中
3.2 EntityChangeEvent —— 事件类
package your.project.common.event;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class EntityChangeEvent extends ApplicationEvent {
public enum ChangeType {
CREATED, // 新增
UPDATED, // 更新
DELETED // 删除
}
private final ChangeType changeType;
private final Object entity; // 要提交给 JaVers 的对象(可以是 Entity 或 DTO)
private final String author; // 操作人标识
public EntityChangeEvent(Object source, ChangeType changeType, Object entity, String author) {
super(source);
this.changeType = changeType;
this.entity = entity;
this.author = author;
}
/** 通过反射获取实体 ID */
public Long getEntityId() {
if (entity != null) {
try {
var method = entity.getClass().getMethod("getId");
return (Long) method.invoke(entity);
} catch (Exception e) {
return null;
}
}
return null;
}
/** 获取实体类型名称 */
public String getEntityType() {
return entity != null ? entity.getClass().getSimpleName() : null;
}
}
3.3 EntityChangeEventPublisher —— 事件发布器
package your.project.common.event;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class EntityChangeEventPublisher {
private static final String DEFAULT_AUTHOR = "system";
private final ApplicationEventPublisher publisher;
/** 获取当前登录用户标识。
* 你需要替换为你们项目中获取当前用户的方法 */
private String getCurrentAuthor() {
// 示例:从 SecurityContext 或自定义 ThreadLocal 获取
// return SecurityContextHolder.getContext().getAuthentication().getName();
return DEFAULT_AUTHOR;
}
/** 发布新增事件 */
public void publishCreated(T entity) {
publishCreated(entity, getCurrentAuthor());
}
public void publishCreated(T entity, String author) {
publisher.publishEvent(new EntityChangeEvent(
this, EntityChangeEvent.ChangeType.CREATED, entity, author));
}
/** 发布更新事件 */
public void publishUpdated(T entity) {
publishUpdated(entity, getCurrentAuthor());
}
public void publishUpdated(T entity, String author) {
publisher.publishEvent(new EntityChangeEvent(
this, EntityChangeEvent.ChangeType.UPDATED, entity, author));
}
/** 发布删除事件 */
public void publishDeleted(T entity) {
publishDeleted(entity, getCurrentAuthor());
}
public void publishDeleted(T entity, String author) {
publisher.publishEvent(new EntityChangeEvent(
this, EntityChangeEvent.ChangeType.DELETED, entity, author));
}
}
3.4 EntityChangeEventListener —— 事件监听器(核心)
package your.project.common.event;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class EntityChangeEventListener {
private final Javers javers;
/**
* 同步监听实体变更事件,将实体状态提交到 JaVers。
* 同步执行保证快照提交与业务操作在同一数据库事务中。
*/
@EventListener
public void handleEvent(EntityChangeEvent event) {
try {
switch (event.getChangeType()) {
case CREATED, UPDATED, DELETED:
// 核心调用:将对象提交给 JaVers
javers.commit(event.getAuthor(), event.getEntity());
break;
}
} catch (Exception e) {
log.error("记录实体变更审计失败: {}", event, e);
throw new RuntimeException("记录实体变更审计失败: " + e.getMessage());
}
}
}
重要说明:
- 监听器不加
@Async,是同步执行的。这意味着 JaVers 的commit()在同一个数据库事务中完成。 - 如果
commit()抛出异常,会向上传播导致业务操作回滚。 javers.commit(author, entity)的两个参数:author是操作人标识(可以是用户ID或用户名),entity是待快照的对象(Entity 或 DTO)。
4. 实体与 DTO 的 JaVers 注解规范
4.1 基础实体类(TimeEntity —— 所有实体的父类)
package your.project.common.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.javers.core.metamodel.annotation.DiffIgnore;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {
@Column(updatable = false)
@CreatedDate
@DiffIgnore // 创建时间不纳入版本对比
private LocalDateTime createTime;
@Column
@LastModifiedDate
@DiffIgnore // 更新时间不纳入版本对比
private LocalDateTime updateTime;
}
package your.project.common.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
@MappedSuperclass
public abstract class BaseEntity extends TimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.TABLE, generator = "id_generator")
@TableGenerator(name = "id_generator")
private Long id;
}
4.2 直接追踪 Entity(模式A)
适用于简单实体,直接对 Entity 类加 JaVers 注解:
package your.project.domain.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.javers.core.metamodel.annotation.PropertyName;
import org.javers.core.metamodel.annotation.TypeName;
import your.project.common.entity.BaseEntity;
@Data
@Entity
@EqualsAndHashCode(callSuper = true)
@Table(name = "your_entity")
@TypeName("YourEntity") // 给 JaVers 看的类型名,建议与类名一致
public class YourEntity extends BaseEntity {
@Column(length = 100)
@PropertyName("名称") // 版本对比时显示的中文名
private String name;
@Column(length = 50)
@PropertyName("状态")
private String status;
@Column(length = 200)
@PropertyName("描述")
private String description;
// 不想纳入版本记录的字段加 @DiffIgnore
@DiffIgnore
@Column(length = 50)
private String internalCode;
}
4.3 使用 SnapshotDTO 追踪(模式B)
适用于需要将关联数据也纳入快照的复杂聚合场景。核心思路:JaVers 提交的不是原始 Entity,而是手动构建的 DTO。
Step 1:定义 DTO(带 JaVers 注解)
package your.project.domain.dto;
import lombok.Data;
import org.javers.core.metamodel.annotation.Id;
import org.javers.core.metamodel.annotation.PropertyName;
import org.javers.core.metamodel.annotation.TypeName;
import org.javers.core.metamodel.annotation.DiffIgnore;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TypeName("YourAggregateSnapshotDTO")
public class YourAggregateSnapshotDTO {
@Id // JaVers 的 @Id,不是 JPA 的
@PropertyName("ID")
private Long id;
@PropertyName("名称")
private String name;
@PropertyName("状态")
private String status;
@PropertyName("子项列表")
private List children;
@DiffIgnore
@PropertyName("创建时间")
private LocalDateTime createTime;
/**
* 工厂方法:从 Entity 构建 DTO
*/
public static YourAggregateSnapshotDTO from(YourEntity entity, List children) {
YourAggregateSnapshotDTO dto = new YourAggregateSnapshotDTO();
dto.setId(entity.getId());
dto.setName(entity.getName());
dto.setStatus(entity.getStatus());
dto.setCreateTime(entity.getCreateTime());
dto.setChildren(children.stream()
.map(ChildSnapshotDTO::from)
.toList());
return dto;
}
}
Step 2:子项用 @Value 标注(JaVers 的 ValueObject)
package your.project.domain.dto;
import lombok.Value;
import org.javers.core.metamodel.annotation.PropertyName;
@Value // JaVers 的 @Value 注解,表示这是一个值对象,不独立追踪版本
public class ChildSnapshotDTO {
@PropertyName("子项ID")
private Long childId;
@PropertyName("子项名称")
private String childName;
public static ChildSnapshotDTO from(ChildEntity entity) {
return new ChildSnapshotDTO(entity.getId(), entity.getName());
}
}
4.4 JaVers 注解速查表
| 注解 | 作用 | 使用位置 |
|---|---|---|
@TypeName("名称") |
给 JaVers 设置类型名称,影响查询和显示 | Entity / DTO 类 |
@Id |
标记 JaVers 的全局唯一标识字段 | DTO 的 ID 字段(Entity 用 JPA 的 @Id) |
@PropertyName("中文名") |
给字段设置显示名称,前端对比表显示 | 所有追踪的字段 |
@DiffIgnore |
忽略该字段,不纳入变更对比和快照 | createTime、updateTime、内部编码等 |
@Value |
标记为 JaVers 值对象(无独立版本,跟随父对象) | 列表中嵌套的子对象 |
5. 业务层集成(如何发布事件触发快照)
5.1 基本原则
在业务操作完成后,Controller 返回之前,调用 eventPublisher.publishXxx()。
5.2 模式A:直接提交 Entity
@Service
@Transactional
@RequiredArgsConstructor
public class YourBiz {
private final YourRepository repository;
private final EntityChangeEventPublisher eventPublisher;
// 新增
public YourEntity create(YourCreateParam param) {
YourEntity entity = new YourEntity();
entity.setName(param.getName());
entity.setStatus(param.getStatus());
entity = repository.save(entity);
// 发布创建事件 → 触发 JaVers 快照
eventPublisher.publishCreated(entity);
return entity;
}
// 更新
public YourEntity update(Long id, YourUpdateParam param) {
YourEntity entity = repository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
entity.setName(param.getName());
entity.setStatus(param.getStatus());
entity = repository.save(entity);
// 发布更新事件 → 触发 JaVers 快照
eventPublisher.publishUpdated(entity);
return entity;
}
// 删除
public void delete(Long id) {
YourEntity entity = repository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
// 先发布删除事件(记录删除前的最后状态)
eventPublisher.publishDeleted(entity);
repository.delete(entity);
}
}
5.3 模式B:提交 DTO(聚合场景)
@Service
@Transactional
@RequiredArgsConstructor
public class YourAggregateBiz {
private final YourEntityRepository entityRepository;
private final ChildEntityRepository childRepository;
private final EntityChangeEventPublisher eventPublisher;
public void update(Long id, YourUpdateParam param) {
YourEntity entity = entityRepository.findById(id)
.orElseThrow(() -> new RuntimeException("数据不存在"));
entity.setName(param.getName());
entity.setStatus(param.getStatus());
entityRepository.save(entity);
// 加载关联数据
List children = childRepository.findByParentId(id);
// 构建 DTO 并发布事件
YourAggregateSnapshotDTO dto = YourAggregateSnapshotDTO.from(entity, children);
eventPublisher.publishUpdated(dto);
}
}
5.4 定时任务/系统自动操作中的快照
// 对于非用户触发的操作(如定时同步),使用指定作者
eventPublisher.publishCreated(entity, "system_sync");
// 或使用默认(会自动获取当前登录用户,非 Web 上下文中为 "system")
eventPublisher.publishUpdated(entity);
6. 后端 API 设计(查询、详情、对比)
6.1 JaVersHistoryService —— 通用查询服务
这是所有实体的版本历史查询的唯一实现,所有 Entity 共用。
package your.project.domain.service;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.javers.core.diff.Diff;
import org.javers.core.metamodel.object.CdoSnapshot;
import org.javers.repository.jql.QueryBuilder;
import org.javers.shadow.Shadow;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@Slf4j
public class JaVersHistoryService {
private final Javers javers;
public JaVersHistoryService(Javers javers) {
this.javers = javers;
}
/**
* 获取实体的历史快照列表(版本元数据,分页)
*
* @param entityId 实体ID(数据库主键)
* @param entityClass 实体类(或 DTO 类)
* @param limit 每页条数
* @param skip 跳过条数
* @return 版本列表(每项包含 version 和 commitMetadata)
*/
public List
6.2 Controller 示例
每个需要版本历史的 Entity 在 Controller 中添加 3 个接口。以 YourEntity 为例:
package your.project.interfaces.web;
import org.javers.core.diff.Diff;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/your-api-path/your-entity")
public class YourEntityController {
@Resource
private JaVersHistoryService jaVersHistoryService;
// ============ 版本历史接口 ============
/** 获取历史快照列表(分页) */
@GetMapping("snapshot-menu/{entityId}")
public List
接口路径规范:建议路径格式为 /{业务前缀}/snapshot-menu/{entityId}、/{业务前缀}/snapshot/{entityId}/{version}、/{业务前缀}/compare/{entityId}。
6.3 关键 API 参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
entityId |
Long | 实体在数据库中的主键 ID(对应 JaVers 的 instanceId) |
entityClass |
Class> | 传给 JaVers 的类型。直接追踪用 Entity.class,DTO 追踪用 DTO.class |
version |
int | 版本号。JaVers 从 1 开始自增,每次 commit 产生一个新版本 |
limit |
int | 每页条数,默认 20 |
skip |
int | 跳过的条数,用于分页。前端第 N 页的 skip = N * limit |
6.4 Diff 返回结构
compareVersions 返回的 Diff 对象 JSON 序列化后的结构:
{
"changes": [
{
"propertyName": "name",
"left": "旧名称",
"right": "新名称"
},
{
"propertyName": "status",
"left": "pending",
"right": "completed"
}
]
}
前端直接使用 changes 数组,每项有 propertyName(字段名)、left(旧值)、right(新值)。
7. 已有数据初始化快照
如果系统中已有历史数据(在集成 JaVers 之前就存在的记录),需要做一次性初始化。
package your.project.application.biz;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.javers.core.Javers;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
public class JaVersInitBiz {
private final Javers javers;
private final YourEntityRepository yourEntityRepository;
/** 为已有数据创建初始快照 */
@Transactional
public void initYourEntities() {
List entities = yourEntityRepository.findAll();
log.info("开始初始化 YourEntity 快照,共 {} 条", entities.size());
for (YourEntity entity : entities) {
try {
javers.commit("system_init", entity);
} catch (Exception e) {
log.error("初始化快照失败: entityId={}", entity.getId(), e);
}
}
log.info("初始化 YourEntity 快照完成");
}
}
提供一个后台接口触发:
@RestController
@RequestMapping("/admin")
public class AdminController {
@Resource
private JaVersInitBiz jaVersInitBiz;
@PostMapping("/javers/init")
public String initSnapshots(@RequestParam(defaultValue = "all") String type) {
switch (type) {
case "yourEntity":
jaVersInitBiz.initYourEntities();
break;
case "all":
jaVersInitBiz.initYourEntities();
// 可以继续添加其他实体
break;
}
return "初始化完成";
}
}
8. 前端 HistoryDialog 组件
8.1 组件概述
HistoryDialog.vue 是一个通用的版本历史弹窗组件,左右分栏布局:
+----------------------------+--------------------------------------+
| 版本列表 (200px) | 详情/对比区 |
| | |
| [对比] 按钮(需选2个版本) | 对比模式:表格(属性 | 旧值 | 新值) |
| | |
| [x] V3 张三 | 单版本模式:JSON 格式完整快照 |
| 2024-01-03 10:00 | |
| | |
| [x] V2 李四 <-- 高亮 | |
| 2024-01-02 10:00 | |
| | |
| [ ] V1 王五 | |
| 2024-01-01 10:00 | |
| | |
| ...(无限滚动加载更多) | |
+----------------------------+--------------------------------------+
8.2 完整组件代码
版本列表
对比
V{{ item.version }}
{{ formatDateTime(item.commitMetadata?.commitDate) }}
加载中...
没有更多数据了
V{{ compareVersion1 }} → V{{ compareVersion2 }}
关闭对比
{{ formatCompareValue(row.left) }}
{{ formatCompareValue(row.right) }}
{{ formatJsonData(selectedSnapshotData) }}
关闭
8.3 组件 Props 说明(通过 open() 方法传入)
| 参数 | 类型 | 说明 |
|---|---|---|
entityId |
Number | 实体在数据库中的主键 ID |
getSnapshots |
Function | API函数:(entityId, { limit, skip }) => Promise |
getSnapshotByVersion |
Function | API函数:(entityId, version) => Promise |
compareVersions |
Function | API函数:(entityId, version1, version2) => Promise |
labelMapObj |
Object | 字段名到中文标签的映射,如 { name: "名称" } |
titleStr |
String | 弹窗标题 |
valueFormatter |
Function | 可选,自定义值格式化:(key, value) => formattedString |
9. 前端 API 层
每个实体需要在 API 文件中添加 3 个函数:
// yourApi.js
import request from "@/utils/request"; // 你们的 HTTP 客户端
const BASE = "/your-api-path/your-entity";
// 获取历史快照列表
export function getYourEntitySnapshots(entityId, params) {
return request.get(`${BASE}/snapshot-menu/${entityId}`, { params });
}
// 获取指定版本完整快照
export function getYourEntitySnapshot(entityId, version) {
return request.get(`${BASE}/snapshot/${entityId}/${version}`);
}
// 比较两个版本
export function compareYourEntityVersions(entityId, version1, version2) {
return request.get(`${BASE}/compare/${entityId}`, {
params: { version1, version2 },
});
}
10. 页面集成示例
在列表页添加"变更历史"按钮
变更历史
labelMap 映射规则
- 如果 Entity 使用了
@PropertyName("中文名"),propertyName会是中文名,labelMap可以传空对象{} - 如果 Entity 没使用
@PropertyName,前端拿到的propertyName是 Java 字段名(英文),此时labelMap必须做映射
11. 完整实施步骤清单
按以下顺序实施,每步均可在局部验证:
后端步骤
前端步骤
验证清单
常见问题
Q: 为什么不直接用 @JaversSpringDataAuditable?
A: 自动审计虽然简单,但有两个问题:(1) 每次 repository.save() 都触发,无法区分临时保存和最终提交;(2) 只能提交原始 Entity,无法提交聚合 DTO。事件驱动方式更灵活,业务代码明确控制何时提交。
Q: DTO 模式和直接 Entity 模式怎么选?
A: 如果实体只包含自身字段(无关联数据),直接用 Entity 模式。如果需要在快照中包含关联数据(如"项目-详情-迭代"的聚合视图),用 DTO 模式。Entity 模式更简单,DTO 模式更强大。
Q: JaVers 快照会不会让数据库越来越大?
A: 每次 commit 产生一条快照记录(JSON 存储)。对于普通业务实体(几百到几千条,修改频率不高),增长非常缓慢。JaVers 不提供自动清理功能,如需清理可自行按 commitDate 删除历史快照。
Q: 如果 Entity 加了新字段,旧快照中该字段值是什么?
A: 旧快照中没有该字段(因为提交时不包含),前端查询详情时会显示为 undefined。这通常是可接受的行为。如果需要在展示时处理,可以在 valueFormatter 中对缺失字段做默认值处理。