精通Spring Boot 3 : 7. Spring Boot 响应式 (2)
启动用户应用程序
可以通过您的 IDE 或执行以下命令来运行用户应用
./gradlew clean bootRun
接下来,您可以在浏览器中输入 http://localhost:8080/users,或者打开另一个终端并使用以下 curl 命令:
curl -s http://localhost:8080/users | jq .
{
"id": "9ca8bbbe-4814-4223-9dc9-1c6aec783e43",
"email": "ximena@email.com",
"name": "Ximena",
"gravatarUrl": "https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar",
"password": "aw2s0me",
"userRole": [
"USER"
],
"active": true
},
{
"id": "f1e5570d-15f4-41e6-bb87-de333871232c",
"email": "norma@email.com",
"name": "Norma",
"gravatarUrl": "https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar",
"password": "aw2s0me",
"userRole": [
"USER",
"ADMIN"
],
"active": true
}
]
如果您想获取有关 Spring R2DBC 的更多信息,请访问参考文档:https://docs.spring.io/spring-data/r2dbc/docs/current/reference/html/#reference。
我与 Spring Boot Reactive 的复古应用程序
切换到我的复古应用程序,我们将使用反应式 MongoDB 持久化和基于注解的控制器端点。如果您正在跟随本教程,可以重用一些之前版本的代码。或者,您也可以通过访问 Spring Initializr (https://start.spring.io) 从头开始生成一个基础项目(不包含任何依赖项)。请确保将 Group 字段设置为 com.apress,将 Artifact 和 Name 字段设置为 myretro。下载项目后,解压缩并导入到您喜欢的 IDE 中。到本节结束时,您应该能够看到图 7-6 所示的结构。
我的复古应用程序的最终结构与 MongoDB 版本非常相似。
打开 build.gradle 文件,参见第 7-8 号列表。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'com.fasterxml.uuid:java-uuid-generator:4.0.1'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Web
implementation 'org.webjars:bootstrap:5.2.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
}
tasks.named('test') {
useJUnitPlatform()
}
7-8 build.gradle
列表 7-8 展示了 build.gradle 中的依赖项,包括新添加的 spring-boot-starter-data-mongodb-reactive 启动依赖。
创建或打开 board 包,并实现 Card、CardType 和 RetroBoard 类。请参考列表 7-9、7-10 和 7-11。
package com.apress.myretro.board;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Card {
private UUID id;
private String comment;
private CardType cardType;
}
7-9 src/main/java/apress/com/myretro/board/Card.java
package com.apress.myretro.board;
public enum CardType {
HAPPY,MEH,SAD
}
7-10 src/main/java/apress/com/myretro/board/CardType.java
package com.apress.myretro.board;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Document
public class RetroBoard {
@Id
private UUID id;
private String name;
@Singular("card")
private List<Card> cards;
public void addCard(Card card){
if (this.cards == null)
this.cards = new ArrayList<>();
this.cards.add(card);
}
public void addCards(List<Card> cards){
if (this.cards == null)
this.cards = new ArrayList<>();
this.cards.addAll(cards);
}
}
7-11 src/main/java/apress/com/myretro/board/RetroBoard.java
Card 类和 CardType 枚举保持不变,但 RetroBoard 类现在包含了@Document 和@Id 注解。@Document 注解将该类标记为 MongoDB 的持久化领域,而@Id 注解则为我们的文档添加了一个标识符,这里是 UUID。
接下来,创建或打开持久化包,以及 RetroBoardRepository 接口和 RetroBoardPersistenceCallback 类。列表 7-12 展示了 RetroBoardRepository 接口。
package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Mono;
import java.util.UUID;
public interface RetroBoardRepository extends ReactiveMongoRepository<RetroBoard,UUID> {
@Query("{'id': ?0}")
Mono<RetroBoard> findById(UUID id);
@Query("{}, { cards: { $elemMatch: { _id: ?0 } } }")
Mono<RetroBoard> findRetroBoardByIdAndCardId(UUID cardId);
default Mono<Void> removeCardFromRetroBoard(UUID retroBoardId, UUID cardId) {
findById(retroBoardId)
.doOnNext(retroBoard -> retroBoard.getCards().removeIf(card -> card.getId().equals(cardId)))
.flatMap(this::save)
.subscribe();
return Mono.empty();
}
}
7-12 src/main/java/apress/com/myretro/persistence/RetroBoardRepository.java
列表 7-11 显示,RetroBoardRepository 接口包含以下内容:
- ReactiveMongoRepository:这是一个专为 Mongo 设计的接口,支持响应式编程。它继承了 ReactiveCrudRepository、ReactiveSortingRepository 和 ReactiveQueryByExampleExecutor 接口,提供了所有常用的方法(如插入、保存、查找等),但使用 Flux 和 Mono 类型。Spring Data 和 Project Reactor 将利用所有响应式支持来实现这些接口。
- Mono: 和之前的版本一样,我们可以定义自己的 find* 方法,甚至可以使用自己的查询,这里使用的是 MongoDB 查询语言。我们使用 @Query 注解来定义一个特定的查询,通过 UUID 在所有 retroBoard 对象中查找一个 Card。当然,这种方法并不是最佳选择,因为如果我们提供 retroBoard 的 UUID 或使用不同的 map reduce 选项,可以更精确地缩小搜索范围。这里的想法是你可以扩展、使用并创建自己的自定义查询。
- Mono: 我们正在创建一个默认方法 removeCardFromRetroBoard;请花点时间分析一下。首先,我们使用 findById 方法(在这种情况下是 retroBoard),如果找到,将返回一个 Mono实体。接着,我们可以通过 doOnNext 方法将其删除,这将返回一个修改后的 Mono实体(因为我们是通过 UUID 删除卡片,如果找到的话)。最后,我们可以使用 flatMap 方法进行转换(这个转换将返回一个 Mono);这个转换的目的只是保存它。请记住,我们正在处理反应式实体,因此需要对流进行 subscribe()。最后,我们只返回一个空的 Mono 实体。
列表 7-13 展示了 RetroBoardPersistenceCallback 类的内容。
package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.data.mongodb.core.mapping.event.ReactiveBeforeConvertCallback;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Slf4j
@Component
public class RetroBoardPersisteceCallback implements ReactiveBeforeConvertCallback<RetroBoard> {
@Override
public Publisher<RetroBoard> onBeforeConvert(RetroBoard entity, String collection) {
if (entity.getId() == null)
entity.setId(java.util.UUID.randomUUID());
if (entity.getCards() == null)
entity.setCards(new java.util.ArrayList<>());
log.info("[CALLBACK] onBeforeConvert {}", entity);
return Mono.just(entity);
}
}
7-13 src/main/java/apress/com/myretro/persistence/RetroBoardPersistenceCallback.java
请注意,在 RetroBoardPersistenceCallback 类中,我们现在使用 ReactiveBeforeConvertCallback 接口。这个接口有一个方法 onBeforeConvert,期望返回一个 Publisher。这个方法将用于在 RetroBoard 实体没有 UUID 时添加一个 UUID。同时,我们在这里返回一个 Mono,使用 Mono.just()方法。再次强调,这是一种实现此业务规则的方法,但如果您愿意,可以在服务中添加逻辑。通过列表 7-13 中的代码,我们尽量使服务保持简洁。
接下来,打开或创建建议包以及 RetroBoardAdvice 类。请参阅列表 7-14。
package com.apress.myretro.advice;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.RetroBoardNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.UUID;
@Slf4j
@Component
@Aspect
public class RetroBoardAdvice {
@Around("execution(* com.apress.myretro.persistence.RetroBoardRepository.findById(..))")
public Object checkFindRetroBoard(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("[ADVICE] {}", proceedingJoinPoint.getSignature().getName());
Mono<RetroBoard> retroBoard = (Mono<RetroBoard>) proceedingJoinPoint.proceed(new Object[]{
UUID.fromString(proceedingJoinPoint.getArgs()[0].toString())
});
if (retroBoard == null)
throw new RetroBoardNotFoundException();
return retroBoard;
}
}
7-14 src/main/java/apress/com/myretro/advice/RetroBoardAdvice.java
RetroBoardAdvice 类将帮助拦截调用,以避免其他错误(我们正在从主代码中移除一些关注点,以避免代码的纠缠和分散)。由于我们使用反应式编程,因此需要使用 Flux 类型或 Mono 类型,在这种情况下我们使用 Mono 类型。
接下来,创建或打开包含 RetroBoardService 类的服务包。请参阅清单 7-15。
package com.apress.myretro.service;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.persistence.RetroBoardRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.UUID;
@AllArgsConstructor
@Service
public class RetroBoardService {
RetroBoardRepository retroBoardRepository;
public Mono<RetroBoard> save(RetroBoard domain) {
return this.retroBoardRepository.save(domain);
}
public Mono<RetroBoard> findById(UUID uuid) {
return this.retroBoardRepository.findById(uuid);
}
public Flux<RetroBoard> findAll() {
return this.retroBoardRepository.findAll();
}
public Mono<Void> delete(UUID uuid) {
return this.retroBoardRepository.deleteById(uuid);
}
public Flux<Card> findAllCardsFromRetroBoard(UUID uuid) {
return this.findById(uuid).flatMapIterable(RetroBoard::getCards);
}
public Mono<Card> addCardToRetroBoard(UUID uuid, Card card) {
return this.findById(uuid).flatMap(retroBoard -> {
if (card.getId() == null)
card.setId(UUID.randomUUID());
retroBoard.getCards().add(card);
return this.save(retroBoard).thenReturn(card);
});
}
public Mono<Card> findCardByUUID(UUID uuidCard) {
Mono<RetroBoard> result = retroBoardRepository.findRetroBoardByIdAndCardId(uuidCard);
return result.flatMapIterable(RetroBoard::getCards).filter(card -> card.getId().equals(uuidCard)).next();
}
public Mono<Void> removeCardByUUID(UUID uuid, UUID cardUUID) {
return retroBoardRepository.removeCardFromRetroBoard(uuid, cardUUID);
}
}
7-15 src/main/java/apress/com/myretro/service/RetroBoardService.java
在列表 7-15 中,我们使用了 RetroBoardRepository 接口,该接口将通过构造函数进行注入(这要感谢 Lombok 的@AllArgsConstructor 注解)。我们根据服务方法使用 Flux 类型或 Mono 类型。每个方法都会发出一个实体类型(无论是 Flux 还是 Mono),我们需要以某种方式对它们进行订阅。但是,我们该如何或在哪里进行订阅呢?一些方法通过 flatMap 或 flatMapIterable 与实体进行交互(即进行转换),即便如此,我们仍然在发出这些类型。
接下来,创建或打开网络包和 RetroBoardController 类,如清单 7-16 所示。
package com.apress.myretro.web;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.service.RetroBoardService;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.UUID;
@AllArgsConstructor
@RestController
@RequestMapping("/retros")
public class RetroBoardController {
private RetroBoardService retroBoardService;
@GetMapping
public Flux<RetroBoard> getAllRetroBoards() {
return retroBoardService.findAll();
}
@PostMapping
public Mono<RetroBoard> saveRetroBoard(@RequestBody RetroBoard retroBoard) {
return retroBoardService.save(retroBoard);
}
@GetMapping("/{uuid}")
public Mono<RetroBoard> findRetroBoardById(@PathVariable UUID uuid) {
return retroBoardService.findById(uuid);
}
@GetMapping("/{uuid}/cards")
public Flux<Card> getAllCardsFromBoard(@PathVariable UUID uuid) {
return retroBoardService.findAllCardsFromRetroBoard(uuid);
}
@PutMapping("/{uuid}/cards")
public Mono<Card> addCardToRetroBoard(@PathVariable UUID uuid, @RequestBody Card card) {
return retroBoardService.addCardToRetroBoard(uuid, card);
}
@GetMapping("/cards/{uuidCard}")
public Mono<Card> getCardByUUID(@PathVariable UUID uuidCard) {
return retroBoardService.findCardByUUID(uuidCard);
}
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/{uuid}/cards/{uuidCard}")
public Mono<Void> deleteCardFromRetroBoard(@PathVariable UUID uuid, @PathVariable UUID uuidCard) {
return retroBoardService.removeCardByUUID(uuid, uuidCard);
}
}
7-16 src/main/java/apress/com/myretro/web/RetroBoardController.java
在 RetroBoardController 类中,我们采用基于注解的编程方式来创建 Web 端点。在这种情况下,每个端点都会暴露 Flux 或 Mono 类型。该类使用 RetroBoardService,并且我们可以看到在没有订阅的情况下仍然可以发出这些类型,原因在于 Spring WebFlux 为我们的反应式控制器处理了订阅。
接下来,创建或打开配置包以及 MyRetroConfiguration 类,如清单 7-17 所示。
package com.apress.myretro.config;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.CardType;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.service.RetroBoardService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.UUID;
@Slf4j
@EnableConfigurationProperties({MyRetroProperties.class})
@Configuration
public class MyRetroConfiguration {
@Bean
ApplicationListener<ApplicationReadyEvent> ready(RetroBoardService retroBoardService) {
return applicationReadyEvent -> {
log.info("Application Ready Event");
UUID retroBoardId = UUID.fromString("9dc9b71b-a07e-418b-b972-40225449aff2");
retroBoardService.save(RetroBoard.builder()
.id(retroBoardId)
.name("Spring Boot Conference 2023")
.card(Card.builder().id(UUID.fromString("bb2a80a5-a0f5-4180-a6dc-80c84bc014c9")).comment("Spring Boot Rocks!").cardType(CardType.HAPPY).build())
.card(Card.builder().id(UUID.randomUUID()).comment("Meet everyone in person").cardType(CardType.HAPPY).build())
.card(Card.builder().id(UUID.randomUUID()).comment("When is the next one?").cardType(CardType.MEH).build())
.card(Card.builder().id(UUID.randomUUID()).comment("Not enough time to talk to everyone").cardType(CardType.SAD).build())
.build()).subscribe();
};
}
}
7-17 src/main/java/apress/com/myretro/config/MyRetroConfiguration.java
MyRetroConfiguration 类正在使用 RetroBoardService,我们正在保存一个 retroBoard。请注意,save 方法会发出一个实体,为了保存它,我们需要进行订阅,这就是我们使用 subscribe() 方法的原因。再次强调,在我们的反应式控制器中,我们不需要手动订阅,因为 Spring WebFlux 会自动处理这一切。
这个包的异常与其他版本类似;MyRetroProperties 和 UsersConfiguration 没有变化。您可以重用这些类。
接下来,创建或打开 application.properties 文件。请参阅列表 7-18。
# MongoDB
spring.data.mongodb.uuid-representation=standard
spring.data.mongodb.database=retrodb
spring.data.mongodb.repositories.type=reactive
7-18 src/main/resources/application.properties
在 application.properties 中,唯一对我们重要的属性是 spring.data.mongodb.uuid-representation。请记住,这个属性很重要,因为 MongoDB 使用自己的实现来处理 UUID 对象。如果我们想使用自己的 UUID,就需要设置这个属性。同时,请注意我们已经指定了数据库的名称;如果不设置名称,默认情况下(Spring Boot 自动配置)会使用名称 test,但您需要在数据库设置(docker-compose.yaml)中相应地进行更改。此外,我们使用一个属性将存储库设置为反应式,即使这是由 Spring Boot 自动配置所设置的。
接下来,创建或打开 docker-compose.yaml 文件。请参阅清单 7-19。
version: "3.1"
services:
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_DATABASE: retrodb
ports:
- "27017:27017"
7-19 docker-compose.yaml
docker-compose.yaml 文件与之前的版本完全相同——使用响应式编程不需要做任何更改。
运行我的怀旧应用
您可以通过 IDE 或以下命令来运行 My Retro App:
./gradlew clean bootRun
你应该能看到 Docker 容器正在启动,Netty 服务器已准备好接受请求。你可以使用 VS Code(以及 REST Client 插件 - humao.rest-client)进行一些测试,或者如果你使用的是 IntelliJ 企业版,默认情况下就可以使用这个功能。
创建或打开 myretro.http 文件,详见列表 7-20。
### Get All Retro Boards
GET http://localhost:8080/retros
Content-Type: application/json
### Get Retro Board
GET http://localhost:8080/retros/9dc9b71b-a07e-418b-b972-40225449aff2
Content-Type: application/json
### Get All Cards from Retro Board
GET http://localhost:8080/retros/9dc9b71b-a07e-418b-b972-40225449aff2/cards
Content-Type: application/json
### Get Single Card No Retro Board
GET http://localhost:8080/retros/cards/bb2a80a5-a0f5-4180-a6dc-80c84bc014c9
Content-Type: application/json
### Create a Retro Board
POST http://localhost:8080/retros
Content-Type: application/json
{
"name": "Spring Boot Videos 2024"
}
### Add Card to Retro
PUT http://localhost:8080/retros/9dc9b71b-a07e-418b-b972-40225449aff2/cards
Content-Type: application/json
{
"comment": "We are back in business",
"cardType": "HAPPY"
}
### Delete Card from Retro
DELETE http://localhost:8080/retros/9dc9b71b-a07e-418b-b972-40225449aff2/cards/bb2a80a5-a0f5-4180-a6dc-80c84bc014c9
Content-Type: application/json
7-20 src/http/myretro.http
myretro.http 文件允许您对不同的端点执行 REST 调用。“测试用户应用程序”部分已经介绍了 WebClient 和 WebTestClient 类;您可以创建自己的集成测试。
概要
现在,您知道在需要使用 Spring Boot 和 Project Reactor 创建反应式应用程序时该如何操作。在本章中,您学习了反应式编程及其在不同框架中的多种实现。
你了解了项目反应器及其如何提供新的 Flux 和 Mono 类型,从而使你的应用程序更容易生成新的流数据。
你了解了使用功能性或注解驱动的反应式应用程序创建 Web API 的不同方法,在这些方法中,你可以通过 Flux 或 Mono 来发出结果。虽然我们没有提到,但你仍然可以使用类似 Mono>的方式来返回一个可以包含自定义 HTTP 头的响应。
第 8 章讲解了如何使用 Spring Boot 进行测试,以及如何为您的应用程序进行集成测试和单元测试。