Hình dung một request đi qua hai lớp kiểm soát như hành khách đi qua sân bay. Filter là cổng an ninh ngoài cùng: ai bước vào nhà ga cũng phải qua — hành khách, khách trung chuyển, cả xe chở hàng — và nó không hề biết bạn sắp ra cổng nào. Interceptor là nhân viên tại cổng ra máy bay: tới đây thì người ta biết chính xác bạn đi chuyến nào, ghế nào — biết cái handler sắp chạy. Bài 11 đã đo thứ tự Filter và Interceptor. Bài này đo phạm vi — cái nào chạy cho request nào — và hai kết quả không như tôi tưởng.
Bốn trường hợp
Một Filter đăng ký cho /*, một Interceptor cho /**, cả hai chỉ ghi lại một dòng:
1. endpoint bình thường /ok
FILTER /ok [REQUEST]
INTERCEPTOR /ok handler=HandlerMethod
2. tài nguyên tĩnh /a.css
FILTER /a.css [REQUEST]
INTERCEPTOR /a.css handler=ResourceHttpRequestHandler
3. URL không tồn tại /khong-co-gi
FILTER /khong-co-gi [REQUEST]
INTERCEPTOR /khong-co-gi handler=ResourceHttpRequestHandler
FILTER /error [ERROR]
INTERCEPTOR /error handler=HandlerMethod
4. endpoint ném ngoại lệ /no
FILTER /no [REQUEST]
INTERCEPTOR /no handler=HandlerMethod
FILTER /error [ERROR]
INTERCEPTOR /error handler=HandlerMethod
Kết quả thứ nhất: Interceptor có chạy cho tệp tĩnh
Câu thường nghe là "Filter thấy mọi request, Interceptor chỉ thấy controller". Vế sau sai.
Trường hợp 2 cho thấy Interceptor chạy cho /a.css với handler=ResourceHttpRequestHandler. Tệp CSS cũng đi qua DispatcherServlet, cũng có handler — chỉ là handler đó phục vụ tệp thay vì gọi phương thức. Xe chở hàng vẫn phải qua cổng an ninh, và vẫn có một quầy xử lý riêng cho nó.
Hệ quả thực tế: nếu Interceptor của bạn kiểm tra xác thực hay đếm chỉ số theo endpoint, nó đang tính cả CSS, JS và ảnh. Muốn chỉ lấy controller thì kiểm kiểu handler:
public boolean preHandle(HttpServletRequest q, HttpServletResponse p, Object h) {
if (!(h instanceof HandlerMethod)) return true; // bỏ qua tài nguyên tĩnh
...
}
Trường hợp 3 còn lạ hơn: URL không tồn tại cũng ra ResourceHttpRequestHandler. Spring thử phân giải nó thành tệp tĩnh trước khi kết luận 404 — nên instanceof HandlerMethod cũng lọc luôn trường hợp này.
Kết quả thứ hai: request lỗi đi qua chuỗi hai lần
Trường hợp 3 và 4 đều có hai dòng FILTER. Lần thứ hai là /error với [ERROR].
Đây là cơ chế của Servlet: khi request kết thúc bằng lỗi, container gửi tiếp một dispatch mới tới trang lỗi. Giống một chuyến bay bị huỷ — bạn bị đẩy ngược ra quầy đổi vé, và trên đường đó lại đi qua cổng an ninh thêm lần nữa. Spring Boot ánh xạ nó vào BasicErrorController, và toàn bộ chuỗi lọc chạy lại.
Ba hệ quả cụ thể:
Đếm chỉ số bị nhân đôi. Filter đếm request sẽ ghi 2 cho một request lỗi. Chữa bằng cách lọc theo DispatcherType:
if (request.getDispatcherType() != DispatcherType.REQUEST) {
chain.doFilter(request, response);
return;
}
MDC mất giá trị ở lần dispatch thứ hai. Filter của tôi đặt MDC.put rồi MDC.clear() trong finally. Lần dispatch /error bắt đầu với MDC rỗng, nên trang lỗi ghi log không có mã request — đúng lúc bạn cần nó nhất.
Xác thực chạy lại. Nếu Filter của bạn gọi CSDL hay dịch vụ ngoài để xác thực, request lỗi trả tiền hai lần.
Mặc định, Spring Boot chỉ đăng ký filter cho DispatcherType.REQUEST. Tôi phải khai tường minh setDispatcherTypes(REQUEST, ERROR) để thấy dòng thứ hai. Nhưng Spring Security thì đăng ký cho cả ERROR, nên hành vi này có thật trong mọi ứng dụng có Security.
Chọn cái nào
| Việc | Dùng |
|---|---|
| Ghi log request/response thô | Filter |
| Nén, CORS, bảo mật, ký tự | Filter |
| Đọc hoặc ghi lại thân request | Filter (cần wrapper) |
| Cần biết controller nào sắp chạy | Interceptor |
Cần đọc @PathVariable, chú thích trên method |
Interceptor |
| Cần chạy sau khi view render xong | Interceptor |
Ranh giới đơn giản: Filter thuộc tầng Servlet, không biết gì về Spring MVC. Interceptor thuộc tầng MVC, biết handler. Cổng an ninh ngoài không biết cổng ra; nhân viên tại cổng ra thì biết.
Nếu cần thông tin về handler — tên phương thức, chú thích tuỳ biến, biến đường dẫn — chỉ Interceptor có.
Đọc thân request hai lần
Đây là chỗ hay vấp: InputStream của request chỉ đọc được một lần. Filter đọc thân để log thì controller nhận về rỗng.
var boc = new ContentCachingRequestWrapper(request);
chain.doFilter(boc, response);
log.info("thân: {}", new String(boc.getContentAsByteArray()));
Hai lưu ý thực tế: phải log sau doFilter (trước đó chưa đọc gì nên bộ đệm rỗng), và nhớ đặt trần kích thước — nếu không, ai đó tải lên 500 MB là bạn giữ cả 500 MB trong bộ nhớ.
Với response, ContentCachingResponseWrapper cần thêm copyBodyToResponse() ở cuối, nếu quên thì client nhận về thân rỗng.
Ba mẫu hay dùng
Mã tương quan — Filter ngoài cùng đặt, mọi dòng log mang theo:
MDC.put("maReq", Optional.ofNullable(req.getHeader("X-Request-Id"))
.orElseGet(() -> UUID.randomUUID().toString()));
try { chain.doFilter(req, res); } finally { MDC.clear(); }
Nhận từ header nếu client gửi lên — thế là trace đi xuyên qua nhiều dịch vụ. finally là bắt buộc: MDC chạy trên ThreadLocal, và bài 78 sê-ri Java đã đo được request thứ tư đọc ra dữ liệu của request thứ hai vì quên dọn.
Đo thời gian — Interceptor với preHandle/afterCompletion. Nhớ rằng afterCompletion chạy kể cả khi có ngoại lệ, và nhận ngoại lệ đó làm tham số.
Giới hạn tần suất — Filter, đặt trước Security để chặn sớm.
Thứ tự
@Bean FilterRegistrationBean<?> f() {
var b = new FilterRegistrationBean<>(new LocCuaToi());
b.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
return b;
}
Số nhỏ chạy trước. Spring Security nằm ở order -100, nên muốn chạy trước Security (giới hạn tần suất, mã tương quan) thì đặt nhỏ hơn -100.
Với Interceptor, thứ tự là thứ tự đăng ký trong addInterceptors.
Chú ý @Component trên một lớp Filter sẽ tự đăng ký nó cho /* với order mặc định — thường không phải điều bạn muốn. Dùng FilterRegistrationBean để kiểm soát.
Nếu chỉ thử một thứ sau khi đọc bài này, thêm đúng một dòng vào Filter của bạn rồi gọi một URL không tồn tại:
System.out.println(req.getDispatcherType() + " " + req.getRequestURI());
Thấy hai dòng nghĩa là mọi thứ Filter đó làm — đếm, xác thực, gọi CSDL — đang chạy hai lần cho mỗi request lỗi. Chục giây đó cho bạn biết ngay chuỗi lọc của mình có an toàn khi chạy lại hay không.
Mẫu số chung
Cái ranh giới Filter/Interceptor không phải đặc sản của Spring — nó là một sự thật kiến trúc mà mọi framework web đều phải đối mặt: middleware sống ở hai độ cao khác nhau. Lớp thấp đứng ở tầng giao thức thô (biết byte, URL, header, không biết gì về định tuyến); lớp cao đứng sau khi đã khớp route (biết handler, biến đường dẫn, chú thích). Cổng an ninh và nhân viên cổng ra, ở mọi sân bay.
- Express/Node:
app.use()cho mọi request (tầng thấp) so với middleware gắn trên router, chạy sau khi khớp route (tầng cao). - .NET: pipeline middleware (
app.Use) so với action filter (IActionFilter) — đúng y hai độ cao. - Python: middleware WSGI thô so với middleware của Django/DRF biết view.
- Rust có
Layercủa tower, Go thì bọchttp.Handler— chính sê-ri Go đã dựng middleware kiểu đó.
Hai điều đáng mang theo. Một: chỉ lớp cao mới biết route đã khớp — nhét xác thực theo từng route vào lớp thấp thì hoặc thiếu thông tin, hoặc vô tình chạy cho cả file tĩnh, đúng cái bẫy ResourceHttpRequestHandler ở trên. Hai: một request không phải lúc nào cũng đi qua pipeline đúng một lần — lỗi sinh ra một lượt dispatch mới (Servlet gọi nó là DispatcherType, .NET có exception-handler middleware chạy lại, Express có nhánh error-handling bốn tham số), nên mọi middleware có tác dụng phụ — đếm, log, gọi CSDL — phải an toàn khi chạy nhiều lần. Sợi chỉ chung: hãy biết middleware của bạn sống ở độ cao nào, và nó có chịu được việc chạy hai lần không — cả hai câu hỏi đều bắt nguồn từ cùng một sự thật là một request không phải một đường thẳng một chiều.
Ngày mai: CORS — và vì sao trình duyệt chặn còn curl thì không.