反应式地持久化MongoDB文档数据

第4章中,我们使用Spring Data MongoDB来定义针对MongoDB文档数据库的基于文档的持久化操作。在本节,我们会借助Spring Data对MongoDB的反应式支持,回顾针对MongoDB的持久化操作。

在开始之前,需要使用Spring Data Reactive MongoDB starter创建一个项目。实际上,这也是在使用Initalizr创建项目时要选择的复选框的名称。当然,也可以使用以下依赖手动将其添加到Maven构建文件中:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>

第4章中,我们还依靠Flapdoodle的嵌入式MongoDB数据库进行测试。但是,Flapdoodle对反应式存储库时的支持并不理想。所以,运行测试时,我们需要一个真正的Mongo数据库运行并监听27017端口。

现在我们开始编写反应式持久化MongoDB的代码,从构成领域的文档类型开始。

定义领域文档类型

和以往一样,我们需要创建定义应用领域的类。在创建过程中,像第4章一样,需要用Spring Data MongoDB的@Document注解来标注它们,以表明它们是将要存储在MongoDB中的文档。我们从Ingredient类开始,如程序清单13.12所示。

程序清单13.12 使用Mongo持久化注解标注的Ingredient类
package tacos;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
@Document
public class Ingredient {

  @Id
  private String id;
  private String name;
  private Type type;

  public enum Type {
    WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
  }

}

你可能会敏锐地发现,这个Ingredient类与我们在第4章创建的Ingredient类是完全一样的。实际上,对于MongoDB的@Document注解标注的类,无论通过反应式还是通过非反应式存储库进行持久化,都是一样的。这意味着,Taco和TacoOrder类也与第4章中创建的相同。为完整起见,也为了让你不必回头再去翻看第4章,这里会再次定义它们。

程序清单13.13是一个类似的添加注解的Taco类。
package tacos;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.rest.core.annotation.RestResource;

import lombok.Data;

@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 = new ArrayList<>();

  public void addIngredient(Ingredient ingredient) {
    this.ingredients.add(ingredient);
  }

}

需要注意,与Ingredient不同,Taco类没有使用@Document注解,这是因为它本身并不会作为一个文档来保存,而是会作为TacoOrder聚合根的一部分来保存。TacoOrder是一个聚合根,所以它会使用@Document注解,如程序清单13.14所示。

程序清单13.14 使用Mongo持久化注解标注的TacoOrder类
package tacos;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import lombok.Data;

@Data
@Document
public class TacoOrder implements Serializable {
  private static final long serialVersionUID = 1L;

  @Id
  private String id;
  private Date placedAt = new Date();

  private User user;

  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 List<Taco> tacos = new ArrayList<>();

  public void addTaco(Taco taco) {
    this.tacos.add(taco);
  }

}

面向反应式MongoDB存储库的领域文档类与面向非反应式的领域文档类并没有什么差异。接下来,我们会看到,反应式MongoDB存储库与非反应式的存储库略有差异。

定义反应式MongoDB存储库

现在我们需要定义两个存储库,其中一个用于TacoOrder聚合根,另一个用于Ingredient。我们不需要为Taco构建存储库,因为它是TacoOrder聚合根的子成员。

相信你对如下的IngredientRepository接口已经很熟悉了:

package tacos.data;

import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.web.bind.annotation.CrossOrigin;

import tacos.Ingredient;
@CrossOrigin(origins = "http://localhost:8080")
public interface IngredientRepository
         extends ReactiveCrudRepository<Ingredient, String> {

}

这个IngredientRepository接口与第4章中定义的接口只有一点不同:它扩展了ReactiveCrudRepository而不是CrudRepository。与我们为Spring Data R2DBC持久化所创建的接口略有不同,它不包含findBySlug()方法。

同样,除了这一点,OrderRepository与第4章中创建的MongoDB存储库也是相同的:

package tacos.data;

import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;

import reactor.core.publisher.Flux;
import tacos.TacoOrder;
import tacos.User;

public interface OrderRepository
         extends ReactiveCrudRepository<TacoOrder, String> {

  Flux<TacoOrder> findByUserOrderByPlacedAtDesc(
          User user, Pageable pageable);

}

