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.
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. 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.
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.
Thử ba mươi giây
Thêm một dòng vào Filter của bạn:
System.out.println(req.getDispatcherType() + " " + req.getRequestURI());
Gọi một URL không tồn tại. 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.
Ngày mai: CORS — và vì sao trình duyệt chặn còn curl thì không.