Spring Boot 响应式 WebFlux 入门

一、概述

友情提示:Reactive Programming ,翻译为反应式编程,又称为响应式编程。国内多数叫响应式编程,本文我们统一使用响应式。不过,比较正确的叫法还是反应式。

Spring Framework 5 在 2017 年 9 月份,发布了 GA 通用版本。既然是一个新的大版本,必然带来了非常多的改进,其中比较重要的一点,就是将响应式编程带入了 Spring 生态。也就是说,将响应式编程“真正”带入了 Java 生态之中。

在此之前,相信(include me),对响应式编程的概念是非常模糊的。甚至说,截止到目前 2019 年 11 月份,对于国内的 Java 开发者,也是知之甚少。

对于我们来说,最早看到的就是 Spring5 提供了一个新的 Web 框架,基于响应式编程的 Spring WebFlux 。至此,SpringMVC 在“干掉” Struts 之后,难道要开始进入 Spring 自己的两个 Web 框架的双雄争霸?

实际上,WebFlux 在出来的两年时间里,据了解到的情况,鲜有项目从采用 SpringMVC 迁移到 WebFlux ,又或者新项目直接采用 WebFlux 。这又是为什么呢?

响应式编程,对我们现有的编程方式,是一场颠覆,对于框架也是。

  • 在 Spring 提供的框架中,实际并没有全部实现好对响应式编程的支持。例如说,Spring Transaction 事务组件,在 Spring 5.2 M2 版本,才提供了支持响应式编程的 ReactiveTransactionManager 事务管理器。
  • 更不要说,Java 生态常用的框架,例如说 MyBatis、Jedis 等等,都暂未提供响应式编程的支持。

所以,WebFlux 想要能够真正普及到我们的项目中,不仅仅需要 Spring 自己体系中的框架提供对响应式编程的很好的支持,也需要 Java 生态中的框架也要做到如此。

即使如此,这也并不妨碍我们来对 WebFlux 进行一个小小的入门。毕竟,响应式编程这把火,终将熊熊燃起。Spring Cloud Gateway即使用的的WebFlux实现。

1.1 响应式编程

简单地说,响应式编程是关于非阻塞应用程序的,这些应用程序是异步的、事件驱动的,并且需要少量的线程来垂直伸缩(即在 JVM 中),而不是水平伸缩(即通过集群)。
以后端 API 请求的处理来举例子。

在现在主流的编程模型中,请求是被同步阻塞处理完成,返回结果给前端。
在响应式的编程模型中,请求是被作为一个事件丢到线程池中执行,等到执行完毕,异步回调结果给主线程,最后返回给前端。
通过这样的方式,主线程(实际是多个,这里只是方便描述哈)不断接收请求,不负责直接同步阻塞处理,从而避免自身被阻塞。

1.2 Reactor 框架

简单来说,Reactor 说是一个响应式编程框架,又快又不占用内存的那种。

Reactor 有两个非常重要的基本概念:

  • Flux ,表示的是包含 0 到 N 个元素的异步序列。当消息通知产生时,订阅者(Subscriber)中对应的方法 #onNext(t), #onComplete(t)#onError(t) 会被调用。
  • Mono 表示的是包含 0 或者 1 个元素的异步序列。该序列中同样可以包含与 Flux 相同的三种类型的消息通知。
  • 同时,Flux 和 Mono 之间可以进行转换。例如:
    • 对一个 Flux 序列进行计数count操作,得到的结果是一个 Mono 对象。
    • 把两个 Mono 序列合并在一起,得到的是一个 Flux 对象。

其实,可以先暂时简单把Mono 理解成 Object ,Flux 理解成 List

1.3 Spring WebFlux

Spring 官方文档对 Spring WebFlux 介绍如下:
Spring Framework 5 提供了一个新的 spring-webflux 模块。该模块包含了:

  • 对响应式支持的 HTTP 和 WebSocket 客户端。
  • 对响应式支持的 Web 服务器,包括 Rest API、HTML 浏览器、WebSocket 等交互方式。

