使用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所示。

程序清单13.1 用于R2DBC持久化的Ingredient实体类
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所示。

程序清单13.2 用于R2DBC持久化的Taco实体类
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类的定义中使用了许多在定义领域实体时使用过的技术。

程序清单13.3 用于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所示。

程序清单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展示了这样的存储库。

程序清单13.5 使用反应式存储库持久化Taco对象
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所示。

程序清单13.6 通过反应式存储库持久化Ingredient对象
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 显示了这个测试类。

程序清单13.7 测试Spring Data R2DBC存储库
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所示。

程序清单13.8 添加一个Taco集合至TacoOrder对象
@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()方法,可以帮助我们实现这一点。

程序清单13.9 将TacoOrder与Taco作为聚合保存
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()方法,它能够达成我们的要求。

程序清单13.10 以聚合的方式读取TacoOrder和Taco
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的测试代码。

程序清单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。