【Spring Boot】第11課-在 Filter 取出 request 與 response 中的資料

本文最後更新於:2025-06-20

Java Servelet 提供了一個叫「Filter」的功能,它可以在 request 到達 Controller 前,或是 response 離開 Controller 後,讓我們做想要的事情。

本文將解說如何透過 Filter 元件來擷取資料,並打印出 API 的處理時間,以及 request 與 response 的相關資料與 body 內容。


一、範例專案介紹

以下的 ProductRequest 類別,會做為 request body。

public class ProductRequest {
    private String name;
    private int price;

    // getter, setter ...
}

以下的 ProductResponse 類別,會做為 response body。

public class ProductResponse {
    private String id;
    private String name;
    private int price;

    // getter, setter ...
}

以下的 Controller 提供 3 支 RESTful API,做為測試之用,包含 GET 與 POST 方法。

@RestController
public class ProductController {
    
    @GetMapping("/products/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable("id") String id) {
        var product = createTestProduct(id);
        return ResponseEntity.ok(product);
    }

    @GetMapping("/products")
    public ResponseEntity<List<ProductResponse>> getProducts(
            @RequestParam(name = "sortField", required = false) String sortField,
            @RequestParam(name = "sortDirection", required = false) String sortDirection) {

        var p1 = createTestProduct("1");
        var p2 = createTestProduct("2");
        var products = List.of(p1, p2);

        return ResponseEntity.ok(products);
    }
    
    @PostMapping("/products")
    public ResponseEntity<ProductResponse> createProduct(@RequestBody ProductRequest productReq) {
        if (productReq.getPrice() < 0) {
            return ResponseEntity.unprocessableEntity().build();
        }
        
        var product = new ProductResponse();
        product.setId("testId");
        product.setName(productReq.getName());
        product.setPrice(productReq.getPrice());

        return ResponseEntity.ok(product);
    }

    private ProductResponse createTestProduct(String id) {
        var product = new ProductResponse();
        product.setId(id);
        product.setName("Test " + id);
        product.setPrice(100);

        return product;
    }
}

這些 API 會接收 URL 上的參數、query string 或 request body,並回傳 response body。

二、實作 Filter

(一)Filter 概觀

現在讓我們來實作 Filter,目的是印出後端 API 對每個 request 的處理時間。

以下建立一個類別叫 LogApiFilter,它繼承了 OncePerRequestFilter 抽象類別,並覆寫 doFilterInternal 方法。

public class LogApiFilter extends OncePerRequestFilter {

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

事實上,只要是實作 javax.servlet.Filter 介面的類別,都能成為 Filter。而此處繼承的 OncePerRequestFilter 也有實作該介面。

那為什麼選擇 OncePerRequestFilter 呢?原因是它能確保後端收到一個 request 後,該 Filter 只會執行一次。舉例來說,筆者在第 12.6 課提到:「將 Filter 添加到 Spring Security 框架的機制時,要避免框架執行一次後,接著 Spring Boot 本身又執行第二次。」

後端收到 reqeust 時,該 Filter 會自動執行 doFilterInternal 方法。參數中的 HttpServletRequestHttpServletResponse 分別代表 request 與 response。

FilterChain(過濾鏈)可說是程式專案中現有 Filter 的集合。我們必須呼叫它的 doFilter 方法,將 request 送到下一個 Filter,最後才會到達 Controller。

當專案中有多個 Filter,我們是可以定義它們的執行順序(在第六節會介紹)。當 Controller 處理完,response 會依照相反的順序,再次經過 Filter,執行上述 doFilter 方法後面的程式碼。

(二)Filter 邏輯

這個 LogApiFilter 會印出 API 的處理時間。因此我們在 doFilter 方法執行前與後,分別記錄當前的時間戳。

public class LogApiFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        
        var startTime = System.currentTimeMillis();
        filterChain.doFilter(request, response);
        var endTime = System.currentTimeMillis();
        
        System.out.printf("%d ms\n", endTime - startTime);
    }
}

最後再將處理時間印在 Console 視窗。

三、註冊並執行 Filter

