WebClient 與 Thymeleaf


在〈簡介 WebFlux〉中看過,WebFlux 本身會訂閱 FluxMono,在有資料的時候,對客戶端進行回應,這不就表示,像是 Server-Sent Event,使用 WebFlux 處理就很方便了?確實是的,來看個簡單的示範:

package cc.openhome;

...略

@SpringBootApplication
@RestController
public class HelloFluxApplication {

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

    @GetMapping("/hello/{name}") 
    public Mono<User> hello(@PathVariable("name") String name) {
        return Mono.just(new User(name));
    }

    @GetMapping("/randomNumber")
    public Flux<ServerSentEvent<Long>> randomNumbers() {
        return Flux.interval(Duration.ofSeconds(1))
                   .map(tick -> Tuples.of(tick, ThreadLocalRandom.current().nextLong()))
                   .map(this::randomNumberEvent)
                   .take(5);
    }

    public ServerSentEvent<Long> randomNumberEvent(Tuple2<Long, Long> data) {
        return ServerSentEvent.<Long>builder()
                              .event("randomNumber")
                              .id(Long.toString(data.getT1()))
                              .data(data.getT2())
                              .build();
    }
}

透過 Flux.interval,我們每秒發送一次資料,之後轉換為隨機數字,然後透過 ServerSentEvent 來建構符合 Server-Sent Event 規範的回應,啟動應用程式之後,可以請求 /randomNumber,就會看到五次定時的 JSON 回應,或者是透過底下的 JavaScript 來顯示隨機數字:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>即時資料</title>
</head>
<body>
        即時資料: <span id="data">0</span>

    <script type="text/javascript">
       new EventSource("randomNumber")
                .addEventListener("randomNumber", 
                    e => document.getElementById('data').innerHTML = e.data
                );
    </script>

</body>
</html>

這個 HTML 一樣可以放在 Spring Boot 的 static 目錄之中。

相較於使用 Servlet API 的 AsyncContext 來實作 Server-Sent Event 回應(可參考《Servet & JSP 技術手冊 - 從 Servlet 到 Spring Boot》中的 AsyncNumber2.java),當然是方便了不少。

對於客戶端請求,WebFlux 提供了 WebClient,也是基於 Reactor 的基礎,可於 WebFlux 中用來請求另一網站的資源,或者是作為功能測試時使用,例如簡單地測試一下〈簡介 WebFlux〉中的回應(User 必須加入無參建構式),accept 可以不設定,預設接受全部類型:

@Test
public void testHello() {
    Mono<String> userName =
        WebClient.create("http://localhost:8080/")
                 .get()
                 .uri("hello/caterpillar")
                 .accept(MediaType.APPLICATION_JSON)
                 .exchange()
                 .flatMap(response -> response.bodyToMono(User.class))
                 .map(User::getName);

    StepVerifier.create(userName)
                .expectNext("caterpillar")
                .expectComplete()
                .verify();
}

若要測試 Server-Sent Event,可以如下:

@Test
public void serverSentEvent() {
     Flux<Long> numbers = 
         WebClient.create("http://localhost:8080")
                  .get()
                  .uri("/randomNumber")
                  .accept(MediaType.TEXT_EVENT_STREAM)
                  .retrieve()
                  .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
                  .map(event -> Long.parseLong(event.data()));

     StepVerifier.create(numbers)
                 .expectNextCount(5)
                 .expectComplete()
                 .verify();
}

到目前為止,都是使用 JSON 作為回應,既然 WebFlux 是非阻斷的 Web 堆疊方案,採用 JSON 回應,而客戶端運用 JavaScript 來進行非同步請求,會是最自然的運用情境,然而,若想回應 HTML 呢?雖然不建議混搭,然而確實是可行的,例如,若想採用 Thymeleaf 作為模版引擎,只要在 build.gradle 中加入:

implementation('org.springframework.boot:spring-boot-starter-thymeleaf')

開發時會經常更新模版,因此可以在 application.properties 中關掉快取:

spring.thymeleaf.cache=false

接著可以修改一下啟動程式:

package cc.openhome;

...略

@SpringBootApplication
@Controller
public class HelloFluxApplication {

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

    @GetMapping("/hi/{name}") 
    public Mono<String> hi(@PathVariable("name") String name, Model model) {
        model.addAttribute("name", Mono.just(name));
        return "hi";
    }

    @GetMapping("/hello/{name}") 
    @ResponseBody
    public Mono<User> hello(@PathVariable("name") String name) {
        return Mono.just(new User(name));
    }

    @GetMapping("/randomNumber")
    @ResponseBody
    public Flux<ServerSentEvent<Long>> randomNumbers() {
        return Flux.interval(Duration.ofSeconds(1))
                   .map(tick -> Tuples.of(tick, ThreadLocalRandom.current().nextLong()))
                   .map(this::randomNumberEvent)
                   .take(5);
    }

    private ServerSentEvent<Long> randomNumberEvent(Tuple2<Long, Long> data) {
        return ServerSentEvent.<Long>builder()
                              .event("randomNumber")
                              .id(Long.toString(data.getT1()))
                              .data(data.getT2())
                              .build();
    }
}

注意一下,這邊標示的是 @Contoller 而不是 @RestController,需要回應 JSON 的處理器方法才標示 @ResponseBody,回應的是路徑字串的處理器方法則不用,另外,該處理器中屬性設置的部份,值可以是 Flux 或是 Mono(單純使用字串也可以),處理器的傳回方法也可以是 Mono(單純使用字串也可以),剩下的就跟 Spring MVC 類似了,例如 hi.html 可以放在 templates 之中:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hi</title>
</head>
<body>
      Hello, <span th:text="${name}">User</span>
</body>
</html>

既然有 WebClient 可以用,那就它來測試好了,不用再開啟瀏覽器了:

@Test
public void htmlHi() {
    WebClient.create("http://localhost:8080/")
             .get()
             .uri("hi/caterpillar")
             .accept(MediaType.TEXT_HTML)
             .exchange()
             .flatMap(resp -> resp.bodyToMono(String.class))
             .subscribe(html -> assertTrue(html.contains("caterpillar")));               
}

你可以在 WebClient 找到以上的範例專案。