Spring Boot
Cơ bản17 phút đọcbài 1/1

OncePerRequestFilter: từ Servlet Filter đến cross-cutting concern trong Spring Boot

SpringSpring Boot

OncePerRequestFilter: từ Servlet Filter đến cross-cutting concern trong Spring Boot

Viết cho người đã làm Spring Boot một thời gian và muốn biết OncePerRequestFilter thực sự giải quyết vấn đề gì, thay vì chỉ biết "cứ extends cái đó cho chắc".


1. Giới thiệu

Ứng dụng web nào cũng có một nhóm logic không thuộc về nghiệp vụ nào, nhưng request nào cũng phải đi qua: xác thực, generate traceId, ghi access log, rate limit, đo latency, xác định tenant.

Viết chúng trong controller thì 200 endpoint sẽ lặp lại cùng một đoạn code, và chỉ cần một endpoint quên là thành lỗ hổng. Servlet Filter sinh ra cho nhóm này: chạy sớm, chi phí thấp, không cần biết gì về nghiệp vụ.

OncePerRequestFilter không phải khái niệm mới. Nó là abstract class của Spring vá một chỗ hở của Servlet Filter. Muốn hiểu chỗ hở đó thì phải bắt đầu từ Filter.


2. Servlet Filter

jakarta.servlet.Filter là interface của Servlet Specification, không phải của Spring. Ba method, hai trong đó là default:

public interface Filter {
    default void init(FilterConfig filterConfig) throws ServletException {}

    void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException;

    default void destroy() {}
}

Các filter xếp thành chuỗi lồng nhau. chain.doFilter() là lời gọi đồng bộ (blocking), chia code thành hai vùng:

public class TimingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        long start = System.nanoTime();   // vùng trước
        try {
            chain.doFilter(req, res);     // xuống filter kế tiếp, cuối cùng là Servlet
        } finally {
            log.info("took={}ms", (System.nanoTime() - start) / 1_000_000);   // vùng sau
        }
    }
}
┌────────── FilterA (trước) ──────────┐
│  ┌─────── FilterB (trước) ───────┐  │
│  │     DispatcherServlet         │  │
│  │       → Controller           │  │
│  └─────── FilterB (sau) ─────────┘  │
└────────── FilterA (sau) ────────────┘

Hai điều cần nhớ.

Không gọi chain.doFilter() thì request dừng ngay tại đó. Đây là cách short-circuit khi cần chặn request, nhưng cũng là nguyên nhân phổ biến nhất của lỗi "API trả 200 mà body rỗng": có người thêm nhánh if rồi quên gọi doFilter trong nhánh đó.

Phần code sau chain.doFilter() chạy khi response gần như đã ghi xong. Nếu response đã committed thì setStatus() hay setHeader() lúc này không có tác dụng, và cũng không ném exception để bạn biết.

Filter đứng trước Spring MVC nên khi nó chạy: chưa có HandlerMethod, @RequestBody chưa parse, exception ném ra không đi qua @ControllerAdvice. Đổi lại, filter được phép wrap request và response, tức thay đổi được hành vi của toàn bộ tầng dưới. Interceptor và AOP không làm được việc này.


3. Filter nằm ở đâu trong một request

Client
  ▼
[ Servlet Container ]
  ▼
[ FILTER CHAIN ]
  │  • CharacterEncodingFilter     (HIGHEST_PRECEDENCE)
  │  • RequestContextFilter        (-105)
  │  • DelegatingFilterProxy       (-100) → FilterChainProxy
  │       └── SecurityFilterChain
  │             • SecurityContextHolderFilter
  │             • CorsFilter / CsrfFilter
  │             • ★ filter custom chèn vào chain này
  │             • ExceptionTranslationFilter
  │             • AuthorizationFilter
  │  • ★ filter custom đăng ký thẳng ở container
  ▼
[ DispatcherServlet ] → HandlerInterceptor → AOP proxy → Controller
  ▼
... quay ngược lại toàn bộ filter chain ...