在服务端方面,WebFlux 提供了 2 种编程模型(翻译成使用方式,可能更易懂):

方式一,基于 Annotated Controller 方式实现:基于 @Controller 和 SpringMVC 使用的其它注解。也就是说,我们大体上可以像使用 SpringMVC 的方式,使用 WebFlux 。
方式二,基于函数式编程方式:函数式,Java 8 lambda 表达式风格的路由和处理。可能有点晦涩,晚点我们看了示例就会明白。
下面,开始让我们开始愉快的快速入门。

2. 快速入门

我们会使用 spring-boot-starter-webflux 实现 WebFlux 的自动化配置。然后实现用户的增删改查接口。接口列表如下:

请求方法 URL 功能
GET /users/list 查询用户列表
GET /users/get 获得指定用户编号的用户
POST /users/add 添加用户
POST /users/update 更新指定用户编号的用户
POST /users/delete 删除指定用户编号的用户

天文1号不是发射了吗!下面,开始神秘的火星之旅~

2.1 引入依赖

在IDEA中,要创建WebFlux项目,必须勾选Spring Reactive Web而不是传统的Spring Web,这里为了简化代码使用到了Lombok。

创建WebFlux 项目

pom.xml 文件中,引入相关依赖。



    
        org.springframework.boot
        spring-boot-starter-parent
        2.3.0.RELEASE
         
    
    4.0.0

    webflux

    
        
        
            org.springframework.boot
            spring-boot-starter-webflux
            2.3.0.RELEASE
        

        
        
            org.springframework.boot
            spring-boot-starter-test
            test
        

    


2.2 Application

创建 Application.java 类,配置 @SpringBootApplication 注解即可。

package com.erbadagang.springboot.springwebflux;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

2.3 基于 Annotated Controller 方式实现

创建 [UserController] 类。代码如下:

package com.erbadagang.springboot.springwebflux.controller;

import com.erbadagang.springboot.springwebflux.dto.UserAddDTO;
import com.erbadagang.springboot.springwebflux.dto.UserUpdateDTO;
import com.erbadagang.springboot.springwebflux.service.UserService;
import com.erbadagang.springboot.springwebflux.vo.UserVO;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.ArrayList;
import java.util.List;

/**
 * 用户 Controller
 */
