Một request đi qua những đâu trong Spring MVC

5 câu hỏi~25 phútTừ khóa: Tomcat connector, Filter chain, DispatcherServlet, HandlerMapping, HandlerAdapter, HandlerInterceptor, HttpMessageConverter, Servlet

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

3. Giải pháp: nhớ 8 trạm

TrạmThuộc vềViệc chính
Tomcat ConnectorTomcatNhận TCP, parse HTTP, lấy thread từ pool
Filter chainServlet APISecurity, CORS, encoding, logging, trước mọi servlet
DispatcherServletSpring MVCFront controller, điều phối tất cả
HandlerMappingSpring MVCURL + method → method nào của controller
HandlerInterceptorSpring MVCpreHandle / postHandle / afterCompletion
HandlerAdapterSpring MVCGọi method, resolve tham số
ControllerCode của bạnGọi service, trả kết quả
HttpMessageConverterSpring MVCJSON ↔ 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ứngTrạmController có chạy?
502 / 504Nginx, Tomcat chưa trả lờiCó thể có hoặc chưa
401 / 403Security filterKhông
404 No handler / No static resourceHandlerMappingKhông
405 Method Not AllowedHandlerMapping (URL đúng, method sai)Không
415 Unsupported Media TypeMessage converterKhông
400 JSON parse errorMessage converterKhông
500Controller / service ném exceptionCó

7. Khi nào cần nghĩ tới chuỗi này

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)

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 ------------------------->|

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


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.