Hai điểm đáng chú ý.

Filter thấy được những request mà Spring MVC không thấy. Request gọi vào URL không map với controller nào vẫn đi qua filter, còn interceptor thì không vì nó đã bị forward sang /error.

Spring Security (starter của spring) thực chất là một filter chain. Container chỉ biết đúng một filter là DelegatingFilterProxy, filter này ủy quyền cho bean springSecurityFilterChain, bên trong là hàng chục filter khác. Nên khi bạn cần một filter chạy xen giữa các filter của Security, phải chèn nó vào bên trong chain đó chứ không đặt song song bên ngoài.


4. Đăng ký Filter

Cách nhanh: gắn @Component kèm @Order. Spring Boot tự quét mọi bean kiểu Filter và đăng ký vào container. Nhược điểm là không kiểm soát được urlPatternsdispatcherTypes, mặc định chỉ chạy với DispatcherType.REQUEST.

Cách đầy đủ: FilterRegistrationBean.

@Bean
public FilterRegistrationBean<CustomFilter> customFilter() {
    var registration = new FilterRegistrationBean<>(new CustomFilter());
    registration.addUrlPatterns("/api/*");
    registration.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR));
    registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
    registration.setName("customFilter");
    return registration;
}

Trong Security chain:

@Bean
SecurityFilterChain apiChain(HttpSecurity http, CustomFilter customFilter) throws Exception {
    return http
        .securityMatcher("/api/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class)
        .build();
}

Chỗ này có bẫy: nếu CustomFilter vừa gắn @Component vừa được addFilterBefore(), nó bị đăng ký hai lần, một ở servlet container và một trong security chain. Cách gọn nhất là bỏ @Component, khởi tạo trực tiếp trong config. Nếu buộc phải giữ @Component thì tắt cơ chế đăng ký tự động:

@Bean
FilterRegistrationBean<CustomFilter> disableAutoRegistration(CustomFilter f) {
    var reg = new FilterRegistrationBean<>(f);
    reg.setEnabled(false);
    return reg;
}

Thứ tự của 1 số filter có sẵn (framework đã build sẵn cả đống filter rồi) để biết custom filer nếu có thì thứ tự ra sao:

FilterOrder
OrderedCharacterEncodingFilterHIGHEST_PRECEDENCE
OrderedRequestContextFilter-105
springSecurityFilterChain-100

Filter cần log hoặc gắn context cho mọi request nên đặt trước -100, để bắt được cả request bị Security từ chối. Filter cần biết user là ai thì phải nằm sau authentication.


5. Filter chạy nhiều lần trong một request

Đây là lý do OncePerRequestFilter tồn tại.

Servlet Spec có 5 DispatcherType: REQUEST, FORWARD, INCLUDE, ERROR, ASYNC. Filter đăng ký với type nào thì container sẽ gọi lại nó từ đầu khi dispatch với type đó, trên cùng một đối tượng HttpServletRequest.

Hai tình huống gặp thường xuyên:

Error dispatch. Controller ném exception, Spring Boot forward sang /error với type ERROR. Filter nào có đăng ký ERROR sẽ chạy lần hai.

Spring Security. Spring Boot đăng ký springSecurityFilterChain với dispatcher type mặc định là ASYNC, ERROR, REQUEST. Nghĩa là mọi filter bạn addFilterBefore() vào security chain đều có khả năng chạy nhiều lần. Cấu hình lại qua spring.security.filter.dispatcher-types.

Với filter idempotent, ví dụ chỉ set một header, chạy hai lần không sao. Nhưng nếu filter đang trừ token rate limit, ghi audit log, tăng counter metrics, đọc request body, hay gọi ra ngoài mạng, thì đó là bug. Và là loại bug hầu như không tái hiện trên môi trường dev, vì dev ít khi gặp error dispatch.


6. Bên trong OncePerRequestFilter

Cây kế thừa: FilterGenericFilterBeanOncePerRequestFilter. Nhờ GenericFilterBean mà filter được quản lý như bean Spring thực thụ, inject service vào bình thường.

Logic doFilter() rút gọn từ Spring Framework 6.x:

@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws ServletException, IOException {

    String attrName = getAlreadyFilteredAttributeName();   // <filterName>.FILTERED
    boolean hasAlreadyFiltered = (request.getAttribute(attrName) != null);

    if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
        chain.doFilter(request, response);              // bỏ qua có chủ đích
    }
    else if (hasAlreadyFiltered) {
        chain.doFilter(request, response);              // chạy rồi, không chạy lại
    }
    else {
        request.setAttribute(attrName, Boolean.TRUE);   // đánh dấu
        try {
            doFilterInternal(httpRequest, httpResponse, chain);
        } finally {
            request.removeAttribute(attrName);
        }
    }
}