@RestController
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 查询用户列表
     *
     * @return 用户列表
     */
    @GetMapping("/list")
    public Flux list() {
        // 查询列表
        List result = new ArrayList<>();
        result.add(new UserVO().setId(1).setUsername("yudaoyuanma"));
        result.add(new UserVO().setId(2).setUsername("woshiyutou"));
        result.add(new UserVO().setId(3).setUsername("chifanshuijiao"));
        // 返回列表
        return Flux.fromIterable(result);
    }

    /**
     * 获得指定用户编号的用户
     *
     * @param id 用户编号
     * @return 用户
     */
    @GetMapping("/get")
    public Mono get(@RequestParam("id") Integer id) {
        // 查询用户
        UserVO user = new UserVO().setId(id).setUsername("username:" + id);
        // 返回
        return Mono.just(user);
    }

    /**
     * 获得指定用户编号的用户
     *
     * @param id 用户编号
     * @return 用户
     */
    @GetMapping("/v2/get")
    public Mono get2(@RequestParam("id") Integer id) {
        // 查询用户
        UserVO user = userService.get(id);
        // 返回
        return Mono.just(user);
    }

    /**
     * 添加用户
     *
     * @param addDTO 添加用户信息 DTO
     * @return 添加成功的用户编号
     */
    @PostMapping("add")
    public Mono add(@RequestBody Publisher addDTO) {
        // 插入用户记录,返回编号
        Integer returnId = 1;
        // 返回用户编号
        return Mono.just(returnId);
    }

    /**
     * 添加用户
     *
     * @param addDTO 添加用户信息 DTO
     * @return 添加成功的用户编号
     */
    @PostMapping("add2")
    public Mono add2(Mono addDTO) {
        // 插入用户记录,返回编号
        Integer returnId = 1;
        // 返回用户编号
        return Mono.just(returnId);
    }

    /**
     * 更新指定用户编号的用户
     *
     * @param updateDTO 更新用户信息 DTO
     * @return 是否修改成功
     */
    @PostMapping("/update")
    public Mono update(@RequestBody Publisher updateDTO) {
        // 更新用户记录
        Boolean success = true;
        // 返回更新是否成功
        return Mono.just(success);
    }

    /**
     * 删除指定用户编号的用户
     *
     * @param id 用户编号
     * @return 是否删除成功
     */
    @PostMapping("/delete") // URL 修改成 /delete ,RequestMethod 改成 DELETE
    public Mono delete(@RequestParam("id") Integer id) {
        // 删除用户记录
        Boolean success = true;
        // 返回是否更新成功
        return Mono.just(success);
    }

}
  • 在类和方法上,我们添加了 @Controller 和 SpringMVC 在使用的 @GetMapping@PostMapping 等注解,提供 API 接口,这个和我们在使用 SpringMVC 是一模一样的。
  • dtovo 包下,有 API 使用到的 DTO 和 VO 类。
  • 因为是入门示例,我们会发现代码十分简单,淡定,淡定(让我想起来Trump跟记者打架让闭嘴,shutup,shutup......)。在后文中,我们会提供和 Spring Data JPA、Spring Data Redis 等等整合的示例。
  • #list() 方法,我们最终调用 Flux#fromIterable(Iterable it) 方法,将 List 包装成 Flux 对象返回。
  • #get(Integer id) 方法,我们最终调用 Mono#just(T data) 方法,将 UserVO 包装成 Mono 对象返回。
  • #add(Publisher addDTO) 方法,参数为 Publisher 类型,泛型为 UserAddDTO 类型,并且添加了 @RequestBody 注解,从 request 的 Body 中读取参数。注意,此时提交参数需要使用 "application/json" 等 Content-Type 内容类型。
  • #add(...) 方法,也可以使用 application/x-www-form-urlencodedmultipart/form-data 这两个 Content-Type 内容类型,通过 request 的 Form Data 或 Multipart Data 传递参数。代码如下:
// UserController.java

/**
 * 添加用户
 *
 * @param addDTO 添加用户信息 DTO
 * @return 添加成功的用户编号
 */
@PostMapping("add2")
public Mono add(Mono addDTO) {
    // 插入用户记录,返回编号
    Integer returnId = UUID.randomUUID().hashCode();
    // 返回用户编号
    return Mono.just(returnId);
}

此时,参数为 Mono 类型,泛型为 UserAddDTO 类型。
当然,我们也可以直接使用参数为 UserAddDTO 类型。如果后续需要使用到 Reactor API ,则我们自己主动调用 Mono#just(T data) 方法,封装出 Publisher 对象。注意,Flux 和 Mono 都实现了 Publisher 接口。

  • #update(Publisher updateDTO)方法,和#add(Publisher addDTO)方法一致,就不重复赘述。
  • #delete(Integer id)方法,和#get(Integer id)方法一致,就不重复赘述。

2.4 基于函数式编程方式

创建 [UserRouter]类。代码如下:

package com.erbadagang.springboot.springwebflux.controller;

import com.erbadagang.springboot.springwebflux.vo.UserVO;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.*;
import static org.springframework.web.reactive.function.server.ServerResponse.*;

/**
 * 用户 Router
 */
@Configuration
public class UserRouter {

