一、概念
JaVers:轻量级、开源的Java框架,用于审核数据的修改
对于领域对象(domain object),我们通常只关注它当前的状态,而很少关注它经历的变化,如某个字段的值A->B。
在某些业务场景中,需要对数据进行比较/审计,则有下面的需求:
- 谁修改了数据
- 数据修改前的状态和修改后的状态
在Java语言和数据库中,很难抽象出一个对象用来表征数据的版本或变化,JaVers提供了这样一种能力,它具有以下特点:
二、典型的用法
1. 引入依赖
- Java8:6.14.0
- Java11:更新版本
2. 创建比较器实例:
public class JaVersTest {
Javers javers = JaversBuilder.javers().build();
}
3. 需要比较的数据Model
对不同对象进行比较时,需要指定相同的部分以确保他们是同一份数据的不同形态,这个部分为GlobalId,以@TypeName和@Id组合而成。
不同的部分为需要进行比较的值,默认为类里面的其他字段。
以下的例子,如果name值为lolo,则
- GlobalId:'Employee/lolo' 注意:这里与model的数据类型无关,因此不同的dto直接也可以比较,只要globalId相同
- Object Value:position、salary、boss、...
Model:
@TypeName("Employee")
@Builder
@AllArgsConstructor
public static class Employee {
@Id
private String name;
private int salary;
private int age;
private Employee boss;
private List subordinates = new ArrayList();
private Address primaryAddress;
private Set skills;
public Employee(String name) {
this.name = name;
}
}
嵌套的model:
@Builder
@AllArgsConstructor
public static class Address {
private String city;
private String street;
}
4.进行比较
public class JaVersTest {
@Test
public void shouldCompareTwoEntities() {
//given
Javers javers = JaversBuilder.javers()
.withListCompareAlgorithm(LEVENSHTEIN_DISTANCE)
.build();
Employee loloOld = Employee.builder().name("lolo")
.age(30)
.salary(10_000)
.primaryAddress(new Address("常德", "0号街道"))
.skills(Collections.singleton("management"))
.subordinates(Collections.singletonList(new Employee("小明")))
.build();
Employee loloNew = Employee.builder().name("lolo")
.age(40)
.salary(20_000)
.primaryAddress(new Address("长沙", "1号街道"))
.skills(Collections.singleton("java"))
.subordinates(Collections.singletonList(new Employee("小华")))
.build();
Diff diff = javers.compare(loloOld, loloNew);
System.out.println(diff);
}
}
输出的结果为:
Diff:
* object removed: Employee/小明
- 'name' value '小明' unset
* new object: Employee/小华
- 'name' = '小华'
* changes on Employee/lolo :
- 'age' changed: '30' -> '40'
- 'primaryAddress.city' changed: '常德' -> '长沙'
- 'primaryAddress.street' changed: '0号街道' -> '1号街道'
- 'salary' changed: '10000' -> '20000'
- 'skills' collection changes :
. 'management' removed
· 'java' added
- 'subordinates' collection changes :
0. 'Employee/小明' changed to 'Employee/小华'
Diff对象包括了一系列变化,主要包含:
- NewObject:该对象只在右边的实体中(new)
- ObjectRemoved:该对象只在左边的实体中(old)
- PropertyChange:最主要的部分,表示某个属性值的变化
PropertyChange包含:
- ContainerChange:集合变化(List、Set...)
- MapChange:Map entry变化
- ReferenceChange:实体引用变化
- ValueChange:值变化(原生)
5.解析Diff对象
提供了多种方式来解析,用于对比和存储,除了上面这个直接打印以外还包括:
- 直接遍历所有change:
System.out.println("iterating over changes:");
diff.getChanges().forEach(change -> System.out.println("- " + change));
输出:
iterating over changes:
- NewObject{ new object: Employee/小华 }
- ObjectRemoved{ object removed: Employee/小明 }
- InitialValueChange{ property: 'name', left:'', right:'小华' }
- TerminalValueChange{ property: 'name', left:'小明', right:'' }
- ValueChange{ property: 'salary', left:'10000', right:'20000' }
- ValueChange{ property: 'age', left:'30', right:'40' }
- ListChange{ property: 'subordinates', elementChanges:1, left.size: 1, right.size: 1}
- SetChange{ property: 'skills', elementChanges:2, left.size: 1, right.size: 1}
- ValueChange{ property: 'city', left:'常德', right:'长沙' }
- ValueChange{ property: 'street', left:'0号街道', right:'1号街道' }
- 按照实体分组遍历:
System.out.println("iterating over changes grouped by objects");
diff.groupByObject().forEach(byObject -> {
System.out.println("* changes on " +byObject.getGlobalId().value() + " : ");
byObject.get().forEach(change -> System.out.println(" - " + change));
});
输出:
iterating over changes grouped by objects
* changes on Employee/小明 :
- ObjectRemoved{ object removed: Employee/小明 }
- TerminalValueChange{ property: 'name', left:'小明', right:'' }
* changes on Employee/小华 :
- NewObject{ new object: Employee/小华 }
- InitialValueChange{ property: 'name', left:'', right:'小华' }
* changes on Employee/lolo :
- ValueChange{ property: 'city', left:'常德', right:'长沙' }
- ValueChange{ property: 'street', left:'0号街道', right:'1号街道' }
- ValueChange{ property: 'salary', left:'10000', right:'20000' }
- ValueChange{ property: 'age', left:'30', right:'40' }
- ListChange{ property: 'subordinates', elementChanges:1, left.size: 1, right.size: 1}
- SetChange{ property: 'skills', elementChanges:2, left.size: 1, right.size: 1}
- JSON序列化:
System.out.println(javers.getJsonConverter().toJson(diff));
输出:
{
"changes": [
{
"changeType": "NewObject",
"globalId": {
"entity": "Employee",
"cdoId": "小华"
}
},
{
"changeType": "ObjectRemoved",
"globalId": {
"entity": "Employee",
"cdoId": "小明"
}
},
{
"changeType": "InitialValueChange",
"globalId": {
"entity": "Employee",
"cdoId": "小华"
},
"property": "name",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": null,
"right": "小华"
},
{
"changeType": "TerminalValueChange",
"globalId": {
"entity": "Employee",
"cdoId": "小明"
},
"property": "name",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": "小明",
"right": null
},
{
"changeType": "ValueChange",
"globalId": {
"entity": "Employee",
"cdoId": "lolo"
},
"property": "salary",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": 10000,
"right": 20000
},
{
"changeType": "ValueChange",
"globalId": {
"entity": "Employee",
"cdoId": "lolo"
},
"property": "age",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": 30,
"right": 40
},
{
"changeType": "ListChange",
"globalId": {
"entity": "Employee",
"cdoId": "lolo"
},
"property": "subordinates",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"elementChanges": [
{
"elementChangeType": "ElementValueChange",
"index": 0,
"leftValue": {
"entity": "Employee",
"cdoId": "小明"
},
"rightValue": {
"entity": "Employee",
"cdoId": "小华"
}
}
]
},
{
"changeType": "SetChange",
"globalId": {
"entity": "Employee",
"cdoId": "lolo"
},
"property": "skills",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"elementChanges": [
{
"elementChangeType": "ValueRemoved",
"index": null,
"value": "management"
},
{
"elementChangeType": "ValueAdded",
"index": null,
"value": "java"
}
]
},
{
"changeType": "ValueChange",
"globalId": {
"valueObject": "JaVersTest$Address",
"ownerId": {
"entity": "Employee",
"cdoId": "lolo"
},
"fragment": "primaryAddress"
},
"property": "city",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": "常德",
"right": "长沙"
},
{
"changeType": "ValueChange",
"globalId": {
"valueObject": "JaVersTest$Address",
"ownerId": {
"entity": "Employee",
"cdoId": "lolo"
},
"fragment": "primaryAddress"
},
"property": "street",
"propertyChangeType": "PROPERTY_VALUE_CHANGED",
"left": "0号街道",
"right": "1号街道"
}
]
}
三、其他用法
javers.compare(address1, address2)
model:
public static class Address {
private String city;
private String street;
}
实体类上不使用@Id和@Type,此时对比的对象为根,GlobalId为"com.test.Address/"
四、自定义比较规则
1. CustomValueComparator(自定义值比较器)
CustomValueComparator主要用于自定义JaVers比较两个对象属性值的方式。
它允许您定义自己的比较逻辑,以便在比较对象时,JaVers可以调用这个自定义比较器来比较属性值。
这对于处理某些特殊类型的属性或需要定制化的比较逻辑非常有用。
以自定义一个BigDecimalComparator为例:
它的目的是以值来判断是否一致而不是对象地址。
实现CustomValueComparator接口:
public class BigDecimalComparator implements CustomValueComparator {
@Override
public boolean equals(BigDecimal a, BigDecimal b) {
return a.compareTo(b) == 0;
}
@Override
public String toString(BigDecimal value) {
return value.stripTrailingZeros().toString();
}
}
使用:
@Test
public void test1() {
Javers javers = JaversBuilder.javers()
.registerValue(BigDecimal.class, new BigDecimalComparator())
.build();
Employee loloOld = Employee.builder().name("lolo")
.salary(new BigDecimal(100))
.build();
Employee loloNew = Employee.builder().name("lolo")
.salary(new BigDecimal("100.0"))
.build();
Diff diff = javers.compare(loloOld, loloNew);
Assert.assertFalse(diff.hasChanges());
}
2.CustomPropertyComparator(自定义属性比较器):
CustomPropertyComparator主要用于自定义JaVers比较对象的属性的方式。
它允许您为某个特定属性定义自己的比较逻辑,而不是为整个对象。
这对于需要针对某个属性定制化比较逻辑的情况非常有用。
以实现一个字符串比较器为例:
它的目的是比较字符串是否雷同(简化为new包含old)
static class FunnyStringComparator implements CustomPropertyComparator {
@Override
public Optional compare(String left, String right, PropertyChangeMetadata metadata, Property property) {
if (right.contains(left)) {
return Optional.empty();
}
List changes = new ArrayList();
changes.add(new ValueAdded(right));
changes.add(new ValueRemoved(left));
return Optional.of(new SetChange(metadata, changes));
}
@Override
public boolean equals(String a, String b) {
return a.equals(b);
}
@Override
public String toString(String value) {
return value;
}
}
使用:
public void test2() {
Javers javers = JaversBuilder.javers()
.registerCustomType(String.class, new FunnyStringComparator())
.build();
Employee loloOld = Employee.builder().name("lolo")
.primaryAddress(new Address("常德", "0号街道"))
.build();
Employee loloNew = Employee.builder().name("lolo")
.primaryAddress(new Address("长沙", "10号街道"))
.build();
Diff diff = javers.compare(loloOld, loloNew);
System.out.println(diff);
}
输出:
这里可以看出,10号街道与0号街道被鉴别为“雷同”,没有识别为diff
Diff:
* changes on Employee/ :
- 'primaryAddress.city' collection changes :
· '长沙' added
. '常德' removed
五、持久化
数据的审计/版本记录需要和主数据一样保存到数据库,并在需要的时候查询。
测试时JaVers使用基于内存的数据库:
public void test3() {
Javers javers = JaversBuilder.javers()
.build();
Employee loloOld = Employee.builder().name("lolo")
.age(10)
.build();
String author = "user1"; // 记录被谁修改了,通常从Cookie中取当前用户
javers.commit(author, loloOld);
Changes changes = javers.findChanges(QueryBuilder.byInstanceId(loloOld.name, Employee.class).build());
System.out.println("第一次变更:" + changes);
Employee loloNew = Employee.builder().name("lolo")
.age(20)
.build();
javers.commit(author, loloNew);
Changes changes2 = javers.findChanges(QueryBuilder.byInstanceId(loloOld.name, Employee.class).build());
System.out.println("第二次变更"+changes2);
}
输出:
第一次变更:Changes (3):
commit 1.00
* changes on Employee/lolo :
- NewObject{ new object: Employee/lolo }
- InitialValueChange{ property: 'name', left:'', right:'lolo' }
- InitialValueChange{ property: 'age', left:'', right:'10' }
第二次变更Changes (4):
commit 2.00
* changes on Employee/lolo :
- ValueChange{ property: 'age', left:'10', right:'20' }
commit 1.00
* changes on Employee/lolo :
- NewObject{ new object: Employee/lolo }
- InitialValueChange{ property: 'name', left:'', right:'lolo' }
- InitialValueChange{ property: 'age', left:'', right:'10' }
可以看出详细记录了每个变化的过程,包括数据新增和修改
配置数据源
SpringBoot集成,有两个starter:
- Javers Spring Boot starter for MongoDB, compatible with Spring Boot starter for Spring Data MongoDB
- Javers Spring Boot starter for SQL, compatible with Spring Boot starter for Spring Data JPA
配置详见官网
JaVers SQL Starter创建一个JaversSqlRepository实例连接数据库并管理数据
六、集成方式
1.基于注解自动审计:@JaversSpringDataAuditable
当使用Spring Data CRUD Repository来持久化数据时,只要在Entity上加上此注解即可跟踪实体变化情况(基于切面)
@JaversSpringDataAuditable
public interface PersonRepository extends MongoRepository {
}
2.基于注解手动审计
没有使用Spring Data repository时,可以在更新数据方法上增加注解@JaversAuditable
@Repository
class UserRepository {
@JaversAuditable
public void save(User user) {
...//
}
public User find(String login) {
...//
}
}
3.手动commit
使用commit方法,直接提交。可以考虑抽取公共接口+泛型统一commit
class baseServiceImpl{
@Transactional(
rollbackFor = {Exception.class}
)
public DTO update(DTO dto, boolean selective) {
Preconditions.checkNotNull(dto);
Entity entity = (AbstractAuditable)this.objectMapper.toEntity(dto);
DTO savedDTO = (BaseDTO)this.objectMapper.toDto(this.repository.update(entity, selective));
Entity savedEntity = (AbstractAuditable)this.objectMapper.toEntity(savedDTO);
if (savedDTO instanceof XXX) {
if (selective) {
savedEntity = (AbstractAuditable)this.objectMapper.toEntity(this.findById(dto.getId()));
}
this.javersService.commit(savedEntity, (EcssHistoryElement)dto, false);
}
return savedDTO;
}
}
七、审计
可以增加审计接口,查询数据的版本记录:
@RestController
@RequestMapping(value = "/audit")
public class AuditController {
private final Javers javers;
@Autowired
public AuditController(Javers javers) {
this.javers = javers;
}
@RequestMapping("/person")
public String getPersonChanges() {
QueryBuilder jqlQuery = QueryBuilder.byClass(Person.class);
List changes = javers.findChanges(jqlQuery.build());
return javers.getJsonConverter().toJson(changes);
}
}
查询接口:JQL:JaVers Query Language
JaversRepository使用javers.find*() 查询数据历史版本
有三种查询模式:
- Shadow:实体类的版本记录。主要包含类型、提交元数据(时间、修改者、id)
- Change:具体的变化
- Snapshot:实体数据的版本记录,主要包含提交次数、GlobalId、版本号、数据的键值对
这三种模式按照需求对应拼接在javers.findxxx后面即可,该方法需要传递查询参数,注意包含四种参数:
- Instance Id:实体的id,相当于GlobalId,和平时使用数据库的主键查询类似
- ValueObject:构造一个实体,通过里面的参数进行查询,类似于添加多个条件组合查询
- class:按照类型来查询
- any:任意查询。适用于查看特定用户一段时间内做的所有变更
参数可以添加若干条件:
- changed property:某个属性变化
- limit,
- skip,
- author,
- commitProperty,
- commitDate,
- commitId,
- snapshot version,
- child ValueObjects,
- initial Changes.
示例:
def "should query for changes (and snapshots) with author filter"() {
given:
def javers = JaversBuilder.javers().build()
javers.commit( "Jim", new Employee(name:"bob", age:29, salary: 900) )
javers.commit( "Pam", new Employee(name:"bob", age:30, salary: 1000) )
javers.commit( "Jim", new Employee(name:"bob", age:31, salary: 1100) )
javers.commit( "Pam", new Employee(name:"bob", age:32, salary: 1200) )
when:
def query = QueryBuilder.byInstanceId("bob", Employee.class).byAuthor("Pam").build()
Changes changes = javers.findChanges( query )
then:
println changes.prettyPrint()
assert changes.size() == 4
assert javers.findSnapshots(query).size() == 2
}
总结:
JaVers是一个用于数据审计的框架,通常需要在待比较的实体上添加@TypeName和@Id注解,用于标记这个Entity的主键,然后使用javers的prepare或commit方法
对比或持久化历史版本,持久化时基于切面+JPA。审计时使用JQL进行查询,支持id、对象属性、类、用户等多种查询方式。