12.3 编写反应式的MongoDB repository

与Cassandra一样,必须要明确知道MongoDB不是关系数据库。管理MongoDB服务器集群和数据建模的方式与处理其他类型数据库时的思维方式是不一样的。

不过,使用MongoDB和Spring Data与使用Spring Data处理JPA或Cassandra并没有太大的差异。我们会在领域类上使用注解,将领域类型映射为文档结构。我们还会编写repository接口,这遵循与JPA和Cassandra一样的编程模型。但是在进行任何操作之前,我们必须在项目中启用Spring Data MongoDB。

12.3.1 启用Spring Data MongoDB

要启用Spring Data MongoDB,我们需要将Spring Data MongoDB starter添加到项目的构建文件中。Spring Data MongoDB有两个独立的可选starter。

如果你使用非反应式的MongoDB,那么需要将如下的依赖添加到构建文件中:

1
2
3
4
5
6
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-data-mongodb
</artifactId>
</dependency>

这项依赖也可以在Spring Initializr中通过选中名为MongoDB的复选框添加进来。但是,本章主要关注的是编写反应式repository,所以我们要选择反应式SpringData MongoDB starter依赖:

1
2
3
4
5
6
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-data-mongodb-reactive
</artifactId>
</dependency>

在Initializr中,我们可以通过选中Reactive MongoDB复选框将反应式SpringData MongoDB starter添加进来。将这个starter添加到构建文件中之后,自动配置功能将会触发,启用Spring Data对自动化repository接口的支持,这一点与第3章的JPA和第11章的Cassandra类似。

默认情况下,Spring Data MongoDB会假定MongoDB在本地运行并监听27017端口。为了测试和开发的便利性,我们可以选择使用嵌入式的Mongo数据库。为了实现这一点,我们需要将Flapdoodle Embedded MongoDB依赖添加到构建文件中:

1
2
3
4
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
</dependency>

与我们在关系型数据库中使用H2类似,Flapdoodle嵌入式数据库带来了使用内存Mongo数据库的便利性。也就是说,我们不需要运行单独的数据库,但是所有的数据会在应用重启的时候丢掉。

嵌入式数据库对于开发和测试是很不错的,一旦我们将应用部署到生产环境,就需要设置几个属性,让Spring Data MongoDB知道访问何处的Mongo数据库以及该如何进行访问:

1
2
3
4
5
6
7
8
spring:
data:
mongodb:
host: mongodb.tacocloud.com
port: 27018
username: tacocloud
password: s3cr3tp455w0rd
database: tacoclouddb

在这里,并不是所有的属性都是必需的。如果Mongo数据库不在本地运行,那么这些属性能够为Spring Data MongoDB指明正确的方向。拆分一下上面的配置,如下就是要设置的每个属性。

  • spring.data.mongodb.host:Mongo运行的主机名(默认为localhost)。
  • spring.data.mongodb.port:Mongo服务器监听的端口(默认为27017)。
  • spring.data.mongodb.username:访问安全Mongo数据库的用户名。
  • spring.data.mongodb.password:访问安全Mongo数据库的密码。
  • spring.data.mongodb.database:数据库名(默认为test)。

在我们的项目中,已经启用了Spring Data MongoDB,所以接下来我们需要为领域对象添加注解,以便于将它们持久化为MongoDB中的文档。

12.3.2 将领域对象映射为文档

Spring Data MongoDB提供了多个注解。在将领域对象映射为要持久化到MongoDB中的文档结构时,这些注解是非常有用的。尽管Spring DataMongoDB提供了多个用于映射的注解,但是其中的3个是最常用的。

  • @Id:将某个属性指明为文档的ID(来自Spring Data Commons)。
  • @Document:将领域类型声明为要持久化到MongoDB中的文档。
  • @Field:指定某个属性持久化到文档中的字段名称(以及可选的顺序配置)。

在这3个注解中,@Id和@Document是严格需要的。除非显式指定,否则没有使用@Field注解的属性将假定字段名与属性名相同。

将这些注解应用到Ingredient类上的效果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-data-mongodb
</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-data-mongodb-reactive
</artifactId>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
</dependency>
spring:
data:
mongodb:
host: mongodb.tacocloud.com
port: 27018
username: tacocloud
password: s3cr3tp455w0rd
database: tacoclouddb
package tacos;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@Document
public class Ingredient {
@Id
private final String id;
private final String name;
private final Type type;
public static enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}

可以看到,我们在类级别使用了@Document注解,表明Ingredient是一个文档实体,可以在Mongo数据库中执行读取和写入操作。默认情况下,集合名(这是Mongo中与关系型数据库的表对等的概念)是基于类名的,只不过第一个字母会变成小写。因为我们没有特别指定,所以Ingredient对象将会持久化到名为ingredient的集合中。但是,我们可以通过设置@Document的collection属性改变这种行为:

1
2
3
4
5
6
7
@Data
@RequiredArgsConstructor
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@Document(collection="ingredients")
public class Ingredient {
...
}

