精通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 进行测试,以及如何为您的应用程序进行集成测试和单元测试。