完成 Filter 的程式邏輯後,必須向 Spring Boot 進行註冊才會生效。本節介紹 2 種註冊方式。

(一)直接註冊成元件

在 Filter 類別冠上 @Component 等元件註解即可。

@Component
public class LogApiFilter extends OncePerRequestFilter {
    // ...
}

只要是實作 javax.servlet.Filter 介面的元件,都能成為 Filter。

(二)使用 FilterRegistrationBean 註冊

另一種方式是建立 FilterRegistrationBean 元件。

@Configuration
public class FilterConfig {
    
    @Bean
    public FilterRegistrationBean<LogApiFilter> logApiFilter() {
        var bean = new FilterRegistrationBean<LogApiFilter>();
        bean.setFilter(new LogApiFilter());
        
        return bean;
    }
}

此處建立的 FilterRegistrationBean 元件,並不是要注入到其他地方,而是會被 Spring Boot 讀取。

在元件的建立過程中,我們只要呼叫 setFilter 方法,將自己實作的 Filter 附加上去,即可完成註冊。

這種做法的好處,是能讓我們在元件的建立過程中,做自己想要的客製化。

(三)確認執行結果

接下來可以啟動專案,試著對 API 發出請求。

確認 Filter 程式確實有在 Console 視窗印出訊息。

四、取得 Request 與 Response 的資料

OncePerRequestFilter.doFilterInternal 方法,接收了 request 與 response 的參數。本節介紹我們可以從中取出哪些資料。

(一)HttpServletRequest 的資料

以下列出 HttpServletRequest 的部份方法及回傳值:

方法 用途 範例回傳值
getMethod 取得 HTTP 方法 GET
getRequestURI 取得當前呼叫的 API 路徑 /products
getQueryString 取得 URL 上問號後方的 query string sortField=price&sortDirection=asc
getParameterMap 取得 query string,但會整理成 Map<String, String[]> 的結構 -
getHeader 取得 request header application/json

(二)HttpServletResponse 的資料

以下列出 HttpServletResponse 的部份方法及回傳值:

方法 用途 範例回傳值
getStatus 取得 HTTP 狀態碼 200
getHeader 取得 response header image/jpg

以下是在 Filter 程式中整合相關資料,並一次印在 Console 視窗。

import org.springframework.util.StringUtils;

public class LogApiFilter extends OncePerRequestFilter {

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

        var startTime = System.currentTimeMillis();
        filterChain.doFilter(request, response);
        var endTime = System.currentTimeMillis();

        var sb = new StringBuilder();
        sb.append(request.getMethod()).append(" ").append(request.getRequestURI());

        var queryString = request.getQueryString();
        if (StringUtils.hasText(queryString)) {
            sb.append("?").append(queryString);
        }

        sb.append("\n").append("Request Content Type: ").append(request.getHeader(HttpHeaders.CONTENT_TYPE));
        sb.append("\n").append("Process Time: ").append(endTime - startTime).append(" ms");
        sb.append("\n").append("Status Code: ").append(response.getStatus());
        sb.append("\n").append("-");

        System.out.println(sb);
    }
}

五、取得 Body 資料

筆者曾經看過一個需求,是想要將 request 與 response 的 body 給寫入 log。這一節就讓我們將 body 的值給取出來。

事實上,Controller 收到的 request body,是來自 HttpServletRequest 中的 InputStream。而前端收到的 response body,則是來自 HttpServletResponse 中的 OutputStream。

但這些資料流只能讀取一次而已,若中途在 Filter 用掉,會導致真正需要 body 的 Controller 與前端讀不到資料。

為了保留 body 中的資料,需要將 request 與 response 分別包裝成 ContentCachingRequestWrapperContentCachingResponseWrapper,再如同往常傳入 FilterChain.doFilter 方法。

public class LogApiFilter extends OncePerRequestFilter {

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

        var requestWrapper = new ContentCachingRequestWrapper(request);
        var responseWrapper = new ContentCachingResponseWrapper(response);

