跳到主要内容

映射

DeepSeek V3 中英对照 Mapping

MappingCassandraConverter 提供了丰富的对象映射支持。MappingCassandraConverter 拥有一个丰富的元数据模型,提供了完整的功能集,用于将领域对象映射到 CQL 表。

映射元数据模型是通过在领域对象上使用注解来填充的。然而,基础设施并不局限于将注解作为唯一的元数据来源。MappingCassandraConverter 还允许您通过遵循一组约定,将领域对象映射到表,而无需提供任何额外的元数据。

在本章中,我们将介绍 MappingCassandraConverter 的功能,如何使用约定将领域对象映射到表,以及如何通过基于注解的映射元数据来覆盖这些约定。

对象映射基础

本节涵盖了 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) {}
}
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
  • PropertyAccessor 持有一个底层对象的可变实例。这是为了实现对不可变属性的修改。

  • 默认情况下,Spring Data 使用字段访问来读写属性值。根据 private 字段的可见性规则,使用 MethodHandles 与字段进行交互。

  • 该类暴露了一个 withId(…) 方法,用于设置标识符,例如,当实例插入到数据存储中并生成标识符时。调用 withId(…) 会创建一个新的 Person 对象。所有后续的修改将在新实例中进行,而之前的实例保持不变。

  • 使用属性访问允许直接调用方法,而无需使用 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
  • identifier 属性是 final 的,但在构造函数中被设置为 null。该类公开了一个 withId(…) 方法,用于设置标识符,例如当实例插入到数据存储中并生成标识符时。原始的 Person 实例保持不变,因为会创建一个新的实例。通常会将相同的模式应用于其他由存储管理的属性,但这些属性可能需要在持久化操作中进行更改。wither 方法是可选的,因为持久化构造函数(参见第 6 点)实际上是一个复制构造函数,设置该属性将被转换为创建一个应用了新标识符值的新实例。

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

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

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

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

  • 该类公开了一个工厂方法和一个构造函数用于对象创建。这里的核心思想是使用工厂方法而不是额外的构造函数,以避免通过 @PersistenceCreator 进行构造函数歧义解析。相反,属性的默认值在工厂方法内处理。如果你希望 Spring Data 使用工厂方法进行对象实例化,请使用 @PersistenceCreator 注解它。

一般建议

  • 尽量使用不可变对象 — 不可变对象创建起来非常简单,因为实例化对象只需要调用其构造函数即可。此外,这可以避免你的领域对象被大量的 setter 方法污染,这些 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);
}
}
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 值类(Value Classes)旨在为更具表现力的领域模型而设计,以使底层概念更加明确。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 值类定义属性的数据类。

备注

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

数据映射与类型转换

本节解释了如何将类型映射到 Apache Cassandra 表示形式,以及如何从 Apache Cassandra 表示形式映射回类型。

Spring Data for Apache Cassandra 支持由 Apache Cassandra 提供的多种数据类型。除了这些类型外,Spring Data for Apache Cassandra 还提供了一组内置的转换器来映射其他类型。您可以提供自定义的转换器来调整类型转换。有关详细信息,请参阅“使用自定义转换器覆盖默认映射”。下表将 Spring Data 类型映射到 Cassandra 类型:

表 1. 类型

类型Cassandra 类型
Stringtext (默认), varchar, ascii
double, Doubledouble
float, Floatfloat
long, Longbigint (默认), counter
int, Integerint
short, Shortsmallint
byte, Bytetinyint
boolean, Booleanboolean
BigIntegervarint
BigDecimaldecimal
java.util.Datetimestamp
com.datastax.driver.core.LocalDatedate
InetAddressinet
ByteBufferblob
java.util.UUIDuuid
TupleValue, 映射的元组类型tuple<…>
UDTValue, 映射的用户定义类型用户类型
java.util.Map<K, V>map
java.util.List<E>list
java.util.Set<E>set
Enumtext (默认), bigint, varint, int, smallint, tinyint
LocalDate
(Joda, Java 8, JSR310-BackPort)
date
LocalTime+ (Joda, Java 8, JSR310-BackPort)time
LocalDateTime, LocalTime, Instant
(Joda, Java 8, JSR310-BackPort)
timestamp
ZoneId (Java 8, JSR310-BackPort)text

