Reactive 的 WebFlux,比較適合 REST 風格、非同步的應用程式,如果是個新專案,從頭開始就朝著這方向前進,要適切 WebFlux 的模型假設應該會比較順利一些。
然而,傳統的 Web 應用程式呢?例如,隨著這系列文件一路發展下來的 gossip 專案,並不是 REST 風格,而且是基於同步的概念,就沒機會套上 WebFlux 嗎?這是個有趣也頗有挑戰性的題目,或許也比較適合既有的專案生態,特意來做做看,可以比較 Spring MVC 與 WebFlux 的不同,利用這簡單的專案轉換,也有利於評估日後是否採用 WebFlux。
當然,雖說 WebFlux 可以運用 Spring MVC 的註解模型,不過 gossip 專案發展到〈套用 Spring Data JDBC〉中的成果,也是費了一番功夫,這也就是說,如果對 Spring MVC 根本就不熟,那麼轉換會遇上更多的困難,相反地,如果專案本身已經適當運用了 Spring MVC,轉換時就可以專注在 Reactive 相關的 API 銜接了。
這邊就從〈套用 Spring Data JDBC〉的 gossip 成果來轉換,首先就是改一下 build.gradle,把 web 改為 webflux:
implementation('org.springframework.boot:spring-boot-starter-webflux')
既然 WebFlux 可以運用 Spring MVC 的註解模型,就先假設那些 @Controller
等的部份都可以運作吧!因此就先來想辦法解決 Security 的部份,首先,頁面防護的部份有點大同小異:
@Bean
public SecurityWebFilterChain securitygWebFilterChain(ServerHttpSecurity http) {
ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
RedirectServerLogoutSuccessHandler logoutSuccessHandler = new RedirectServerLogoutSuccessHandler();
logoutSuccessHandler.setLogoutSuccessUrl(URI.create("/?logout"));
return http
.authorizeExchange()
.pathMatchers("/member", "/new_message", "/del_message", "/logout").hasRole("MEMBER")
.anyExchange().permitAll()
.and()
.formLogin()
.loginPage("/")
.authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/member"))
.authenticationFailureHandler((webFilterExchange, ex) -> {
ServerWebExchange webExchange = webFilterExchange.getExchange();
return webExchange
.getFormData()
.map(formData -> URI.create(String.format("/?username=%s&error", formData.getFirst("username"))))
.flatMap(uri -> redirectStrategy.sendRedirect(webExchange, uri));
})
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.and().csrf().disable()
.build();
}
要注意的地方在於,loginPage
設定的路徑,也相當於設定了非 Reactive 版本中 loginProcessingUrl
(因為我也找不到有哪個方法可以另外設定),在登入、登出相關的處理器,有興趣可以自己查看一下 API 文件,它們都是 Reactive 的版本,也就是 ServerAuthenticationSuccessHandler
、ServerAuthenticationFailureHandler
的實作類別。
因為不使用 Servlet API 了,如果你要取得請求、回應、表單等資訊,在 WebFlux 中是透過 ServerWebExchange
,例如,表單資訊可以從 getFormData
取得,而傳回型態是個 Mono<MultiValueMap<String, String>>
,因為呼叫 ServerAuthenticationFailureHandler
的方法 onAuthenticationFailure
時,必須傳回 Mono<Void>
,這就用到了 map
、flatMap
等來轉換。
若要使用 JDBC 來做驗證,Reactive 版本中並沒有〈套用 jdbcAuthentication〉裏預設好的策略,這無妨,自行提供 Reactive 版本的 ReactiveUserDetailsService
與 ReactiveAuthenticationManager
就是了:
@Bean
public ReactiveUserDetailsService userDetailsService(AccountDAO accountDAO, Scheduler scheduler) {
return username -> {
return Mono.defer(() -> {
return Mono.justOrEmpty(accountDAO.accountByUsername(username))
.map(acct -> {
return User.withUsername(username)
.password(acct.getPassword())
.roles("MEMBER")
.build();
});
}).subscribeOn(scheduler);
};
}
@Bean
public ReactiveAuthenticationManager authenticationManager(ReactiveUserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
UserDetailsRepositoryReactiveAuthenticationManager manager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
manager.setPasswordEncoder(passwordEncoder);
return manager;
}
@Bean
public Scheduler scheduler() {
return Schedulers.elastic();
}
基本上,這麼設定完成後,就可以先啟動應用程式看看,應該可以正常看到首頁,也可以進行登入了,不過,登出時,必須得用 POST 來請求,因此可以修改一下 member.html 中登出的頁面片段,改用表單來發送請求(懂 JavaScript 的話,用 Fetch API 之類的也可以):
...
<div class='leftPanel'>
<p><img src='images/caterpillar.jpg' alt='Gossip 微網誌' /></p>
<p>歡迎您,<span sec:authentication="name">User</span></p>
<form method='post' action='/logout'>
<button type='submit'>登出</button>
</form>
</div>
...
登入登出沒問題了,試著新增一下訊息會發現一直找不到 blabla
請求參數,這是因為 WebFlux 中的 @RequestParam
,只能用來擷取 URL 上附的請求參數,POST 的請求參數不附在 URL,必須透過 ServerWebExchange
的 getFormData
來取得,例如,MemberController
中,被 @PostMapping
標註的處理器,可以做以下的修改:
package cc.openhome.controller;
...略
@Controller
public class MemberController {
...略
@PostMapping("new_message")
protected String newMessage(
ServerWebExchange webExchange,
Principal principal,
Model model) {
String blabla = param(webExchange, "blabla");
if(blabla.length() == 0) {
return REDIRECT_MEMBER_PATH;
}
String username = principal.getName();
if(blabla.length() <= 140) {
userService.addMessage(username, blabla);
return REDIRECT_MEMBER_PATH;
}
else {
model.addAttribute("blabla", blabla);
model.addAttribute("messages", userService.messages(username));
return MEMBER_PATH;
}
}
@PostMapping("del_message")
protected String delMessage(
ServerWebExchange webExchange,
Principal principal) {
String millis = param(webExchange, "millis");
if(millis != null) {
userService.deleteMessage(principal.getName(), millis);
}
return REDIRECT_MEMBER_PATH;
}
private String param(ServerWebExchange webExchange, String paramName) {
return webExchange.getFormData().block().getFirst(paramName);
}
}
因為 getFormData
傳回型態是 Mono<MultiValueMap<String, String>>
,目前控制器還是同步風格的程式碼居多,若暫時不想大改程式,直接呼叫 block
方法,就可以用同步風格來取得想要的資料,當然,這只是為了讓應用程式先能運作,在 WebFlux 運用阻斷風格,並沒辦法發揮 WebFlux 高效處理的真正益處。
另外,在 Thymeleaf 模版部份,也會有 POST 的請求參數不附在 URL,WebFlux 就不理會 param.xxx
的問題,因此,上頭將 blabla
請求參數設為屬性範圍,而 member.html 中改為取屬性,而不是 param.blabla
:
<form method='post' action='new_message'>
分享新鮮事...<br>
<span th:if="${blabla != null}">訊息要 140 字以內</span><br>
<textarea cols='60' rows='4' name='blabla' th:text="${blabla}">Blablablabla...</textarea>
<br>
<button type='submit'>送出</button>
</form>
AccountController
中被 @PostMapping
標註的處理器,也得做對應的修改,這邊就不列出了;現在還有個地方必須得修改,因為我們改變了 MemberController
中 newMessage
的參數,還記得有個過濾輸入訊息的 HtmlSanitizer
嗎?為了簡化範例,當時參數順序是寫死的,因此也得做點修改才行:
package cc.openhome.aspect;
...
@Component
@Aspect
public class HtmlSanitizer {
@Autowired
private PolicyFactory policy;
@Around("execution(* cc.openhome.controller.MemberController.newMessage(..))")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
Object[] args = proceedingJoinPoint.getArgs();
args[0] = new SanitizedServerWebExchange((ServerWebExchange) args[0]);;
return proceedingJoinPoint.proceed(args);
}
class SanitizedServerWebExchange extends ServerWebExchangeDecorator {
public SanitizedServerWebExchange(ServerWebExchange origin) {
super(origin);
}
@Override
public Mono<MultiValueMap<String, String>> getFormData() {
ServerWebExchange origin = this.getDelegate();
return new Mono<MultiValueMap<String, String>>() {
@Override
public void subscribe(CoreSubscriber<? super MultiValueMap<String, String>> actual) {
origin.getFormData().subscribe(actual);
}
@Override
public MultiValueMap<String, String> block() {
MultiValueMap<String, String> multiValueMap = origin.getFormData().block();
multiValueMap.computeIfPresent("blabla", (param, values) -> {
values.set(0, policy.sanitize(values.get(0)));
return values;
});
return multiValueMap;
}
};
}
}
}
在這邊有點技巧性,為了能介入取得表單資料的過程,繼承 ServerWebExchangeDecorator
,定義了一個 SanitizedServerWebExchange
,然後重新定義了 getFormData
方法,傳回自定義的 Mono
實作,目的是在呼叫 block
方法時,傳回的 MultiValueMap
會是過濾後的結果。
那麼為什麼不直接 origin.getFormData().map(...)
來傳回過濾後的結果呢?這會發生以下的錯誤:
"block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-3"
詳情跟 Netty 執行緒處理細節有關,這麼做的話,會在 Netty 配給的執行緒上進行過濾操作,然而,Netty 的執行緒是不允許阻斷的,為了繞過這個問題,只能暫時用上頭的方式 workaround 過去,實際上若是都採用 Reactive 風格的話,可以有個更簡單的解法。
總之,修改完成後,重新啟動應用程式,應該就能常運作了。
你可以在 gossip 找到以上的範例專案,當然,可以的話,再進一步 Reactive 風格一些,這在下一篇文件再來看看怎麼改。