Reactive Spring實戰 -- WebFlux使用教程

binecy發表於2021-03-07

WebFlux是Spring 5提供的響應式Web應用框架。
它是完全非阻塞的,可以在Netty,Undertow和Servlet 3.1+等非阻塞伺服器上執行。
本文主要介紹WebFlux的使用。

FluxWeb vs noFluxWeb

WebFlux是完全非阻塞的。
在FluxWeb前,我們可以使用DeferredResult和AsyncRestTemplate等方式實現非阻塞的Web通訊。
我們先來比較一下這兩者。

注意:關於同步阻塞與非同步非阻塞的效能差異,本文不再闡述。
阻塞即浪費。我們通過非同步實現非阻塞。只有存在阻塞時,非同步才能提高效能。如果不存在阻塞,使用非同步反而可能由於執行緒排程等開銷導致效能下降。

下面例子模擬一種業務場景。
訂單服務提供介面查詢訂單資訊,同時,該介面實現還需要呼叫倉庫服務查詢倉庫資訊,商品服務查詢商品資訊,並過濾,取前5個商品資料。

OrderService提供如下方法

public void getOrderByRest(DeferredResult<Order> rs, long orderId) {
    // [1]
    Order order = mockOrder(orderId);
    // [2]
    ListenableFuture<ResponseEntity<User>> userLister = asyncRestTemplate.getForEntity("http://user-service/user/mock/" + 1, User.class);
    ListenableFuture<ResponseEntity<List<Goods>>> goodsLister =
                    asyncRestTemplate.exchange("http://goods-service/goods/mock/list?ids=" + StringUtils.join(order.getGoodsIds(), ","),
                            HttpMethod.GET,  null, new ParameterizedTypeReference<List<Goods>>(){});
    // [3]
    CompletableFuture<ResponseEntity<User>> userFuture = userLister.completable().exceptionally(err -> {
        logger.warn("get user err", err);
        return new ResponseEntity(new User(), HttpStatus.OK);
    });
    CompletableFuture<ResponseEntity<List<Goods>>> goodsFuture = goodsLister.completable().exceptionally(err -> {
        logger.warn("get goods err", err);
        return new ResponseEntity(new ArrayList<>(), HttpStatus.OK);
    });
    // [4]
    warehouseFuture.thenCombineAsync(goodsFuture, (warehouseRes, goodsRes)-> {
            order.setWarehouse(warehouseRes.getBody());
            List<Goods> goods = goodsRes.getBody().stream()
                    .filter(g -> g.getPrice() > 10).limit(5)
                    .collect(Collectors.toList());
            order.setGoods(goods);
        return order;
    }).whenCompleteAsync((o, err)-> {
        // [5]
        if(err != null) {
            logger.warn("err happen:", err);
        }
        rs.setResult(o);
    });
}
  1. 載入訂單資料,這裡mack了一個資料。
  2. 通過asyncRestTemplate獲取倉庫,產品資訊,得到ListenableFuture。
  3. 設定ListenableFuture異常處理,避免因為某個請求報錯導致介面失敗。
  4. 合併倉庫,產品請求結果,組裝訂單資料
  5. 通過DeferredResult設定介面返回資料。

可以看到,程式碼較繁瑣,通過DeferredResult返回資料的方式也與我們同步介面通過方法返回值返回資料的方式大相徑庭。

這裡實際存在兩處非阻塞

  1. 使用AsyncRestTemplate實現傳送非同步Http請求,也就是說通過其他執行緒呼叫倉庫服務和產品服務,並返回CompletableFuture,所以不阻塞getOrderByRest方法執行緒。
  2. DeferredResult負責非同步返回Http響應。
    getOrderByRest方法中並不阻塞等待AsyncRestTemplate返回,而是直接返回,等到AsyncRestTemplate返回後通過回撥函式設定DeferredResult的值將資料返回給Http,可對比以下阻塞等待的程式碼
ResponseEntity<Warehouse> warehouseRes = warehouseFuture.get();
ResponseEntity<List<Goods>> goodsRes = goodsFuture.get();
order.setWarehouse(warehouseRes.getBody());
order.setGoods(goodsRes.getBody());
return order;

下面我們使用WebFlux實現。
pom引入依賴

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>

服務啟動類OrderServiceReactive

@EnableDiscoveryClient
@SpringBootApplication
public class OrderServiceReactive
{
    public static void main( String[] args )
    {
        new SpringApplicationBuilder(
                OrderServiceReactive.class)
                .web(WebApplicationType.REACTIVE).run(args);
    }
}

