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管理的依赖时无需指定版本。
完整项目描述符(在线查看)还包含Lombok、H2嵌入式数据库和标准Spring Boot测试启动器。
3. 宠物收容所示例
我们的简化宠物收容所领域仅包含三个实体:
- Pet:收容所照顾的动物,直到被Owner领养
- Species:Pet的物种
- Owner:领养一个或多个Pets的人
注意Pet可能没有Owner。没有Owner的Pet表示可被领养。
当收容所接收新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获取。