在〈簡介 WebFlux〉中看過,WebFlux 本身會訂閱 Flux
或 Mono
,在有資料的時候,對客戶端進行回應,這不就表示,像是 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 找到以上的範例專案。