每种支持的 Java 类型都映射到默认的 Cassandra 数据类型。通过使用 @CassandraType 注解,可以将 Java 类型映射到其他 Cassandra 类型,如下例所示:

示例 1. 枚举映射到数值类型

@Table
public class EnumToOrdinalMapping {

@PrimaryKey String id;

@CassandraType(type = Name.INT) Condition asOrdinal;
}

public enum Condition {
NEW, USED
}
java

基于约定的映射

MappingCassandraConverter 在没有提供额外映射元数据的情况下,使用一些约定将领域对象映射到 CQL 表。这些约定包括:

  • 简单的(简短的)Java 类名通过转换为小写字母映射到表名。例如,com.bigbank.SavingsAccount 映射到名为 savingsaccount 的表。

  • 转换器使用任何已注册的 Spring Converter 实例来覆盖对象属性到表列的默认映射。

  • 对象的属性用于与表中的列进行相互转换。

你可以通过在 CassandraMappingContext 上配置 NamingStrategy 来调整命名约定。命名策略对象实现了从实体类和实际属性中派生表、列或用户定义类型的约定。

以下示例展示了如何配置 NamingStrategy

示例 2:在 CassandraMappingContext 上配置 NamingStrategy

CassandraMappingContext context = new CassandraMappingContext();

// default naming strategy
context.setNamingStrategy(NamingStrategy.INSTANCE);

// snake_case converted to upper case (SNAKE_CASE)
context.setNamingStrategy(NamingStrategy.SNAKE_CASE.transform(String::toUpperCase));
java

映射配置

除非明确配置,否则在创建 CassandraTemplate 时默认会创建一个 MappingCassandraConverter 实例。你可以创建自己的 MappingCassandraConverter 实例,以告知它在启动时扫描类路径以查找域类,从而提取元数据并构建索引。

此外,通过创建你自己的实例,你可以注册 Spring Converter 实例,用于将特定类映射到数据库或从数据库映射回来。以下示例配置类设置了 Cassandra 映射支持:

示例 3. 用于配置 Cassandra 映射支持的 @Configuration

@Configuration
public class SchemaConfiguration extends AbstractCassandraConfiguration {

@Override
protected String getKeyspaceName() {
return "bigbank";
}

// the following are optional

@Override
public CassandraCustomConversions customConversions() {

return CassandraCustomConversions.create(config -> {
config.registerConverter(new PersonReadConverter()));
config.registerConverter(new PersonWriteConverter()));
});
}

@Override
public SchemaAction getSchemaAction() {
return SchemaAction.RECREATE;
}

// other methods omitted...
}
java

AbstractCassandraConfiguration 需要你实现定义 keyspace 的方法。AbstractCassandraConfiguration 还有一个名为 getEntityBasePackages(…) 的方法。你可以重写它,告诉转换器在哪里扫描带有 @Table 注解的类。

你可以通过重写 customConversions 方法向 MappingCassandraConverter 添加额外的转换器。

备注

AbstractCassandraConfiguration 创建了一个 CassandraTemplate 实例,并将其以 cassandraTemplate 的名称注册到容器中。

基于元数据的映射

为了充分利用 Spring Data for Apache Cassandra 支持中的对象映射功能,你应该使用 @Table 注解来标注映射的领域对象。这样做可以让类路径扫描器找到并预处理你的领域对象,以提取必要的元数据。只有标注的实体才会用于执行模式操作。在最坏的情况下,SchemaAction.RECREATE_DROP_UNUSED 操作会删除你的表,并且你会丢失数据。请注意,表是从会话键空间访问的。然而,你可以指定一个自定义键空间,以便使用特定键空间中的表/UDT。

以下示例展示了一个简单的领域对象:

示例 4. 示例领域对象

package com.mycompany.domain;

@Table
public class Person {

@Id
private String id;

@CassandraType(type = Name.VARINT)
private Integer ssn;

private String firstName;

private String lastName;
}
java
important

@Id 注解告诉映射器你希望使用哪个属性作为 Cassandra 的主键。复合主键可能需要稍微不同的数据模型。

主键处理

