JaVers数据历史+审计

2023年 9月 14日 111.6k 0

一、概念

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

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

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

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

    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号街道"
        }
      ]
    }
    

    三、其他用法

  • 比较两个不同的实体,即无相同的Id表征是同一个实体,仅对比差异:
  • 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、对象属性、类、用户等多种查询方式。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论