        var startTime = System.currentTimeMillis();
        filterChain.doFilter(requestWrapper, responseWrapper);
        var endTime = System.currentTimeMillis();
        
        // ...
    }
}

這兩個 Wrapper 的特色,是會在內部暫存一個 byte array(byte[])來存放 body 的資料。我們只要呼叫 getContentAsByteArray 方法,就能一直獲取 body 的內容。

import org.springframework.util.StringUtils;

public class LogApiFilter extends OncePerRequestFilter {

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

        var requestWrapper = new ContentCachingRequestWrapper(request);
        var responseWrapper = new ContentCachingResponseWrapper(response);

        // ...

        var sb = new StringBuilder();
        
        // ...

        var requestBodyStr = getBodyString(requestWrapper.getContentAsByteArray());
        if (StringUtils.hasText(requestBodyStr)) {
            sb.append("\n").append("Request Body: ").append(requestBodyStr);
        }

        var responseBodyStr = getBodyString(responseWrapper.getContentAsByteArray());
        if (StringUtils.hasText(responseBodyStr)) {
            sb.append("\n").append("Response Body: ").append(responseBodyStr);
        }

        // ...
        
        responseWrapper.copyBodyToResponse();
    }

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

在範例程式中,會先將 body 的 byte array 轉為字串,並去除換行等特殊符號,最後印在 Console 視窗。

要注意的是,讀取完 ContentCachingResponseWrapper 的資料後,要額外呼叫 copyBodyToResponse 方法,將暫存的 byte array 複製到 response 的 OutputStream 中,否則前端將收不到真正的 response body 喔!

六、其他設定

(一)套用 API 路徑

若希望 Filter 只在特定的 API 路徑生效,可以在建立 FilterRegistrationBean 時,或是在 OncePerRequestFilter 中來設定。

在建立 FilterRegistrationBean 時,可透過 addUrlPatternssetUrlPatterns 方法來提供路徑規則。前者是使用陣列來添加,後者是使用 Collection 來覆蓋。

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<LogApiFilter> logApiFilter() {
        var bean = new FilterRegistrationBean<LogApiFilter>();
        // ...
        bean.setUrlPatterns(List.of("/*"));

        return bean;
    }
}

在撰寫 API 路徑規則時,可搭配「*」符號來當作萬用字元,達到模糊匹配的效果。下表整理出使用範例:

路徑規則 適用路徑說明 不適用路徑舉例
/api /api(精確匹配) /api/
/* 所有路徑 -
/api/* /api/ 和以它為開頭的所有路徑 api/login

要注意的是,「*」符號不支援寫在中間,所以只能匹配特定開頭的 API 路徑。

至於 OncePerRequestFilter,也可以透過實作 shouldNotFilter 方法,來決定是否執行 Filter 的程式。

import org.springframework.util.StringUtils;

public class LogApiFilter extends OncePerRequestFilter {
    // ...

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        var uri = request.getRequestURI();
        return StringUtils.startsWithIgnoreCase(uri, "/articles");
    }
}

該方法接收 HttpServletRequest 參數,我們可以取得裡面的內容(如 URL),實作出較複雜的判斷方式。若回傳 true,則 Filter 就不會執行。

(二)執行順序

我們可以控制 Filter 的執行順序,若不特別設定的話,順序是不確定的。

在建立 FilterRegistrationBean 時,可透過 setOrder 方法來指定順序。數字越小,代表越先處理 request,且越晚處理 response。

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<LogApiFilter> logApiFilter() {
        var bean = new FilterRegistrationBean<LogApiFilter>();
        // ...
        bean.setOrder(0);

        return bean;
    }
}

若讀者是直接將 Filter 建立成 Spring Boot 元件,則可直接在類別冠上 @Order 註解。

@Order(0)
public class LogApiFilter extends OncePerRequestFilter {
    // ...
}

同樣是數字越小,越先處理 request,越晚處理 response。


本文的完成後專案,請點我

上一課:【Spring Boot】第10.1課 - 使用 MockMvc 進行 API 整合測試

下一課:【Spring Boot】第12.1課-初探 Spring Security 的認證與授權


張貼留言