Một request đi qua những đâu trong Spring MVC
Bắt đầu Spring MVC nhé. Mình gọi POST /api/orders là vào thẳng OrderController. Ở giữa có những bước gì mà mình không thấy?
Có khá nhiều bước. Bạn chỉ viết controller, còn Tomcat và Spring lo phần còn lại. Hiểu chuỗi này thì debug 404, 415, 401, log trùng... nhanh hơn rất nhiều.
1. Bài toán: shop-app, tạo đơn hàng
Project xuyên suốt chương này là shop-app (Spring Boot 3): OrderController, OrderService, ProductService, OrderRepository, ProductRepository.
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest req) {
return orderService.create(req);
}
}
Client gửi JSON, nhận lại JSON. Code chỉ có vài dòng, nhưng ai đọc socket, ai parse JSON, ai chọn đúng method?
2. Vấn đề: không biết chuỗi thì không biết lỗi nằm ở đâu
- Trả 404 nhưng log controller không in gì: lỗi ở mapping hay ở Nginx?
- Trả 415 Unsupported Media Type: controller chưa hề chạy.
- Trả 401: Spring Security (một Filter) chặn từ trước DispatcherServlet.
- Muốn log thời gian xử lý: đặt ở Filter, Interceptor hay AOP?
3. Giải pháp: nhớ 8 trạm
| Trạm | Thuộc về | Việc chính |
|---|---|---|
| Tomcat Connector | Tomcat | Nhận TCP, parse HTTP, lấy thread từ pool |
| Filter chain | Servlet API | Security, CORS, encoding, logging, trước mọi servlet |
| DispatcherServlet | Spring MVC | Front controller, điều phối tất cả |
| HandlerMapping | Spring MVC | URL + method → method nào của controller |
| HandlerInterceptor | Spring MVC | preHandle / postHandle / afterCompletion |
| HandlerAdapter | Spring MVC | Gọi method, resolve tham số |
| Controller | Code của bạn | Gọi service, trả kết quả |
| HttpMessageConverter | Spring MVC | JSON ↔ object (Jackson) |
Phần trước Tomcat (DNS, load balancer, Nginx) đã học ở bài Nginx. Bên trong Tomcat (connector, thread pool) ở bài Tomcat. Bài này bắt đầu từ lúc Tomcat đã có một thread cầm request.
4. Code từng bước: tự nhìn thấy từng trạm
Bước 1: Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Starter này kéo theo Tomcat embedded, Spring MVC và Jackson.
Bước 2: Config, bật log của DispatcherServlet
logging:
level:
org.springframework.web: DEBUG
Bước 3: Code Java, một Filter và một Interceptor để in ra thứ tự
@Component
public class TraceFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
System.out.println("[1] Filter before " + Thread.currentThread().getName());
chain.doFilter(req, res); // đi tiếp vào DispatcherServlet
System.out.println("[6] Filter after status=" + res.getStatus());
}
}
@Component
public class TraceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
System.out.println("[2] preHandle " + handler);
return true; // false = dừng, không gọi controller
}
@Override
public void postHandle(HttpServletRequest req, HttpServletResponse res, Object handler,
ModelAndView mv) {
System.out.println("[4] postHandle");
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object handler, Exception ex) {
System.out.println("[5] afterCompletion ex=" + ex);
}
}
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final TraceInterceptor traceInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(traceInterceptor).addPathPatterns("/api/**");
}
}
Trong OrderController.create() thêm System.out.println("[3] Controller");.
Bước 4: Test bằng curl
curl -X POST localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"productId":1,"quantity":2}'
# log:
[1] Filter before http-nio-8080-exec-1
DEBUG DispatcherServlet : POST "/api/orders", parameters={}
DEBUG RequestMappingHandlerMapping : Mapped to OrderController#create(CreateOrderRequest)
[2] preHandle com.shop.order.OrderController#create(CreateOrderRequest)
DEBUG RequestResponseBodyMethodProcessor : Read "application/json" to [CreateOrderRequest[...]]
[3] Controller
DEBUG RequestResponseBodyMethodProcessor : Writing [OrderResponse[...]]
[4] postHandle
[5] afterCompletion ex=null
DEBUG DispatcherServlet : Completed 200 OK
[6] Filter after status=200
Tất cả chạy trên cùng một thread http-nio-8080-exec-1.
5. Cách hoạt động
Client ── HTTP ──> (Nginx) ──> Tomcat :8080
│
Connector (NIO) đọc socket, parse HTTP
lấy thread http-nio-8080-exec-N
│
┌──────────── Filter chain ────────────┐
│ CharacterEncodingFilter │
│ Spring Security FilterChainProxy │ ← 401/403 ở đây
│ TraceFilter │
└──────────────────┬───────────────────┘
▼
DispatcherServlet.doDispatch()
│
① HandlerMapping: POST /api/orders → OrderController#create
│ (không có → 404)
② Interceptor.preHandle() (false → dừng)
│
③ HandlerAdapter.handle()
├─ ArgumentResolver: @RequestBody
│ └─ HttpMessageConverter (Jackson) JSON → CreateOrderRequest
│ (Content-Type sai → 415, JSON hỏng → 400)
├─ gọi OrderController.create()
│ └─ OrderService → OrderRepository → DB
└─ ReturnValueHandler: @ResponseBody
└─ HttpMessageConverter: OrderResponse → JSON
│
④ Interceptor.postHandle()
⑤ (exception → HandlerExceptionResolver → @ControllerAdvice)
⑥ Interceptor.afterCompletion()
│
Filter chain chạy nốt phần "after" (theo thứ tự ngược)
│
Tomcat ghi response ra socket, trả thread về pool
Filter giống lớp vỏ ngoài: bọc toàn bộ, kể cả khi không có controller nào khớp. Interceptor nằm trong DispatcherServlet nên biết handler là method nào.
6. So sánh: lỗi xuất hiện ở trạm nào
| Triệu chứng | Trạm | Controller có chạy? |
|---|---|---|
| 502 / 504 | Nginx, Tomcat chưa trả lời | Có thể có hoặc chưa |
| 401 / 403 | Security filter | Không |
| 404 No handler / No static resource | HandlerMapping | Không |
| 405 Method Not Allowed | HandlerMapping (URL đúng, method sai) | Không |
| 415 Unsupported Media Type | Message converter | Không |
| 400 JSON parse error | Message converter | Không |
| 500 | Controller / service ném exception | Có |
7. Khi nào cần nghĩ tới chuỗi này
- Nên: debug lỗi 4xx mà log controller trống; chọn chỗ đặt logging, auth, correlation id; giải thích cho phỏng vấn.
- Không cần: viết CRUD bình thường. Spring Boot đã cấu hình sẵn mọi trạm, đừng tự đăng ký lại DispatcherServlet.
Filter với Interceptor đều chạy trước controller. Vậy check token mình đặt ở chỗ nào cũng được đúng không?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: cả hai đều chạy được, nhưng vị trí khác nhau nên hệ quả khác nhau.
Request ─> [Filter] ─> DispatcherServlet ─> [Interceptor] ─> Controller
▲ ▲
chạy cho MỌI request chỉ chạy khi đã tìm được handler
(cả static, cả /error, biết method nào, annotation gì
cả URL không tồn tại)
- Check token nên chặn càng sớm càng tốt, và Spring Security vốn là một Filter (
FilterChainProxy). Request không hợp lệ bị trả 401 trước khi Spring MVC tốn công parse JSON. - Interceptor hợp với việc cần biết handler: ví dụ đọc annotation
@RateLimitedtrên method controller. - Filter làm việc với
HttpServletRequestthô; Interceptor có thêmObject handler(thường làHandlerMethod).
So sánh chi tiết Filter, Interceptor và AOP có ở bài 4.2.8.
Vậy OrderService gọi DB mất 2 giây thì thread Tomcat đứng chờ cả 2 giây đó luôn à?
Đúng rồi. Spring MVC là mô hình thread-per-request: từ Filter tới lúc ghi xong response, cùng một thread http-nio-8080-exec-N đi hết.
exec-1: Filter → Dispatcher → Controller → Service → JDBC (block 2s) → JSON → Filter
|<------------------------- thread bị giữ ~2s ------------------------->|
- Vì vậy
ThreadLocal(SecurityContext, MDC log, transaction) hoạt động được: tất cả cùng một thread. - Pool mặc định 200 thread. 200 request đều chờ DB thì request thứ 201 phải xếp hàng. Chi tiết và cách cấu hình ở bài Tomcat.
- Java 21 có thể bật virtual threads (
spring.threads.virtual.enabled=true): vẫn là mô hình thread-per-request, nhưng thread rẻ nên block không còn đắt như trước.
Trong log mình thấy request lỗi còn bị gọi lại vào /error, Filter chạy hai lần. Sao vậy?
Đúng là có chuyện đó. Khi exception không được @ControllerAdvice xử lý, hoặc gọi response.sendError(), Tomcat thực hiện một ERROR dispatch nội bộ tới /error (Spring Boot có BasicErrorController ở đó).
REQUEST dispatch: Filter → DispatcherServlet → Controller ném exception
│
ERROR dispatch: Filter → DispatcherServlet → BasicErrorController (/error)
└─ JSON {"status":500,...}
Vì vậy nên kế thừa OncePerRequestFilter: nó tự bỏ qua lần dispatch thứ hai (trừ khi bạn override shouldNotFilterErrorDispatch()). Xử lý lỗi gọn hơn bằng @RestControllerAdvice ở bài 4.2.7.
Phỏng vấn hỏi "request đi qua Spring MVC thế nào" thì trả lời sao?
Q1. Mô tả luồng xử lý một request trong Spring MVC.
🇻🇳 Tiếng Việt:
Tomcat connector nhận kết nối, parse HTTP và giao cho một thread trong pool. Request đi qua Filter chain, trong đó có Spring Security, rồi tới DispatcherServlet. DispatcherServlet hỏi HandlerMapping để tìm method controller, chạy preHandle của interceptor, rồi dùng HandlerAdapter gọi method: argument resolver và HttpMessageConverter chuyển JSON thành object. Giá trị trả về được converter ghi thành JSON. Sau đó chạy postHandle, afterCompletion, phần sau của filter, và Tomcat ghi response.
🇬🇧 English:
The Tomcat connector accepts the connection, parses HTTP and hands it to a pooled thread. The request passes through the filter chain, including Spring Security, then reaches the DispatcherServlet. It asks HandlerMappings for the matching controller method, runs interceptor preHandle, and invokes the method through a HandlerAdapter, where argument resolvers and HttpMessageConverters turn JSON into objects. The return value is written back as JSON by a converter. Then postHandle, afterCompletion and the rest of the filters run, and Tomcat writes the response.
Q2. Filter và Interceptor khác nhau thế nào?
🇻🇳 Tiếng Việt:
Filter thuộc Servlet API, chạy trước DispatcherServlet cho mọi request, kể cả static và /error. Interceptor thuộc Spring MVC, chạy bên trong DispatcherServlet sau khi đã tìm được handler, nên biết method controller nào sẽ chạy. Security và CORS thường đặt ở filter; logic cần thông tin handler đặt ở interceptor.
🇬🇧 English:
A Filter belongs to the Servlet API and runs before the DispatcherServlet for every request, including static resources and /error. An Interceptor belongs to Spring MVC and runs inside the DispatcherServlet after the handler is resolved, so it knows which controller method will run. Security and CORS usually live in filters; logic that needs handler details goes into interceptors.
Q3. Trả 415 hoặc 404 mà controller không log gì, bạn kiểm tra gì?
🇻🇳 Tiếng Việt:
Cả hai đều xảy ra trước controller. 404 là HandlerMapping không tìm thấy: kiểm tra path, context-path, method HTTP, prefix ở Nginx. 415 là không có message converter cho Content-Type: kiểm tra header Content-Type của client. Bật log DEBUG cho org.springframework.web sẽ thấy rõ trạm nào từ chối.
🇬🇧 English:
Both happen before the controller. A 404 means no HandlerMapping matched: check the path, context path, HTTP method and any Nginx prefix. A 415 means no message converter supports the Content-Type: check the client's header. Enabling DEBUG logs for org.springframework.web shows which step rejected the request.
Cách nhớ ngắn gọn
- Tomcat → Filter → DispatcherServlet → Mapping → Interceptor → Adapter → Controller → Converter.
- Filter = vỏ ngoài, Servlet API. Interceptor = bên trong, biết handler.
- 4xx mà controller im lặng = lỗi ở trạm trước controller.
- Một request = một thread, từ đầu tới cuối.
Tự kiểm tra
Spring Security nằm ở trạm nào?
Trong Filter chain (FilterChainProxy), trước DispatcherServlet.
Ai tìm ra method OrderController#create cho POST /api/orders?
RequestMappingHandlerMapping.
Ai chuyển JSON thành CreateOrderRequest?
MappingJackson2HttpMessageConverter, được gọi qua argument resolver của @RequestBody.
preHandle trả false thì sao?
DispatcherServlet dừng, controller không chạy; interceptor phải tự ghi response.
Vì sao Filter có thể chạy hai lần cho một request lỗi?
Do ERROR dispatch tới /error. Dùng OncePerRequestFilter để tránh.