WebApplicationType.REACTIVE啟動WebFlux。

OrderController實現如下

@GetMapping("/{id}")
public Mono<Order> getById(@PathVariable long id) {
    return service.getOrder(id);
}

注意返回一個Mono資料,Mono與Flux是Spring Reactor提供的非同步資料流。
WebFlux中通常使用Mono,Flux作為資料輸入,輸出值。
當介面返回Mono,Flux,Spring知道這是一個非同步請求結果。
關於Spring Reactor,可參考《理解Reactor的設計與實現

OrderService實現如下

public Mono<Order> getOrder(long orderId) {
    // [1]
    Mono<Order> orderMono = mockOrder(orderId);
    // [2]
    return orderMono.flatMap(o -> {
        // [3]
        Mono<User> userMono =  getMono("http://user-service/user/mock/" + o.getUserId(), User.class).onErrorReturn(new User());
        Flux<Goods> goodsFlux = getFlux("http://goods-service/goods/mock/list?ids=" +
                StringUtils.join(o.getGoodsIds(), ","), Goods.class)
                .filter(g -> g.getPrice() > 10)
                .take(5)
                .onErrorReturn(new Goods());
        // [4]
        return userMono.zipWith(goodsFlux.collectList(), (u, gs) -> {
            o.setUser(u);
            o.setGoods(gs);
            return o;
        });
    });
}

private <T> Mono<T> getMono(String url, Class<T> resType) {
    return webClient.get().uri(url).retrieve().bodyToMono(resType);
}

// getFlux
  1. 載入訂單資料,這裡mock了一個Mono資料
  2. flatMap方法可以將Mono中的資料轉化型別,這裡轉化後的結果還是Order。
  3. 獲取倉庫,產品資料。這裡可以看到,對產品過濾,取前5個的操作可以直接新增到Flux上。
  4. zipWith方法可以組合兩個Mono,並返回新的Mono型別,這裡組合倉庫、產品資料,最後返回Mono
    可以看到,程式碼整潔不少,並且介面返回Mono,與我們在同步介面中直接資料的做法類似,不需要藉助DeferredResult這樣的工具類。

我們通過WebClient發起非同步請求,WebClient返回Mono結果,雖然它並不是真正的資料(它是一個資料釋出者,等請求資料返回後,它才把資料送過來),但我們可以通過操作符方法對他新增邏輯,如過濾,排序,組合,就好像同步操作時已經拿到資料那樣。
而在AsyncRestTemplate,則所有的邏輯都要寫到回撥函式中。

WebFlux是完全非阻塞的。
Mono、Flux的組合函式非常有用。
上面方法中先獲取訂單資料,再同時獲取倉庫,產品資料,
如果介面引數同時傳入了訂單id,倉庫id,產品id,我們也可以同時獲取這三個資料,再組裝起來

public Mono<Order> getOrder(long orderId, long warehouseId, List<Long> goodsIds) {
    Mono<Order> orderMono = mockOrderMono(orderId);

    return orderMono.zipWith(getMono("http://warehouse-service/warehouse/mock/" + warehouseId, Warehouse.class), (o,w) -> {
        o.setWarehouse(w);
        return o;
    }).zipWith(getFlux("http://goods-service/goods/mock/list?ids=" +
            StringUtils.join(goodsIds, ","), Goods.class)
            .filter(g -> g.getPrice() > 10).take(5).collectList(), (o, gs) -> {
        o.setGoods(gs);
        return o;
    });
}

如果我們需要序列獲取訂單,倉庫,商品這三個資料,實現如下

public Mono<Order> getOrderInLabel(long orderId) {
    Mono<Order> orderMono = mockOrderMono(orderId);

    return orderMono.zipWhen(o -> getMono("http://warehouse-service/warehouse/mock/" + o.getWarehouseId(), Warehouse.class), (o, w) -> {
        o.setWarehouse(w);
        return o;
    }).zipWhen(o -> getFlux("http://goods-service/goods/mock/list?ids=" +
                    StringUtils.join(o.getGoodsIds(), ",") + "&label=" + o.getWarehouse().getLabel() , Goods.class)
            .filter(g -> g.getPrice() > 10).take(5).collectList(), (o, gs) -> {
        o.setGoods(gs);
        return o;
    });
}

