物件對應基礎知識

本節涵蓋 Spring Data 物件對應、物件建立、欄位和屬性存取、可變性和不可變性的基礎知識。請注意,本節僅適用於不使用底層資料儲存區(例如 JPA)物件對應的 Spring Data 模組。另請務必查閱特定儲存區章節,以了解特定儲存區的物件對應,例如索引、自訂欄或欄位名稱等。

Spring Data 物件對應的核心職責是建立領域物件的實例,並將儲存區原生資料結構對應到這些實例。這表示我們需要兩個基本步驟

  1. 透過使用公開的建構子之一來建立實例。

  2. 實例填充以實例化所有公開的屬性。

物件建立

Spring Data 會自動嘗試偵測持久實體的建構子,以用於實例化該類型的物件。解析演算法如下:

  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` 物件。所有後續變更都會在新實例中發生,而先前的實例保持不變。
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 方法。如果您需要這些方法,最好將它們設為套件保護,以便它們只能由有限數量的共置類型調用。僅限建構子的實例化比屬性填充快達 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 會遮蔽 SuperType.field。根據類別設計,使用建構子可能是設定 SuperType.field 的唯一預設方法。或者,在 setter 中呼叫 super.setField(…) 可以設定 SuperType 中的 field。所有這些機制都在某種程度上產生衝突,因為屬性共用相同的名稱,但可能代表兩個不同的值。如果類型不可指派,Spring Data 會跳過父類型屬性。也就是說,覆寫屬性的類型必須可指派給其父類型屬性類型才能註冊為覆寫,否則父類型屬性會被視為暫時性的。我們通常建議使用不同的屬性名稱。

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

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

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

  3. 無法使用 @AccessType(PROPERTY),因為父屬性通常無法設定,而無需對 setter 實作做任何進一步的假設。

Kotlin 支援

Spring Data 調整 Kotlin 的特性以允許物件建立和變更。

Kotlin 物件建立

Kotlin 類別支援實例化,所有類別預設都是不可變的,並且需要明確的屬性宣告來定義可變屬性。

Spring Data 會自動嘗試偵測持久實體的建構子,以用於實例化該類型的物件。解析演算法如下:

  1. 如果存在使用 @PersistenceCreator 註解的建構子,則會使用該建構子。

  2. 如果類型是 Kotlin 資料類別,則會使用主要建構子。

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

  4. 如果存在單一建構子,則會使用該建構子。

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

  6. 如果類型是 Java Record,則會使用標準建構子。

  7. 如果存在無參數建構子,則會使用該建構子。其他建構子將被忽略。

請考慮以下 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 資料類別的委派屬性。在所有其他情況下,您可以透過使用 @delegate:org.springframework.data.annotation.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 模組通常支援持有不同值的覆寫屬性。從程式設計模型的角度來看,有幾件事需要考慮

  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 具有不可為 Null 值類型的簡單值類別。
2 資料類別,定義使用 EmailAddress 值類別的屬性。
使用非原始值類型的不可為 Null 屬性在編譯後的類別中會展平為值類型。可為 Null 的原始值類型或可為 Null 的值中值類型會以其包裝器類型表示,這會影響值類型在資料庫中的表示方式。