Bốn chi tiết đáng để ý.

doFilterfinal. Bạn không override được. Đây là chủ ý: nó khóa bất biến "chỉ chạy một lần" lại, bạn chỉ được cài doFilterInternal. Muốn can thiệp sâu hơn thì phải qua các hook có sẵn.

Cờ đánh dấu là request attribute, không phải ThreadLocal. Request attribute sống theo vòng đời của HttpServletRequest và giữ nguyên qua các lần dispatch, đúng thứ ta cần. ThreadLocal sẽ hỏng ngay với async dispatch vì thread đã đổi.

Attribute bị xóa trong finally. Nghĩa là cơ chế chống lặp chỉ có tác dụng khi lần gọi thứ hai nằm lồng bên trong doFilterInternal của lần đầu. Đó đúng là cách FORWARD, ERROR và ASYNC hoạt động, nên trong thực tế nó đủ dùng — nhưng đừng kỳ vọng nó chống được hai request hoàn toàn tách biệt.

Tên attribute lấy từ getFilterName(), tức filter name trong FilterConfig, hoặc bean name nếu không có. Nếu cùng một instance được đăng ký ở hai nơi thì tên trùng nhau nên cơ chế chống lặp vẫn hoạt động.


7. Các hook

HookMặc địnhÝ nghĩa
doFilterInternal(req, res, chain)abstractLogic chính
shouldNotFilter(req)falseTrả true để bỏ qua hẳn filter
shouldNotFilterAsyncDispatch()trueKhông chạy lại khi ASYNC dispatch
shouldNotFilterErrorDispatch()trueKhông chạy khi ERROR dispatch

Cái bạn sẽ dùng nhiều nhất là shouldNotFilter:

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    String path = request.getRequestURI().substring(request.getContextPath().length());
    return path.startsWith("/actuator/") || "OPTIONS".equals(request.getMethod());
}

Nó tốt hơn viết if trong doFilterInternal ở ba điểm: không phải set rồi xóa request attribute, ý định rõ ràng hơn, và test riêng được.

Về cách lấy path, tránh request.getServletPath(). Giá trị của nó phụ thuộc servlet mapping và đổi khi bạn cấu hình spring.mvc.servlet.path, dễ chạy đúng ở local mà sai trên môi trường khác. Nếu cần match pattern phức tạp thì dùng PathPatternParser và compile pattern một lần trong constructor.

Còn shouldNotFilterAsyncDispatch() thì để mặc định, trừ khi filter của bạn set ThreadLocal và app có dùng DeferredResult / CompletableFuture. Khi đó nhánh async chạy trên thread khác, ThreadLocal không còn. Trả false để filter chạy lại và set lại, nhưng logic phải idempotent: đọc giá trị cũ từ request attribute chứ không sinh mới.


8. Những chỗ dễ sai

Quên gọi chain.doFilter(). HTTP 200, body rỗng, không log lỗi, controller không bao giờ được gọi. Rà lại mọi nhánh ifcatch.

Không dọn ThreadLocal. Servlet container dùng thread pool. MDC.put() mà không remove() trong finally thì request sau dùng lại thread đó sẽ thừa hưởng giá trị cũ. Nhẹ thì log lẫn dữ liệu giữa các user, nặng thì context sai và rò rỉ dữ liệu giữa các khách hàng.

