攔截處理器


在 Servlet API 中,若要在 Servlet 處理請求的前後做點橫切主要流程的服務,可以使用過濾器,在使用 Spring MVC 之後,雖然說請求實際上會經由 DispatcherServlet,然後依設定決定要呼叫哪個控制器中的方法,然而,你還是可以使用過濾器來處理這類服務。

例如,從〈準備 gossip 專案〉開始,雖然一路重構 gossip 應用程式,然而,在 gossip 應用程式中,有個使用 Java HTML Sanitizer 來消毒訊息內容的元件 cc.openhome.web.HtmlSanitizer,就是使用過濾器實作的。

這是因為過濾器一定是在 Servlet 前執行,而 DispatcherServlet 是個 Servlet,因此 HttpServletRequest 的替換,一定會在 DispatcherServlet 前進行,自然地,後面取得的請求參數值,就是消毒後的訊息內容。

然而,若過濾器對你來說粒度太大,新增訊息主要邏輯,是在 MemberControllernewMessage 方法實作,你想要在該方法前進行消毒呢?

單就攔截控制器中的處理器方法的話,有個古老的 API 自 Spring 1.x 時代就存在,那就是 HandlerInterceptor,在我舊版的 Spring 文件中 〈Handler Interceptor〉也介紹過,HandlerInterceptor 介面有三個方法要實作:

boolean preHandle(HttpServletRequest request,
                  HttpServletResponse response, 
                  Object handler) throws Exception;
void postHandle(HttpServletRequest request, 
                HttpServletResponse response, 
                Object handler, 
                ModelAndView modelAndView)    throws Exception;
void afterCompletion(HttpServletRequest request,
                     HttpServletResponse response, 
                     Object handler, 
                     Exception ex) throws Exception;

正如舊文件中談過的,preHandler 會在控制器處理請求之前被呼叫,HandlerInterceptor 類似 Servlet API 中的過濾器,以鏈狀的方式組織,傳回的 boolean 決定是(傳回 true)否(傳回 false)呼叫接下來的 HandlerInterceptor 或是控制器來處理請求;postHandler 會在控制器處理完請求之後被呼叫,afterCompletion 方法會在頁面繪製完成之後被呼叫。

(關於 ModelAndView,可參考舊版文件中的〈ModelAndView〉。)

在 Spring 5.1.2 中,HandlerInterceptor 依然可以使用,只不過因為支援 Java 8 介面預設方法實作,你可以在實作介面時只實作感興趣的方法(preHandle 預設傳回 true),不必再使用 HandlerInterceptorAdapter,另一方面,可以透過 JavaConfig 的方式來註冊 HandlerInterceptor 也比較方便了,例如,若 LoggingInterceptor 實作了 HandlerInterceptor,而你想介入 MemberController 標註 @PostMapping("new_message") 的方法,可以如下設定:

...略
public class WebConfig implements WebMvcConfigurer, ApplicationContextAware {

    ...略

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoggingInterceptor())
                .addPathPatterns("/new_message");
    }
}

不過,HandlerInterceptor 雖然可以透過 preHandle 傳回 false 來攔截請求,也傳入了 HttpServletRequest,然而,你無法消毒請求參數的值,因為不若過濾器中 FilterChaindoFilter 方法,可傳入請求包裹器。

這時就可以用上之前談 AOP 時的 @Around 了:

package cc.openhome.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.owasp.html.PolicyFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@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] = policy.sanitize(args[0].toString());
        return proceedingJoinPoint.proceed(args);
    }
}

在這邊取得了 newMessage 的第一個參數,也就是使用者發送的訊息,在 PolicyFactorysanitize 消毒過後,重新設定為第一個引數,然後呼叫 ProceedingJoinPointproceed 時傳入,這樣就完成了訊息的過澸。

由於消毒策略是會變動的,上頭使用了 @Autowired 來自動綁定 PolicyFactory,為了便於設定,可以在 WebConfig 組態:

...略
@EnableAspectJAutoProxy 
@ComponentScan(basePackages = {"cc.openhome.controller", "cc.openhome.aspect"})
public class WebConfig implements WebMvcConfigurer, ApplicationContextAware {
    ...略

    @Bean
    public PolicyFactory htmlPolicy() {
        return new HtmlPolicyBuilder()
                    .allowElements("a", "b", "i", "del", "pre", "code")
                    .allowUrlProtocols("http", "https")
                    .allowAttributes("href").onElements("a")
                    .requireRelNofollowOnLinks()
                    .toFactory();
    }
    ...略
}

別忘了加上 @EnableAspectJAutoProxy,而且掃描 Aspect 所在的套件,現在原本消毒訊息用的過濾器可以刪掉了,訊息處理的粒度縮小在 MemberControllernewMessage 方法上,並且仍是個橫切主要流程的服務。

如果 HandlerInterceptor 與符合 Pointcut 的 Aspect 同時存在,那麼 HandlerInterceptor 會是在 Aspect 的前後執行。

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