使用R2DBC
反应式关系型数据库连接(reactive relational database connectivity)通常简称为R2DBC,是使用反应式类型处理关系型数据库的一个相对较新的方案。它可以作为JDBC的替代方案,能够使传统的关系型数据库(如MySQL、PostgreSQL、H2和Oracle)实现非阻塞的持久化操作。它建立在反应式流的基础之上,因此与JDBC有较大的不同。它是一个独立的规范,与Java SE无关。
Spring Data R2DBC是Spring Data的一个子项目,提供了对R2DBC自动生成存储库的支持,这与我们在第3章中看到的Spring Data JDBC类似。然而,与Spring Data JDBC不同的是,Spring Data R2DBC并不严格要求遵守领域驱动的设计理念。实际上,稍后我们就会看到,相对于Spring Data JDBC,使用Spring Data R2DBC对聚合根进行数据持久化需要我们做更多的工作。
要使用Spring Data R2DBC,需要在项目的构建文件中添加一个starter依赖。对于基于Maven构建的项目,该依赖如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
如果使用Initializr,创建项目时勾选Spring Data R2DBC复选框即可。
我们还需要一个关系型数据库(和对应的R2DBC驱动)来持久化数据。我们的项目将使用基于内存的H2数据库。因此,需要添加两个依赖项,即H2数据库本身和H2 R2DBC驱动,其Maven依赖项如下所示:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-h2</artifactId>
<scope>runtime</scope>
</dependency>
如果使用不同的数据库,那么需要为其添加对应的R2DBC驱动依赖。
现在,项目的依赖已经准备就绪,接下来我们看一下Spring Data R2DBC是如何运行的。我们从定义领域实体开始。
为R2DBC定义领域实体
为了学习Spring Data R2DBC,我们只重建Taco Cloud应用的持久层,并且仅关注持久化taco和订单数据所需的组件,这包括为TacoOrder、Taco和Ingredient创建领域实体,以及每个领域实体对应的存储库。
我们要创建的第一个领域实体类是Ingredient类,如程序清单13.1所示。
package tacos;
import org.springframework.data.annotation.Id;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@Data
@NoArgsConstructor
@RequiredArgsConstructor
@EqualsAndHashCode(exclude = "id")
public class Ingredient {
@Id
private Long id;
private @NonNull String slug;
private @NonNull String name;
private @NonNull Type type;
public enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}
可以看到,它与我们之前创建的Ingredient类的其他形式并没有太大的差异。不过,这里有两个地方需要我们格外注意。
-
Spring Data R2DBC要求属性有setter方法。因此,我们没有将大多数属性定义为final类型的,而是将它们定义为非final的。为了帮助Lombok创建一个包含所有必需参数的构造器,我们用@NonNull注解来标注大部分的属性。这样一来,Lombok和@RequiredArgsConstructor注解在生成的构造器中就会包含这些属性。
-
当通过Spring Data R2DBC 存储库保存对象时,如果该对象的ID属性不为空,那么该操作将被视为一个更新操作。在Ingredient中,id原本为String类型,并且会在创建的时候被赋值,但是在Spring Data R2DBC中这样做会导致出现错误。所以,这里将原来String类型的ID转移到名为slug的新属性中,这个属性只是Ingredient的一个伪ID。我们还使用了一个由数据库生成的、值为Long类型的ID属性。
相应的数据库表在schema.sql中是这样定义的:
create table Ingredient (
id identity,
slug varchar(4) not null,
name varchar(25) not null,
type varchar(10) not null
);
Taco实体类与Spring Data JDBC中对应的类也很相似,如程序清单13.2所示。
package tacos;
import java.util.HashSet;
import java.util.Set;
import org.springframework.data.annotation.Id;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@Data
@NoArgsConstructor
@RequiredArgsConstructor
public class Taco {
@Id
private Long id;
private @NonNull String name;
private Set<Long> ingredientIds = new HashSet<>();
public void addIngredient(Ingredient ingredient) {
ingredientIds.add(ingredient.getId());
}
}
与Ingredient类相同,我们需要为实体的字段添加setter方法,因此这里使用了@NonNull注解,并且没有将属性定义为final类型。
但这里特别有趣的一点在于,Taco并没有使用Ingredient对象的集合,而是使用Set<Long>来引用隶属于该taco的Ingredient对象的ID。选择Set而不是List是为了保证唯一性,但是为什么我们必须使用Set<Long>而不是Set<Ingredient>作为配料的集合?
与其他Spring Data项目不同,Spring Data R2DBC目前并不支持实体之间直接的关联关系(至少目前不支持)。作为一个相对较新的项目,Spring Data R2DBC仍在努力攻克以非阻塞的方式处理关联关系的挑战。
Spring Data R2DBC的未来版本中,这一点可能发生变化。但在此之前,我们不能让Taco引用一个Ingredient的集合,并期望持久化操作能够按照我们的预期运行。当涉及处理关联关系时,我们有如下方案可选。
-
在定义实体时,引用关联对象的ID。在这种情况下,如果可能,将数据库表中相应的列应定义为数组类型。H2和PostgreSQL数据库都支持将列定义为数组类型,但许多其他的数据库不支持。另外,即便数据库支持数组类型的列,我们也可能无法将实体定义为被引用表的外键,从而无法强制进行引用完整性的校验。
-
定义实体及其关联的数据库表,使其完全匹配。对于集合,这意味着被引用的对象会有一列将其映射回引用表。例如,Taco对象的表需要有一列指向TacoOrder,因为Taco是TacoOrder的一部分。
-
将被引用的实体序列化为JSON,并将JSON存储在一个大的VARCHAR列中。如果不需要查询被引用的对象,那么这种方式非常有用。然而,由于VARCHAR列的长度限制,它对JSON序列化对象的大小有潜在的要求。此外,被引用的对象会存储为一个简单的字符串值,所以无法利用数据库模式来保证引用数据的完整性(这个字符串值可能会包含任意的内容)。
虽然这些方案都不理想,但权衡利弊之后,我们为Taco对象选择了第一个方案。Taco类具有Set<Long>属性,可以引用一个或多个Ingredient 的ID。这意味着,相应的表必须有一个数组类型的列来存储这些ID。对于H2数据库,Taco表是这样定义的:
create table Taco (
id identity,
name varchar(50) not null,
ingredient_ids array
);
将ingredient_ids列定义为array类型适用于H2数据库。对于PostgreSQL来说,我们可以将该列定义为integer[]类型。关于定义数组列的细节,请参阅所选择的数据库的文档。注意,并不是所有的数据库都支持数组列,所以可能需要选择其他方案来建立模型之间的关联关系。
最终,如程序清单13.3所示,为了实现基于Spring Data R2DBC的持久化,我们在TacoOrder类的定义中使用了许多在定义领域实体时使用过的技术。
package tacos;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.data.annotation.Id;
import lombok.Data;
@Data
public class TacoOrder {
@Id
private Long id;
private String deliveryName;
private String deliveryStreet;
private String deliveryCity;
private String deliveryState;
private String deliveryZip;
private String ccNumber;
private String ccExpiration;
private String ccCVV;
private Set<Long> tacoIds = new LinkedHashSet<>();
private List<Taco> tacos = new ArrayList<>();
public void addTaco(Taco taco) {
this.tacos.add(taco);
}
}
可以看到,除了属性更多之外,TacoOrder类遵循了与Taco类相同的模式。它通过一个Set<Long>引用其子Taco对象。稍后,我们将看到如何将完整的Taco对象放入TacoOrder,即使Spring Data R2DBC不能直接支持这种关联关系。
Taco_Order数据库表的定义如下:
create table Taco_Order (
id identity,
delivery_name varchar(50) not null,
delivery_street varchar(50) not null,
delivery_city varchar(50) not null,
delivery_state varchar(2) not null,
delivery_zip varchar(10) not null,
cc_number varchar(16) not null,
cc_expiration varchar(5) not null,
cc_cvv varchar(3) not null,
taco_ids array
);
就像Taco表用array列引用配料数据一样,TacoOrder表用定义为array类型的taco_ids列引用其子taco。同样,这个模式是针对H2数据库的。关于是否支持array类型的列,以及如何创建它们,请查阅相关的数据库文档。
我们已经定义了实体及其对应的数据库模式,接下来我们会创建存储库,并通过它们保存和获取taco数据。
定义反应式存储库
在第3章和第4章中,我们将存储库定义为接口,并扩展Spring Data的CrudRepository接口。但是,这个基础的CrudRepository存储库接口处理的是单个对象和Iterable集合。现在,我们期望反应式存储库能够处理Mono和Flux对象。
Spring Data提供了ReactiveCrudRepository来定义反应式存储库。ReactiveCrudRepository的操作与CrudRepository非常相似。如果要创建一个存储库,只需要定义一个扩展ReactiveCrudRepository的接口,如下所示:
package tacos.data;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import tacos.TacoOrder;
public interface OrderRepository
extends ReactiveCrudRepository<TacoOrder, Long> {
}
从表面上看,这个OrderRepository与我们在第3章和第4章中定义的OrderRepository唯一的区别是它扩展自ReactiveCrudRepository而不是CrudRepository。但根本的差异在于,它的方法返回的是Mono和Flux,而不是单个的TacoOrder或Iterable<TacoOrder>集合。举例来说,findById()方法返回的是Mono<TacoOrder>,而findAll()方法返回的则是Flux<TacoOrder>。
为了了解这个反应式存储库是如何运行的,假设我们想获取所有的TacoOrder对象并将它们的投递名称打印到标准输出流。此时,我们所编写的代码可以如程序清单13.4所示。
@Autowired
OrderRepository orderRepo;
...
orderRepository.findAll()
.doOnNext(order -> {
System.out.println(
"Deliver to: " + order.getDeliveryName());
})
.subscribe();
在这里,对findAll()的调用会返回一个Flux<TacoOrder>对象,在这个Flux对象上我们添加了doOnNext()操作来打印投递名称。最后,对subscribe()的调用启动了流经Flux的数据流。
在第3章Spring Data JDBC的样例中,Taco是聚合中的一个子成员,而TacoOrder是包含有该子成员的聚合根。因此,Taco对象作为TacoOrder的一部分被持久化,我们没有必要定义一个专门用于持久化Taco的存储库。但是Spring Data R2DBC并不支持这样的聚合根,所以我们需要一个用于持久化Taco的TacoRepository。程序清单13.5展示了这样的存储库。
package tacos.data;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import tacos.Taco;
public interface TacoRepository
extends ReactiveCrudRepository<Taco, Long> {
}
可以看到,TacoRepository与OrderRepository没有太大的差异。TacoRepository扩展了ReactiveCrudRepository,使我们在持久化Taco时可以使用反应式类型。在这里,TacoRepository没有带给我们太多的惊喜。
IngredientRepository会稍微有趣一些,如程序清单13.6所示。
package tacos.data;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
import tacos.Ingredient;
public interface IngredientRepository
extends ReactiveCrudRepository<Ingredient, Long> {
Mono<Ingredient> findBySlug(String slug);
}
和其他两个反应式存储库类似,IngredientRepository也扩展了ReactiveCrudRepository。但是,我们需要根据slug值去查找Ingredient对象,所以IngredientRepository中包含一个返回Mono<Ingredient>对象的findBySlug()方法。
接下来,我们看一下如何编写测试以检验存储库是否能正常运行。
测试R2DBC存储库
Spring Data R2DBC提供了为R2DBC存储库编写集成测试的支持。具体来说,当测试类使用@DataR2dbcTest注解时,Spring会创建一个应用上下文,并将自动生成的Spring Data R2DBC存储库以bean的形式注入测试类,配合StepVerifier,就能为我们创建的所有存储库编写自动化测试了。
为简洁起见,我们将只关注一个测试类:IngredientRepositoryTest。这个类将测试IngredientRepository是否能够保存Ingredient对象、获取单个Ingredient、获取所有已保存的Ingredient对象。程序清单 13.7 显示了这个测试类。
package tacos.data;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import tacos.Ingredient;
import tacos.Ingredient.Type;
@DataR2dbcTest
public class IngredientRepositoryTest {
@Autowired
IngredientRepository ingredientRepo;
@BeforeEach
public void setup() {
Flux<Ingredient> deleteAndInsert = ingredientRepo.deleteAll()
.thenMany(ingredientRepo.saveAll(
Flux.just(
new Ingredient("FLTO", "Flour Tortilla", Type.WRAP),
new Ingredient("GRBF", "Ground Beef", Type.PROTEIN),
new Ingredient("CHED", "Cheddar Cheese", Type.CHEESE)
)));
StepVerifier.create(deleteAndInsert)
.expectNextCount(3)
.verifyComplete();
}
@Test
public void shouldSaveAndFetchIngredients() {
StepVerifier.create(ingredientRepo.findAll())
.recordWith(ArrayList::new)
.thenConsumeWhile(x -> true)
.consumeRecordedWith(ingredients -> {
assertThat(ingredients).hasSize(3);
assertThat(ingredients).contains(
new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));
assertThat(ingredients).contains(
new Ingredient("GRBF", "Ground Beef", Type.PROTEIN));
assertThat(ingredients).contains(
new Ingredient("CHED", "Cheddar Cheese", Type.CHEESE));
})
.verifyComplete();
StepVerifier.create(ingredientRepo.findBySlug("FLTO"))
.assertNext(ingredient -> {
ingredient.equals(new Ingredient("FLTO", "Flour Tortilla",
Type.WRAP));
});
}
}
setup()方法首先创建了一个由测试Ingredient对象组成的Flux,并通过saveAll()方法将其保存到注入的IngredientRepository中。随后,使用StepVerifier验证我们确实保存了3种配料。在内部,StepVerifier会订阅配料的Flux,从而打开数据流。
在shouldSaveAndFetchIngredients()测试方法中,我们使用另一个StepVerifier来校验存储库findAll()方法所返回的配料。它通过recordWith()方法将配料收集到一个ArrayList中,然后在传递给consumeRecordedWith()的Lambda表达式中探查ArrayList的内容,并验证其是否包含预期的Ingredient对象。
在shouldSaveAndFetchIngredients()方法的最后,我们测试了存储库的findBySlug()方法,它通过传入参数FLTO来获取单个配料,创建一个Mono<Ingredient>。随后,我们使用StepVerifier验证Mono发布的下一个条目是 Ingredient对象Flour Tortilla。
虽然在这里我们只专注于测试IngredientRepository,但同样的技术也可以用来测试Spring Data R2DBC生成的其他存储库。
目前一切都很顺利。我们定义了领域类和它们各自的存储库,还编写了测试类来测试它们是否能够运行。我们如果愿意,可以按照这样的方式使用它们。但是,存储库使TacoOrder的持久化变得很不方便,因为只有在创建并持久化作为该订单组成部分的Taco对象后,才能持久化引用子Taco对象的TacoOrder对象。此外,读取TacoOrder时,我们只能得到一个Taco ID的集合,而不是完整定义的Taco对象。
如果能将TacoOrder作为一个聚合根进行持久化,并使它和其子Taco对象一起持久化,那会非常便利。同理,如果我们获取TacoOrder时,能够获得包含完整定义的Taco对象,而不仅仅是ID,就更好了。接下来,我们定义一个服务级别的类,将其放在OrderRepository和TacoRepository之前,并模仿第3章中OrderRepository的持久化行为。
定义OrderRepository的聚合根服务
要将TacoOrder和Taco对象一起持久化,并使TacoOrder作为聚合根,首先要为TacoOrder类添加一个Taco集合的属性,如程序清单13.8所示。
@Data
public class TacoOrder {
...
@Transient
private transient List<Taco> tacos = new ArrayList<>();
public void addTaco(Taco taco) {
this.tacos.add(taco);
if (taco.getId() != null) {
this.tacoIds.add(taco.getId());
}
}
}
除了向TacoOrder类添加名为tacos的List<Taco>类型新属性外,现在的addTaco()方法还会将给定的Taco添加到该列表中(同时,也会像以前一样将其id添加到tacoIds集合中)。
请注意,tacos属性使用了@Transient注解(同时还使用了Java的transient关键字)。这表明Spring Data R2DBC不应该尝试持久化这个属性。如果没有@Transient注解,Spring Data R2DBC就会试图持久化它,并引发错误,因为Spring Data R2DBC还不支持这样的关联关系数据。
保存TacoOrder时,只有tacoIds属性会写入数据库,而tacos属性会被忽略。即便如此,至少现在TacoOrder有一个地方可以存放Taco对象了。这对于在保存TacoOrder的同时保存Taco对象,以及在获取TacoOrder的同时读取Taco对象都是很有用的。
现在我们可以创建一个服务bean来保存和读取TacoOrder对象,以及它们各自的Taco对象。我们从保存TacoOrder开始。程序清单13.9中定义的TacoOrderAggregateService类有一个save()方法,可以帮助我们实现这一点。
package tacos.web.api;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Mono;
import tacos.Taco;
import tacos.TacoOrder;
import tacos.data.OrderRepository;
import tacos.data.TacoRepository;
@Service
@RequiredArgsConstructor
public class TacoOrderAggregateService {
private final TacoRepository tacoRepo;
private final OrderRepository orderRepo;
public Mono<TacoOrder> save(TacoOrder tacoOrder) {
return Mono.just(tacoOrder)
.flatMap(order -> {
List<Taco> tacos = order.getTacos();
order.setTacos(new ArrayList<>());
return tacoRepo.saveAll(tacos)
.map(taco -> {
order.addTaco(taco);
return order;
}).last();
})
.flatMap(orderRepo::save);
}
}
尽管程序清单13.9中没有多少代码,但在save()方法中,有很多事情需要详细解释。首先,以参数形式传入的TacoOrder会通过Mono.just()方法包裹到一个Mono中。这样一来,在save()方法的剩余部分,我们就可以将其作为一个反应式类型处理。
接下来,我们要做的事情是对刚刚创建的Mono<TacoOrder>执行flatMap()操作。map()和flatMap()都能对通过Mono或Flux的数据对象进行转换,但我们在转换的过程中会产生Mono<TacoOrder>,而flatMap()操作能够确保我们在映射后可以继续得到Mono<TacoOrder>而不是Mono<Mono<TacoOrder>,如果我们使用map()来代替flatMap(),就会得到后者。
映射的目的是确保TacoOrder最终包含子Taco对象的ID,并在这个过程中保存这些Taco对象。对于新的TacoOrder来说,每个Taco对象的最初ID可能为null,在Taco对象保存后,我们才能知道这些ID。
在从TacoOrder中获取List<Taco>(在保存Taco对象时会使用它)后,我们将tacos属性重置为空列表。在Taco保存并分配到ID之后,我们会用新的Taco对象重建这个列表。
通过在注入的TacoRepository上调用saveAll()方法,可以保存所有Taco对象。saveAll()方法会返回一个Flux<Taco>,我们通过map()方法遍历它。这里主要是要将每个Taco对象都添加到TacoOrder中,转换是次要的。为了确保最终出现在Flux上的是TacoOrder而不是Taco,映射操作返回了TacoOrder而不是Taco。对last()的调用确保我们不会因为映射操作而产生重复的TacoOrder对象(每个Taco一个)。
此时,所有Taco对象都已经保存到数据库,而且连同它们新分配的ID一起放回父TacoOrder对象。接下来要做的是保存TacoOrder,这是最后一个flatMap()调用要完成的任务。同样,这里选择使用 flatMap() 来确保调用 OrderRepository.save()返回的 Mono<TacoOrder> 不会包裹在另一个 Mono 中。我们希望自己的save()方法返回Mono<TacoOrder>而不是Mono<Mono<TacoOrder>>。
接下来,我们看另一个方法:将通过ID读取一个TacoOrder对象,并组建TacoOrder中所有的子Taco对象。程序清单13.10展示了一个新的findById()方法,它能够达成我们的要求。
public Mono<TacoOrder> findById(Long id) {
return orderRepo
.findById(id)
.flatMap(order -> {
return tacoRepo.findAllById(order.getTacoIds())
.map(taco -> {
order.addTaco(taco);
return order;
}).last();
});
}
这个新的findById()方法要比save()方法更简短。但是,在这段短小的代码中仍然有很多内容需要我们仔细研究。
首先,通过调用OrderRepository的findById()方法获取TacoOrder。这样会返回一个Mono<TacoOrder>对象,我们对其进行扁平化映射,使其从只包含Taco ID的TacoOrder转化为包括完整Taco对象的TacoOrder。
传给flatMap()方法的lambda表达式调用了TacoRepository.findAllById()方法,以一次性地获取tacoIds属性中引用的所有Taco对象。该方法会产生一个Flux<Taco>,我们通过map()操作遍历它,将每个Taco对象添加到父TacoOrder对象中,就像我们在save()方法中用saveAll()保存所有的Taco对象一样。
同样,这里map()操作更像用于迭代Taco对象的一种手段,而不是特定的转换操作。传给map()的lambda表达式每次都返回父TacoOrder,所以我们最终得到Flux<TacoOrder>而不是Flux<Taco>。对last()的调用会获取Flux中最后一个条目,并返回一个Mono<TacoOrder>,这就是我们需要findById()方法返回的内容。
如果你还没有熟悉反应式的思维方式,那么save()和findById()方法中的代码可能会难以理解。反应式编程需要一种不同的思维方式,一开始可能会让人感到困惑,但随着反应式编程能力的增强,你会逐渐认识到它是非常优雅的。
就像对待任何代码一样(尤其是像TacoOrderAggregateService这样看起来有些令人难以理解的代码),我们最好为其编写测试代码,以确保它能够按预期运行。测试代码也可以作为一个样例,阐述如何使用TacoOrderAggregateService。程序清单13.11展示了TacoOrderAggregateService的测试代码。
package tacos.web.api;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.test.annotation.DirtiesContext;
import reactor.test.StepVerifier;
import tacos.Taco;
import tacos.TacoOrder;
import tacos.data.OrderRepository;
import tacos.data.TacoRepository;
@DataR2dbcTest
@DirtiesContext
public class TacoOrderAggregateServiceTests {
@Autowired
TacoRepository tacoRepo;
@Autowired
OrderRepository orderRepo;
TacoOrderAggregateService service;
@BeforeEach
public void setup() {
this.service = new TacoOrderAggregateService(tacoRepo, orderRepo);
}
@Test
public void shouldSaveAndFetchOrders() {
TacoOrder newOrder = new TacoOrder();
newOrder.setDeliveryName("Test Customer");
newOrder.setDeliveryStreet("1234 North Street");
newOrder.setDeliveryCity("Notrees");
newOrder.setDeliveryState("TX");
newOrder.setDeliveryZip("79759");
newOrder.setCcNumber("4111111111111111");
newOrder.setCcExpiration("12/24");
newOrder.setCcCVV("123");
newOrder.addTaco(new Taco("Test Taco One"));
newOrder.addTaco(new Taco("Test Taco Two"));
StepVerifier.create(service.save(newOrder))
.assertNext(this::assertOrder)
.verifyComplete();
StepVerifier.create(service.findById(1L))
.assertNext(this::assertOrder)
.verifyComplete();
}
private void assertOrder(TacoOrder savedOrder) {
assertThat(savedOrder.getId()).isEqualTo(1L);
assertThat(savedOrder.getDeliveryName()).isEqualTo("Test Customer");
assertThat(savedOrder.getDeliveryName()).isEqualTo("Test Customer");
assertThat(savedOrder.getDeliveryStreet()).isEqualTo("1234 North Street");
assertThat(savedOrder.getDeliveryCity()).isEqualTo("Notrees");
assertThat(savedOrder.getDeliveryState()).isEqualTo("TX");
assertThat(savedOrder.getDeliveryZip()).isEqualTo("79759");
assertThat(savedOrder.getCcNumber()).isEqualTo("4111111111111111");
assertThat(savedOrder.getCcExpiration()).isEqualTo("12/24");
assertThat(savedOrder.getCcCVV()).isEqualTo("123");
assertThat(savedOrder.getTacoIds()).hasSize(2);
assertThat(savedOrder.getTacos().get(0).getId()).isEqualTo(1L);
assertThat(savedOrder.getTacos().get(0).getName())
.isEqualTo("Test Taco One");
assertThat(savedOrder.getTacos().get(1).getId()).isEqualTo(2L);
assertThat(savedOrder.getTacos().get(1).getName())
.isEqualTo("Test Taco Two");
}
}
程序清单13.11包含了很多代码,但其中大部分是在assertOrder()方法中断言TacoOrder的内容。我们重点关注其他部分。
这个测试类使用了@DataR2dbcTest注解,它会让Spring创建一个应用上下文,并将所有存储库都视为bean。@DataR2dbcTest会寻找一个带有@SpringBootConfiguration注解的配置类来定义Spring的应用上下文。在单模块项目中,带有@SpringBootApplication注解的引导类(它本身也使用了@SpringBootConfiguration注解)可以承担此项任务。但在我们的多模块项目中,这个测试类与引导类并不在同一个项目中,所以需要一个这样的简单配置类:
package tacos;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
@SpringBootConfiguration
@EnableAutoConfiguration
public class TestConfig {
}
这个类不仅满足了添加@SpringBootConfiguration注解的需求,还启用了自动配置的功能,能够确保存储库(以及其他的内容)会自动创建。
单独运行TacoOrderAggregateServiceTests时,它应该能够顺利通过。但在IDE中,不同的测试运行环境可能会共享JVM和Spring应用上下文,如果与其他持久化测试一起运行这个测试,可能会导致有冲突的数据被写入内存H2数据库。我们在这里使用了@DirtiesContext注解,确保Spring应用上下文在测试运行前被重置,从而在每次运行时使用一个新的、空的H2数据库。
setup()方法会使用注入测试类的TacoRepository和OrderRepository对象创建TacoOrderAggregateService实例。TacoOrderAggregateService被分配给一个实例变量,以便测试方法使用。
现在终于可以测试聚合服务了。shouldSaveAndFetchOrders()代码的前几行构建了一个TacoOrder对象,而且我们使用几个测试Taco填充了该对象。然后,我们通过TacoOrderAggregateService的save()方法保存TacoOrder,该方法返回一个代表已保存订单的Mono<TacoOrder>。借助StepVerifier,我们能够断言返回的Mono中的TacoOrder及其包含的子Taco对象符合预期。
接下来调用服务的findById()方法。它也会返回Mono<TacoOrder>。与调用save()相同,我们使用StepVerifier遍历返回的Mono中的每一个TacoOrder(应该只有一个),并断言它符合我们的预期。
在这两个StepVerifier中,我们都调用verifyComplete()了方法,确保Mono中没有更多的对象,并且该Mono已处于完成状态。
值得注意的是,尽管可以采用类似的聚合操作以确保Taco对象总是包含完整定义的Ingredient对象,但我们没有选择这样做。鉴于Ingredient是它自己的聚合根,它可能被多个Taco对象引用,所以每个Taco只会包含一个Set<Long>来引用Ingredient的ID,使得后续可以通过IngredientRepository单独查询。
尽管聚合实体可能需要更多的工作,但Spring Data R2DBC提供了一种方法,让我们能够以反应式的方式处理关系型数据。这并不是Spring提供的唯一的反应式持久化方案。接下来,我们看一下如何借助反应式的Spring Data存储库来使用MongoDB。