Exception trong filter không đi qua @ControllerAdvice. Filter nằm ngoài DispatcherServlet, exception nổi lên container và trả về error page mặc định, format khác hẳn API của bạn. Phải tự bắt và tự ghi response.

Set header sau chain.doFilter(). Có thể bị bỏ qua nếu response đã committed, và không báo lỗi. Set trước, hoặc dùng ContentCachingResponseWrapper để hoãn thời điểm commit.

Đọc body làm hỏng controller. getInputStream() chỉ đọc được một lần. Đọc body trong filter thì bắt buộc phải wrap request bằng ContentCachingRequestWrapper hoặc wrapper tự viết, và nhớ giới hạn kích thước.

Đặt order cao hơn CharacterEncodingFilter. Filter đó ở HIGHEST_PRECEDENCE. Chạy trước nó mà đọc parameter có tiếng Việt là nhận về chuỗi lỗi font.

Đăng ký trùng. @Component cộng addFilterBefore như mục 4. Filter chạy 2 lần với request khớp security chain, 1 lần với request không khớp, rất khó lần ra.

Gọi I/O đồng bộ. Filter chạy cho mọi request. Một truy vấn DB 5ms trong filter ở hệ thống 3.000 RPS tương đương 15 giây chờ mỗi giây. Cache lại và luôn đặt timeout.


9. Filter, Interceptor hay AOP

FilterHandlerInterceptorAOP @Around
Vị tríTrước DispatcherServletSau handler mappingQuanh method của bean
Biết handler / annotationkhông
Truy cập tham số đã parsekhôngchưa, ở preHandle
Wrap request/responsekhôngkhông
Chạy khi 404khôngkhông
Exception vào @ExceptionHandlerkhông

Cần can thiệp ở tầng HTTP thô hoặc phải chạy trước Security thì dùng Filter. Cần biết đang gọi endpoint nào, đọc annotation trên method thì dùng Interceptor. Logic gắn với method nghiệp vụ và muốn tái dùng cả ngoài web layer thì dùng AOP.

Với WebFlux thì OncePerRequestFilter không dùng được, nó chỉ có trong stack Servlet. Bên đó dùng WebFilter, và vấn đề chạy nhiều lần cũng không tồn tại vì WebFlux không có cơ chế dispatch lại.


10. Hiệu năng

Filter nằm trên hot path, mọi chi phí đều nhân với QPS.

  • Thoát sớm bằng shouldNotFilter, rẻ hơn set rồi xóa request attribute.
  • Compile Pattern một lần ở static hoặc constructor, đừng gọi String.matches() trong doFilterInternal.
  • ContentCachingResponseWrapper copy toàn bộ response vào heap. Response 1MB nhân 2.000 RPS là 2GB/s allocation. Chỉ bật cho endpoint cần, hoặc sampling 1%.
  • Sắp xếp filter theo chi phí: filter rẻ và chặn được sớm đặt trước filter đắt.
  • Cache mọi lời gọi mạng hay DB trong filter, và đặt TTL hợp lý.
  • Dùng async appender cho log. Một dòng log.info ghi thẳng xuống đĩa trong filter đủ để giới hạn throughput của cả service.

11. Viết test

Test quan trọng nhất là test chứng minh filter chỉ chạy một lần. Nếu ai đó lỡ đổi sang implements Filter thì nó đỏ ngay:

@Test
void shouldRunOnlyOnce_onNestedDispatch() throws Exception {
    var filter = new CustomFilter();          // filter đếm số lần doFilterInternal chạy
    var request = new MockHttpServletRequest("GET", "/api/orders");

    // chain lồng: mô phỏng filter bị gọi lại từ bên trong, giống ERROR/ASYNC dispatch
    FilterChain nested = (req, res) -> filter.doFilter(req, res, new MockFilterChain());
    filter.doFilter(request, new MockHttpServletResponse(), nested);

    assertThat(filter.getInvocationCount()).isEqualTo(1);
}

