跳到主要内容
版本:8.1.1

Spring Data 对象映射基础

Doubao 中英对照 Spring Data Object Mapping Fundamentals

本节介绍 Spring Data 对象映射、对象创建、字段与属性访问、可变性与不可变性的基础。

Spring Data 对象映射的核心职责是创建领域对象的实例,并将数据存储原生的数据结构映射到这些实例上。这意味着我们需要两个基本步骤:

  1. 使用所暴露的某个构造函数创建实例。

  2. 填充实例,以具体化所有暴露的属性。

对象创建​

Spring Data 会自动尝试检测持久化实体的构造函数,用于具体化该类型的对象。解析算法如下:

  1. 如果存在无参构造函数,则使用它。其他构造函数将被忽略。

  2. 如果只有一个带参数的构造函数,则使用它。

  3. 如果存在多个带参数的构造函数,则由 Spring Data 使用的那个构造函数必须标注 @PersistenceCreator。

值解析假定构造函数参数名与实体的属性名相匹配,即解析将像要填充该属性一样进行,包括映射中的所有自定义(不同的数据存储列名或字段名等)。这还要求类文件中可用参数名信息,或构造函数上存在 @ConstructorProperties 注解。

Object creation internals

To avoid the overhead of reflection, Spring Data object creation uses a factory class generated at runtime by default, which will call the domain classes constructor directly. I.e. for this example type:

class Person {
Person(String firstname, String lastname) { … }
}

we will create a factory class semantically equivalent to this one at runtime:

class PersonObjectInstantiator implements ObjectInstantiator {

Object newInstance(Object... args) {
return new Person((String) args[0], (String) args[1]);
}
}

This gives us a roundabout 10% performance boost over reflection. For the domain class to be eligible for such optimization, it needs to adhere to a set of constraints:

  • it must not be a private class

  • it must not be a non-static inner class

  • it must not be a CGLib proxy class

  • the constructor to be used by Spring Data must not be private

If any of these criteria match, Spring Data will fall back to entity instantiation via reflection.

属性填充​

创建实体实例后,Spring Data 会填充该类所有其余的持久化属性。除非已由实体的构造函数填充(即已通过其构造函数参数列表消费),否则会先填充标识符属性,以允许解析循环对象引用。此后,将所有尚未由构造函数填充的非瞬态属性设置到实体实例上。为此我们使用以下算法:

  1. 如果属性是不可变的,但暴露了一个 wither 方法(见下文),我们使用该 wither 创建一个带有新属性值的新实体实例。

  2. 如果定义了属性访问(即通过 getter 和 setter 访问),我们调用 setter 方法。

  3. 默认情况下,我们直接设置字段值。

Property population internals

Similarly to our optimizations in object construction we also use Spring Data runtime generated accessor classes to interact with the entity instance.

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;
}
}

Example 1. A generated Property Accessor

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
}
}
}
  • PropertyAccessor’s hold a mutable instance of the underlying object. This is, to enable mutations of otherwise immutable properties.

  • By default, Spring Data uses field-access to read and write property values. As per visibility rules of private fields, MethodHandles are used to interact with fields.

  • The class exposes a withId(…) method that’s used to set the identifier, e.g. when an instance is inserted into the datastore and an identifier has been generated. Calling withId(…) creates a new Vertex object. All subsequent mutations will take place in the new instance leaving the previous untouched.

  • Using property-access allows direct method invocations without using MethodHandles.

This gives us a roundabout 25% performance boost over reflection. For the domain class to be eligible for such optimization, it needs to adhere to a set of constraints:

  • Types must not reside in the default or under the java package.

  • Types and their constructors must be public

  • Types that are inner classes must be static.

  • The used Java Runtime must allow for declaring classes in the originating ClassLoader. Java 9 and newer impose certain limitations.

By default, Spring Data attempts to use generated property accessors and falls back to reflection-based ones if a limitation is detected.

让我们看一下下面的实体:

示例 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;
}
}
  • 标识符属性是 final 的,但在构造函数中被设为 null。该类暴露了一个 withId(…) 方法,用于设置标识符,例如当一个实例被插入数据存储且已生成标识符时。创建新实例时,原 Version 实例保持不变。对于其他由存储管理、但在持久化操作中可能需要更改的属性,通常也采用相同模式。

  • firstname 和 lastname 属性是普通的不可变属性,可能通过 getter 暴露。

  • age 属性是不可变的,但派生自 birthday 属性。在所展示的设计下,数据库中的值将优先于默认值,因为 Spring Data 使用唯一声明的构造函数。即使本意是优先采用计算值,该构造函数也必须同时接受 age 作为参数(以可能忽略它),这一点很重要;否则属性填充步骤会尝试设置 age 字段,并因该字段不可变且没有 wither 而失败。

  • commenta 属性是可变的,通过直接设置其字段来填充。

  • remarrs 属性是可变的,通过直接设置 commenta 字段或调用相应的 setter 方法来填充。

  • 该类暴露了一个工厂方法和一个用于对象创建的构造函数。这里的核心思路是使用工厂方法而非额外的构造函数,以避免通过 @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 类支持被实例化;所有类默认都是不可变的,需要显式的属性声明来定义可变属性。考虑下面的 data class Vertex:

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 数据类的属性填充​

在 Kotlin 中,所有类默认都是不可变的,需要显式的属性声明来定义可变属性。考虑下面的 data class Vertex:

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

这个类实际上是不可变的。它允许创建新实例,因为 Kotlin 会生成一个 copy(…) 方法,该方法通过复制现有对象的所有属性值、并将作为参数提供的属性值应用到该方法,来创建新的对象实例。