Filter, Interceptor và AOP: logic cắt ngang đặt ở đâu
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
- Request logging + traceId: mọi request (kể cả 404, 401, static file) đều phải có log và traceId trong MDC để bài 4.2.7 dùng.
- Tenant id: đọc
X-Tenant-Id, kiểm tra hợp lệ, lưu vào context cho repository dùng. - Timing: biết
OrderService.createmất bao lâu, kể cả khi gọi từ Kafka consumer.
2. Vấn đề
- Viết thẳng vào từng controller method: lặp ở 40 endpoint, quên một cái là mất log.
- Đặt sai tầng: log trong interceptor thì request bị Spring Security chặn (401) không bao giờ được log; đo timing bằng interceptor thì không đo được service gọi từ consumer.
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 Filter | HandlerInterceptor | AOP (@Aspect) | |
|---|---|---|---|
| Thuộc | Servlet spec (Tomcat) | Spring MVC | Spring AOP (proxy) |
| Chạy khi | Mọi request, kể cả static, 404, request bị Security chặn | Request đã map tới handler trong DispatcherServlet | Gọi method của bean qua proxy, không cần HTTP |
| Thấy được | Request/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 request | Không gọi chain.doFilter | preHandle trả false | Không gọi proceed() |
| Exception đi qua @ControllerAdvice | Không | Có | Có (nếu trong luồng MVC) |
| Thứ tự | @Order / FilterRegistrationBean.setOrder | Thứ tự đăng ký / .order() | @Order trên aspect |
| Ví dụ | traceId, request log, CORS, security, gzip | tenant, locale, rate limit theo endpoint, audit theo annotation controller | timing, retry, cache, @Transactional, audit nghiệp vụ |
7. Khi nào nên dùng / không nên dùng
- Filter: việc cần cho mọi request, không cần biết controller nào. Security của Spring là một chuỗi filter (bài 4.4.1).
- Interceptor: việc của tầng web cần biết handler hoặc chỉ cho một nhóm path.
- AOP: việc ở tầng service, không phụ thuộc HTTP. Không dùng AOP để đọc header HTTP.
- Không đọc body trong filter/interceptor bằng
getInputStream()trực tiếp: stream chỉ đọc được một lần, controller sẽ nhận body rỗng. DùngContentCachingRequestWrappernếu thật cần.
Location, ETag thế nào? Đó là ResponseEntity.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.
- Clear trong
finally(filter) hoặcafterCompletion(interceptor), vì chúng luôn chạy kể cả khi lỗi. - Không dùng
postHandleđể dọn: lỗi thì nó không chạy. - Code chạy
@Asynchoặc thread pool khác không thấy ThreadLocal của request: phải truyền tay hoặc dùngTaskDecorator. Chi tiết ThreadLocal ở bài 3.8.
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:
- Request bị Spring Security chặn (401/403): chưa tới controller.
- 404 (không có handler), 400 do parse JSON lỗi trước khi gọi method, 405.
- Thời gian chính xác: không tính phần serialize response và filter.
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
- Filter = cửa ngoài, mọi request, HTTP thô (traceId, access log, security).
- Interceptor = trong DispatcherServlet, biết handler (tenant, locale).
- AOP = quanh method bean, không cần HTTP (timing, transaction, audit).
- Dọn ThreadLocal/MDC ở finally / afterCompletion.
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).