使用Cassandra存储库
Cassandra 是一个分布式、高性能、始终可用、最终一致、列分区存储的 NoSQL 数据库。
在对这个数据库的表述中,有一大堆的形容词,每个形容词都精确说明了 Cassandra 的优势。简单来讲,Cassandra 处理的是要写入表中的数据行,这些数据会被分区到一对多的分布式节点上。没有任何一个节点会持有所有的数据,任何给定的数据行都会跨多个节点保存副本,从而消除了单点故障。
Spring Data Cassandra 为 Cassandra 数据库提供了自动化存储库的支持,这与 Spring Data JPA 对关系型数据库的支持非常类似,但是也有很大的差异。除此之外,Spring Data Cassandra 还提供了用于将应用的领域模型映射为后端数据库结构的注解。
在我们进一步探索 Cassandra 之前,很重要的一点是需要理解 Cassandra 尽管与关系型数据库(如 Oracle 和 SQL Server)有很多类似的概念,但它并不是一个关系型数据库,更像一头在许多方面有着不同表现的怪兽。我会阐述 Cassandra 的差异性,因为这与 Spring Data 的运行方式有关。但是,我建议你去阅读 Cassandra 的官方文档,以完整了解它的特点。
首先,我们需要在 Taco Cloud 项目中启用 Spring Data Cassandra。
启用Spring Data Cassandra
要开始使用 Spring Data Cassandra,我们需要将非反应式 Spring Data Cassandra 的 Spring Boot starter 依赖添加进来。我们实际上有两个独立的 Spring Data Cassandra starter 依赖可供选择,其中一个用于反应式的数据持久化,另一个用于标准的、非反应式的持久化。
我们会在第 15 章进一步讨论编写反应式存储库的问题。不过,现在我们要在构建文件中使用非反应式的 starter 依赖,如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-cassandra</artifactId>
</dependency>
这个依赖也可以在 Initializr 中通过选择 Cassandra 复选框来添加。
我们需要知道,这个依赖要代替我们在第 3 章使用的 Spring Data JPA starter 或 Spring Data JDBC 依赖。我们不再使用 JPA 或 JDBC 将 Taco Cloud 数据持久化到关系型数据库中,而是使用 Spring Data 将数据持久化到 Cassandra 中。因此,需要在构建文件中移除 Spring Data JPA 或 Spring Data JDBC starter 依赖,以及所有关系型数据库相关的依赖(如 JDBC 驱动或 H2 依赖)。
Spring Data Cassandra starter 依赖会将一组依赖引入项目,具体来讲,也就是 Spring Data Cassandra。将 Spring Data Cassandra 添加到运行时类路径后,创建 Cassandra 存储库的自动配置功能就会被触发了。这意味着我们能够以最少的显式配置开始编写 Cassandra 存储库。
Cassandra 会以节点集群的形式运行,这些节点共同组成了一个完整的数据库系统。如果没有可用的 Cassandra 集群,可以使用 Docker 启动一个用于开发的单节点集群,如下所示:
$ docker network create cassandra-net
$ docker run --name my-cassandra \
--network cassandra-net \
-p 9042:9042 \
-d cassandra:latest
这会启动一个单节点的集群,并在主机上暴露节点的端口(9042),这样一来,应用就可以访问它了。
不过,我们还需要提供少量的配置。至少,我们需要配置键空间(keyspace)的名称,以使存储库在这个键空间中进行各种操作。为了实现这一点,我们需要首先创建一个键空间。
在 Cassandra 中,键空间指的是 Cassandra 节点中表的一个分组,大致类似于关系型数据库中表、视图和约束关系的分组。 |
尽管我们可以配置 Spring Data Cassandra 自动创建键空间,但通常手动创建会更容易一些(或使用已有的键空间)。借助 Cassandra 的 CQL(Cassandra Query Language)shell,可以为 Taco Cloud 应用创建一个键空间。使用如下的 Docker 命令启动 CQL shell:
$ docker run -it --network cassandra-net --rm cassandra cqlsh my-cassandra
如果这个命令启动 CQL shell 失败并且提示 “Unable to connect to any servers”,请等待一两分钟,然后再次进行尝试。在 CQL shell 能够连接之前,我们需要确保 Cassandra 集群已经完全启动。 |
当 shell 准备就绪之后,使用如下所示的 create keyspace 命令:
cqlsh> create keyspace tacocloud
... with replication = {'class':'SimpleStrategy', 'replication_factor':1}
... and durable_writes = true;
简言之,这个命令会创建一个名为 tacocloud 的键空间,并且配置其为简单副本和持久性写入。通过将副本因子设置为 1,可以让 Cassandra 为每行数据保留一个副本。副本策略会确定副本处理的方式。对于单个数据中心来说,SimpleStrategy 副本策略可以满足需求,但是如果 Cassandra 集群跨多个数据中心,那就应该考虑使用 NetworkTopologyStrategy 策略。关于副本策略如何运行以及创建键空间的其他方式,我推荐你参阅 Cassandra 文档以了解更多细节。
现在,我们已经创建了一个键空间,接下来需要配置 spring.data.cassandra.keyspace- name 属性来告诉 Spring Data Cassandra 使用这个键空间,如下所示:
spring:
data:
cassandra:
keyspace-name: taco_cloud
schema-action: recreate
local-datacenter: datacenter1
在这里,我们将 spring.data.cassandra.schema-action 设置为 recreate。这个设置对于开发很有用,因为它能确保每当应用启动的时候,所有的表以及用户定义的类型都会废弃并重建。它的默认值是 none,意味着当应用启动的时候,不会对数据库模式采取任何措施,这对于生产环境很有用,因为此时我们不想在应用启动的废弃所有的表。
最后,spring.data.cassandra.local-datacenter 属性会确定本地数据中心的名称,用来设置 Cassandra 的负载均衡策略。在单节点的环境中,要使用的值是 “datacenter1”。关于 Cassandra 负载均衡策略以及如何设置本地数据中心的更多信息,请参阅 DataStax Cassandra 驱动的参考文档。
默认情况下,Spring Data Cassandra 会假设 Cassandra 在本地运行并监听 9042 端口。如果情况并非如此,比如需要在生产环境下配置,那么你可能需要按照如下的方式设置 spring.data.cassandra.contact-points 和 spring.data.cassandra.port 属性:
spring:
data:
cassandra:
keyspace-name: tacocloud
local-datacenter: datacenter1
contact-points:
- casshost-1.tacocloud.com
- casshost-2.tacocloud.com
- casshost-3.tacocloud.com
port: 9043
注意,spring.data.cassandra.contact-points 属性是用来设置 Cassandra 主机名的地方。其中,“contact-points”(联系点)包含 Cassandra 节点运行的主机。默认情况下,它被设置为 localhost,但是我们可以将它设置成一个主机名的列表。它会尝试每个联系点,直到找到一个能连接上的。这样能够确保在 Cassandra 集群中不会出现单点故障,应用能够根据给定的某个联系点建立与集群的连接。
我们可能还需要为 Cassandra 集群指定用户名和密码。这可以通过设置 spring.data.cassandra.username 和 spring.data.cassandra.password 属性来实现,如下所示:
spring:
data:
cassandra:
...
username: tacocloud
password: s3cr3tP455w0rd
在使用本地运行的 Cassandra 数据库时,这就是我们需要设置的所有属性。但是,除了这两个属性之外,你可能还想要设置其他的属性,这取决于配置 Cassandra 集群的方式。
现在,Spring Data Cassandra 已经在我们的项目中启用并完成了配置,接下来就可以将领域类型映射为 Cassandra 的表并编写存储库了。但我们先回过头来看一下 Cassandra 数据模型的几个基本要点。
理解Cassandra数据模型
正如前文所述,Cassandra 与关系型数据库有很大的差异。在将领域对象映射到 Cassandra 表之前,理解 Cassandra 数据模型与关系型数据库中的持久化数据模型之间的一些差异是很重要的。
关于 Cassandra 数据模型,有一些重要的地方需要我们理解。
-
Cassandra 表可以有任意数量的列,但是并非所有的行都需要使用这些列。
-
Cassandra 数据库会被分割到多个分区中。特定表的任意行都可能会由一个或多个分区进行管理,但是不太可能所有的分区包含所有的行。
-
Cassandra 表有两种类型的键——分区键(partition key)和集群键(clustering key)。Cassandra 会对每行数据的分区键进行哈希操作以确定该行由哪些分区来进行管理。集群键用来确定数据行在分区中的顺序(不一定是它们在查询结果中的顺序)。请参考 Cassandra 文档,以了解 Cassandra 数据模型的更多知识,包括分区、集群和它们对应的键。
-
Cassandra 对读操作进行了高度的优化。因此,非常常见和推荐的方式是让表保持高度非规范化(denormalized),并且在多个表中重复存储数据。(例如,客户信息可能会保存在专门的客户表中,同时也会重复存储在订单的表中,来记录该客户所创建的订单。)
可以说,调整 Taco Cloud 领域类型以使用 Cassandra 并不是简单地将 JPA 注解替换为 Cassandra 注解那么简单。我们需要重新思考数据的模型。
为Cassandra持久化映射领域类型
在第 3 章中,我们为领域模型(Taco、Ingredient、TacoOrder等)标记了 JPA 规范所提供的注解。这些注解会将领域模型以要持久化的实体的形式映射到关系型的数据库上。尽管这些注解不适用于 Cassandra 的持久化,但是 Spring Data Cassandra 提供了自己的一组映射注解以达成与之相似的目的。
我们先从 Ingredient 类开始,因为对它进行 Cassandra 映射是最简单的。实现了 Cassandra 映射的新版 Ingredient 类如下所示:
package tacos;
import org.springframework.data.cassandra.core.mapping.PrimaryKey;
import org.springframework.data.cassandra.core.mapping.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
@Table("ingredients")
public class Ingredient {
@PrimaryKey
private String id;
private String name;
private Type type;
public enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}
Ingredient 类似乎与我刚刚说的内容矛盾了,因为它就是简单地替换了几个注解。在这里,我们不再使用 JPA 持久化的 @Entity 注解,而是为这个类添加了 @Table 注解,表明配料数据要持久化到名为 ingredients 的表中。另外,我们也不再使用 @Id 注解来标记 id 属性,而是使用 @PrimaryKey 注解。到目前为止,我们似乎只是替换了一些注解而已。
但是,不要被 Ingredient 类的映射蒙骗了。Ingredient 类是最简单的领域类型之一。在为 Taco 类添加映射以支持 Cassandra 持久化的时候,事情就变得有意思了,如程序清单4.1所示。
package tacos;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.springframework.data.cassandra.core.cql.Ordering;
import org.springframework.data.cassandra.core.cql.PrimaryKeyType;
import org.springframework.data.cassandra.core.mapping.Column;
import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn;
import org.springframework.data.cassandra.core.mapping.Table;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import lombok.Data;
@Data
@Table("tacos") ⇽---持久化到“tacos”表中
public class Taco {
@PrimaryKeyColumn(type = PrimaryKeyType.PARTITIONED) ⇽---定义分区键
private UUID id = Uuids.timeBased();
@NotNull
@Size(min = 5, message = "Name must be at least 5 characters long")
private String name;
@PrimaryKeyColumn(type = PrimaryKeyType.CLUSTERED, ⇽---定义集群键
ordering = Ordering.DESCENDING)
private Date createdAt = new Date();
@Size(min = 1, message = "You must choose at least 1 ingredient")
@Column("ingredients") ⇽---将列表映射到“ingredients”列
private List<IngredientUDT> ingredients = new ArrayList<>();
public void addIngredient(Ingredient ingredient) {
this.ingredients.add(TacoUDRUtils.toIngredientUDT(ingredient));
}
}
我们可以看到,映射 Taco 类要麻烦一些。与 Ingredient 类似,@Table 注解用来表明 taco 应该被写入到名为 tacos 的表中。只不过,这是唯一与 Ingredient 类相似的地方了。
其中,id 属性依然是主键,但它只是两个主键列中的一个。具体来讲,id 属性使用了 @PrimaryKeyColumn 注解,并且 type 为 PrimaryKeyType.PARTITIONED。这意味着 id 属性会作为分区键,用来确定每行 taco 数据分别要写入哪个分区。
你可能也注意到了,现在 id 属性是 UUID 类型,而不再是 Long。尽管这不是必需的,但是保存自动生成的 ID 值的属性通常是 UUID 类型。另外,对于新的 Taco 对象,UUID 会使用一个基于时间的 UUID 值进行初始化(但是,从数据库中读取已有的 Taco 对象时,它可能会被重写)。
继续往下看,createdAt 属性被映射为另外一个主键列。但是,在这里 @PrimaryKeyColumn 的 type 属性被设置为 PrimaryKeyType.CLUSTERED,从而将 createdAt 属性设置成了集群键。如前文所述,集群键用来确定一个分区内数据行的顺序。具体来讲,数据行被设置为降序排列,因此,在一个给定的分区内,新的数据行将会在 tacos 表中优先出现。
最后,ingredients 属性现在是一个 IngredientUDT 对象的 List,而不再是 Ingredient 对象的 List。你可能还记得,Cassandra 的表是高度非规范化的,可能会包含与其他表重复的数据。尽管 ingredient 表会作为存储所有可用配料记录的表,但是某个 taco 选择的配料会重复存储在 ingredients 列中。我们不会简单地引用 ingredients 表中的一行或多行数据,而是在 ingredients 属性中包含每个选中配料的完整数据。
但是,为什么又要引入一个新的 IngredientUDT 类?为什么不重复使用 Ingredient 类?简单来讲,包含数据集合的列(比如这里的 ingredients 列),必须是原始类型(整数、字符串等)或用户自定义类型(user-defined type)的集合。
在 Cassandra 中,用户自定义类型能够让我们声明比简单原始类型更丰富的列。通常情况下,它们用来以非规范化的方式实现类似于关系型数据库中外键的功能。但是,外键只会持有对另外一个表中某行数据的引用,与之不同的是,使用用户自定义类型的列实际上会带有从另外一个表中某行复制的数据。在 tacos 表的 ingredients 列中,它包含了一个数据结构的集合,该结构就是配料本身的定义。
我们不能使用 Ingredient 作为用户自定义类型,因为 @Table 注解已经将其以实体的形式映射到了 Cassandra 的持久化中。因此,必须创建一个新的类来定义配料该如何存储到 taco 表的 ingredients 列中。IngredientUDT 类(其中,UDT 代表了用户自定义类型)就是完成这项工作的,如下所示:
package tacos;
import org.springframework.data.cassandra.core.mapping.UserDefinedType;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
@UserDefinedType("ingredient")
public class IngredientUDT {
private final String name;
private final Ingredient.Type type;
}
尽管 IngredientUDT 和 Ingredient 看上去非常相似,但是前者的映射需求要简单得多。IngredientUDT 使用了 @UserDefinedType 注解表明这是 Cassandra 中的一个用户自定义的类型。除此之外,它就是带有几个属性的简单类。
你可能还注意到了 IngredientUDT 类没有 id 属性,虽然它也可以包含源 Ingredient 的 id 属性的拷贝,但我们没有必要这样做。实际上,用户自定义类型可以包含任意我们想要的属性,并不需要与任意的表定义一一对应。
直观了解用户自定义类型中的数据如何与持久化到表中的数据建立关联是一件很困难的事情。图4.1展示了整个 Taco Cloud 数据库的数据模型,包括用户自定义的类型。

