跳到主要内容

对象映射

QWen Max 中英对照 Object Mapping

MappingMongoConverter 提供了丰富的映射支持。转换器持有元数据模型,该模型提供了将域对象映射到 MongoDB 文档的全部功能集。通过在您的域对象上使用注解来填充映射元数据模型。然而,基础设施不限于仅使用注解作为元数据信息的唯一来源。MappingMongoConverter 还允许您遵循一组约定,在不提供任何额外元数据的情况下将对象映射到文档。

本节介绍了 MappingMongoConverter 的特性,包括基础知识、如何使用约定将对象映射到文档以及如何使用基于注解的映射元数据来覆盖这些约定。

对象映射基础

本节涵盖Spring Data对象映射、对象创建、字段和属性访问、可变性和不可变性的基础知识。请注意,本节仅适用于不使用底层数据存储的对象映射的Spring Data模块(如JPA)。同时,请务必查阅特定于存储的部分,以了解特定于存储的对象映射,如索引、自定义列名或字段名等。

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) {}
}
java

我们将在运行时创建一个语义上等价于以下代码的工厂类:

class PersonObjectInstantiator implements ObjectInstantiator {

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

这种方法相比反射可以带来大约 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) {
// 字段赋值
}

Person withId(Long id) {
return new Person(id, this.firstname, this.lastame);
}

void setLastname(String lastname) {
this.lastname = lastname;
}
}
java

生成的属性访问器

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
}
}
}
java
1属性访问器持有底层对象的一个可变实例。这是为了能够修改原本不可变的属性。
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;
}
}
java
  • 标识符属性是最终的,但在构造函数中设置为 null。该类公开了一个 withId(…) 方法,用于设置标识符,例如当实例被插入数据存储并且生成了标识符时。原始的 Person 实例保持不变,因为会创建一个新的实例。对于其他由存储管理但在持久化操作中可能需要更改的属性,通常也会应用相同的模式。wither 方法是可选的,因为持久化构造函数(见 6)实际上是一个复制构造函数,并且设置属性将被转换为使用新的标识符值创建一个新实例。

  • firstnamelastname 属性是普通的不可变属性,可能通过 getter 方法暴露。

  • age 属性是从 birthday 属性派生的不可变属性。按照所示的设计,数据库值将优先于默认值,因为 Spring Data 使用唯一声明的构造函数。即使意图是应该优先考虑计算,也很重要的是,这个构造函数也接受 age 作为参数(可能忽略它),否则属性填充步骤将尝试设置 age 字段,并且由于它是不可变的且没有 with… 方法而失败。

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

  • remarks 属性是可变的,并通过调用 setter 方法来填充。

  • 该类公开了一个用于对象创建的工厂方法和构造函数。这里的核心思想是使用工厂方法而不是额外的构造函数,以避免通过 @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);
}
}
java

两个类都使用可赋值类型定义了一个 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. 如果有一个无参构造函数,则使用该构造函数。其他构造函数将被忽略。

考虑以下 dataPerson

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

上面的类编译成一个具有显式构造函数的典型类。我们可以通过添加另一个构造函数并用 @PersistenceCreator 注解来指示构造函数偏好,从而自定义这个类:

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

@PersistenceCreator
constructor(id: String) : this(id, "unknown")
}
kotlin

Kotlin 通过允许在未提供参数时使用默认值来支持参数可选性。当 Spring Data 检测到具有参数默认值的构造函数时,如果数据存储不提供值(或简单地返回 null),则它会省略这些参数,这样 Kotlin 就可以应用参数默认值。考虑以下对 name 应用参数默认值的类

data class Person(var id: String, val name: String = "unknown")
kotlin

每当 name 参数不在结果中或其值为 null 时,则 name 默认为 unknown

备注

委托属性不支持 Spring Data。映射元数据为 Kotlin 数据类过滤委托属性。在所有其他情况下,您可以通过使用 @delegate:org.springframework.data.annotation.Transient 注解该属性来排除委托属性的合成字段。