Test tích hợp thì dùng @SpringBootTest kèm @AutoConfigureMockMvc, khi đó filter chain thật được áp dụng. Với MockMvcBuilders.standaloneSetup() thì phải .addFilters(filter) thủ công. Lưu ý MockMvc không mô phỏng ERROR dispatch của container, muốn kiểm tra hành vi hai lần chạy thì phải chạy container thật bằng webEnvironment = RANDOM_PORT.


12. Use case

12.1. JWT authentication

Đây là ví dụ hay gặp nhất, và cũng là ví dụ rõ nhất cho cả hai lý do ở trên.

Phải là Filter vì SecurityContext cần sẵn sàng trước khi AuthorizationFilter kiểm tra quyền, mà HandlerInterceptor chạy sau toàn bộ security chain nên đã quá muộn.

Phải là OncePerRequestFilter vì security chain đăng ký cả ERROR dispatch. Controller ném exception thì filter verify token hai lần. Với HMAC thì mất thêm chừng 50µs, không đáng kể. Nhưng với RS256 phải fetch JWKS, hoặc opaque token phải gọi introspection sang Auth Server, thì bạn đang nhân đôi tải lên hệ thống identity.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String BEARER_PREFIX = "Bearer ";

    private final JwtTokenService tokenService;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final SecurityContextHolderStrategy holderStrategy =
            SecurityContextHolder.getContextHolderStrategy();

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

        String header = request.getHeader(HttpHeaders.AUTHORIZATION);

        // Không có token thì đi tiếp, không ném 401 ở đây
        if (header == null || !header.startsWith(BEARER_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            AuthenticatedUser user = tokenService.verify(header.substring(BEARER_PREFIX.length()));

            var authentication = new UsernamePasswordAuthenticationToken(
                    user, null, user.authorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContext context = holderStrategy.createEmptyContext();
            context.setAuthentication(authentication);
            holderStrategy.setContext(context);

        } catch (InvalidTokenException ex) {
            // Có token nhưng hỏng, đây là lỗi thật, trả 401 ngay
            holderStrategy.clearContext();
            authenticationEntryPoint.commence(request, response,
                    new BadCredentialsException("Invalid or expired token", ex));
            return;   // không gọi filterChain.doFilter()
        }

        filterChain.doFilter(request, response);
    }
}

Ba quyết định đáng bàn.

Không có token không phải lỗi. Nhiều implementation ném 401 ngay khi thiếu header, và đó là sai chỗ. Filter chỉ có nhiệm vụ dựng Authentication; việc endpoint nào cần đăng nhập là chuyện của AuthorizationFilter dựa trên authorizeHttpRequests. Làm đúng thì endpoint permitAll() chạy bình thường mà không cần liệt kê từng path trong shouldNotFilter.

Token hỏng thì dừng ngay. Nếu chỉ clearContext() rồi đi tiếp, client nhận 403 từ AuthorizationFilter thay vì 401. Sai semantic HTTP, và client không biết lúc nào cần refresh token.

Không gọi SecurityContextHolder static. Từ Spring Security 6, inject SecurityContextHolderStrategy giúp code vẫn đúng khi chiến lược bị đổi, và test dễ hơn.

Vì filter không đi qua @ControllerAdvice, muốn body lỗi thống nhất với phần còn lại của API thì cài AuthenticationEntryPoint trả về ProblemDetail.

12.2. Correlation ID

Cần Filter vì phải chạy sớm nhất, để mọi dòng log đều có traceId, kể cả log của Security khi từ chối request.

public class CorrelationIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String cid = req.getHeader("X-Correlation-Id");
        if (!StringUtils.hasText(cid) || cid.length() > 64) {   // validate, tránh log injection
            cid = UUID.randomUUID().toString();
        }
        MDC.put("correlationId", cid);
        res.setHeader("X-Correlation-Id", cid);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove("correlationId");
        }
    }
}

