Spring Data 物件對映基礎

本節介紹 Spring Data 物件對映的基礎知識、物件建立、欄位和屬性訪問、可變性與不可變性。

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

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

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

物件建立

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

  1. 如果存在無參建構函式,則使用該建構函式。其他建構函式將被忽略。

  2. 如果只存在一個帶引數的建構函式,則使用該建構函式。

  3. 如果存在多個帶引數的建構函式,則 Spring Data 將使用標有 @PersistenceCreator 註解的那個。

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

物件建立內部機制

預設情況下,為避免反射開銷,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. 如果屬性是不可變的,但公開了一個 *wither* 方法(見下文),我們使用該 *wither* 方法建立一個帶有新屬性值的新實體例項。

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

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

屬性填充內部機制

與我們的 物件構建最佳化 類似,我們還使用 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;
  }
}
示例 1. 生成的屬性訪問器
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(…) 會建立一個新的 Vertex 物件。所有後續修改都將在新例項中進行,而不會影響前一個例項。
4 使用屬性訪問允許直接呼叫方法,而無需使用 MethodHandles

這比反射提供了大約 25% 的效能提升。為了使領域類符合此最佳化條件,它需要遵守一組約束:

  • 型別不能位於預設包或 java 包下。

  • 型別及其建構函式必須是 public

  • 內部類必須是 static 的。

  • 所使用的 Java Runtime 必須允許在原始的 ClassLoader 中宣告類。Java 9 及更高版本施加了一些限制。

預設情況下,Spring Data 嘗試使用生成的屬性訪問器,如果檢測到限制,則回退到基於反射的訪問器。

我們來看下面的實體:

示例 2. 示例實體
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);
  }

  void setRemarks(String remarks) {                                         (5)
    this.remarks = remarks;
  }
}
1 識別符號屬性是 final 的,但在建構函式中設定為 null。該類公開了一個 withId(…) 方法,用於設定識別符號,例如當例項插入資料儲存並生成了識別符號時。原始的 Vertex 例項保持不變,因為建立了一個新的例項。對於其他由儲存管理的但可能需要為持久化操作更改的屬性,通常也應用相同的模式。
2 firstnamelastname 屬性是普通的不可變屬性,可能透過 getter 方法暴露。
3 age 屬性是不可變的,但從 birthday 屬性派生。根據所示設計,資料庫值將優先於預設值,因為 Spring Data 使用唯一宣告的建構函式。即使本意是應該優先使用計算結果,重要的是該建構函式也接受 age 作為引數(以潛在忽略它),否則屬性填充步驟將嘗試設定 age 欄位,並因此欄位不可變且沒有相應的 wither 方法而失敗。
4 comment 屬性是可變的,透過直接設定其欄位進行填充。
5 remarks 屬性是可變的,透過直接設定 comment 欄位或呼叫 setter 方法進行填充,用於
6 該類公開了一個工廠方法和一個用於物件建立的建構函式。這裡的核心思想是使用工廠方法代替額外的建構函式,以避免透過 @PersistenceCreator 進行建構函式消歧。相反,屬性的預設值處理在工廠方法中進行。

一般建議

  • 儘量堅持使用不可變物件 — 不可變物件建立起來很簡單,因為具體化物件只需呼叫其建構函式即可。此外,這可以防止您的領域物件充斥著允許客戶端程式碼操縱物件狀態的 setter 方法。如果需要這些方法,最好將它們設為包級可見性,以便只能由有限數量的位於同一包中的型別呼叫。僅透過建構函式具體化比屬性填充快達 30%。

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

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

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

  • 對於要生成的識別符號,仍然使用 final 欄位並結合 wither 方法。

  • 使用 Lombok 避免樣板程式碼 — 由於持久化操作通常需要一個接受所有引數的建構函式,其宣告會成為樣板引數到欄位賦值的繁瑣重複,使用 Lombok 的 @AllArgsConstructor 可以最好地避免這種情況。

關於不可變對映的注意事項

雖然我們儘可能推薦使用不可變對映和結構,但在對映方面存在一些限制。考慮一個雙向關係,其中 A 在建構函式中引用 B,而 B 又引用 A,或者更復雜的場景。這種先有雞還是先有蛋的情況對於 Spring Data Neo4j 是無法解決的。在例項化 A 的過程中,它急切地需要一個完全例項化的 B,而 B 又需要一個 A 的例項(準確地說,是*同一個*例項)。通常情況下 SDN 允許這種模型,但如果從資料庫返回的資料包含上述描述的這種結構,它將在執行時丟擲 MappingException。在這種情況下,或者在您無法預見返回的資料是什麼樣的情況下,為關係使用可變欄位會更合適。

Kotlin 支援

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

Kotlin 物件建立

支援例項化 Kotlin 類,所有類預設都是不可變的,並且需要顯式屬性宣告來定義可變屬性。考慮以下 dataVertex

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

Kotlin data 類的屬性填充

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

data class Person(val id: String, val name: String)

這個類實際上是不可變的。它允許建立新例項,因為 Kotlin 會生成一個 copy(…) 方法,該方法複製現有物件的所有屬性值並應用作為方法引數提供的屬性值,從而建立新的物件例項。