Kotlin 数据类的属性填充

在 Kotlin 中,所有类默认都是不可变的,并且需要显式的属性声明来定义可变属性。考虑以下 dataPerson

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

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

Kotlin 覆写属性

Kotlin 允许声明属性覆盖以在子类中修改属性。

open class SuperType(open var field: Int)

class SubType(override var field: Int = 1) :
SuperType(field) {
}
kotlin

这种安排会生成两个名为 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;
}
}
java

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
kotlin
  • 一个具有非可空值类型的简单值类。

  • 数据类,使用 EmailAddress 值类定义属性。

备注

非空属性使用非原始值类型时,在编译后的类中会被扁平化为值类型。可空的原始值类型或可空的值中值类型则用其包装类型表示,这会影响值类型在数据库中的表示方式。

约定映射

MappingMongoConverter 在没有提供额外的映射元数据时,有一些将对象映射到文档的约定。这些约定是:

  • 短的 Java 类名按照以下方式映射到集合名称。类 com.bigbank.SavingsAccount 映射到 savingsAccount 集合名称。

  • 所有嵌套对象都作为文档中的嵌套对象存储,而不是 作为 DBRefs 存储。

  • 转换器使用注册到其中的任何 Spring 转换器来覆盖对象属性到文档字段和值的默认映射。

  • 对象的字段用于转换为文档中的字段。不使用公共 JavaBean 属性。

  • 如果你有一个非零参数构造函数,并且其构造函数参数名称与文档的顶级字段名称匹配,则使用该构造函数。否则,使用零参数构造函数。如果有多个非零参数构造函数,则会抛出异常。

_id 字段在映射层中的处理方式。

MongoDB 要求所有文档都有一个 _id 字段。如果你没有提供该字段,驱动程序将分配一个带有生成值的 ObjectId。_id 字段可以是任何类型(数组除外),只要它是唯一的。驱动程序自然支持所有基本类型和日期。当使用 MappingMongoConverter 时,有一些规则来决定 Java 类中的属性如何映射到 _id 字段。

以下概述了哪个字段将被映射到 _id 文档字段:

  • @Idorg.springframework.data.annotation.Id)注解的字段将被映射到 _id 字段。
    此外,可以通过 @Field 注解自定义文档字段的名称,在这种情况下,文档将不包含 _id 字段。

  • 没有注解但名为 id 的字段将被映射到 _id 字段。

表 1. _id 字段定义的翻译示例

字段定义MongoDB 中生成的 Id-字段名
String id_id
@Field String id_id
@Field("x") String idx
@Id String x_id
@Field("x") @Id String y_id (@Field(name) 被忽略,@Id 优先)

以下概述了映射到 _id 文档字段的属性将进行何种类型转换(如果有)。

  • 如果在 Java 类中声明了一个名为 id 的字段,并且其类型为 String 或 BigInteger,那么如果可能的话,它将被转换并存储为 ObjectId。ObjectId 作为字段类型也是有效的。如果你在应用程序中指定了 id 的值,那么 MongoDB 驱动程序会将其转换为 ObjectId。如果指定的 id 值无法转换为 ObjectId,则该值将原样存储在文档的 _id 字段中。如果该字段用 @Id 注解,也是如此。

  • 如果在 Java 类中有一个字段用 @MongoId 注解,那么它将根据其实际类型进行转换和存储。除非 @MongoId 声明了所需的字段类型,否则不会发生进一步的转换。如果未为 id 字段提供值,则会创建一个新的 ObjectId 并转换为属性的类型。

  • 如果在 Java 类中有一个字段用 @MongoId(FieldType.…) 注解,那么将尝试将值转换为声明的 FieldType。如果未为 id 字段提供值,则会创建一个新的 ObjectId 并转换为声明的类型。

  • 如果在 Java 类中名为 id 的字段未声明为 String、BigInteger 或 ObjectID,那么你应该在应用程序中为其赋值,以便它可以原样存储在文档的 _id 字段中。

  • 如果 Java 类中没有名为 id 的字段,那么驱动程序将生成一个隐式的 _id 字段,但不会将其映射到 Java 类的属性或字段。