Nếu dự án đã dùng Micrometer Tracing hoặc OpenTelemetry thì lấy traceId sẵn có thay vì tự sinh.

12.3. Access log

Cần Filter vì phải thấy raw body và raw response, kể cả khi không controller nào khớp.

public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();   // không bọc endpoint streaming, upload lớn, SSE
        return uri.startsWith("/actuator") || uri.startsWith("/api/files/");
    }

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

        var wrappedReq = new ContentCachingRequestWrapper(req, 4 * 1024);
        var wrappedRes = new ContentCachingResponseWrapper(res);
        long start = System.nanoTime();

        try {
            chain.doFilter(wrappedReq, wrappedRes);
        } finally {
            log.info("{} {} status={} took={}ms",
                    req.getMethod(), req.getRequestURI(), wrappedRes.getStatus(),
                    (System.nanoTime() - start) / 1_000_000);

            wrappedRes.copyBodyToResponse();   // quên dòng này là client nhận body rỗng
        }
    }
}

Quên copyBodyToResponse() là lỗi im lặng khó chịu nhất trong cả bài, vì không có exception nào báo. Và tuyệt đối không log Authorization, Cookie, password hay PII.

12.4. Rate limiting

Cần Filter vì phải chặn request trước khi nó tiêu thread và DB connection. Cần OncePerRequestFilter vì chạy hai lần là tiêu hai token.

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

    Bucket bucket = bucketProxy.builder().build(resolveKey(req), this::defaultConfig);
    ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

    if (probe.isConsumed()) {
        res.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
        chain.doFilter(req, res);
    } else {
        res.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        res.setHeader(HttpHeaders.RETRY_AFTER,
                String.valueOf(TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill())));
        // không gọi chain.doFilter()
    }
}

12.5. Tổng hợp

Trường hợpVì sao FilterVì sao OncePerRequestOrder
Correlation IDChạy sớm nhất, log được mọi thứTránh sinh 2 traceIdHIGHEST + 10
Access logThấy cả request không map handlerTránh ghi trùngHIGHEST + 20
Rate limitingChặn trước khi tốn tài nguyênTránh tiêu 2 tokentrước -100
JWT authDựng SecurityContext trước authzTránh gọi 2 lần sang IdPtrong Security chain
Multi-tenancyTrước DataSource routingTránh set/clear lồng nhausau auth
Verify chữ ký webhookCần raw bytes, phải wrap requestTránh verify 2 lầntrước auth

13. Checklist review

  • Có thực sự cần Filter, hay Interceptor / AOP hợp hơn?
  • Đã extends OncePerRequestFilter thay vì implements Filter?
  • Mọi nhánh code đều gọi chain.doFilter() hoặc cố ý chặn và ghi response?
  • Mọi ThreadLocalMDC đều dọn trong finally?
  • Order đặt tường minh, đúng tương quan với springSecurityFilterChain (-100)?
  • shouldNotFilter loại trừ /actuator, health check, endpoint streaming?
  • Nếu wrap response: đã gọi copyBodyToResponse()?
  • Nếu đọc body: đã wrap request và giới hạn kích thước?
  • Exception trả về đúng format lỗi của API?
  • Không log token, cookie, PII?
  • Không có I/O đồng bộ chưa cache trên hot path?
  • Không đăng ký kép?

Lời kết

OncePerRequestFilter chỉ hơn trăm dòng code, được framework khóa lại bằng final method và template method, để người dùng không làm sai kể cả khi chưa hiểu vấn đề gốc.

Thứ đáng nhớ không phải cú pháp extends OncePerRequestFilter, mà là ba câu hỏi nên đặt ra cho mọi cross-cutting logic:

  1. Nó cần chạy sớm đến mức nào? Quyết định Filter, Interceptor hay AOP.
  2. Nếu nó chạy hai lần thì sao? Quyết định có cần chống lặp không.
  3. Nó tốn bao nhiêu cho mỗi request, nhân với QPS thì thành bao nhiêu? Quyết định cache và thứ tự.