Cassandra 要求 CQL 表至少有一个分区键字段。表还可以声明一个或多个聚类键字段。当你的 CQL 表具有复合主键时,你必须创建一个 @PrimaryKeyClass 来定义复合主键的结构。在此上下文中,“复合主键”指的是一个或多个分区列,可选择性地与一个或多个聚类列组合。

主键可以使用任何单一简单的 Cassandra 类型或映射的用户自定义类型。不支持集合类型的主键。

简单主键

一个简单的主键由实体类中的一个分区键字段组成。由于只有一个字段,我们可以安全地假设它就是一个分区键。以下代码片段展示了一个在 Cassandra 中定义的 CQL 表,其主键为 user_id

示例 5. 在 Cassandra 中定义的 CQL 表

CREATE TABLE user (
user_id text,
firstname text,
lastname text,
PRIMARY KEY (user_id))
;
none

以下示例展示了一个经过注解的 Java 类,它对应于之前定义的 Cassandra:

示例 6. 带注释的实体

@Table(value = "login_event")
public class LoginEvent {

@PrimaryKey("user_id")
private String userId;

private String firstname;
private String lastname;

// getters and setters omitted

}
java

复合键

复合主键(或称为组合键)由多个主键字段组成。也就是说,一个复合主键可以由多个分区键、一个分区键和一个聚类键,或者多个主键字段组成。

在使用 Spring Data for Apache Cassandra 时,复合键可以通过两种方式来表示:

  • 嵌入到一个实体中。

  • 通过使用 @PrimaryKeyClass

复合键的最简单形式是一个包含一个分区键和一个聚类键的键。

以下示例展示了一个 CQL 语句来表示表及其复合键:

示例 7. 带有复合主键的 CQL 表

CREATE TABLE login_event(
person_id text,
event_code int,
event_time timestamp,
ip_address text,
PRIMARY KEY (person_id, event_code, event_time))
WITH CLUSTERING ORDER BY (event_time DESC)
;
none

扁平复合主键

扁平复合主键作为平面字段嵌入到实体中。主键字段使用 @PrimaryKeyColumn 进行注解。选择操作需要查询包含各个字段的谓词,或者使用 MapId。以下示例展示了一个带有扁平复合主键的类:

示例 8. 使用一个扁平复合主键

@Table(value = "login_event")
class LoginEvent {

@PrimaryKeyColumn(name = "person_id", ordinal = 0, type = PrimaryKeyType.PARTITIONED)
private String personId;

@PrimaryKeyColumn(name = "event_code", ordinal = 1, type = PrimaryKeyType.PARTITIONED)
private int eventCode;

@PrimaryKeyColumn(name = "event_time", ordinal = 2, type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING)
private LocalDateTime eventTime;

@Column("ip_address")
private String ipAddress;

// getters and setters omitted
}
java

主键类

主键类是一个复合主键类,它映射到实体的多个字段或属性。它使用 @PrimaryKeyClass 注解进行标注,并且应该定义 equalshashCode 方法。这些方法的值相等语义应与主键映射到的数据库类型的数据库相等性保持一致。主键类可以与仓库一起使用(作为 Id 类型),并在单个复杂对象中表示实体的身份。以下示例展示了一个复合主键类:

示例 9. 复合主键类

@PrimaryKeyClass
class LoginEventKey implements Serializable {

@PrimaryKeyColumn(name = "person_id", ordinal = 0, type = PrimaryKeyType.PARTITIONED)
private String personId;

@PrimaryKeyColumn(name = "event_code", ordinal = 1, type = PrimaryKeyType.PARTITIONED)
private int eventCode;

@PrimaryKeyColumn(name = "event_time", ordinal = 2, type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING)
private LocalDateTime eventTime;

// other methods omitted
}
java

以下示例展示了如何使用复合主键:

示例 10. 使用复合主键

@Table(value = "login_event")
public class LoginEvent {

@PrimaryKey
private LoginEventKey key;

@Column("ip_address")
private String ipAddress;

// getters and setters omitted
}
java

嵌入式实体支持

嵌入式实体用于设计 Java 领域模型中的值对象,其属性被扁平化到表中。在以下示例中,你可以看到 User.name 被注解为 @Embedded。这样做的结果是 UserName 的所有属性都被折叠到 user 表中,该表由 3 列组成(user_idfirstnamelastname)。

