Filter, Interceptor và AOP: logic cắt ngang đặt ở đâu

5 câu hỏi~25 phútTừ khóa: Servlet Filter, OncePerRequestFilter, FilterRegistrationBean, HandlerInterceptor, preHandle, postHandle, afterCompletion, @Aspect, @Around, MDC, traceId, tenant id, ThreadLocal, ordering

Tiếp đi: Filter, Interceptor và AOP. Mình cần 3 thứ cho shop-app: log mọi request kèm traceId, đọc header X-Tenant-Id, và đo thời gian chạy của service. Ba cái này nhìn giống nhau quá, đặt ở đâu?

Cả ba đều "chạy quanh code chính", khác nhau ở tầng và thông tin chúng nhìn thấy. Chọn đúng tầng là câu phỏng vấn rất hay gặp.

1. Bài toán

2. Vấn đề

3. Giải pháp: ba tầng

Client
  │
Tomcat
  │
┌─ Servlet Filter chain ───────────────────────────────┐
│  TraceIdFilter → (Spring Security filters) → ...     │   thấy: HttpServletRequest/Response thô
└──────────────────────────────────────────────────────┘
  │
DispatcherServlet
  │
┌─ HandlerInterceptor ─────────────────────────────────┐
│  preHandle → [controller] → postHandle → afterCompl. │   thấy: request + handler (method nào)
└──────────────────────────────────────────────────────┘
  │
Controller → ┌─ AOP proxy ─┐ → Service → Repository
             │ @Around     │   thấy: method + tham số Java, bất kể ai gọi
             └─────────────┘

4. Code từng bước

Bước 1: dependency. spring-boot-starter-web; thêm spring-boot-starter-aop cho @Aspect.

Bước 2: Filter cho traceId + request log (đặt sớm, trước Security):

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class TraceIdFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = Optional.ofNullable(req.getHeader("X-Request-Id"))
                .filter(s -> s.matches("[A-Za-z0-9-]{8,64}"))
                .orElseGet(() -> UUID.randomUUID().toString());
        long start = System.nanoTime();
        MDC.put("traceId", traceId);
        res.setHeader("X-Request-Id", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            log.info("{} {} -> {} in {} ms", req.getMethod(), req.getRequestURI(),
                    res.getStatus(), (System.nanoTime() - start) / 1_000_000);
            MDC.clear();                       // thread của Tomcat được tái dùng!
        }
    }
}

(Nếu đã dùng Micrometer Tracing thì traceId có sẵn trong MDC, filter chỉ cần log.)

