Hibernate Envers – 使用自訂欄位擴充修訂訊息
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 上找到:
-
[spring-boot-starter-data-jpa](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-jpa)
-
[spring-data-envers](https://mvnrepository.com/artifact/org.springframework.data/spring-data-envers)
注意:使用SpringBoot 管理的依賴項時,無需指定版本。
完整的項目描述符可在線獲取,還包括 Lombok、H2 嵌入式資料庫和標準 Spring Boot 測試啟動器。
3. 寵物收容所範例
我們簡單的寵物收容所網域僅包含三個實體:
-
Pet
:收容所將照顧的動物,直到Owner
決定收養為止。 -
Species
:給定Pet
的物種 -
Owner
:收養一隻或多隻Pets
的人
這個簡化的類別圖顯示了這些實體之間的關係:
請注意, Pet
可能沒有Owner
。沒有Owner
Pet
可以被領養。
當收容所收到新的Pet
時,它將被分配一個唯一的標識符,該標識符在其整個生命週期中不會改變。但是,其名稱可以隨時變更。例如,如果Owner
出於某種原因決定將Pet
送回收容所,下一位Owner
可以選擇給它一個新名字。
對於我們的庇護所來說,追蹤這些變化非常重要,因此我們將使用 Envers 來實現所需的審計表。現在,保留給定Pet
的舊名稱和Owners
是不夠的。市法規要求我們也登記提交收養和申報表的庇護所員工的姓名。
此外,我們假設該應用程式在後端運行並為來自行動或 SPA 前端的請求提供服務。在這種情況下,為審計記錄添加一些上下文資訊也很重要。在我們的例子中,我們假設遠端位址就足夠了。這些額外的欄位將會被加入到 Envers 已用於其版本控制的標準REVINFO
表中。
4. 領域層實現
在先前的教學中,我們已經介紹了 Envers 的基礎知識。對於大多數情況,我們需要的只是將@Audited
註解添加到我們的領域類別中:
@Entity
@Audited
@Data
@NoArgsConstructor
public class Species {
@Id @GeneratedValue
private Long id;
@Column(unique = true)
private String name;
// ... static methods omitted
}
@Entity
@Audited
@Data
public class Pet {
@Id @GeneratedValue
private Long id;
@Column(unique = true, nullable = false)
private UUID uuid;
private String name;
// A null ownes implies the pet is available for adoption
@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;
// ... static methods omitted
}
除了那些與業務相關的實體之外,我們還需要一個額外的實體作為我們的擴展修訂實體。在這裡,我們將使用必需的欄位來擴充 Enver 的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 -> {
// ... map revision to a suitable DTO
})
.toList();
此方法傳回的Revision
條目允許我們直接查詢時間戳記、操作和原始實體值。為了到達實際的修訂實體,我們需要使用getMetadata().getDelegate()
。
由於@RevisionEntity
註釋,該委託將成為我們擴展修訂實體的實例。然後我們可以直接使用它來檢索額外的欄位:
return petsRepo.findRevisions(pet.getId()).stream()
.map(r -> {
CustomRevisionEntity rev = r.getMetadata().getDelegate();
// ... map revision info as needed
})
.toList();
6. CustomRevisionListener
實現
現在我們知道如何擴展預設修訂版並從儲存庫存取它,我們需要實作將填充這些額外欄位的實體偵聽器。
在這裡,值得一提的是,儘管我們在@EntityListeners
註解中使用了監聽器類別名,但實際上,我們也可以使用介面。只要上下文中存在與聲明的類型相容的 Spring 管理的 bean,Envers 就能夠使用它。
無論如何, @PrePersist
要求該類型有一個帶有生命週期相關註釋之一的void
方法: @Pre/PostPersist, @Pre/PostDelete
等。
@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
,它將在運行時提供我們需要的上下文資訊。使用這種方法可以很好地與上下文資訊的實際來源分離,並強制明確的關注點分離。它還使測試服務/儲存庫/網域變得更容易,因為我們可以提供模擬供應商而不是真實的供應商。
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"));
}
// ... other test beans omitted
}
除了測試用例本身之外,這裡的關鍵點是使用@TestConfiguration
內部類別來提供一個實現RequestInfo
所需的Supplier
的bean 。在這裡,我們只是提供固定數據,但我們也可以模擬更複雜的場景。
9. 結論
在本文中,我們展示瞭如何使用自訂欄位擴展預設的 Envers 修訂實體並將其整合到基於 Spring Boot 的應用程式中。
與往常一樣,所有程式碼都可以在 GitHub 上取得。