跳到主要内容
版本:5.1.1

对象映射

Doubao 中英对照 Mapping

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

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

在本章中,我们将描述 MappingCassandraConverter 的特性、如何使用约定将领域对象映射到表,以及如何用基于注解的映射元数据覆盖这些约定。

对象映射基础​

本节介绍 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 表达式来自定义。有关更多详细信息,请参阅特定存储映射的章节。

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. 如果属性是不可变的,但暴露了一个 with… 方法(见下文),则我们使用 with… 方法来创建一个带有新属性值的新实体实例。

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

  3. 如果属性是可变的,我们直接设置该字段。

  4. 如果属性是不可变的,我们使用持久化操作所使用的构造函数(参见 对象创建)来创建该实例的副本。

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

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.lastname);
}

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
}
}
}
  • 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 Person 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.

让我们看一下以下实体:

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
private @Transient String summary; 6

static Person of(String firstname, String lastname, LocalDate birthday) { 7

return new Person(null, firstname, lastname, birthday,
Period.between(birthday, LocalDate.now()).getYears());
}

Person(Long id, String firstname, String lastname, LocalDate birthday, int age) { 7

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;
}
}
  • 标识符属性是 final 的,但在构造函数中被设置为 null。该类暴露了一个 withId(…) 方法,用于设置标识符,例如当一个实例被插入数据存储并已生成标识符时。由于创建了一个新的实例,原始的 Person 实例保持不变。对于其他由存储管理、但在持久化操作中可能需要更改的属性,通常也会应用同样的模式。wither 方法是可选的,因为持久化构造函数(见 6)实际上是一个复制构造函数,设置该属性会被转换为创建一个应用了新标识符值的全新实例。

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

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

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

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

  • summary 属性是瞬态的,不会被持久化,因为它标注了 @Transient。Spring Data 不使用 Java 的 transient 关键字来排除属性的持久化,因为 transient 是 Java 序列化框架的一部分。请注意,该属性可以与持久化构造函数一起使用,但其值默认为 null(如果属性类型是基本类型,则为相应的基本类型初始值)。

  • 该类暴露了一个工厂方法和一个构造函数用于对象创建。这里的核心思想是使用工厂方法而非额外的构造函数,以避免通过 @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 数据类过滤委托属性。在所有其他情况下,您可以通过用 @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
  • 一个具有非空值类型的简单值类。

  • 使用 EmailAddress 值类定义一个属性的数据类。

备注

使用非基本类型值类型的非空属性,在编译后的类中会被展开为该值类型。可空的基本类型值类型或可空的“值中值”类型会用其包装类型表示,这会影响值类型在数据库中的表示方式。

数据映射与类型转换​

本节说明类型如何与 Apache Cassandra 表示形式之间进行相互映射。

Spring Data for Apache Cassandra 支持 Apache Cassandra 提供的若干类型。除这些类型之外,Spring Data for Apache Cassandra 还提供了一组内置转换器来映射附加类型。您可以提供自己的自定义转换器来调整类型转换。有关更多详细信息,请参阅 "用自定义转换器覆盖默认映射"。下表将 Spring Data 类型映射到 Cassandra 类型:

表 1. 类型

Java 类型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,映射的用户定义类型用户定义类型
Vector, CqlVectorvector<…>
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

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

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

@Table
public class EnumToOrdinalMapping {

@PrimaryKey String id;

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

public enum Condition {
NEW, USED
}

基于约定的映射​

当未提供额外的映射元数据时,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));

映射配置​

除非显式配置,否则在创建 CassandraTemplate 时会默认创建一个 MappingCassandraConverter 实例。您可以创建自己的 MappingCassandraConverter 实例,以告知它在启动时从 classpath 的何处扫描您的领域类,以提取元数据并构造索引。

此外,通过创建您自己的实例,您可以注册 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...
}

AbstractCassandraConfiguration 要求您实现定义键空间的方法。AbstractCassandraConfiguration 还有一个名为 getEntityBasePackages(…) 的方法。您可以覆盖它,以告知转换器在何处扫描标注了 @Table 注解的类。

您可以通过覆盖 customConversions 方法,向 MappingCassandraConverter 添加额外的转换器。

备注

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

基于元数据的映射​

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

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

示例 4. 示例领域对象

@Table
public class Person {

@Id
private String id;

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

private String firstName;

private String lastName;
}
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))
;

以下示例展示了一个 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

}

复合键​

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

使用 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)
;

扁平复合主键​

扁平复合主键作为扁平字段内嵌在实体中。主键字段用 @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
}

主键类​

主键类是一个复合主键类,它被映射到实体的多个字段或属性。它用 @PrimaryKeyClass 标注,并且应该定义 equals 和 hashCode 方法。这些方法的值相等语义应与该键所映射的数据库类型的数据库相等性保持一致。主键类可以与仓库一起使用(作为 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
}

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

示例 10. 使用复合主键

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

@PrimaryKey
private LoginEventKey key;

@Column("ip_address")
private String ipAddress;

// getters and setters omitted
}

内嵌实体支持​

内嵌实体用于在您的 Java 领域模型中设计值对象,其属性会被展开到表中。在以下示例中您可以看到,User.name 用 @Embedded 标注。这样做的结果是,UserName 的所有属性都被折叠到 user 表中,该表由 3 列组成(user_id、firstname、lastname)。

备注

内嵌实体只能包含简单属性类型。不能将一个内嵌实体嵌套到另一个内嵌实体中。

然而,如果结果集中 firstname 和 lastname 列的值实际上都是 null,那么根据 @Embedded 的 onEmpty 设置,整个 name 属性会被设置为 null——当所有嵌套属性都为 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;
}
  • 如果 firstname 和 lastname 都为 null,则该属性为 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;
}
  • @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 索引。

  • @SaiIndexed:应用于字段级别。允许在会话初始化期间定义 SAI(存储附加索引)索引的创建。

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

  • @VectorType:应用于字段级别,用于指定 Cassandra 向量类型。在使用模式生成时需要该注解。

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

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

  • @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
}

以下示例展示了如何映射一个 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
}
备注

使用用户定义类型需要一个通过映射上下文配置的 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
}

索引创建​

如果您希望在应用程序启动时创建二级索引,可以用 @Indexed、@SaiIndexed 或 @SASI 标注特定的实体属性。索引创建会为标量类型、用户定义类型和集合类型创建简单的二级索引。

您可以配置一个 SASI 索引来应用分析器,例如 StandardAnalyzer 或 NonTokenizingAnalyzer(分别通过使用 @StandardAnalyzed 和 @NonTokenizingAnalyzed)。

Map 类型区分 ENTRY、KEYS 和 VALUES 索引。索引创建会从被标注的元素派生索引类型。以下示例展示了创建索引的若干方式:

示例 15. Map 索引的变体

@Table
class PersonWithIndexes {

@Id
private String key;

@SASI
@StandardAnalyzed
private String names;

@SaiIndexed
@VectorType(dimensions = 1536) // required for table generation
private Vector vector;

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

private Map<@Indexed String, String> keys;

private Map<String, @Indexed String> values;

// …
}
备注

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

警告

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