zipWith方法會同時請求待合併的兩個Mono資料,而zipWhen方法則會阻塞等待第一個Mono資料到達在請求第二個Mono資料。
orderMono.zipWhen(...).zipWhen(...),第一個zipWhen方法會阻塞等待orderMono資料返回再使用order資料構造新的Mono資料,第二個zipWhen方法也會等待前面zipWhen構建的Mono資料返回再構建新Mono,
所以在第二個zipWhen方法中,可以呼叫o.getWarehouse().getLabel(),因為第一個zipWhen已經獲取到倉庫資訊。

下面說一個WebFlux的使用。
分為兩部分,WebFlux服務端與WebClient。

WebFlux服務端

底層容器切換

WebFlux預設使用Netty實現服務端非同步通訊,可以通過更換依賴包切換底層容器

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <exclusions>
    <exclusion>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-netty</artifactId>
    </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>

註解

WebFlux支援SpringMvc大部分的註解,如
對映:@Controller,@GetMapping,@PostMapping,@PutMapping,@DeleteMapping
引數繫結:@PatchMapping,@RequestParam,@RequestBody,@RequestHeader,@PathVariable,@RequestAttribute,@SessionAttribute
結果解析:@ResponseBody,@ModelAttribute
這些註解的使用方式與springMvc相同

命令式對映

WebFlux支援使用指令式程式設計指定對映關係

@Bean
public RouterFunction<ServerResponse> monoRouterFunction(InvoiceHandler invoiceHandler) {
    return route()
            .GET("/invoice/{orderId}",  accept(APPLICATION_JSON), invoiceHandler::get)
            .build();
}

呼叫"/invoice/{orderId}",請求會轉發到invoiceHandler#get方法

invoiceHandler#get方法實現如下

public Mono<ServerResponse> get(ServerRequest request) {
    Invoice invoice = new Invoice();
    invoice.setId(999L);
    invoice.setOrderId(Long.parseLong(request.pathVariable("orderId")));
    return ok().contentType(APPLICATION_JSON).body(Mono.just(invoice), Warehouse.class);
}

Filter

可以通過實現WebFilter介面新增過濾器

@Component
public class TokenCheckFilter implements WebFilter {
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        if(!exchange.getRequest().getHeaders().containsKey("token")) {
            ServerHttpResponse response =  exchange.getResponse();
            response.setStatusCode(HttpStatus.FORBIDDEN);
            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            return response.writeWith(Mono.just(response.bufferFactory().wrap("{\"msg\":\"no token\"}".getBytes())));
        } else {
            exchange.getAttributes().put("auth", "true");
            return chain.filter(exchange);
        }
    }
}

上面實現的是前置過濾器,在呼叫邏輯方法前的檢查請求token

實現後置過濾器程式碼如下

@Component
public class LogFilter  implements WebFilter {
    private static final Logger logger = LoggerFactory.getLogger(LogFilter.class);
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        // [1]
        logger.info("request before, url:{}, statusCode:{}", exchange.getRequest().getURI(), exchange.getResponse().getStatusCode());
        return chain.filter(exchange)
            .doFinally(s -> {
                // [2]
                logger.info("request after, url:{}, statusCode:{}", exchange.getRequest().getURI(), exchange.getResponse().getStatusCode());
            });
    }
}

注意,[1]處exchange.getResponse()返回的是初始化狀態的response,並不是請求處理後返回的response。

異常處理

通過@ExceptionHandler註解定義一個全域性的異常處理器

@ControllerAdvice
public class ErrorController {
    private static final Logger logger = LoggerFactory.getLogger(ErrorController.class);

    @ResponseBody
    @ExceptionHandler({NullPointerException.class})
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String nullException(NullPointerException e) {
        logger.error("global err handler", e);
        return "{\"msg\":\"There is a problem\"}";
    }
}

WebFluxConfigurer

WebFlux中可以通過WebFluxConfigurer做自定義配置,如配置自定義的結果解析

@Configuration
@EnableWebFlux
public class WebConfig implements WebFluxConfigurer {
    public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
        configurer.addCustomResolver(new HandlerMethodArgumentResolver() {
            ...
        });
    }

    public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
        configurer.customCodecs().register(new HttpMessageWriter() {
            ...
        });
    }
}

configureArgumentResolvers方法配置引數繫結處理器
configureHttpMessageCodecs方法配置Http請求報文,響應報文解析器

@EnableWebFlux要求Spring從WebFluxConfigurationSupport引入Spring WebFlux 配置。如果你的依賴中引入了spring-boot-starter-webflux,Spring WebFlux 將自動配置,不需要新增該註解。
但如果你只使用Spring WebFlux而沒有使用Spring Boot,這是需要新增@EnableWebFlux啟動Spring WebFlux自動化配置。