    @Bean
    public RouterFunction userListRouterFunction() {
        return RouterFunctions.route(RequestPredicates.GET("/users2/list"),
                new HandlerFunction() {

                    @Override
                    public Mono handle(ServerRequest request) {
                        // 查询列表
                        List result = new ArrayList<>();
                        result.add(new UserVO().setId(1).setUsername("yudaoyuanma"));
                        result.add(new UserVO().setId(2).setUsername("woshiyutou"));
                        result.add(new UserVO().setId(3).setUsername("chifanshuijiao"));
                        // 返回列表
                        return ServerResponse.ok().bodyValue(result);
                    }

                });
    }

    @Bean
    public RouterFunction userGetRouterFunction() {
        return RouterFunctions.route(RequestPredicates.GET("/users2/get"),
                new HandlerFunction() {

                    @Override
                    public Mono handle(ServerRequest request) {
                        // 获得编号
                        Integer id = request.queryParam("id")
                                .map(s -> StringUtils.isEmpty(s) ? null : Integer.valueOf(s)).get();
                        // 查询用户
                        UserVO user = new UserVO().setId(id).setUsername(UUID.randomUUID().toString());
                        // 返回列表
                        return ServerResponse.ok().bodyValue(user);
                    }

                });
    }

    @Bean
    public RouterFunction demoRouterFunction() {
        return route(GET("/users2/demo"), request -> ok().bodyValue("demo"));
    }

}
  • 在类上,添加 @Configuration 注解,保证该类中的 Bean 们,都被扫描到。

  • 在每个方法中,我们都通弄 RouterFunctions#route(RequestPredicate predicate, HandlerFunction handlerFunction) 方法,定义了一条路由。

    • 第一个参数 predicate 参数,是 RequestPredicate 类型,请求谓语,用于匹配请求。可以通过 RequestPredicates 来构建各种条件。
    • 第二个参数 handlerFunction 参数,是 RouterFunction 类型,处理器函数。
  • 每个方法定义的路由,胖友自己看下代码,一眼能看的明白。一般来说,采用第三个方法的写法,更加简洁。注意,需要使用 static import 静态引入,代码如下:

import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.*;
import static org.springframework.web.reactive.function.server.ServerResponse.*;

加推荐基于 Annotated Controller 方式实现的编程方式,更符合我们现在的开发习惯,学习成本也相对低一些。同时,和 API 接口文档工具 Swagger 也更容易集成。

3. 测试接口

在开发完接口,我们会进行接口的自测。一般情况下,我们先启动项目,然后使用 Postman、curl、浏览器,手工模拟请求后端 API 接口。
如访问url
实际上,WebFlux 提供了 Web 测试客户端 WebTestClient 类,方便我们快速测试接口。下面,我们对 UserController提供的接口,进行下单元测试。
MockMvc 提供了集成测试和单元测试的能力。

3.1 集成测试

创建 [UserControllerTest]测试类,我们来测试一下简单的 UserController 的每个操作。核心代码如下:

// UserControllerTest.java

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
@AutoConfigureWebFlux
@AutoConfigureWebTestClient
public class UserControllerTest {

 @Autowired
 private WebTestClient webClient;

 @Test
 public void testList() {
 webClient.get().uri("/users/list")
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody().json("[\n" +
 "    {\n" +
 "        \"id\": 1,\n" +
 "        \"username\": \"yudaoyuanma\"\n" +
 "    },\n" +
 "    {\n" +
 "        \"id\": 2,\n" +
 "        \"username\": \"woshiyutou\"\n" +
 "    },\n" +
 "    {\n" +
 "        \"id\": 3,\n" +
 "        \"username\": \"chifanshuijiao\"\n" +
 "    }\n" +
 "]"); // 响应结果
 }

 @Test
 public void testGet() {
 // 获得指定用户编号的用户
 webClient.get().uri("/users/get?id=1")
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody().json("{\n" +
 "    \"id\": 1,\n" +
 "    \"username\": \"username:1\"\n" +
 "}"); // 响应结果
 }

