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

相关推荐
Rust研习社5 小时前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
IT_陈寒5 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro6 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax6 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH6 小时前
Koa和Express的区别
后端
MariaH7 小时前
Koa框架的使用
后端
luckdewei8 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某9 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy9 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom9 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github