在查询和更新时,MongoTemplate 会使用转换器来处理 QueryUpdate 对象的转换,这些对象对应于上述保存文档的规则,因此你在查询中使用的字段名称和类型将能够与领域类中的内容匹配。

数据映射和类型转换

Spring Data MongoDB 支持所有可以表示为 BSON(MongoDB 的内部文档格式)的类型。除了这些类型之外,Spring Data MongoDB 还提供了一组内置的转换器来映射额外的类型。你可以提供自己的转换器来调整类型转换。有关进一步的细节,请参阅 自定义转换 - 覆盖默认映射

内置类型转换:

表 2. 类型

类型类型转换示例
String原生{"firstname" : "Dave"}
double, Double, float, Floatnative{"weight" : 42.5}
int, Integer, short, Shortnative
32位整数
{"height" : 42}
long, Longnative
64 位整数
{"height" : 42}
Date, Timestampnative{"date" : ISODate("2019-11-12T23:00:00.809Z")}
byte[]native{"bin" : { "$binary" : "AQIDBA==", "$type" : "00" }}
java.util.UUID (Legacy UUID)原生{"uuid" : { "$binary" : "MEaf1CFQ6lSphaa3b9AtlA==", "$type" : "03" }}
Datenative{"date" : ISODate("2019-11-12T23:00:00.809Z")}
ObjectIdnative{"_id" : ObjectId("5707a2690364aba3136ab870")}
数组, List, BasicDBList原生{"cookies" : [ … ]}
boolean, Boolean原生{"active" : true}
null原生{"value" : null}
Documentnative{"value" : { … }}
Decimal128原生{"value" : NumberDecimal(…)}
AtomicInteger
calling get() before the actual conversion
转换器
32位整数
{"value" : "741" }
AtomicLong
calling get() before the actual conversion
converter
64 位整数
{"value" : "741" }
BigInteger转换器
String
{"value" : "741" }
BigDecimal转换器
String
{"value" : "741.99" }
URL转换器{"website" : "https://spring.io/projects/spring-data-mongodb/" }
Locale转换器{"locale : "en_US" }
char, Character转换器{"char" : "a" }
NamedMongoScript转换器
Code
{"_id" : "script name", value: (some javascript code)}
java.util.Currency转换器{"currencyCode" : "EUR"}
Instant
(Java 8)
native{"date" : ISODate("2019-11-12T23:00:00.809Z")}
Instant
(Joda, JSR310-BackPort)
转换器{"date" : ISODate("2019-11-12T23:00:00.809Z")}
LocalDate
(Joda, Java 8, JSR310-BackPort)
converter / 原生 (Java8)[1]{"date" : ISODate("2019-11-12T00:00:00.000Z")}
LocalDateTime, LocalTime
(Joda, Java 8, JSR310-BackPort)
converter / native (Java8)[2]{"date" : ISODate("2019-11-12T23:00:00.809Z")}
DateTime (Joda)转换器{"date" : ISODate("2019-11-12T23:00:00.809Z")}
ZoneId (Java 8, JSR310-BackPort)转换器{"zoneId" : "ECT - Europe/Paris"}
Box转换器{"box" : { "first" : { "x" : 1.0 , "y" : 2.0} , "second" : { "x" : 3.0 , "y" : 4.0}}
Polygon转换器{"polygon" : { "points" : [ { "x" : 1.0 , "y" : 2.0} , { "x" : 3.0 , "y" : 4.0} , { "x" : 4.0 , "y" : 5.0}]}}
Circle转换器{"circle" : { "center" : { "x" : 1.0 , "y" : 2.0} , "radius" : 3.0 , "metric" : "NEUTRAL"}}
Point转换器{"point" : { "x" : 1.0 , "y" : 2.0}}
GeoJsonPoint转换器{"point" : { "type" : "Point" , "coordinates" : [3.0 , 4.0] }}
GeoJsonMultiPoint转换器{"geoJsonLineString" : {"type":"MultiPoint", "coordinates": [ [ 0 , 0 ], [ 0 , 1 ], [ 1 , 1 ] ] }}
Sphere转换器{"sphere" : { "center" : { "x" : 1.0 , "y" : 2.0} , "radius" : 3.0 , "metric" : "NEUTRAL"}}
GeoJsonPolygon转换器{"polygon" : { "type" : "Polygon", "coordinates" : [[ [ 0 , 0 ], [ 3 , 6 ], [ 6 , 1 ], [ 0 , 0 ] ]] }}
GeoJsonMultiPolygon转换器{"geoJsonMultiPolygon" : { "type" : "MultiPolygon", "coordinates" : [ [ [ [ -73.958 , 40.8003 ] , [ -73.9498 , 40.7968 ] ] ], [ [ [ -73.973 , 40.7648 ] , [ -73.9588 , 40.8003 ] ] ] ] }}
GeoJsonLineString转换器{ "geoJsonLineString" : { "type" : "LineString", "coordinates" : [ [ 40 , 5 ], [ 41 , 6 ] ] }}
GeoJsonMultiLineString转换器{"geoJsonLineString" : { "type" : "MultiLineString", coordinates: [ [ [ -73.97162 , 40.78205 ], [ -73.96374 , 40.77715 ] ], [ [ -73.97880 , 40.77247 ], [ -73.97036 , 40.76811 ] ] ] }}
备注

集合处理

集合处理依赖于 MongoDB 返回的实际值。

  • 如果文档中包含映射到集合的字段,映射将不会更新该属性。这意味着该值将保持为 null、Java 默认值或在对象创建期间设置的任何值。

  • 如果文档中包含要映射的字段,但该字段持有 null 值(例如:{ 'list' : null }),则属性值将被设置为 null

  • 如果文档中包含要映射到集合的字段,并且该字段null(例如:{ 'list' : [ …​ ] }),则会用映射的值填充集合。

通常,如果你使用构造函数创建,则可以获取要设置的值。如果查询响应未提供属性值,属性填充可以使用默认初始化值。

映射配置

除非显式配置,否则在创建 MongoTemplate 时,默认会创建一个 MappingMongoConverter 的实例。你可以自己创建 MappingMongoConverter 的实例。这样做可以让你指定类路径中的哪些位置可以找到你的领域类,以便 Spring Data MongoDB 可以提取元数据并构建索引。此外,通过创建自己的实例,你可以注册 Spring 转换器,以将特定的类映射到数据库中以及从数据库中映射出来。

您可以使用基于Java或基于XML的元数据来配置 MappingMongoConverter 以及 com.mongodb.client.MongoClient 和 MongoTemplate。以下示例展示了该配置:

@Configuration
public class MongoConfig extends AbstractMongoClientConfiguration {

@Override
public String getDatabaseName() {
return "database";
}

// the following are optional

@Override
public String getMappingBasePackage() { 1
return "com.bigbank.domain";
}

@Override
void configureConverters(MongoConverterConfigurationAdapter adapter) { 2

adapter.registerConverter(new org.springframework.data.mongodb.test.PersonReadConverter());
adapter.registerConverter(new org.springframework.data.mongodb.test.PersonWriteConverter());
}

@Bean
public LoggingEventListener<MongoMappingEvent> mappingEventsListener() {
return new LoggingEventListener<MongoMappingEvent>();
}
}
java
  • mapping base package 定义了用于扫描实体的根路径,这些实体用于预先初始化 MappingContext。默认情况下,使用配置类的包。

  • 为特定领域类型配置额外的自定义转换器,用你的自定义实现替换这些类型的默认映射过程。

AbstractMongoClientConfiguration 要求你实现一些方法来定义一个 com.mongodb.client.MongoClient 以及提供一个数据库名称。AbstractMongoClientConfiguration 还有一个名为 getMappingBasePackage(…) 的方法,你可以重写该方法来告诉转换器在哪里扫描带有 @Document 注解的类。

你可以通过覆盖 customConversionsConfiguration 方法来添加额外的转换器。可以通过 MongoConverterConfigurationAdapter.useNativeDriverJavaTimeCodecs() 启用 MongoDB 的本地 JSR-310 支持。前面示例中还展示了一个 LoggingEventListener,它会记录发布到 Spring 的 ApplicationContextEvent 基础设施上的 MongoMappingEvent 实例。

提示

Java 时间类型

我们建议使用上述方法中的 MongoConverterConfigurationAdapter.useNativeDriverJavaTimeCodecs() 来启用 MongoDB 的原生 JSR-310 支持,因为它采用的是基于 UTC 的方式。Spring Data Commons 继承的 java.time 类型的默认 JSR-310 支持使用本地机器时区作为参考,仅应为了向后兼容而使用。

备注

AbstractMongoClientConfiguration 创建了一个 MongoTemplate 实例,并将其以 mongoTemplate 的名称注册到容器中。

base-package 属性告诉它在哪里扫描带有 @org.springframework.data.mongodb.core.mapping.Document 注解的类。

提示

如果你想要依赖 Spring Boot 来启动 Data MongoDB,但仍然希望覆盖配置的某些方面,你可能需要暴露该类型的 bean。对于自定义转换,例如,你可以选择注册一个类型为 MongoCustomConversions 的 bean,它将被 Boot 基础设施识别。要了解更多关于这方面的信息,请确保阅读 Spring Boot 参考文档

基于元数据的映射

要充分利用Spring Data MongoDB支持中的对象映射功能,你应该使用 @Document 注解标注你的映射对象。尽管对于映射框架来说,这个注解不是必需的(即使没有任何注解,你的POJO也可以正确映射),但它可以让类路径扫描器找到并预处理你的领域对象,以提取必要的元数据。如果你不使用这个注解,那么在第一次存储领域对象时,你的应用程序会受到轻微的性能影响,因为映射框架需要构建其内部元数据模型,以便了解领域对象的属性及其持久化方式。以下是一个领域对象的示例:

示例 1. 示例领域对象

package com.mycompany.domain;

@Document
public class Person {

@Id
private ObjectId id;

@Indexed
private Integer ssn;

private String firstName;

@Indexed
private String lastName;
}
java
important

@Id 注解告诉映射器你希望使用哪个属性作为 MongoDB 的 _id 属性,而 @Indexed 注解则告诉映射框架在文档的该属性上调用 createIndex(…),从而使搜索更快。自动索引创建仅适用于用 @Document 注解的类型。

注意

自动索引创建默认是禁用的,需要通过配置启用(参见Index Creation)。

映射注解概述

MappingMongoConverter 可以使用元数据来驱动对象到文档的映射。可用的注解如下:

  • @Id: 应用于字段级别,用于标记用于标识目的的字段。

  • @MongoId: 应用于字段级别,用于标记用于标识目的的字段。接受一个可选的 FieldType 以自定义 id 转换。

  • @Document: 应用于类级别,表示该类是映射到数据库的候选者。您可以指定存储数据的集合名称。

  • @DBRef: 应用于字段,表示它将使用 com.mongodb.DBRef 存储。

  • @DocumentReference: 应用于字段,表示它将作为指向另一个文档的指针存储。这可以是一个单值(默认为 id),或者通过转换器提供的 Document

  • @Indexed: 应用于字段级别,描述如何对字段进行索引。

  • @CompoundIndex(可重复): 应用于类型级别,声明复合索引。

  • @GeoSpatialIndexed: 应用于字段级别,描述如何对字段进行地理空间索引。

  • @TextIndexed: 应用于字段级别,标记字段以包含在文本索引中。

  • @HashIndexed: 应用于字段级别,用于在哈希索引中分区数据跨分片集群。

  • @Language: 应用于字段级别,设置文本索引的语言覆盖属性。

  • @Transient: 默认情况下,所有字段都映射到文档。此注解排除应用它的字段存储在数据库中。瞬态属性不能在持久化构造函数中使用,因为转换器无法为构造函数参数实例化一个值。

  • @PersistenceConstructor: 标记给定的构造函数——即使是包保护的构造函数——在从数据库实例化对象时使用。构造函数参数按名称映射到检索到的 Document 中的键值。

  • @Value: 此注解是 Spring 框架的一部分。在映射框架中,它可以应用于构造函数参数。这允许您使用 Spring 表达式语言语句来转换从数据库中检索到的键值,然后再用于构造域对象。为了引用给定文档的属性,必须使用诸如 @Value("#root.myProperty") 的表达式,其中 root 指的是给定文档的根。

  • @Field: 应用于字段级别,允许描述字段在 MongoDB BSON 文档中的名称和类型,从而允许名称和类型与类的字段名以及属性类型不同。

  • @Version: 应用于字段级别,用于乐观锁,并在保存操作时检查是否被修改。初始值为 zero(对于基本类型为 one),每次更新时自动递增。

映射元数据基础设施是在一个独立的、与技术无关的 spring-data-commons 项目中定义的。MongoDB 支持中使用了特定的子类来支持基于注解的元数据。如果有需求,也可以实施其他策略。

这里是一个更复杂的映射示例

@Document
@CompoundIndex(name = "age_idx", def = "{'lastName': 1, 'age': -1}")
public class Person<T extends Address> {

@Id
private String id;

@Indexed(unique = true)
private Integer ssn;

@Field("fName")
private String firstName;

@Indexed
private String lastName;

private Integer age;

@Transient
private Integer accountTotal;

@DBRef
private List<Account> accounts;

private T address;

public Person(Integer ssn) {
this.ssn = ssn;
}

@PersistenceConstructor
public Person(Integer ssn, String firstName, String lastName, Integer age, T address) {
this.ssn = ssn;
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.address = address;
}

public String getId() {
return id;
}

// no setter for Id. (getter is only exposed for some unit testing)

public Integer getSsn() {
return ssn;
}

// other getters/setters omitted
}
java
提示

@Field(targetType=…​) 可以在映射基础设施推断的原生 MongoDB 类型与预期类型不匹配时派上用场。例如 BigDecimal,它被表示为 String 而不是 Decimal128,仅仅是因为早期版本的 MongoDB 服务器不支持 Decimal128

public class Balance {

@Field(targetType = DECIMAL128)
private BigDecimal value;

// ...
}
java

你甚至可以考虑使用自己的自定义注解。

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Field(targetType = FieldType.DECIMAL128)
public @interface Decimal128 { }

// ...

public class Balance {

@Decimal128
private BigDecimal value;

// ...
}
java

特殊字段名称

一般来说,MongoDB 使用点(.)字符作为嵌套文档或数组的路径分隔符。这意味着在查询(或更新语句)中,像 a.b.c 这样的键会针对如下所示的对象结构:

{
'a' : {
'b' : {
'c' :
}
}
}
json

因此,直到MongoDB 5.0,字段名称都不能包含点(.)。
通过使用MappingMongoConverter#setMapKeyDotReplacement,可以在存储Map结构时通过用另一个字符替换点来规避一些限制。

converter.setMapKeyDotReplacement("-");
// ...

source.map = Map.of("key.with.dot", "value")
converter.write(source,...) // -> map : { 'key-with-dot', 'value' }
java

随着MongoDB 5.0的发布,对Document字段名称中包含特殊字符的限制已被取消。我们强烈建议阅读MongoDB 参考文档中关于在字段名称中使用点的限制的更多信息。
要允许在Map结构中使用点,请在MappingMongoConverter上设置preserveMapKeys

使用 @Field 允许以两种方式自定义字段名称,以考虑点号。

  1. @Field(name = "a.b"): name 被认为是一个路径。操作期望一个嵌套对象的结构,如 { a : { b : … } }

  2. @Field(name = "a.b", fieldNameType = KEY): name 被认为是原样名称。操作期望一个字段具有给定的值,如 { 'a.b' : ….. }

注意

由于点字符在 MongoDB 查询和更新语句中具有特殊性质,因此包含点的字段名称不能直接定位,因此被排除在派生查询方法的使用之外。考虑以下具有 categoryId 属性的 Item,该属性映射到名为 cat.id 的字段。

public class Item {

@Field(name = "cat.id", fieldNameType = KEY)
String categoryId;

// ...
}
java

它的原始表示形式如下

{
'cat.id' : "5b28b5e7-52c2",
...
}
json

由于我们不能直接定位 cat.id 字段(因为这会被解释为路径),所以我们需要 聚合框架的帮助。

template.query(Item.class)
// $expr : { $eq : [ { $getField : { input : '$$CURRENT', 'cat.id' }, '5b28b5e7-52c2' ] }
.matching(expr(ComparisonOperators.valueOf(ObjectOperators.getValueOf("value")).equalToValue("5b28b5e7-52c2"))) 1
.all();
java
  • 映射层负责将属性名称 value 翻译成实际的字段名称。在这里使用目标字段名称也是完全有效的。

template.update(Item.class)
.matching(where("id").is("r2d2"))
// $replaceWith: { $setField : { input: '$$CURRENT', field : 'cat.id', value : 'af29-f87f4e933f97' } }
.apply(AggregationUpdate.newUpdate(ReplaceWithOperation.replaceWithValue(ObjectOperators.setValueTo("value", "af29-f87f4e933f97")))) 1
.first();
java
  • [#1] 映射层负责将属性名称 value 翻译成实际的字段名称。在这里使用目标字段名称也是完全有效的。

上面的例子展示了特殊字段位于顶层文档时的简单情况。嵌套层次的增加会增加与该字段交互所需的聚合表达式的复杂性。

自定义对象构造

映射子系统允许通过使用 @PersistenceConstructor 注解标注构造函数来自定义对象的构建。构造函数参数的值按以下方式解析:

  • 如果参数用 @Value 注解标注,则会计算给定的表达式,并将结果用作参数值。

  • 如果 Java 类型具有与输入文档的给定字段匹配的属性,则使用其属性信息来选择合适的构造函数参数,以传递输入字段值。这仅在 Java .class 文件中存在参数名称信息时才有效,可以通过使用调试信息编译源代码或在 Java 8 中使用 javac 的新 -parameters 命令行开关来实现。

  • 否则,将抛出 MappingException,指示无法绑定给定的构造函数参数。

class OrderItem {

private @Id String id;
private int quantity;
private double unitPrice;

OrderItem(String id, @Value("#root.qty ?: 0") int quantity, double unitPrice) {
this.id = id;
this.quantity = quantity;
this.unitPrice = unitPrice;
}

// getters/setters ommitted
}

Document input = new Document("id", "4711");
input.put("unitPrice", 2.5);
input.put("qty",5);
OrderItem item = converter.read(OrderItem.class, input);
java
备注

quantity 参数的 @Value 注解中的 SpEL 表达式如果给定的属性路径无法解析,则回退到值 0

有关使用 @PersistenceConstructor 注解的其他示例,请参见 MappingMongoConverterUnitTests 测试套件。

映射框架事件

在整个映射过程中会触发事件。这在Lifecycle Events部分中进行了描述。

在 Spring ApplicationContext 中声明这些 bean 会导致每当事件被调度时就调用它们。


1. 使用 UTC 时区偏移。通过 MongoConverterConfigurationAdapter 进行配置

2. 使用 UTC 时区偏移。通过 MongoConverterConfigurationAdapter 配置

部分总结