 @Test
 public void testGet2() {
 // 获得指定用户编号的用户
 webClient.get().uri("/users/v2/get?id=1")
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody().json("{\n" +
 "    \"id\": 1,\n" +
 "    \"username\": \"test\"\n" +
 "}"); // 响应结果
 }

 @Test
 public void testAdd() {
 Map params = new HashMap<>();
 params.put("username", "yudaoyuanma");
 params.put("password", "nicai");
 // 添加用户
 webClient.post().uri("/users/add")
 .bodyValue(params)
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody().json("1"); // 响应结果。因为没有提供 content 的比较,所以只好使用 json 来比较。竟然能通过
 }

 @Test
 public void testAdd2() { // 发送文件的测试,可以参考 https://dev.to/shavz/sending-multipart-form-data-using-spring-webtestclient-2gb7 文章
 BodyInserters.FormInserter formData = // Form Data 数据,需要这么拼凑
 BodyInserters.fromFormData("username", "yudaoyuanma")
 .with("password", "nicai");
 // 添加用户
 webClient.post().uri("/users/add2")
 .body(formData)
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody().json("1"); // 响应结果。因为没有提供 content 的比较,所以只好使用 json 来比较。竟然能通过
 }

 @Test
 public void testUpdate() {
 Map params = new HashMap<>();
 params.put("id", 1);
 params.put("username", "yudaoyuanma");
 // 修改用户
 webClient.post().uri("/users/update")
 .bodyValue(params)
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody(Boolean.class) // 期望返回值类型是 Boolean
 .consumeWith((Consumer>) result -> // 通过消费结果,判断符合是 true 。
 Assert.assertTrue("返回结果需要为 true", result.getResponseBody()));
 }

 @Test
 public void testDelete() {
 // 删除用户
 webClient.post().uri("/users/delete?id=1")
 .exchange() // 执行请求
 .expectStatus().isOk() // 响应状态码 200
 .expectBody(Boolean.class) // 期望返回值类型是 Boolean
 .isEqualTo(true); // 这样更加简洁一些
//                .consumeWith((Consumer>) result -> // 通过消费结果,判断符合是 true 。
//                        Assert.assertTrue("返回结果需要为 true", result.getResponseBody()));
 }

}
  • 在类上,我们添加了 @AutoConfigureWebTestClient 注解,用于自动化配置我们稍后注入的 WebTestClient Bean 对象 webClient 。在后续的测试中,我们会看到都是通过 webClient 调用后端 API 接口。而每一次调用后端 API 接口,都会执行真正的后端逻辑。因此,整个逻辑,走的是集成测试,会启动一个真实的 Spring 环境。
  • 每次 API 接口的请求,都通过 RequestHeadersSpec 来构建。构建完成后,通过 RequestHeadersSpec#exchange() 方法来执行请求,返回 ResponseSpec 结果。
    • WebTestClient 的 #get()#head()#delete()#options() 方法,返回的是 RequestHeadersUriSpec 对象。
    • WebTestClient 的 #post()#put()#delete()#patch() 方法,返回的是 RequestBodyUriSpec 对象。
    • RequestHeadersUriSpec 和 RequestBodyUriSpec 都继承了 RequestHeadersSpec 接口。
  • 执行完请求后,通过调用 RequestBodyUriSpec 的各种断言方法,添加对结果的预期,相当于做断言。如果不符合预期,则会抛出异常,测试不通过。

3.2 单元测试

为了更好的展示 WebFlux 单元测试的示例,我们需要改写 UserController 的代码,让其会依赖 UserService 。修改点如下:

  • 创建 [UserService]类。代码如下:
// UserService.java

    @Service
    public class UserService {

     public UserVO get(Integer id) {
     return new UserVO().setId(id).setUsername("test");
     }

    }
  • 在 [UserController]类中,增加 GET /users/v2/get 接口,获得指定用户编号的用户。代码如下:
// UserController.java

@Autowired
private UserService userService;

/**
 * 获得指定用户编号的用户
 *
 * @param id 用户编号
 * @return 用户
 */
@GetMapping("/v2/get")
public Mono get2(@RequestParam("id") Integer id) {
    // 查询用户
    UserVO user = userService.get(id);
    // 返回
    return Mono.just(user);
}

在代码中,我们注入了 UserService Bean 对象 userService ,然后在新增的接口方法中,会调用 UserService#get(Integer id) 方法,获得指定用户编号的用户。
创建 [UserControllerTest2]测试类,我们来测试一下简单的 UserController 的新增的这个 API 操作。代码如下:

// UserControllerTest2.java

@RunWith(SpringRunner.class)
@WebFluxTest(UserController.class)
public class UserControllerTest2 {

    @Autowired
    private WebTestClient webClient;

    @MockBean
    private UserService userService;

    @Test
    public void testGet2() throws Exception {
        // Mock UserService 的 get 方法
        System.out.println("before mock:" + userService.get(1)); // <1.1>
        Mockito.when(userService.get(1)).thenReturn(
                new UserVO().setId(1).setUsername("username:1")); // <1.2>
        System.out.println("after mock:" + userService.get(1)); // <1.3>

        // 查询用户列表
        webClient.get().uri("/users/v2/get?id=1")
                .exchange() // 执行请求
                .expectStatus().isOk() // 响应状态码 200
                .expectBody().json("{\n" +
                "    \"id\": 1,\n" +
                "    \"username\": \"username:1\"\n" +
                "}"); // 响应结果
    }

}
  • 在类上添加 @WebFluxTest 注解,并且传入的是 UserController 类,表示我们要对 UserController 进行单元测试。
  • 同时,@WebFluxTest 注解,是包含了 @UserController 的组合注解,所以它会自动化配置我们稍后注入的 WebTestClient Bean 对象 mvc 。在后续的测试中,我们会看到都是通过 webClient 调用后端 API 接口。但是!每一次调用后端 API 接口,并不会执行真正的后端逻辑,而是走的 Mock 逻辑。也就是说,整个逻辑,走的是单元测试会启动一个 Mock 的 Spring 环境。

注意上面每个加粗的地方!

  • userService 属性,我们添加了 [@MockBean]注解,实际这里注入的是一个使用 Mockito 创建的 UserService Mock 代理对象。如下图所示:[图片上传失败...(image-46836b-1596810612894)]

    • UserController 中,也会注入一个 UserService 属性,此时注入的就是该 Mock 出来的 UserService Bean 对象。

    • 默认情况下,

    • <1.1> 处,我们调用 UserService#get(Integer id) 方法,然后打印返回结果。执行结果如下:before mock:null, 结果竟然返回的是 null 空。理论来说,此时应该返回一个 id = 1 的 UserVO 对象。实际上,因为此时的 userService 是通过 Mockito 来 Mock 出来的对象,其所有调用它的方法,返回的都是空。

    • <1.2> 处,通过 Mockito 进行 Mock userService#get(Integer id) 方法,当传入的 id = 1 方法参数时,返回 id = 1 并且 username = "username:1" 的 UserVO 对象。

    • <1.3> 处,再次调用 UserService#get(Integer id) 方法,然后打印返回结果。执行结果如下:after cn.iocoder.springboot.lab27.springwebflux.vo.UserVO@23202c31
      打印的就是我们 Mock 返回的 UserVO 对象。

  • 后续,使用 webClient 完成一次后端 API 调用,并进行断言结果是否正确。执行成功,单元测试通过。

底线


本文源代码使用 Apache License 2.0开源许可协议,这里是本文源码Gitee地址,可通过命令git clone+地址下载代码到本地,也可直接点击链接通过浏览器方式查看源代码。

你可能感兴趣的:(Spring Boot 响应式 WebFlux 入门)