我们还会看到,id属性使用了@Id注解。这表明该属性将会作为要持久化的文档的ID。我们可以将@Id注解用到任意Serializable类型的字段上,包括String和Long。在本例中,我们已经使用String定义的id属性作为自然标识符,因此不需要将其更改为其他类型。

到目前为止,一切都很顺利。但是,不要忘了,在本章前面的内容中,我们曾说过Ingredient是进行Cassandra映射时最简单的一个领域类型。其他的类型,比如Taco,就稍微困难一些了。接下来,我们看一下如何映射Taco类,看看它会有哪些惊喜。

在将领域类型映射为MongoDB文档时,我们肯定需要为Taco添加@Document注解。同时,我们还需要通过@Id注解指定ID属性。在添加完支持MongoDB持久化的注解后,我们就会得到如下的Taco类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@RestResource(rel="tacos", path="tacos")
@Document
public class Taco {
@Id
private String id;
@NotNull
@Size(min=5, message="Name must be at least 5 characters long")
private String name;
private Date createdAt = new Date();
@Size(min=1, message="You must choose at least 1 ingredient")
private List<Ingredient> ingredients;
}

不管你是否相信,这就是所有的内容。在Cassandra中,我们还需要处理两个不同的主键字段并且要引用用户定义类型,但这是Cassandra特有的。对于MongoDB来说,Taco的映射要简单得多。

即便如此,在Taco中还是有一些有意思的事情值得关注。首先,我们要注意,id属性变成了String类型(而不是JPA版本中的Long类型或Cassandra版本中的UUID类型)。正如我在前文所述,@Id注解可以用到任意Serializable类型上。如果选择使用String属性作为ID,我们就可以在保存的时候让Mongo自动设置一个值给它。将其设置为String类型之后,我们就得到了一个数据库管理赋值的ID,而不用再担心如何手动设置该属性。

我们再看一下ingredients属性。它是一个List<Ingredient>,与第3章中的JPA版本非常类似。与JPA版本不同的是,这个列表不会存储到单独的MongoDB集合中。与Cassandra对应的功能类似,配料列表会直接、以非规范化的形式存储到taco文档中。不过,与Cassandra不同,我们不需要创建用户定义类型,MongoDB非常乐意使用任何类型,不管它是带有@Document注解的另一个类型还是简单的POJO,都是可以的。

看到将Taco映射为文档持久化非常容易,我们可以松口气了。这种映射的便利性会延续到Order领域类吗?你可以自行看一下带有MongoDB注解的Order类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@Document
public class Order implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private String id;
private Date placedAt = new Date();
@Field("customer")
private User user;
// other properties omitted for brevity's sake
private List<Taco> tacos = new ArrayList<>();
public void addDesign(Taco design) {
this.tacos.add(design);
}
}

简单起见,我删除了投递和信用卡相关的各种字段。从剩下的部分可以清楚地看出,与其他领域类型一样,我们只需要@Document@Id注解。即便如此,我们也为user属性使用了@Field,指定在持久化文档中它将会存储为customer。

User领域类的MongoDB持久化映射依然非常简单,看到这里,相信你并不会对此感到意外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@RequiredArgsConstructor
@Document
public class User implements UserDetails {
private static final long serialVersionUID = 1L;
@Id
private String id;
private final String username;
private final String password;
private final String fullname;
private final String street;
private final String city;
private final String state;
private final String zip;
private final String phoneNumber;
// UserDetails method omitted for brevity's sake
}

虽然有一些更高级和不常见的场景需要额外的映射,但是我们会发现,对于大多数情况,@Document@Id以及偶尔用到的@Field对于MongoDB映射来说已经足够了。对于Taco Cloud的领域类型,它们完全可以胜任。

剩下的事情就是编写repository接口了。

12.3.3 编写反应式的MongoDB repository接口

Spring Data MongoDB提供的自动化repository功能与Spring Data JPA和Spring Data Cassandra类似。在为MongoDB编写反应式repository的时候,我们可以在ReactiveCrudRepository和ReactiveMongoRepository之间进行选择。核心的差异在于,ReactiveMongoRepository提供多个特殊的insert()方法,它们针对新文档的持久化进行了优化,而ReactiveCrudRepository依赖save()方法来保存新文档和已有的文档。

如何编写非反应式的MongoDB repository?

本章主要关注如何使用Spring Data编写反应式的repository。如果出于某种原因,你希望使用非反应式的repository,那么可以通过让repository接口扩展CrudRepository或MongoRepository来实现,而不是选择扩展ReactiveCrudRepository或ReactiveMongo Repository。这样,我们就可以让repository返回带有Mongo注解的领域类型或这些领域类型的集合。

尽管不是严格要求的,但是你可以将spring-boot-starter-data-mongodb-reactive依赖替换为spring-boot-starter- data-mongodb。