具体到我们刚刚创建的用户自定义类型,请注意 Taco 有一个 IngredientUDT 对象的列表,这个列表中的数据复制自 Ingredient 对象。当 Taco 被持久化的时候,Taco 对象及 IngredientUDT 对象的列表都会被持久化到 tacos 表中。IngredientUDT 对象的列表会全部持久化到 ingredients 列中。
还有一种方式可以帮助我们理解用户自定义类型是如何使用的,那就是从数据库中查询 tacos 表中的行。我们可以使用 CQL 和 Cassandra 自带的 cqlsh 工具,查询后将会看到如下所示的结果:
cqlsh:tacocloud> select id, name, createdAt, ingredients from tacos;
id | name | createdat | ingredients
--------- + -------- + --------- + ----------------------------------------
827390... | Carnivore | 2018-04...| [{name: 'Flour Tortilla', type: 'WRAP'},
{name: 'Carnitas', type: 'PROTEIN'},
{name: 'Sour Cream', type: 'SAUCE'},
{name: 'Salsa', type: 'SAUCE'},
{name: 'Cheddar', type: 'CHEESE'}]
(1 rows)
我们可以看到,id、name 和 createdat 列包含的是简单的值。在这方面,它们与对关系型数据库执行类似的查询并没有太大的差异。但是,ingredients 列就有些不同了。因为它被设置成了包含用户自定义 ingredient 类型(通过 IngredientUDT 进行的定义)的一个列表,它的值显示为一个由 JSON 对象组成的 JSON 数组。
在图4.1中,你可能注意到了其他的用户自定义类型。我们继续将领域映射到 Cassandra 表时,肯定会创建更多的类型,包括 TacoOrder 类所使用的类型。程序清单4.2展示了 TacoOrder 类,且针对 Cassandra 的持久化进行了修改。
package tacos;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import org.springframework.data.cassandra.core.mapping.Column;
import org.springframework.data.cassandra.core.mapping.PrimaryKey;
import org.springframework.data.cassandra.core.mapping.Table;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import lombok.Data;
@Data
@Table("orders") ⇽---映射到“orders”表
public class TacoOrder implements Serializable {
private static final long serialVersionUID = 1L;
@PrimaryKey ⇽---声明主键
private UUID id = Uuids.timeBased();
private Date placedAt = new Date();
// delivery and credit card properties omitted for brevity's sake
@Column("tacos") ⇽---将列表映射到“tacos”列
private List<TacoUDT> tacos = new ArrayList<>();
public void addTaco(TacoUDT taco) {
this.tacos.add(taco);
}
}
程序清单4.2有意略掉了 TacoOrder 的许多属性,这些属性并不适合用来讨论 Cassandra 的数据模型。剩下的一些属性和映射关系使用类似于定义 Taco 的方式定义。@Table 用来将 TacoOrder 映射到 orders 表,这与我们在前面使用 @Table 的方式类似。在本例中,我们不关心排序,所以 id 属性只是简单地添加了 @PrimaryKey 注解,这会将其指定为分区键以及默认排序的集群键。
在这里,比较有意思的是 tacos 属性,它是 List<TacoUDT>,而不是 Taco 对象的列表。TacoOrder 和 Taco/TacoUDT 的关系类似于 Taco 和 Ingredient/IngredientUDT 的关系。也就是说,我们不会通过外键的形式在单独的表中连接多行的数据,而是让 orders 表包含所有相关的 taco 数据。这样对表进行优化后,能够实现快速读取。
TacoUDT 类与 IngredientUDT 类非常相似,只不过前者包含了一个集合以引用另外一个用户自定义类型,如下所示。
package tacos;
import java.util.List;
import org.springframework.data.cassandra.core.mapping.UserDefinedType;
import lombok.Data;
@Data
@UserDefinedType("taco")
public class TacoUDT {
private final String name;
private final List<IngredientUDT> ingredients;
}
尽管复用在第 3 章中我们创建的领域类看上去会更好一些,或者最多把 JPA 的注解换成 Cassandra 的注解,但是 Cassandra 持久化的特质决定了它需要我们重新思考如何对数据进行建模。现在我们已经映射了领域模型,接下来就可以编写存储库了。
编写Cassandra存储库
正如我们在第 3 章所看到的,使用 Spring Data 编写存储库非常简单,只需声明一个接口,让它扩展 Spring Data 的基础存储库接口。我们也可以为自定义查询有选择性地声明额外的查询方法。实际上,编写 Cassandra 存储库并没有太大的差异。
其实,我们几乎不用修改已经编写好的存储库,就可以使其适用于 Cassandra 的持久化,例如第 3 章所创建的 IngredientRepository:
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Ingredient;
public interface IngredientRepository
extends CrudRepository<Ingredient, String> {
}
通过扩展 CrudRepository 接口,IngredientRepository 就能够持久化 ID 属性(在 Cassandra 中称为主键属性)为 String 的 Ingredient 对象。这已经非常完美了!IngredientRepository 不需要任何的变更。
OrderRepository 所需的变更稍微多一点。在扩展 CrudRepository 的时候,要声明的 ID 参数类型不是 Long 类型了,而是要修改为 UUID:
package tacos.data;
import java.util.UUID;
import org.springframework.data.repository.CrudRepository;
import tacos.TacoOrder;
public interface OrderRepository
extends CrudRepository<TacoOrder, UUID> {
}
Cassandra 还有很多强大的功能,当它与 Spring Data 协作时,可以在 Spring 应用中发挥出它的能量。现在,让我们把注意力转移到 Spring Data 存储库支持的另一个数据库——MongoDB 上。