1. 概述

对于记录型系统应用,追踪领域实体的变更是一项常见需求。对于基于JPA的应用,使用Hibernate Envers能以近乎透明的方式实现该需求,使其成为热门选择。

开箱即用的Envers仅捕获修改实体的字段、变更类型和时间戳。但多数情况下,我们需要向变更事件添加额外字段。典型场景是添加触发变更请求关联的用户和远程IP地址。

本教程将以Spring Boot宠物收容所应用为例,展示如何扩展Envers向标准审计数据添加自定义字段。

2. 项目配置

首先添加必需的Spring Data JPA和Envers依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>3.3.5</version>
</dependency>

<dependency>
    <groupId>org.springframework.data</groupId>
     <artifactId>spring-data-envers</artifactId>
    <version>3.4.0</version>
</dependency>

这些依赖的最新版本可在Maven Central获取:

⚠️ 注意:使用SpringBoot管理的依赖时无需指定版本。

完整项目描述符(在线查看)还包含LombokH2嵌入式数据库和标准Spring Boot测试启动器

3. 宠物收容所示例

我们的简化宠物收容所领域仅包含三个实体:

  • Pet:收容所照顾的动物,直到被Owner领养
  • SpeciesPet的物种
  • Owner:领养一个或多个Pets的人

简化类图展示了这些实体间的关系: 宠物收容所领域模型

注意Pet可能没有Owner。没有OwnerPet表示可被领养。

当收容所接收新Pet时,会分配一个终身不变的唯一标识符。但它的名字随时可变。例如,若Owner因故归还Pet,下一位Owner可选择赋予新名字。

对我们的收容所而言,追踪这些变更至关重要,因此我们将使用Envers实现所需的审计表。仅保留Pet的旧名字和Owner是不够的,城市法规要求我们同时记录办理领养和归还手续的员工姓名。

此外,假设该应用运行在后端,为移动端或SPA前端提供服务。这种场景下,向审计记录添加上下文信息同样重要。本例中我们假设远程地址足够。**这些额外字段将添加到Envers已用于版本控制的标准REVINFO**。

4. 领域层实现

之前的教程中,我们已涵盖Envers基础。多数情况下,只需在领域类添加*@Audited*注解

@Entity
@Audited
@Data
@NoArgsConstructor
public class Species {
    @Id @GeneratedValue
    private Long id;

    @Column(unique = true)
    private String name;

   // ... 静态方法省略
}

@Entity
@Audited
@Data
public class Pet {
    @Id @GeneratedValue
    private Long id;

    @Column(unique = true, nullable = false)
    private UUID uuid;

    private String name;

    // null owner表示宠物可被领养
    @ManyToOne
    @JoinColumn(name = "owner_id", nullable = true)
    private Owner owner;

    @ManyToOne
    @JoinColumn(name = "species_id")
    private Species species;
}

@Entity
@Audited
@Data
public class Owner {
    @Id @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToMany(fetch = FetchType.EAGER)
    private List<Pet> pets;

    // ... 静态方法省略
}

除业务实体外,还需额外实体作为扩展修订实体。这里我们扩展Envers的DefaultRevisionEntity添加所需字段:

@Entity
@RevisionEntity
@EqualsAndHashCode(callSuper = true)
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(CustomRevisionListener.class)
public class CustomRevisionEntity extends DefaultRevisionEntity {
    private String remoteHost;
    private String remoteUser;
}

注意*@RevisionEntity*注解,它标记该实体为一个或多个实体相关变更的根节点。此外还需指定*@EntityListener*以填充自定义字段。

5. 仓储层实现

仓储层基于Spring Data标准JpaRepository,按需添加额外查询方法:

public interface PetRepository extends JpaRepository<Pet,Long>, RevisionRepository<Pet,Long,Long> {
    List<Pet> findPetsByOwnerNullAndSpecies(Species species);
    Optional<Pet> findPetByUuid(UUID uuid);
}

本例中仅向Pet实体添加历史检索支持,通过PetRepository扩展的RevisionRepository接口实现。

运行时,Spring Data Envers集成会为PetRepository提供合适实现。例如使用findRevisions()方法列出给定Pet的所有变更:

return petsRepo.findRevisions(pet.getId()).stream()
  .map(r -> {
     // ... 将修订映射为合适的DTO
  })
  .toList();

此方法返回的Revision条目允许直接查询时间戳、操作类型和原始实体值。要获取实际修订实体需使用*getMetadata().getDelegate()*。