首先,我们来定义将Ingredient对象持久化为文档的repository。在数据库初始化完成之后,我们不会频繁地创建配料的文档,甚至有可能永远不会这样做。因此,ReactiveMongoRepository提供的优化没有太多的用处,我们可以让IngredientRepository扩展ReactiveCrudRepository:

1
2
3
4
5
6
7
8
package tacos.data;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.web.bind.annotation.CrossOrigin;
import tacos.Ingredient;
@CrossOrigin(origins="*")
public interface IngredientRepository
extends ReactiveCrudRepository<Ingredient, String> {
}

稍等片刻!它看起来与我们在12.2.4小节中为Cassandra编写的IngredientRepository接口是完全一样的!实际上,这是同一个接口,没有任何变化。这凸显了扩展ReactiveCrudRepository的一个好处,也就是它在各种数据库类型之间具有更强的可移植性,并且针对MongoDB和Cassandra都可以很好地运行。

因为它是一个反应式repository,所以它的方法处理的是Flux和Mono,而不是原始领域类型或这些领域类型的集合。例如,findAll()方法将返回Flux<Ingredient>,而不是Iterable<Ingredient>。同样,findById()将返回Mono<Ingredient>,而不是Optional<Ingredient>。因此,这个反应式repository可以作为端到端反应式流的一部分。

现在,为了将Taco持久化为MongoDB中的文档,我们定义另一个repository。与配料文档不同,我们会频繁创建taco文档。因此,ReactiveMongoRepository优化过的insert()方法就很有价值了。如下的代码片段展现了支持MongoDB的TacoRepository接口:

1
2
3
4
5
6
7
8
package tacos.data;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Flux;
import tacos.Taco;
public interface TacoRepository
extends ReactiveMongoRepository<Taco, String> {
Flux<Taco> findByOrderByCreatedAtDesc();
}

相对于ReactiveCrudRepository,使用ReactiveMongoRepository唯一的缺点在于它是专属于MongoDB的,不能迁移至其他数据库。在你的项目中,你需要确定这种代价是否值得。如果你预计不会在某个时刻切换到不同的数据库,那么尽可以选择ReactiveMongoRepository并充分利用它针对数据插入操作所带来的优化。

注意,在TacoRepository中,我们引入了一个新的方法。这个方法支持显示最近创建的taco。在JPA版本的repository中,我们需要通过扩展PagingAndSortingRepository实现该功能。但是,在反应式repository中,PagingAndSortingRepository并没有太大的用处(尤其是分页功能)。在Cassandra版本中,排序是通过表定义中的集群键实现的,所以在repository中获取最近创建的taco时,我们并不需要特殊的处理。

对于MongoDB来说,我们想要获取最近创建的taco。尽管名字看上去有些奇怪,但是findByOrderByCreatedAtDesc()方法遵循自定义查询方法命名约定。它说明我们想要查找Taco对象,没有任何查询条件,我们在这里没有设置任何必须匹配的属性。然后,我们告诉它将结果按照createdAt属性降序排列。

在这里,命名中使用空By子句的原因在于方法名称中还有另一个By,这样做可以避免方法名称出现误解。如果将其命名为findAllOrderByCreatedAtDesc(),那么名称中的AllOrder部分将被忽略,Spring Data将尝试通过匹配createdAtDesc属性来查找taco。因为不存在该属性,所以应用将会报错,无法正常启动。

因为findByOrderByCreatedAtDesc()返回的是一个Flux<Taco>,所以我们不用担心分页的事情。相反,我们只需要使用take操作获取Flux发布的前12个Taco即可。例如,在显示最近创建的taco的控制器中,我们可以按照如下方式调用findByOrderBy CreatedAtDesc():

1
2
Flux<Taco> recents = repo.findByOrderByCreatedAtDesc()
.take(12);

最终得到的Flux所发布的Taco条目不会超过12个。

再看OrderRepository接口,它非常简单:

1
2
3
4
5
6
7
package tacos.data;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Flux;
import tacos.Order;
public interface OrderRepository
extends ReactiveMongoRepository<Order, String> {
}

我们会频繁创建Order文档,所以OrderRepository扩展了ReactiveMongoRepository,从而充分利用其insert()方法所带来的优化。除此之外,相对于我们已经定义的repository,它并没有什么新奇之处。

最后,我们看一下将User对象持久化为文档的repository:

1
2
3
4
5
6
7
8
package tacos.data;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Mono;
import tacos.User;
public interface UserRepository
extends ReactiveMongoRepository<User, String> {
Mono<User> findByUsername(String username);
}

讲解到现在,你对这个repository接口应该没有丝毫感到惊讶的地方了。与其他repository类似,它扩展了ReactiveMongoRepository(当然,它也可以扩展ReactiveCrudRepository)。唯一的与众不同之处在于,它有一个findByUsername()方式,这是在第4章中我们为了支持认证功能添加上去的。在这里,将它修改为返回Mono<User>,而不是原始的User对象。