【Spring連載】使用Spring Data----對象映射基礎Object Mapping Fundamentals
- 一、對象創建
- 1.1 對象創建內部機制Object creation internals
- 二、屬性填充Property population
- 2.1 屬性填充內部機制Property population internals
- 三、一般建議
- 3.1 覆蓋屬性
- 四、Kotlin支持
- 4.1 Kotlin 對象創建
- 4.2 Kotlin data 類的屬性填充
- 4.3 Kotlin 覆蓋屬性
- 4.4 Kotlin Value 類
本節介紹Spring Data對象映射、對象創建、字段和屬性訪問、可變性和不變性的基本原理。請注意,本節僅適用于不使用底層數據存儲(如JPA)的對象映射的Spring Data模塊。此外,請務必了解特定于存儲對象的映射,如索引、自定義列名或字段名等。
SpringData對象映射的核心職責是創建域對象的實例,并將store-native數據結構映射到這些實例上。這意味著我們需要兩個基本步驟:
- 使用公開的構造函數之一創建實例。
- 實例填充以物化(materialize)所有公開的屬性。
一、對象創建
Spring Data會自動嘗試檢測用于物化該類型對象的持久實體的構造函數。解析算法的工作原理如下:
- 如果有一個用@PersistenceCreator注解的靜態工廠方法,那么就使用它。
- 如果存在單個構造函數,則使用它。
- 如果有多個構造函數,并且恰好有一個構造函數是用@PersistenceCreator注解的,則使用它。
- 如果類型是Java Record,則使用規范構造函數。
- 如果存在無參數構造函數,則使用它。其他構造函數將被忽略。
值解析假定構造函數/工廠方法參數名稱與實體的屬性名稱匹配,即解析將像填充屬性一樣執行,包括映射中的所有自定義項(不同的數據存儲列或字段名等)。這還需要類文件中可用的參數名稱信息或構造函數上存在的@ConstructorProperties注解。
值解析可以通過使用Spring Framework的@Value值注解(使用特定于存儲的SpEL表達式)進行自定義。有關更多詳細信息,請參閱有關特定于存儲的映射的部分。
1.1 對象創建內部機制Object creation internals
為了避免反射的開銷,Spring Data對象創建默認使用運行時生成的工廠類,它將直接調用域類構造函數。例如,對于這個示例類型:
class Person {Person(String firstname, String lastname) { … }
}
框架將在運行時創建一個語義上等同于這個的工廠類:
class PersonObjectInstantiator implements ObjectInstantiator {Object newInstance(Object... args) {return new Person((String) args[0], (String) args[1]);}
}
這使我們的性能比反射提高了10%。對于有資格進行此類優化的域類,它需要遵守一組約束:
- 它不能是private class
- 它不能是非靜態的內部類
- 它不能是CGLib代理類
- Spring Data要使用的構造函數不能是私有的
如果這些條件中沒有任何一個匹配,Spring Data將返回到通過反射實例化實體。
二、屬性填充Property population
一旦創建了實體的實例,Spring Data就會填充該類的所有剩余持久屬性。除非已經由實體的構造函數填充(即使用其構造函數參數列表),否則將首先填充標識符屬性,以允許解析循環對象引用。之后,在實體實例上設置所有尚未由構造函數填充的非瞬態(non-transient)屬性。為此,框架使用以下算法:
- 如果屬性是不可變的,但公開了with…方法(見下文),框架將使用with…方法創建一個具有新屬性值的新實體實例。
- 如果定義了屬性訪問(即通過getters和setters進行訪問),框架將調用setter方法。
- 如果屬性是可變的,框架直接設置字段。
- 如果屬性是不可變的,框架將使用持久性操作(請參見5.1對象創建)使用的構造函數來創建實例的副本。
- 默認情況下,框架直接設置字段值。
2.1 屬性填充內部機制Property population internals
與對象構造方面的優化(5.1.1章節)類似,框架也使用Spring Data運行時生成的訪問器類與實體實例進行交互。
class Person {private final Long id;private String firstname;private @AccessType(Type.PROPERTY) String lastname;Person() {this.id = null;}Person(Long id, String firstname, String lastname) {// Field assignments}Person withId(Long id) {return new Person(id, this.firstname, this.lastame);}void setLastname(String lastname) {this.lastname = lastname;}
}
生成的屬性訪問器
class PersonPropertyAccessor implements PersistentPropertyAccessor {private static final MethodHandle firstname; --------2 private Person person; --------1 public void setProperty(PersistentProperty property, Object value) {String name = property.getName();if ("firstname".equals(name)) {firstname.invoke(person, (String) value); --------2 } else if ("id".equals(name)) {this.person = person.withId((Long) value); --------3 } else if ("lastname".equals(name)) {this.person.setLastname((String) value); --------4 }}
}1. PropertyAccessor持有基礎對象的可變實例。這是為了實現其他不可變屬性的變化。
2. 默認情況下,Spring Data使用字段訪問來讀取和寫入屬性值。根據私有字段的可見性規則,MethodHandles用于與字段交互。
3. 該類公開了一個用于設置標識符的withId(…)方法,例如,當一個實例插入到數據存儲中并生成了標識符時。調用withId(…)將創建一個新的Person對象。所有后續的變化(mutations)都將發生在新的實例中,而不影響先前的實例。
4. 使用屬性訪問允許在不使用MethodHandles的情況下直接調用方法。
這使我們的性能比反射提高了25%。對于有資格進行此類優化的域類,它需要遵守一組約束:
- 類型不能位于默認包中或java包下。
- 類型及其構造函數必須是public
- 作為內部類的類型必須是static。
- 所使用的Java運行時必須允許在原始ClassLoader中聲明類。Java 9和更新版本會帶來某些限制。
默認情況下,Spring Data會嘗試使用生成的屬性訪問器,如果檢測到限制,則會返回到基于反射的訪問器。
讓我們來看看以下實體:
一個示例實體
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) { --------6return new Person(null, firstname, lastname, birthday,Period.between(birthday, LocalDate.now()).getYears());}Person(Long id, String firstname, String lastname, LocalDate birthday, int age) { --------6this.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. identifier屬性是final,但在構造函數中設置為null。該類公開了一個用于設置標識符的withId(…)方法,例如,當一個實例插入到數據存儲中并生成了標識符時。原始Person實例在創建新實例時保持不變。同樣的模式通常應用于存儲管理的其他屬性,但可能必須更改這些屬性才能進行持久性操作。wither方法是可選的,因為持久性構造函數(請參見6)實際上是一個復制構造函數,設置屬性將轉化為創建一個應用了新標識符值的新實例。
2. firstname和lastname屬性是通過getter公開的普通不可變屬性。
3. age屬性是不可變的,但派生自birthday屬性。在顯示的設計中,數據庫值將勝過默認值,因為Spring Data使用了唯一聲明的構造函數。即使目的是首選(preferred)計算,重要的是該構造函數也要將年齡作為參數(可能會忽略它),否則屬性填充步驟將試圖設置年齡字段,但由于它是不可變的,并且不存在with…方法,因此失敗。
4. comment屬性是可變的,可以通過直接設置其字段來填充。
5. remarks屬性是可變的,并且通過調用setter方法來填充。
6. 該類公開了一個工廠方法和一個用于創建對象的構造函數。這里的核心思想是使用工廠方法而不是額外的構造函數,以避免通過@PersistenceCreator消除構造函數的歧義。相反,屬性的默認設置是在工廠方法中處理的。如果你希望Spring Data使用工廠方法進行對象實例化,請使用@PersistenceCreator對其進行注解。
三、一般建議
- 盡量堅持使用不可變的對象——創建不可變對象很簡單,因為物化(materializing )對象只需調用其構造函數。此外,這避免了域對象中充斥著允許客戶端代碼操作對象狀態的setter方法。如果你需要這些,最好讓它們受到包保護,這樣它們只能由有限數量的共存(co-located)類型調用。僅構造函數的物化比屬性填充快30%。
- 提供一個包含全部參數的構造函數——即使你不能或不想將實體塑造(model)為不可變的值,提供一個將實體的所有屬性(包括可變屬性)作為參數的構造函數仍然有價值,因為這允許對象映射跳過屬性填充以獲得最佳性能。
- 使用工廠方法而不是重載構造函數來避免@PersistenceCreator——對于優化性能所需的全參數構造函數,我們通常希望公開更多特定于應用程序用例的構造函數,這些構造函數省略了自動生成的標識符等。使用靜態工廠方法來公開全參數構造函數的這些變體是一種既定模式。
- 確保遵守允許使用生成的實例化器和屬性訪問器類的約束——
- 對于要生成的標識符,仍然將final字段與all-arguments持久性構造函數(首選)或with…方法結合使用——
- 使用Lombok避免樣板(boilerplate)代碼?—?由于持久性操作通常需要構造函數接受所有參數,因此它們的聲明變成了樣板參數到字段賦值的乏味重復,最好使用Lombok的@AllArgsConstructor來避免這種情況。
3.1 覆蓋屬性
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;}@Overridepublic String getField() {return this.field;}public void setField(String field) {this.field = field;// optionalsuper.setField(field);}
}
這兩個類都使用可賦值類型定義字段。然而,SubType隱藏了SuperType.field。根據類設計,使用構造函數可能是設置SuperType.field的唯一默認方法。或者,在setter中調用super.setField(…)可以在SuperType中設置字段。所有這些機制都會在一定程度上造成沖突,因為屬性共享相同的名稱,但可能表示兩個不同的值。如果類型不可賦值,則Spring Data將跳過super-type屬性。也就是說,被重寫屬性的類型必須可分配給要注冊為重寫的super-type屬性類型,否則該超類型屬性被視為瞬態屬性。我們通常建議使用不同的屬性名稱。
Spring Data模塊通常支持具有不同值的重寫屬性。從編程模型的角度來看,需要考慮以下幾點:
- 應該持久化哪個屬性(默認為所有聲明的屬性)?可以通過使用@Transient注解特性來排除屬性(properties)。
- 如何在數據存儲中表示屬性?對不同的值使用相同的字段/列名通常會導致數據損壞,因此應使用顯式字段/列名對至少一個屬性進行注解。
- 不能使用@AccessType(PROPERTY),因為在不對setter實現進行任何進一步假設的情況下,通常不能設置super-property。
四、Kotlin支持
Spring Data適配了Kotlin的特性,允許對象創建和變化(mutation)。
4.1 Kotlin 對象創建
Kotlin類支持實例化,默認情況下所有類都是不可變的,并且需要顯式屬性聲明來定義可變屬性。
Spring Data會自動嘗試檢測用于物化(materialize)該類型對象的持久實體的構造函數。解析算法的工作原理如下:
- 如果有一個構造函數是用@PersistenceCreator注解的,那么就會使用它。
- 如果類型是Kotlin data cass,則使用主構造函數。
- 如果有一個用@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) {@PersistenceCreatorconstructor(id: String) : this(id, "unknown")
}
Kotlin通過允許在未提供參數的情況下使用默認值來支持參數可選性。當Spring Data檢測到具有參數默認值的構造函數時,如果數據存儲不提供值(或只是返回null),則它將忽略這些參數,因此Kotlin可以應用參數默認值。參見以下類,該類將參數默認值應用于name
data class Person(var id: String, val name: String = "unknown")
每當name參數不是結果的一部分或其值為空時,則name默認為unknown。
4.2 Kotlin data 類的屬性填充
在Kotlin中,所有的類在默認情況下都是不可變的,并且需要顯式的屬性聲明來定義可變屬性。參見以下data class Person:
data class Person(val id: String, val name: String)
這個類實際上是不可變的。它允許創建新實例,因為Kotlin生成一個copy(…)方法,該方法創建新對象實例,從現有對象復制所有屬性值,并應用提供的屬性值作為方法的參數。
4.3 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上的Getters和setters只設置SubType.field,而不設置SuperType.field。在這種安排中,使用構造函數是設置SuperType.field的唯一默認方法。可以通過“this.SuperType.field=…”將方法添加到SubType以設置“SuperType.field”,但不在支持的約定范圍內。屬性重寫在一定程度上會造成沖突,因為屬性共享相同的名稱,但可能表示兩個不同的值。我們通常建議使用不同的屬性名稱。
Spring Data模塊通常支持具有不同值的重寫屬性。從編程模型的角度來看,需要考慮以下幾點:
- 應該持久化哪個屬性(默認為所有聲明的屬性)?可以通過使用@Transient注解特性來排除這些屬性。
- 如何在數據存儲中表示屬性?對不同的值使用相同的字段/列名通常會導致數據損壞,因此應使用顯式字段/列名對至少一個屬性進行注解。
- 不能使用@AccessType(PROPERTY),因為不能設置super-property。
4.4 Kotlin Value 類
Kotlin Value類是為更具表現力的領域模型(domain model)而設計的,以使底層概念易于理解。Spring Data可以讀取和寫入使用Value類定義屬性的類型。
參見以下領域模型:
@JvmInline
value class EmailAddress(val theAddress: String) --------1 data class Contact(val id: String, val name:String, val emailAddress: EmailAddress) ---21. 具有不可為null的值類型的簡單value類。
2. 使用EmailAddress值類定義屬性的Data class。
使用非基本值類型的非空屬性在編譯類中被展平(flattened)為value類型。可空的原始值類型或可空的value-in-value類型用其包裝器類型表示,這會影響值類型在數據庫中的表示方式。