【Spring Boot】第16課-使用 Filter 擷取請求與回應

我們可能會想在請求(request)到達 Controller 前,或回應(response)離開 Controller 後,固定執行一些程式。比方說將請求與回應的內容擷取下來,寫入日誌(log)。

本文將解說如何透過「Filter」元件來擷取 HTTP 的請求與回應。藉此打印出處理請求的時間,以及 request、response 的主體。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch15-fin

一、實作 Filter 程式

首先我們來實作第一個 Filter,它能打印出後端對每個請求的處理時間。請建立一個類別叫做 LogProcessTimeFilter,再繼承 OncePerRequestFilter 抽象類別,並覆寫 doFilterInternal 方法。

public class LogProcessTimeFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// TODO
}
}

事實上,只要是實作 javax.servlet.Filter 介面的類別,都能註冊為 Spring 的 Filter 元件,OncePerRequestFilter 也是如此。不過它是由 Spring 提供的,能加強確保後端接收到一個請求,該 Filter 只會執行一次。

收到請求時,Filter 會自動執行 doFilterInternal 方法。其參數包含請求(HttpServletRequest)、回應(HttpServletResponse)與過濾鏈(FilterChain)。

FilterChain 會將現有的 Filter 給串連起來,當請求進入後端,需要依序經過它們才會到達 Controller。相對地,當回應離開 Controller,則是按照相反的順序經過那些 Filter。

接下來繼續完成 Filter 的執行方法。

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
 
FilterChain chain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
chain.doFilter(request, response);
long processTime = System.currentTimeMillis() - startTime;

System.out.println(processTime + " ms");
}

由於這是目前唯一的 Filter,因此呼叫 FilterChaindoFilter 方法,相當於將請求送達 Controller。再執行到下一行,意味著回應已經從 Controller 離開。我們可藉此計算處理時間,並打印出來。如此就完成一個簡單的 Filter。

等到 doFilter 方法執行完成後,HttpServletResponse 才會存有真正的回應資料。在這之前裡面都是預設值。


二、註冊 Filter 元件

實作完 Filter 的程式後,需要向 Spring 註冊,才會建立它的元件。我們透過第14課的做法,建立一個 Configuration 類別,並宣告元件建立方法。

@Configuration
public class FilterConfig {

@Bean
public FilterRegistrationBean logProcessTimeFilter() {
FilterRegistrationBean<LogProcessTimeFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new LogProcessTimeFilter());
bean.addUrlPatterns("/*");
bean.setName("logProcessTimeFilter");

return bean;
}
}

該建立方法並不是直接回傳 Filter,而是 FilterRegistrationBean。此處分別對這個物件設置 Filter 物件、要套用的 API 路徑及 Filter 名稱。

讀者可設置哪些 API 被呼叫才需要觸發這個 Filter。透過 addUrlPatternssetUrlPatterns 方法,能傳入路徑格式。「*」符號代表全部,例如上述是將 Filter 套用到全部的 API。若只想套用到產品的 API,設置 /products/* 即可。

啟動後端程式後,Spring 便會註冊 Filter 元件並生效,讀者不必特別在哪個地方注入。呼叫 API 後,在 Console 視窗便可一一看到打印出來的資料。


三、設置 Filter 的執行順序

我們可以在程式中配置不同用途的 Filter,並安排執行順序。接下來再實作一個 Filter,它能打印出被呼叫的 API 及其回應的狀態碼。

public class LogApiFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
chain.doFilter(request, response);

int httpStatus = response.getStatus();
String httpMethod = request.getMethod();
String uri = request.getRequestURI();
String params = request.getQueryString();

if (params != null) {
uri += "?" + params;
}

System.out.println(String.join(" ", String.valueOf(httpStatus), httpMethod, uri));
}
}

在元件建立方法中,使用 FilterRegistrationBeansetOrder 方法,可以設定執行順序。數字越低,代表越先擷取請求,但越晚擷取回應。若不設定,則預設的順序為是按照 Filter 的名稱遞減。

@Bean
public FilterRegistrationBean logApiFilter() {
FilterRegistrationBean<LogApiFilter> bean = new FilterRegistrationBean<>();
// 其餘略過
bean.setOrder(0);

return bean;
}

@Bean
public FilterRegistrationBean logProcessTimeFilter() {
FilterRegistrationBean<LogProcessTimeFilter> bean = new FilterRegistrationBean<>();
// 其餘略過
bean.setOrder(1);

return bean;
}

完成後重新啟動程式,讀者可再次呼叫 API,觀察打印出來的結果。


四、擷取請求與回應

接著讓我們利用 Filter 打印出請求與回應主體(body),本節將在 LogApiFilter 實作。

Controller 收到的請求主體及呼叫方收到的回應主體,分別是由 HttpServletRequestHttpServletResponseInputStreamOuputStream 轉化而來。但資料流只能讀取一次而已,若中途在 Filter 用掉,該特性可能會導致後續讀不到資料的狀況。

為了保留主體中的資料,此處將請求與回應分別包裝成 ContentCachingRequestWrapperContentCachingResponseWrapper,再如同往常傳入 FilterChain

public class LogApiFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
chain.doFilter(requestWrapper, responseWrapper);

logAPI(request, response);

// TODO
}

private void logAPI(HttpServletRequest request, HttpServletResponse response) {
// 先前打印 API 的程式
}
}

這兩個 Wrapper 的特色是會在內部另外備份一個 ByteArrayOutputStream。我們只要呼叫 Wrapper 的 getContentAsByteArray 方法,便能不限次數地獲得主體內容。

public class LogApiFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {

ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
chain.doFilter(requestWrapper, responseWrapper);

logAPI(request, response);
logBody(requestWrapper, responseWrapper);

responseWrapper.copyBodyToResponse();
}

private void logAPI(HttpServletRequest request, HttpServletResponse response) {
// 先前打印 API 的程式
}

private void logBody(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response) {
String requestBody = getContent(request.getContentAsByteArray());
System.out.println("Request: " + requestBody);

String responseBody = getContent(response.getContentAsByteArray());
System.out.println("Response: " + responseBody);
}

private String getContent(byte[] content) {
String body = new String(content);
return body.replaceAll("[\n\t]", "");
}
}

在上面的範例程式中,筆者從 Wrapper 取得主體的位元組陣列(byte[]) 後轉為字串,得到請求與回應的 JSON 資料。接著去除換行與定位符號後打印出來。

最後呼叫 ContentCachingResponseWrappercopyBodyToResponse 方法,將上述備份的資料複製到回應主體,如此 API 呼叫方才能收到預期的內容。


五、使用標記建立 Filter

最後筆者補充另一種產生 Filter 元件的方式,那就是直接使用標記。以第一節的 LogProcessTimeFilter 為例,若不想使用元件建立方法,我們可以改寫成如下。

@WebFilter(urlPatterns = "/*", filterName = "logProcessTimeFilter")
public class LogProcessTimeFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 其餘略過
}
}

使用 Java Servlet 的 @WebFilter 標記,就能簡單地配置 Filter 元件。但該標記並非由 Spring 提供,因此請在啟動類別 Application.java 額外加上 @ServletComponentScan 標記。

透過 urlPatterns 參數,傳入字串或陣列來設置套用的API。而透過 filterName 參數可設置 Filter 的名稱。

本文的完成範例:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch16

上一篇:【Spring Boot】第15課-元件的作用範圍(scope)

下一篇:【Spring Boot】第17課-Spring Security 的驗證與授權

留言