JaVers-数据历史+审计

一、概念

JaVers:轻量级、开源的Java框架,用于审核数据的修改 对于领域对象(domain object),我们通常只关注它当前的状态,而很少关注它经历的变化,如某个字段的值A->B。 在某些业务场景中,需要对数据进行比较/审计,则有下面的需求:

  • 谁修改了数据
  • 数据修改前的状态和修改后的状态

在Java语言和数据库中,很难抽象出一个对象用来表征数据的版本或变化,JaVers提供了这样一种能力,它具有以下特点:

  1. 轻量级且通用性强。不依赖于任何数据模型、容器、数据库
  2. 配置简单,使用JSON序列化
  3. 数据的版本/快照与主数据保存在相同的数据库
  4. 使用DDD的基本概念描述数据,如Entity和Value Object,与JPA类似

二、典型的用法

1. 引入依赖

  • Java8:6.14.0
  • Java11:更新版本

2. 创建比较器实例:

java 复制代码
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:

java 复制代码
@TypeName("Employee")
@Builder
@AllArgsConstructor
public static class Employee {

    @Id
    private String name;

    private int salary;

    private int age;

    private Employee boss;

    private List<Employee> subordinates = new ArrayList<>();

    private Address primaryAddress;

    private Set<String> skills;

    public Employee(String name) {
        this.name = name;
    }
}

嵌套的model:

java 复制代码
@Builder
    @AllArgsConstructor
    public static class Address {
        private String city;

        private String street;

    }

4.进行比较

java 复制代码
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);

    }
}

输出的结果为:

text 复制代码
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:
java 复制代码
 System.out.println("iterating over changes:");
        diff.getChanges().forEach(change -> System.out.println("- " + change));

输出:

text 复制代码
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号街道' }
  • 按照实体分组遍历:
java 复制代码
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));
});

输出:

text 复制代码
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序列化:
java 复制代码
System.out.println(javers.getJsonConverter().toJson(diff));

输出:

text 复制代码
{
  "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号街道"
    }
  ]
}

三、其他用法

  1. 比较两个不同的实体,即无相同的Id表征是同一个实体,仅对比差异:
java 复制代码
javers.compare(address1, address2)

model:

java 复制代码
public static class Address {
        private String city;

        private String street;

    }

实体类上不使用@Id和@Type,此时对比的对象为根,GlobalId为"com.test.Address/"

四、自定义比较规则

1. CustomValueComparator(自定义值比较器)

CustomValueComparator主要用于自定义JaVers比较两个对象属性值的方式。 它允许您定义自己的比较逻辑,以便在比较对象时,JaVers可以调用这个自定义比较器来比较属性值。 这对于处理某些特殊类型的属性或需要定制化的比较逻辑非常有用。

以自定义一个BigDecimalComparator为例: 它的目的是以值来判断是否一致而不是对象地址。

实现CustomValueComparator接口:

java 复制代码
public class BigDecimalComparator implements CustomValueComparator<BigDecimal> {
    @Override
    public boolean equals(BigDecimal a, BigDecimal b) {
        return a.compareTo(b) == 0;
    }

    @Override
    public String toString(BigDecimal value) {
        return value.stripTrailingZeros().toString();
    }
}

使用:

java 复制代码
@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)

java 复制代码
static class FunnyStringComparator implements CustomPropertyComparator<String, SetChange> {
    @Override
    public Optional<SetChange> compare(String left, String right, PropertyChangeMetadata metadata, Property property) {
        if (right.contains(left)) {
            return Optional.empty();
        }
        List<ContainerElementChange> 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;
    }
}

使用:

java 复制代码
 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

text 复制代码
Diff:
* changes on Employee/ :
  - 'primaryAddress.city' collection changes :
     · '长沙' added
     . '常德' removed

五、持久化

数据的审计/版本记录需要和主数据一样保存到数据库,并在需要的时候查询。 测试时JaVers使用基于内存的数据库:

java 复制代码
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);
    }

输出:

text 复制代码
第一次变更: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上加上此注解即可跟踪实体变化情况(基于切面)

java 复制代码
@JaversSpringDataAuditable
public interface PersonRepository extends MongoRepository<Person, String> {
}

2.基于注解手动审计

没有使用Spring Data repository时,可以在更新数据方法上增加注解@JaversAuditable

java 复制代码
@Repository
class UserRepository {
    @JaversAuditable
    public void save(User user) {
        ...//
    }

    public User find(String login) {
        ...//
    }
}

3.手动commit

使用commit方法,直接提交。可以考虑抽取公共接口+泛型统一commit

java 复制代码
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;
    }
}

七、审计

可以增加审计接口,查询数据的版本记录:

java 复制代码
@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<Change> 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.

示例:

java 复制代码
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、对象属性、类、用户等多种查询方式。

相关推荐
风象南21 分钟前
Spring Boot 的 3 种动态 Bean 注入技巧
java·spring boot·后端
excel10 小时前
Nginx 与 Node.js(PM2)的对比优势及 HTTPS 自动续签配置详解
后端
bobz96512 小时前
vxlan 为什么一定要封装在 udp 报文里?
后端
bobz96512 小时前
vxlan 直接使用 ip 层封装是否可以?
后端
郑道14 小时前
Docker 在 macOS 下的安装与 Gitea 部署经验总结
后端
3Katrina14 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
汪子熙14 小时前
HSQLDB 数据库锁获取失败深度解析
数据库·后端
高松燈14 小时前
若伊项目学习 后端分页源码分析
后端·架构
没逻辑14 小时前
主流消息队列模型与选型对比(RabbitMQ / Kafka / RocketMQ)
后端·消息队列
倚栏听风雨15 小时前
SwingUtilities.invokeLater 详解
后端