备注

嵌入式实体只能包含简单的属性类型。无法将一个嵌入式实体嵌套到另一个嵌入式实体中。

然而,如果结果集中的 firstnamelastname 列值实际上为 null,根据 @EmbeddedonEmpty 行为,整个 name 属性将被设置为 null,因为当所有嵌套属性都为 null 时,onEmpty 会将对象置为 null
与此行为相反,USE_EMPTY 会尝试使用默认构造函数或接受结果集中可空参数值的构造函数来创建一个新实例。

示例 11. 嵌入对象的示例代码

public class User {

@PrimaryKey("user_id")
private String userId;

@Embedded(onEmpty = USE_NULL) 1
UserName name;
}

public class UserName {
private String firstname;
private String lastname;
}
java
  • 如果 firstnamelastnamenull,则属性为 null。使用 onEmpty=USE_EMPTY 来实例化 UserName,其属性可能为 null 值。

你可以通过使用 @Embedded 注解的可选 prefix 元素,在实体中多次嵌入值对象。该元素表示一个前缀,并会附加到嵌入对象的每个列名前。需要注意的是,如果多个属性渲染到相同的列名,它们将相互覆盖。

提示

利用快捷方式 @Embedded.Nullable@Embedded.Empty 来代替 @Embedded(onEmpty = USE_NULL)@Embedded(onEmpty = USE_EMPTY),以减少冗余并同时相应地设置 JSR-305 @javax.annotation.Nonnull

public class MyEntity {

@Id
Integer id;

@Embedded.Nullable 1
EmbeddedEntity embeddedEntity;
}
java
  • @Embedded(onEmpty = USE_NULL) 的快捷方式。

映射注解概述

MappingCassandraConverter 可以使用元数据来驱动对象到 Cassandra 表中行的映射。以下是相关注解的概述:

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

  • @Table:应用于类级别,表示该类是映射到数据库的候选类。您可以指定存储对象的表名。在指定键空间时,表名将在所有 DML 和 DDL 操作中前缀键空间。

  • @PrimaryKey:类似于 @Id,但允许您指定列名。

  • @PrimaryKeyColumn:Cassandra 特定的主键列注解,允许您指定主键列属性,例如用于聚类或分区。可以用于单个或多个属性,以指示单个或复合(组合)主键。如果在实体中的属性上使用,请确保同时应用 @Id 注解。

  • @PrimaryKeyClass:应用于类级别,表示该类是复合主键类。必须在实体类中使用 @PrimaryKey 引用。

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

  • @PersistenceConstructor:标记给定的构造函数(即使是包保护的构造函数),用于从数据库实例化对象。构造函数参数通过名称映射到检索行中的键值。

  • @Value:此注解是 Spring Framework 的一部分。在映射框架中,它可以应用于构造函数参数。这使您可以使用 Spring 表达式语言语句在构造域对象之前转换数据库中检索到的键值。为了引用给定 Row/UdtValue/TupleValue 的属性,必须使用如下表达式:@Value("#root.getString(0)"),其中 root 引用给定文档的根。

  • @ReadOnlyProperty:应用于字段级别,将属性标记为只读。实体绑定的插入和更新语句不包括此属性。

  • @Column:应用于字段级别。描述在 Cassandra 表中表示的列名,从而使名称与类的字段名不同。可以用于构造函数参数,以在构造函数创建期间自定义列名。

  • @Embedded:应用于字段级别。启用嵌入对象的使用,用于映射到表或用户定义类型的类型。嵌入对象的属性被展平到其父结构中。

  • @Indexed:应用于字段级别。描述在会话初始化时要创建的索引。

  • @SASI:应用于字段级别。允许在会话初始化期间创建 SASI 索引。

  • @CassandraType:应用于字段级别,指定 Cassandra 数据类型。默认情况下,类型从属性声明中派生。

  • @Frozen:应用于字段级别的类类型和参数化类型。声明一个冻结的 UDT 列或冻结的集合,如 List<@Frozen UserDefinedPersonType>

  • @UserDefinedType:应用于类型级别,指定 Cassandra 用户定义数据类型(UDT)。在指定键空间时,UDT 名称将在所有 DML 和 DDL 操作中前缀键空间。默认情况下,类型从声明中派生。

  • @Tuple:应用于类型级别,将类型用作映射的元组。

  • @Element:应用于字段级别,指定映射元组中的元素或字段序号。默认情况下,类型从属性声明中派生。可以用于构造函数参数,以在构造函数创建期间自定义元组元素序号。

  • @Version:应用于字段级别,用于乐观锁定,并在保存操作时检查修改。初始值为 zero,每次更新时自动递增。

