物件對映基礎

本節涵蓋了 Spring Data 物件對映的基礎知識,包括物件建立、欄位和屬性訪問、可變性與不可變性。請注意,本節僅適用於不使用底層資料儲存(如 JPA)物件對映的 Spring Data 模組。同時,請務必查閱特定於儲存的部分,以瞭解特定於儲存的物件對映,例如索引、自定義列或欄位名稱等。

Spring Data 物件對映的核心職責是建立域物件例項,並將儲存原生的資料結構對映到這些物件上。這意味著我們需要兩個基本步驟:

  1. 使用公開的建構函式之一建立例項。

  2. 填充例項以具體化所有公開的屬性。

物件建立

Spring Data 會自動嘗試檢測要用於具體化該型別物件的持久化實體(persistent entity)的建構函式。解析演算法如下所示:

  1. 如果存在一個使用 @PersistenceCreator 註解的靜態工廠方法,則使用該方法。

  2. 如果只有一個建構函式,則使用該建構函式。

  3. 如果存在多個建構函式,並且只有一個使用了 @PersistenceCreator 註解,則使用該建構函式。

  4. 如果型別是 Java Record,則使用規範建構函式。

  5. 如果存在一個無引數建構函式,則使用該建構函式。其他建構函式將被忽略。

值解析假定建構函式/工廠方法的引數名稱與實體的屬性名稱相匹配,即解析將按照屬性將被填充的方式執行,包括對映中的所有自定義項(不同的資料儲存列或欄位名稱等)。這還需要類檔案中提供引數名稱資訊,或者在建構函式上存在 @ConstructorProperties 註解。

值解析可以透過使用 Spring Framework 的 @Value 註解以及特定於儲存的 SpEL 表示式進行自定義。更多詳情請參閱特定於儲存的對映部分。

物件建立內部機制

預設情況下,為了避免反射帶來的開銷,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%。為了使域類符合這種最佳化條件,它需要遵守一組約束:

  • 它不能是私有類

  • 它不能是非靜態內部類

  • 它不能是 CGLib 代理類

  • Spring Data 要使用的建構函式不能是私有的

如果滿足這些條件中的任何一個,Spring Data 將回退到透過反射進行實體例項化。

屬性填充

實體例項建立後,Spring Data 會填充該類的所有剩餘持久化屬性。除非已由實體的建構函式填充(即作為其建構函式引數列表中的值),否則將首先填充識別符號屬性,以便解析迴圈物件引用。之後,所有未已由建構函式填充的非瞬態屬性將被設定到實體例項上。為此,我們使用以下演算法:

  1. 如果屬性是不可變的,但公開了 with… 方法(見下文),則我們使用 with… 方法建立一個帶有新屬性值的新實體例項。

  2. 如果定義了屬性訪問(即透過 getter 和 setter 訪問),我們將呼叫 setter 方法。

  3. 如果屬性是可變的,我們直接設定欄位。

  4. 如果屬性是不可變的,我們將使用持久化操作使用的建構函式(參閱 物件建立)來建立例項的副本。

  5. 預設情況下,我們直接設定欄位值。

屬性填充內部機制