Bước 3a: Interceptor cho tenant id (chỉ cho /api/**, cần biết handler):

public final class TenantContext {
    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
    public static void set(String t) { CURRENT.set(t); }
    public static String get() { return CURRENT.get(); }
    public static void clear() { CURRENT.remove(); }
}

@Component
@RequiredArgsConstructor
public class TenantInterceptor implements HandlerInterceptor {
    private final TenantRegistry tenants;

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        String tenant = req.getHeader("X-Tenant-Id");
        if (tenant == null || !tenants.exists(tenant)) {
            throw new InvalidTenantException(tenant);   // đi qua @RestControllerAdvice → 400
        }
        TenantContext.set(tenant);
        return true;                                    // false = dừng, không gọi controller
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
                                Object handler, Exception ex) {
        TenantContext.clear();                          // luôn chạy, kể cả khi lỗi
    }
}

@Configuration
@RequiredArgsConstructor
class WebConfig implements WebMvcConfigurer {
    private final TenantInterceptor tenantInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tenantInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**");
    }
}

Bước 3b: AOP cho timing service (cơ chế proxy ở bài 4.1.3):

@Aspect
@Component
@Slf4j
public class TimingAspect {
    @Around("within(com.shop..service..*)")
    public Object time(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            if (ms > 200) log.warn("Slow {} took {} ms", pjp.getSignature().toShortString(), ms);
        }
    }
}

Bước 4: test bằng curl.

curl -i -H 'X-Tenant-Id: shop-hn' localhost:8080/api/orders/42
HTTP/1.1 200
X-Request-Id: 1b7e...           ← do filter đặt
# log:
# [traceId=1b7e...] Slow OrderService.get(..) took 250 ms
# [traceId=1b7e...] GET /api/orders/42 -> 200 in 263 ms

curl -i localhost:8080/api/orders/42          # thiếu tenant → 400 từ interceptor
curl -i localhost:8080/khong-ton-tai          # 404 vẫn có log nhờ filter

5. Cách hoạt động và thứ tự

TraceIdFilter (order -2^31)          ┐ trước
  Spring Security FilterChainProxy (order -100)
    DispatcherServlet
      TenantInterceptor.preHandle     (theo thứ tự addInterceptor)
        Controller.get()
          TimingAspect @Around → OrderService.get()
        TenantInterceptor.postHandle  (chỉ khi KHÔNG lỗi; @RestController đã ghi body rồi)
      TenantInterceptor.afterCompletion (luôn chạy)
TraceIdFilter finally: log + MDC.clear()   ┘ sau

Nhiều interceptor: preHandle chạy 1→2→3, postHandle/afterCompletion chạy 3→2→1.

6. So sánh

Servlet FilterHandlerInterceptorAOP (@Aspect)
ThuộcServlet spec (Tomcat)Spring MVCSpring AOP (proxy)
Chạy khiMọi request, kể cả static, 404, request bị Security chặnRequest đã map tới handler trong DispatcherServletGọi method của bean qua proxy, không cần HTTP
Thấy đượcRequest/response thô, có thể wrap (đọc body, nén)Request + HandlerMethod (annotation trên controller)Method, tham số Java, kết quả, exception
Chặn requestKhông gọi chain.doFilterpreHandle trả falseKhông gọi proceed()
Exception đi qua @ControllerAdviceKhôngCóCó (nếu trong luồng MVC)
Thứ tự@Order / FilterRegistrationBean.setOrderThứ tự đăng ký / .order()@Order trên aspect
Ví dụtraceId, request log, CORS, security, gziptenant, locale, rate limit theo endpoint, audit theo annotation controllertiming, retry, cache, @Transactional, audit nghiệp vụ

7. Khi nào nên dùng / không nên dùng

Mình thấy @Component filter tự đăng ký luôn. Vậy muốn filter chỉ chạy cho /api/* thì sao?

Đúng rồi, Boot tự đăng ký mọi bean Filter cho /*. Muốn giới hạn path và thứ tự rõ ràng, bỏ @Component và dùng FilterRegistrationBean:

@Bean
FilterRegistrationBean<AuditFilter> auditFilter() {
    var reg = new FilterRegistrationBean<>(new AuditFilter());
    reg.addUrlPatterns("/api/*");
    reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
    return reg;
}

Bẫy hay gặp: vừa @Component vừa đưa filter vào http.addFilterBefore(...) của Spring Security → filter chạy hai lần (một ở servlet chain, một trong Security chain). OncePerRequestFilter giúp chặn trùng trong cùng request, nhưng tốt nhất chỉ đăng ký một chỗ.

Sao phải MDC.clear() và TenantContext.clear()? Request xong thì ThreadLocal tự mất mà?

Không mất. Tomcat dùng thread pool, thread xử lý request A sẽ được tái dùng cho request B. Không clear thì request B (có thể của tenant khác) đọc được tenant của A: lỗi rò dữ liệu giữa khách hàng, rất nghiêm trọng.

Vậy log request bằng AOP trên mọi @RestController có được không? Mình thấy nhiều project làm vậy.

Được, và có cái lợi: thấy tham số đã bind sẵn (DTO), không cần đọc body. Nhưng nó bỏ sót:

Nên: access log (method, path, status, thời gian) ở Filter; log nghiệp vụ chi tiết (ai hủy order nào, lý do gì) ở AOP hoặc trong service. Nhớ che dữ liệu nhạy cảm (password, token, số thẻ) trước khi log tham số.

Phỏng vấn hỏi Filter, Interceptor và AOP khác nhau thế nào thì trả lời sao?

Q1. Filter, Interceptor và AOP khác nhau thế nào?

🇻🇳 Tiếng Việt:

Filter thuộc Servlet spec, chạy trước DispatcherServlet cho mọi request, thấy request và response thô, hợp cho traceId, access log, security, CORS. Interceptor thuộc Spring MVC, chạy trong DispatcherServlet quanh handler, biết method controller nào được gọi, hợp cho tenant, locale, kiểm tra theo annotation. AOP bọc method của bean qua proxy, không phụ thuộc HTTP, hợp cho transaction, timing, retry, audit nghiệp vụ ở tầng service.

🇬🇧 English:

A Filter belongs to the Servlet spec and runs before the DispatcherServlet for every request, seeing the raw request and response; it suits trace ids, access logs, security and CORS. An Interceptor belongs to Spring MVC and runs inside the DispatcherServlet around the handler, so it knows which controller method is called; it suits tenant resolution, locale and annotation-based checks. AOP wraps bean methods through a proxy, independent of HTTP; it suits transactions, timing, retries and business auditing in the service layer.

Q2. Thứ tự chạy thế nào?

🇻🇳 Tiếng Việt:

Filter chạy ngoài cùng theo @Order, Spring Security là một filter ở giữa chuỗi. Sau đó DispatcherServlet gọi preHandle của các interceptor theo thứ tự đăng ký, rồi controller, trong đó lời gọi service đi qua AOP proxy. Chiều về, postHandle và afterCompletion chạy ngược thứ tự; afterCompletion luôn chạy kể cả khi lỗi, còn postHandle thì không.

🇬🇧 English:

Filters run outermost in @Order order, with Spring Security as one filter in the chain. Then the DispatcherServlet calls each interceptor's preHandle in registration order, then the controller, whose service calls go through AOP proxies. On the way back, postHandle and afterCompletion run in reverse order; afterCompletion always runs even on errors, while postHandle does not.

Q3. Dùng ThreadLocal trong filter/interceptor cần lưu ý gì?

🇻🇳 Tiếng Việt:

Phải luôn clear trong finally hoặc afterCompletion vì thread của Tomcat được tái dùng, không clear thì request sau đọc được dữ liệu của request trước, ví dụ tenant khác. Và ThreadLocal không tự truyền sang thread @Async, phải dùng TaskDecorator hoặc truyền tay.

🇬🇧 English:

Always clear it in a finally block or in afterCompletion, because Tomcat reuses threads; otherwise the next request may read the previous request's data, such as another tenant. ThreadLocal values also do not propagate to @Async threads, so I use a TaskDecorator or pass them explicitly.

Cách nhớ ngắn gọn


Tự kiểm tra

Request bị Spring Security trả 401: Filter log, Interceptor, AOP controller, cái nào thấy được?

Chỉ Filter đặt trước Security filter.

Controller ném exception: postHandle có chạy không? afterCompletion?

postHandle không; afterCompletion có (nhận exception).

Đo thời gian OrderService.create gọi từ Kafka consumer dùng cách nào?

AOP @Around (hoặc Micrometer @Timed), vì không có HTTP request.

Filter đọc request.getInputStream() để log body thì controller bị gì?

Body rỗng vì stream chỉ đọc một lần; dùng ContentCachingRequestWrapper.

Filter vừa @Component vừa addFilterBefore trong Security thì sao?

Đăng ký hai lần, chạy hai lần (trừ khi OncePerRequestFilter chặn).