映射元数据基础设施定义在独立的 spring-data-commons 项目中,该项目与技术和数据存储无关。

以下示例展示了一个更复杂的映射:

示例 12. 映射的 Person

@Table("my_person")
public class Person {

@PrimaryKeyClass
public static class Key implements Serializable {

@PrimaryKeyColumn(ordinal = 0, type = PrimaryKeyType.PARTITIONED)
private String type;

@PrimaryKeyColumn(ordinal = 1, type = PrimaryKeyType.PARTITIONED)
private String value;

@PrimaryKeyColumn(name = "correlated_type", ordinal = 2, type = PrimaryKeyType.CLUSTERED)
private String correlatedType;

// other getters/setters omitted
}

@PrimaryKey
private Person.Key key;

@CassandraType(type = CassandraType.Name.VARINT)
private Integer ssn;

@Column("f_name")
private String firstName;

@Column
@Indexed
private String lastName;

private Address address;

@CassandraType(type = CassandraType.Name.UDT, userTypeName = "myusertype")
private UdtValue usertype;

private Coordinates coordinates;

@Transient
private Integer accountTotal;

@CassandraType(type = CassandraType.Name.SET, typeArguments = CassandraType.Name.BIGINT)
private Set<Long> timestamps;

private Map<@Indexed String, InetAddress> sessions;

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

public Person.Key getKey() {
return key;
}

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

public Integer getSsn() {
return ssn;
}

public void setFirstName(String firstName) {
this.firstName = firstName;
}

// other getters/setters omitted
}
java

以下示例展示了如何映射一个 UDT Address

示例 13. 映射用户定义类型 Address

@UserDefinedType("address")
public class Address {

@CassandraType(type = CassandraType.Name.VARCHAR)
private String street;

private String city;

private Set<String> zipcodes;

@CassandraType(type = CassandraType.Name.SET, typeArguments = CassandraType.Name.BIGINT)
private List<Long> timestamps;

// other getters/setters omitted
}
java
备注

使用用户定义类型(User-Defined Types)需要一个配置了映射上下文的 UserTypeResolver。有关如何配置 UserTypeResolver 的详细信息,请参阅配置章节

以下示例展示了如何映射一个元组:

示例 14. 映射元组

@Tuple
class Coordinates {

@Element(0)
@CassandraType(type = CassandraType.Name.VARCHAR)
private String description;

@Element(1)
private long longitude;

@Element(2)
private long latitude;

// other getters/setters omitted
}
java

索引创建

如果你想在应用启动时创建二级索引,可以使用 @Indexed@SASI 注解来标记特定的实体属性。索引创建会为标量类型、用户定义类型和集合类型创建简单的二级索引。

你可以配置 SASI 索引以应用一个分析器,例如 StandardAnalyzerNonTokenizingAnalyzer(分别通过使用 @StandardAnalyzed@NonTokenizingAnalyzed 来实现)。

Map 类型区分 ENTRYKEYSVALUES 索引。索引的创建从注解的元素中推导出索引类型。以下示例展示了创建索引的几种方式:

示例 15. 映射索引的变体

@Table
class PersonWithIndexes {

@Id
private String key;

@SASI
@StandardAnalyzed
private String names;

@Indexed("indexed_map")
private Map<String, String> entries;

private Map<@Indexed String, String> keys;

private Map<String, @Indexed String> values;

// …
}
java
备注

@Indexed 注解可以应用于嵌入实体的单个属性,也可以与 @Embedded 注解一起使用,在这种情况下,嵌入实体的所有属性都会被索引。

警告

在会话初始化时创建索引可能会对应用程序启动产生严重的性能影响。