反应式地持久化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所示。
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章,这里会再次定义它们。
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所示。
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对象可以写入数据库和从数据库中读取。
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所示。
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。