得益于*@RevisionEntity*注解,该代理将是扩展修订实体的实例。可直接使用它检索额外字段:

return petsRepo.findRevisions(pet.getId()).stream()
  .map(r -> {
      CustomRevisionEntity rev = r.getMetadata().getDelegate();
      // ... 按需映射修订信息
  })
  .toList();

6. CustomRevisionListener 实现

了解了如何扩展默认修订并从仓储访问它后,需实现填充这些额外字段的实体监听器。

这里需注意,尽管在*@EntityListeners注解中使用了监听器类名,实践中也可使用接口。*只要上下文中存在与声明类型兼容的Spring管理bean,Envers就能使用它。

无论如何,该类型需有一个带生命周期相关注解的void方法:*@Pre/PostPersist, @Pre/PostDelete等。我们的目的只需一个@PrePersist*注解方法:

@Component
@RequiredArgsConstructor
public class CustomRevisionListener {
    private final Supplier<Optional<RequestInfo>> requestInfoSupplier;

    @PrePersist
    private void onPersist(CustomRevisionEntity entity) {
        var info = requestInfoSupplier.get();
        if (info.isEmpty()) {
            return;
        }

        entity.setRemoteHost(info.get().remoteHost());
        entity.setRemoteUser(info.get().remoteUser());
    }
}

该类是常规Spring*@Component,可使用所有标准注入模式。这里使用构造器注入的Supplier*,运行时提供所需上下文信息。这种方式实现了与上下文信息实际来源的优雅解耦,并强制关注点分离。同时简化了服务/仓储/领域测试,可用mock替代真实supplier。

7. 服务层

查看AdoptionService在线查看)的几个方法。首先是*registerForAdoption()*:

public UUID registerForAdoption( String speciesName) {
    var species = speciesRepo.findByName(speciesName)
      .orElseThrow(() -> new IllegalArgumentException("Unknown Species: " + speciesName));

    var pet = new Pet();
    pet.setSpecies(species);
    pet.setUuid(UUID.randomUUID());
    petsRepo.save(pet);
    return pet.getUuid();
}

这里没什么特别,这反而是好消息! Envers集成大多是非侵入性的,开箱即用。接下来是*listPetStory()*实现:

public List<PetHistoryEntry> listPetHistory(UUID petUuid) {
    var pet = petsRepo.findPetByUuid(petUuid)
      .orElseThrow(() -> new IllegalArgumentException("No pet with UUID '" + petUuid + "' found"));

    return petsRepo.findRevisions(pet.getId()).stream()
      .map(r -> {
          CustomRevisionEntity rev = r.getMetadata().getDelegate();
          return new PetHistoryEntry(r.getRequiredRevisionInstant(),
            r.getMetadata().getRevisionType(),
            r.getEntity().getUuid(),
            r.getEntity().getSpecies().getName(),
            r.getEntity().getName(),
            r.getEntity().getOwner() != null ? r.getEntity().getOwner().getName() : null,
            rev.getRemoteHost(),
            rev.getRemoteUser());
      })
      .toList();
}

实现使用PetRepository的修订相关方法检索Revision条目列表,然后将这些条目映射为暴露给客户端的PetHistoryEntry记录。

8. 测试

完成本教程,创建测试模拟猫咪在收容所接收后经历多次领养的生命周期:

@Test
void whenAdoptPet_thenSuccess() {
    var petUuid = adoptionService.registerForAdoption("cat");
    var kitty = adoptionService.adoptPet(petUuid, "adam", "kitty");

    List<PetHistoryEntry> kittyHistory = adoptionService.listPetHistory(kitty.getUuid());
    assertNotNull(kittyHistory);
    assertTrue(kittyHistory.size() > 0 , "kitty should have a history");
    for (PetHistoryEntry e : kittyHistory) {
        log.info("Entry: {}", e);
    }
}

@TestConfiguration
static class TestConfig {
    @Bean
    Supplier<Optional<RequestInfo>> requestInfoSupplier() {
        return () -> Optional.of(new RequestInfo("example.com", "thomas"));        
    }
    
    // ... 其他测试bean省略
}

除测试用例外,关键点是使用*@TestConfiguration内部类提供实现所需RequestInfo Supplier*的bean。这里提供固定数据,也可模拟更复杂场景。

9. 结论

本文展示了如何用自定义字段扩展默认Envers修订实体,并将其集成到Spring Boot应用中。

所有代码可在GitHub获取


原始标题:Hibernate Envers – Extending Revision Info with Custom Fields | Baeldung