反应式和非反应式MongoDB存储库的唯一区别体现为其要扩展ReactiveCrudRepository还是CrudRepository。但是,在选择扩展ReactiveCrudRepository时,存储库的客户端必须要做好处理Flux和Mono等反应式类型的准备。在我们为反应式存储库编写测试代码时,这一点就会非常明显。

测试反应式MongoDB存储库

为MongoDB存储库编写测试代码的关键是用@DataMongoTest注解来标注测试类。这个注解的功能与13.1节中使用的@DataR2dbcTest注解类似,会确保创建Spring应用上下文,并将自动生成的存储库作为bean注入测试代码。这样,测试代码就可以使用这些注入的存储库来设置测试数据,并对数据库执行其他操作。

例如程序清单13.15中的IngredientRepositoryTest类。它会测试IngredientRepository,并断言Ingredient对象可以写入数据库和从数据库中读取。

程序清单13.15 测试反应式MongoDB存储库
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.mongo.DataMongoTest;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import tacos.Ingredient;
import tacos.Ingredient.Type;

@DataMongoTest
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.findById("FLTO"))
         .assertNext(ingredient -> {
             ingredient.equals(new Ingredient("FLTO", "Flour Tortilla",
    Type.WRAP));
         });
  }
}

这个测试代码与我们已经编写的对基于R2DBC的存储库测试类似,但仍有一些差异。它首先向数据库写入3个Ingredient对象。然后,使用两个StepVerifier实例来验证Ingredient对象可以通过存储库来读取,先获取所有Ingredient对象的集合,再通过ID获取单个Ingredient。

就像基于R2DBC的测试代码一样,@DataMongoTest注解会寻找一个带有@SpringBoot Configuration注解的类来创建应用上下文。与之前创建的测试一样,这也可以通过创建一个测试的配置类来实现。

这里的独特之处在于,第一个StepVerifier将所有Ingredient对象收集到一个ArrayList中,然后断言ArrayList包含每个Ingredient。findAll()方法并不能保证结果文档的顺序是一致的,这样我们就无法使用assertNext()或expectNext()断言了。通过将所有的Ingredient对象收集到一个列表中,我们可以断言这个列表包含全部3个对象,无须关心它们的顺序如何。

OrderRepository的测试看起来非常相似,如程序清单13.16所示。

程序清单13.16 测试Mongo OrderRepository
package tacos.data;

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.mongo.DataMongoTest;

import reactor.test.StepVerifier;
import tacos.Ingredient;
import tacos.Taco;
import tacos.TacoOrder;
import tacos.Ingredient.Type;

@DataMongoTest
public class OrderRepositoryTest {

  @Autowired
  OrderRepository orderRepo;

  @BeforeEach
  public void setup() {
    orderRepo.deleteAll().subscribe();
  }
  @Test
  public void shouldSaveAndFetchOrders() {
    TacoOrder order = createOrder();

    StepVerifier
      .create(orderRepo.save(order))
      .expectNext(order)
      .verifyComplete();

    StepVerifier
      .create(orderRepo.findById(order.getId()))
      .expectNext(order)
      .verifyComplete();

    StepVerifier
      .create(orderRepo.findAll())
      .expectNext(order)
      .verifyComplete();
  }

  private TacoOrder createOrder() {
    TacoOrder order = new TacoOrder();
      ...
    return order;
  }

}

shouldSaveAndFetchOrders()方法做的第一件事是构建一个订单,其中包括客户、支付信息,以及几个taco(为简洁起见,这里省略了createOrder()方法的细节)。然后,它使用StepVerifier保存TacoOrder对象,并断言save()方法返回已保存的TacoOrder。接下来,它试图通过ID来获取订单信息,并断言它收到了完整的TacoOrder对象。最后,它获取所有TacoOrder对象(应该只有一个)并断言它是预期的TacoOrder。

如前所述,我们需要一个可用的MongoDB服务器并监听27017端口以运行这个测试。Flapdoodle的嵌入式MongoDB与反应式存储库不能很好地协同运行。如果已经安装了Docker,那么可以像这样轻松地启动一个暴露27017端口的MongoDB服务器。

$ docker run -p27017:27017 mongo

我们还可以采用其他的方式搭建MongoDB,更多细节请参考官方文档。

现在我们已经看到了如何为R2BDC和MongoDB创建反应式存储库,接下来我们看一下Spring Data支持的另一个反应式持久化方案:Cassandra。