物件對映基礎
本節涵蓋了 Spring Data 物件對映、物件建立、欄位和屬性訪問、可變性和不可變性的基礎知識。請注意,本節僅適用於那些不使用底層資料儲存(如 JPA)自身物件對映的 Spring Data 模組。此外,請務必查閱特定資料儲存章節,以瞭解特定於資料儲存的物件對映,例如索引、自定義列或欄位名稱等。
Spring Data 物件對映的核心職責是建立領域物件例項,並將資料儲存的原生資料結構對映到這些物件上。這意味著我們需要兩個基本步驟:
-
使用公開的建構函式之一建立例項。
-
填充例項以具體化所有公開的屬性。
物件建立
Spring Data 會自動嘗試檢測持久化實體要用於具體化該型別物件的建構函式。解析演算法如下:
-
如果存在唯一一個使用
@PersistenceCreator
註解的靜態工廠方法,則使用它。 -
如果存在唯一一個建構函式,則使用它。
-
如果存在多個建構函式,且只有一個使用
@PersistenceCreator
註解,則使用它。 -
如果型別是 Java
Record
,則使用規範建構函式。 -
如果存在無參建構函式,則使用它。其他建構函式將被忽略。
值解析假定建構函式/工廠方法引數名稱與實體的屬性名稱匹配,即解析將像填充屬性一樣執行,包括對映中的所有自定義(不同的資料儲存列或欄位名稱等)。這也要求類檔案中提供引數名稱資訊,或建構函式上存在 @ConstructorProperties
註解。
可以使用 Spring Framework 的 @Value
註解,透過特定於資料儲存的 SpEL 表示式來定製值解析。詳情請查閱特定於資料儲存的對映章節。
屬性填充
建立實體例項後,Spring Data 會填充該類的所有剩餘持久化屬性。除非實體建構函式已填充(即透過其建構函式引數列表消費),否則將首先填充識別符號屬性,以允許解析迴圈物件引用。之後,所有尚未由建構函式填充的非瞬時屬性將在實體例項上設定。為此,我們使用以下演算法:
-
如果屬性是不可變的但公開了
with…
方法(見下文),我們使用with…
方法建立一個帶有新屬性值的新實體例項。 -
如果定義了屬性訪問(即透過 getter 和 setter 訪問),我們呼叫 setter 方法。
-
如果屬性是可變的,我們直接設定欄位。
-
如果屬性是不可變的,我們使用持久化操作要使用的建構函式(見 物件建立)來建立例項的副本。
-
預設情況下,我們直接設定欄位值。
讓我們看一下以下實體
class Person {
private final @Id Long id; (1)
private final String firstname, lastname; (2)
private final LocalDate birthday;
private final int age; (3)
private String comment; (4)
private @AccessType(Type.PROPERTY) String remarks; (5)
static Person of(String firstname, String lastname, LocalDate birthday) { (6)
return new Person(null, firstname, lastname, birthday,
Period.between(birthday, LocalDate.now()).getYears());
}
Person(Long id, String firstname, String lastname, LocalDate birthday, int age) { (6)
this.id = id;
this.firstname = firstname;
this.lastname = lastname;
this.birthday = birthday;
this.age = age;
}
Person withId(Long id) { (1)
return new Person(id, this.firstname, this.lastname, this.birthday, this.age);
}
void setRemarks(String remarks) { (5)
this.remarks = remarks;
}
}
1 | 識別符號屬性是 final 的,但在建構函式中被設定為 null 。該類公開了一個 withId(…) 方法,用於設定識別符號,例如當例項插入資料儲存並生成識別符號時。由於建立了一個新例項,原始的 Person 例項保持不變。通常對於其他由資料儲存管理但可能需要為持久化操作而更改的屬性,也採用相同的模式。wither 方法是可選的,因為持久化建構函式(見 6)實際上是一個複製建構函式,設定屬性將被轉換為建立一個應用了新識別符號值的新例項。 |
2 | firstname 和 lastname 屬性是普通的不可變屬性,可能透過 getter 方法公開。 |
3 | age 屬性是一個不可變但從 birthday 屬性派生的屬性。按照所示設計,由於 Spring Data 使用了唯一宣告的建構函式,資料庫中的值將優先於預設值。即使目的是優先使用計算結果,重要的是該建構函式也接受 age 作為引數(可能會忽略它),否則屬性填充步驟將嘗試設定 age 欄位,並且由於它是不可變的且沒有 with… 方法而失敗。 |
4 | comment 屬性是可變的,透過直接設定其欄位來填充。 |
5 | remarks 屬性是可變的,透過呼叫 setter 方法來填充。 |
6 | 該類公開了一個用於物件建立的工廠方法和一個建構函式。這裡的核心思想是使用工廠方法而不是額外的建構函式,以避免透過 @PersistenceCreator 進行建構函式消歧的需要。相反,屬性的預設值處理在工廠方法內進行。如果您希望 Spring Data 使用工廠方法進行物件例項化,請使用 @PersistenceCreator 對其進行註解。 |
一般建議
-
儘量堅持使用不可變物件 — 不可變物件建立起來非常簡單,因為例項化物件只是呼叫其建構函式的問題。此外,這也避免了您的領域物件充斥著允許客戶端程式碼操縱物件狀態的 setter 方法。如果需要這些方法,最好將它們設為包私有,以便只能由有限的、位於同一包下的型別呼叫。僅使用建構函式例項化比屬性填充快至 30%。
-
提供一個全參建構函式 — 即使您無法或不想將實體建模為不可變值,提供一個將實體的所有屬性(包括可變屬性)作為引數的建構函式仍然有價值,因為這允許物件對映跳過屬性填充以獲得最佳效能。
-
使用工廠方法代替過載建構函式以避免使用
@PersistenceCreator
— 由於需要全參建構函式以獲得最佳效能,我們通常希望公開更多特定於應用用例的建構函式,這些建構函式省略了諸如自動生成的識別符號之類的東西。一種既定的模式是使用靜態工廠方法來公開全參建構函式的這些變體。 -
確保您遵守允許使用生成的例項化器和屬性訪問器類的約束 —
-
對於需要生成的識別符號,仍然結合全參持久化建構函式(首選)或
with…
方法使用 final 欄位 — -
使用 Lombok 避免樣板程式碼 — 由於持久化操作通常需要一個接受所有引數的建構函式,它們的宣告會變成乏味的樣板引數到欄位賦值的重複,最好透過使用 Lombok 的
@AllArgsConstructor
來避免。
覆蓋屬性
Java 允許靈活設計領域類,其中子類可以定義一個在其超類中已宣告同名屬性的屬性。考慮以下示例
public class SuperType {
private CharSequence field;
public SuperType(CharSequence field) {
this.field = field;
}
public CharSequence getField() {
return this.field;
}
public void setField(CharSequence field) {
this.field = field;
}
}
public class SubType extends SuperType {
private String field;
public SubType(String field) {
super(field);
this.field = field;
}
@Override
public String getField() {
return this.field;
}
public void setField(String field) {
this.field = field;
// optional
super.setField(field);
}
}
兩個類都使用可賦值的型別定義了一個 field
。然而,SubType
遮蔽了 SuperType.field
。根據類設計,使用建構函式可能是設定 SuperType.field
的唯一預設方法。另外,在 setter 中呼叫 super.setField(…)
可以設定 SuperType
中的 field
。所有這些機制在一定程度上產生了衝突,因為屬性共享同一個名稱,但可能代表兩個不同的值。如果型別不可賦值,Spring Data 會跳過超類屬性。也就是說,被覆蓋屬性的型別必須可賦值給其超類屬性型別才能被註冊為覆蓋,否則超類屬性被認為是瞬態的。我們通常建議使用不同的屬性名稱。
Spring Data 模組通常支援持有不同值的被覆蓋屬性。從程式設計模型的角度來看,有幾點需要考慮
-
哪個屬性應該被持久化(預設為所有宣告的屬性)?您可以透過使用
@Transient
註解來排除屬性。 -
如何在資料儲存中表示屬性?對不同值使用相同的欄位/列名通常會導致資料損壞,因此您應該至少使用一個顯式的欄位/列名來註解其中一個屬性。
-
不能使用
@AccessType(PROPERTY)
,因為在不進一步假定 setter 實現的情況下,超類屬性通常無法設定。
Kotlin 支援
Spring Data 適配了 Kotlin 的特性,以允許物件建立和修改。
Kotlin 物件建立
支援 Kotlin 類進行例項化,所有類預設都是不可變的,需要顯式宣告屬性以定義可變屬性。
Spring Data 會自動嘗試檢測持久化實體要用於具體化該型別物件的建構函式。解析演算法如下:
-
如果存在一個使用
@PersistenceCreator
註解的建構函式,則使用它。 -
如果型別是 Kotlin 資料類,則使用主建構函式。
-
如果存在唯一一個使用
@PersistenceCreator
註解的靜態工廠方法,則使用它。 -
如果存在唯一一個建構函式,則使用它。
-
如果存在多個建構函式,且只有一個使用
@PersistenceCreator
註解,則使用它。 -
如果型別是 Java
Record
,則使用規範建構函式。 -
如果存在無參建構函式,則使用它。其他建構函式將被忽略。
考慮以下 data
類 Person
data class Person(val id: String, val name: String)
上面的類會編譯成一個帶有顯式建構函式的典型類。我們可以透過新增另一個建構函式並使用 @PersistenceCreator
對其進行註解來定製此類,以指示建構函式偏好。
data class Person(var id: String, val name: String) {
@PersistenceCreator
constructor(id: String) : this(id, "unknown")
}
Kotlin 支援透過允許在未提供引數時使用預設值來實現引數可選性。當 Spring Data 檢測到帶有引數預設值的建構函式時,如果資料儲存未提供值(或僅返回 null
),則它會使這些引數缺失,以便 Kotlin 可以應用引數預設值。考慮以下對 name
應用引數預設值的類
data class Person(var id: String, val name: String = "unknown")
每當 name
引數不是結果的一部分或其值為 null
時,name
將預設為 unknown
。
Spring Data 不支援委託屬性。對映元資料會過濾掉 Kotlin 資料類的委託屬性。在所有其他情況下,您可以透過使用 @Transient 註解屬性來排除委託屬性的合成欄位。 |
Kotlin 資料類的屬性填充
在 Kotlin 中,所有類預設都是不可變的,需要顯式宣告屬性以定義可變屬性。考慮以下 data
類 Person
data class Person(val id: String, val name: String)
這個類實際上是不可變的。它允許建立新例項,因為 Kotlin 會生成一個 copy(…)
方法,該方法建立新的物件例項,複製現有物件的所有屬性值,並將作為引數提供給方法的屬性值應用上去。
Kotlin 覆蓋屬性
Kotlin 允許宣告 屬性覆蓋 以修改子類中的屬性。
open class SuperType(open var field: Int)
class SubType(override var field: Int = 1) :
SuperType(field) {
}
這樣的安排會產生兩個名為 field
的屬性。Kotlin 會為每個類中的每個屬性生成屬性訪問器(getter 和 setter)。實際上,程式碼看起來如下所示
public class SuperType {
private int field;
public SuperType(int field) {
this.field = field;
}
public int getField() {
return this.field;
}
public void setField(int field) {
this.field = field;
}
}
public final class SubType extends SuperType {
private int field;
public SubType(int field) {
super(field);
this.field = field;
}
public int getField() {
return this.field;
}
public void setField(int field) {
this.field = field;
}
}
SubType
上的 getter 和 setter 只設置 SubType.field
而不設定 SuperType.field
。在這種安排下,使用建構函式是設定 SuperType.field
的唯一預設方法。在 SubType
中新增一個方法透過 this.SuperType.field = …
來設定 SuperType.field
是可能的,但這超出了支援的約定範圍。屬性覆蓋在一定程度上產生了衝突,因為屬性共享相同的名稱,但可能代表兩個不同的值。我們通常建議使用不同的屬性名稱。
Spring Data 模組通常支援持有不同值的被覆蓋屬性。從程式設計模型的角度來看,有幾點需要考慮
-
哪個屬性應該被持久化(預設為所有宣告的屬性)?您可以透過使用
@Transient
註解來排除屬性。 -
如何在資料儲存中表示屬性?對不同值使用相同的欄位/列名通常會導致資料損壞,因此您應該至少使用一個顯式的欄位/列名來註解其中一個屬性。
-
不能使用
@AccessType(PROPERTY)
,因為無法設定超類屬性。
Kotlin 值類
Kotlin 值類旨在構建更具表達力的領域模型,使底層概念更清晰。Spring Data 可以讀寫使用值類定義屬性的型別。
考慮以下領域模型
@JvmInline
value class EmailAddress(val theAddress: String) (1)
data class Contact(val id: String, val name:String, val emailAddress: EmailAddress) (2)
1 | 一個帶有非空值型別的簡單值類。 |
2 | 使用 EmailAddress 值類定義屬性的資料類。 |
使用非基本值型別的非空屬性在編譯後的類中會扁平化為值型別。可空的原始值型別或可空的值中值型別會用其包裝型別表示,這會影響值型別在資料庫中的表示方式。 |