與我們在 物件建立 中的最佳化類似,我們也使用 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 使用欄位訪問來讀取和寫入屬性值。根據 private 欄位的可見性規則,使用 MethodHandles 與欄位互動。
3 該類公開了一個 withId(…) 方法,用於設定識別符號,例如當例項插入資料儲存並生成了識別符號時。呼叫 withId(…) 會建立一個新的 Person 物件。所有後續修改都將在新例項中進行,而原例項保持不變。通常,對於由儲存管理但可能需要為持久化操作而更改的其他屬性,也採用相同的模式。wither 方法是可選的,因為持久化建構函式(參閱 6)實際上是一個複製建構函式,設定屬性將被轉換為建立一個應用了新識別符號值的新例項。
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) { (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 firstnamelastname 屬性是普通的不可變屬性,可能透過 getter 方法公開。
3 age 屬性是一個不可變但派生自 birthday 屬性的屬性。按照所示的設計,資料庫中的值將優先於預設值,因為 Spring Data 使用唯一宣告的建構函式。即使預期應該優先使用計算結果,重要的是這個建構函式也要接受 age 作為引數(以便潛在地忽略它),否則屬性填充步驟將嘗試設定 age 欄位,並因為它是不可變的且沒有 with… 方法而失敗。
4 comment 屬性是可變的,透過直接設定其欄位進行填充。
5 remarks 屬性是可變的,透過呼叫 setter 方法進行填充。
6 該類公開了一個工廠方法和一個建構函式用於物件建立。這裡的核心思想是使用工廠方法而不是額外的建構函式,以避免需要透過 @PersistenceCreator 進行建構函式消歧。相反,屬性的預設值處理在工廠方法內進行。如果您希望 Spring Data 使用工廠方法進行物件例項化,請使用 @PersistenceCreator 註解標記該方法。

一般性建議

  • 儘量堅持使用不可變物件 — 不可變物件非常容易建立,因為例項化物件只需要呼叫其建構函式即可。此外,這避免了您的域物件充斥著 setter 方法,這些方法允許客戶端程式碼操縱物件狀態。如果您需要這些方法,最好將它們設為包私有(package protected),以便只能由有限數量的同位置型別呼叫。僅使用建構函式進行例項化比屬性填充快高達 30%。

  • 提供一個全引數建構函式 — 即使您不能或不想將實體建模為不可變值,提供一個接受實體所有屬性(包括可變屬性)作為引數的建構函式仍然有價值,因為這允許物件對映跳過屬性填充,從而獲得最佳效能。

  • 使用工廠方法而不是過載建構函式來避免 @PersistenceCreator — 由於最佳效能需要一個全引數建構函式,我們通常希望公開更多特定於應用程式用例的建構函式,這些建構函式省略了諸如自動生成的識別符號等。使用靜態工廠方法來公開這些全引數建構函式的變體是一個已建立的模式。

  • 確保您遵守允許使用生成的例項化器和屬性訪問器類的約束 — 

  • 對於要生成的識別符號,仍應結合使用 final 欄位和全引數持久化建構函式(首選)或 with… 方法 — 

  • 使用 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 遮蔽(shadows)了 SuperType.field。根據類設計,使用建構函式可能是設定 SuperType.field 的唯一預設方法。或者,在 setter 中呼叫 super.setField(…) 可以設定 SuperType 中的 field。所有這些機制在某種程度上都會產生衝突,因為屬性共享相同的名稱,但可能代表兩個不同的值。如果型別不可賦值,Spring Data 會跳過超類屬性。也就是說,被覆蓋屬性的型別必須可以賦值給其超類屬性的型別,才能被註冊為覆蓋;否則,超類屬性被視為瞬態(transient)。我們通常建議使用不同的屬性名稱。

Spring Data 模組通常支援持有不同值的被覆蓋屬性。從程式設計模型的角度來看,有幾點需要考慮:

  1. 哪個屬性應該被持久化(預設為所有宣告的屬性)?您可以透過使用 @Transient 註解排除屬性。

  2. 如何在資料儲存中表示屬性?為不同值使用相同的欄位/列名通常會導致資料損壞,因此您應該使用顯式的欄位/列名至少註解其中一個屬性。

  3. 不能使用 @AccessType(PROPERTY),因為通常無法在不進一步假設 setter 實現的情況下設定超類屬性。

Kotlin 支援

Spring Data 適應 Kotlin 的特性以支援物件建立和修改。

Kotlin 物件建立

支援例項化 Kotlin 類,所有類預設是不可變的,需要顯式宣告屬性才能定義可變屬性。

Spring Data 會自動嘗試檢測要用於具體化該型別物件的持久化實體(persistent entity)的建構函式。解析演算法如下所示:

  1. 如果存在一個使用 @PersistenceCreator 註解的建構函式,則使用該建構函式。

  2. 如果型別是 Kotlin data class,則使用主建構函式。

  3. 如果存在一個使用 @PersistenceCreator 註解的靜態工廠方法,則使用該方法。

  4. 如果只有一個建構函式,則使用該建構函式。

  5. 如果存在多個建構函式,並且只有一個使用了 @PersistenceCreator 註解,則使用該建構函式。

  6. 如果型別是 Java Record,則使用規範建構函式。

  7. 如果存在一個無引數建構函式,則使用該建構函式。其他建構函式將被忽略。

考慮以下 dataPerson

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 Data classes 的委託屬性。在所有其他情況下,您可以透過使用 @Transient 註解屬性來排除委託屬性的合成欄位。

Kotlin data classes 的屬性填充

在 Kotlin 中,所有類預設都是不可變的,需要顯式宣告屬性才能定義可變屬性。考慮以下 dataPerson

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 模組通常支援持有不同值的被覆蓋屬性。從程式設計模型的角度來看,有幾點需要考慮:

  1. 哪個屬性應該被持久化(預設為所有宣告的屬性)?您可以透過使用 @Transient 註解排除屬性。

  2. 如何在資料儲存中表示屬性?為不同值使用相同的欄位/列名通常會導致資料損壞,因此您應該使用顯式的欄位/列名至少註解其中一個屬性。

  3. 不能使用 @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 值類作為屬性的資料類。
使用非原始值型別的非空屬性在編譯後的類中會被展平為值型別。可空的原始值型別或可空的巢狀值型別(value-in-value types)會用其包裝型別表示,這會影響值型別在資料庫中的表示方式。