Spring Flux支援CORS,Spring Security,HTTP/2,更多內容不再列出,請參考官方文件。

WebClient

WebClient可以傳送非同步Web請求,並支援響應式程式設計。
下面說一個WebClient的使用。

底層框架

WebClient底層使用的Netty實現非同步Http請求,我們可以切換底層庫,如Jetty

@Bean
public JettyResourceFactory resourceFactory() {
    return new JettyResourceFactory();
}

@Bean
public WebClient webClient() {
    HttpClient httpClient = HttpClient.create();
    ClientHttpConnector connector =
            new JettyClientHttpConnector(httpClient, resourceFactory());
    return WebClient.builder().clientConnector(connector).build();
}

連線池

WebClient預設是每個請求建立一個連線。
我們可以配置連線池複用連線,以提高效能。

ConnectionProvider provider = ConnectionProvider.builder("order")
    .maxConnections(100)
    .maxIdleTime(Duration.ofSeconds(30))
    .pendingAcquireTimeout(Duration.ofMillis(100))  
    .build();
return WebClient
    .builder().clientConnector(new ReactorClientHttpConnector(HttpClient.create(provider)));

maxConnections:允許的最大連線數
pendingAcquireTimeout:沒有連線可用時,請求等待的最長時間
maxIdleTime:連線最大閒置時間

超時

底層使用Netty時,可以如下配置超時時間

import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;

HttpClient httpClient = HttpClient.create()
        .doOnConnected(conn -> conn
                .addHandlerLast(new ReadTimeoutHandler(10))
                .addHandlerLast(new WriteTimeoutHandler(10)));

或者直接使用responseTimeout

HttpClient httpClient = HttpClient.create()
        .responseTimeout(Duration.ofSeconds(2));
Post Json

WebClient可以傳送json,form,檔案等請求報文,
看一個最常用的Post Json請求

webClient.post().uri("http://localhost:9004/order/")
    .contentType(MediaType.APPLICATION_JSON)
    .body(Mono.just(order), Order.class)
    .retrieve().bodyToMono(String.class)

異常處理

可以在ResponseSpec中指定異常處理

private <T> Mono<T> getMono(String url, Class<T> resType) {
return webClient
    .get().uri(url).retrieve()
    .onStatus(HttpStatus::is5xxServerError, clientResponse -> {
        return Mono.error(...);
    })
    .onStatus(HttpStatus::is4xxClientError, clientResponse -> {
        return Mono.error(...);
    })
    .onStatus(HttpStatus::isError, clientResponse -> {
        return Mono.error(...);
    })
    .bodyToMono(resType)
}

也可以在HttpClient上配置

HttpClient httpClient = HttpClient.create()
        .doOnError((req, err) -> {
            log.error("err on request:{}", req.uri(), err);
        }, (res, err) -> {
            log.error("err on response:{}", res.uri(), err);
        })

同步返回結果

使用block方法可以阻塞執行緒,等待請求返回

private <T> T syncGetMono(String url, Class<T> resType) {
    return webClient
            .get().uri(url).retrieve()
            .bodyToMono(resType).block();
}

獲取響應資訊

exchangeToMono可以獲取到響應的header,statusCode等資訊

private <T> Mono<T> getMonoWithInfo(String url, Class<T> resType) {
    return webClient
            .get()
            .uri(url)
            .exchangeToMono(response -> {
                logger.info("request url:{},statusCode:{},headers:{}", url, response.statusCode(), response.headers());
                return response.bodyToMono(resType);
            });
}

註冊中心與Ribbon

經驗證,WebClient支援Eureka註冊中心與Ribbon轉發,使用方式與restTemplate相同。
不過@LoadBalanced需要新增在WebClient.Builder上

@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
    return WebClient.builder();
}

官方文件:https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html
文章完整程式碼:https://gitee.com/binecy/bin-springreactive/tree/master/order-service

實際專案中,執行緒阻塞場景往往不只有Http請求阻塞,還有Mysql請求,Redis請求,Kafka請求等等導致的阻塞。從這些資料來源中獲取資料時,大多數都是阻塞直到資料來源返回資料。
而Reactive Spring強大在於,它也支援這些資料來源的非阻塞響應式程式設計。
下一篇文章,我們來看一個如何實現Redis的非阻塞響應式程式設計。

如果您覺得本文不錯,歡迎關注我的微信公眾號,系列文章持續更新中。您的關注是我堅持的動力。

相關文章