Về bản web

caltalys.dev

Tôi POST JSON lên, mọi field đều null - và Spring vẫn trả 200?

Dãy quầy có người nhận-tất: vì sao thiếu một annotation thì Spring nhìn thấy request nhưng không ai đọc body, và không ai báo cho bạn biết.

Caltalys · 18/08/2026 · 11 phút đọc

Lại là thằng em hôm trước. Lần này nó không nhắn lúc hai giờ sáng nữa - nó nhắn lúc sáu giờ chiều, kèm một cái screenshot Postman. Nó viết một endpoint nhận đăng ký user:

@Controller
public class UserController {
    @PostMapping("/users")
    public String create(UserForm form) {
        service.save(form); // form.getName() == null, form.getEmail() == null
        return "ok";
    }
}

Frontend gửi JSON chuẩn chỉ. Content-Type: application/json đàng hoàng. Postman bắn thử cũng vậy. Response trả về 200. Không exception, không warning, không một dòng log nào. Chỉ là mọi field trong form đều null, và database mọc thêm một dòng user rỗng.

Nó hỏi:

thang em - 18:00 Anh ơi, JSON em đúng mà, sao Spring không đọc?

Spring có đọc. Chính xác hơn: Spring có nhìn thấy request đó. Nhưng nó không đọc body - vì không ai bảo nó đọc. Và để hiểu “không ai bảo” nghĩa là gì, chúng ta phải đi theo một request, từ lúc nó chạm vào Tomcat cho tới lúc nó chui vào tham số form của bạn.

Trước hết, thống nhất ba từ, vì chỗ này người ta hay lẫn. DispatcherServlet là cái servlet duy nhất của Spring MVC - mọi request đều đổ về nó trước, nó là cửa khẩu. Handler là method create() của bạn - đích đến cuối cùng. Còn HandlerAdapter là người đứng giữa: DispatcherServlet không biết gì về @PostMapping, về tham số, về giá trị trả về của bạn - nó chỉ biết đưa request cho HandlerAdapter và nói “mày lo”. Toàn bộ phép màu annotation nằm trong người trung gian này, cụ thể là RequestMappingHandlerAdapter.

Nói gọn: DispatcherServlet là cửa khẩu, HandlerAdapter là phòng dịch vụ, còn method của bạn là người ngồi trong cùng, chỉ nhận giấy tờ đã xử lý xong.

Bây giờ, bức tranh toàn cảnh. Mọi request đi qua một method duy nhất: DispatcherServlet.doDispatch(). Nó không dài, và đọc từ trên xuống là bốn nhịp.

Nhịp một - tra sổ địa chỉ. getHandler() hỏi các HandlerMapping: “URL này, HTTP method này, của ai?” RequestMappingHandlerMapping - thằng đã quét toàn bộ @RequestMapping/@PostMapping lúc khởi động - tra bảng và trả về đúng method create() của bạn. Nếu không ai nhận, bạn ăn 404. Đây là nhịp ồn ào duy nhất của cả hành trình: sai ở đây, bạn biết ngay.

Nhịp hai - tìm người phiên dịch. getHandlerAdapter() chọn RequestMappingHandlerAdapter cho các handler dạng annotation. Nhịp này gần như không bao giờ sai, nhắc để bạn biết nó tồn tại.

Nhịp ba - đúc tham số. Đây là nhịp quan trọng nhất bài, và là nơi thằng em kia gục ngã.

RequestMappingHandlerAdapter giữ một cái List<HandlerMethodArgumentResolver> - khoảng gần ba chục phần tử, xếp theo thứ tự cố định. Với mỗi tham số trong method của bạn, Spring chạy một vòng for dọc cái List đó, hỏi từng thằng đúng một câu: supportsParameter() - “mày có nhận xử lý tham số này không?”. Thằng đầu tiên giơ tay thắng. Vòng for dừng ngay tại đó, không hỏi tiếp ai nữa.

Nếu bạn thấy chỗ này quen quen thì đúng rồi đấy: lại là một cái List, lại là một vòng for hỏi từng thằng “mày có nhận không”. Đúng cái cấu trúc đã làm @Value của bài trước bằng 0. Cùng khuôn mặt, khác hiện trường - nhưng lần này nó hỏng theo một kiểu khác hẳn, và kiểu đó nguy hiểm hơn nhiều.

@RequestParam có người phụ trách riêng. @PathVariable có người riêng. @RequestBody có người riêng - tên là RequestResponseBodyMethodProcessor, và chỉ nó mới đọc body request rồi gọi Jackson.

Nhớ kỹ cái List này, và nhớ kỹ luật “ai giơ tay trước thắng”. Đó là chìa khóa của cả bài.

Nhịp bốn - xử lý hàng trả về. Đối xứng hoàn hảo với nhịp ba: một cái List<HandlerMethodReturnValueHandler> khác, cũng vòng for, cũng hỏi từng thằng supportsReturnType(), cũng ai giơ tay trước thắng. Giá trị bạn return sẽ được thằng thắng cuộc định đoạt: ghi thẳng ra response, hay đem đi tìm view.

Nói thật với bạn một chuyện: bốn nhịp này trong source không nằm phẳng một hàng. Nhịp một, hai nằm trong doDispatch(), còn nhịp ba, bốn nằm sâu hơn hai tầng, trong ServletInvocableHandlerMethod.invokeAndHandle(). Mình duỗi chúng ra thành một hàng cho dễ kể, nhưng bạn mở source sẽ thấy đường đi vòng hơn một chút.

Bốn nhịp. Tra sổ → tìm phiên dịch → đúc tham số → xử lý hàng trả về.

Bây giờ quay lại cái form toàn null.

Tham số UserForm form của thằng em không có annotation nào. Vòng for nhịp ba vẫn chạy. Người phụ trách @RequestParam nhìn qua: không có annotation, không phải kiểu đơn giản - lắc đầu. Người phụ trách @RequestBody nhìn qua: không có chữ @RequestBody - lắc đầu. Cứ thế lắc đầu dọc gần hết cái List.

Nhưng khoan. Nếu tất cả đều lắc đầu, lẽ ra phải có một exception kiểu “không ai xử lý được tham số này” chứ? Spring có exception đó thật. Vấn đề là bạn sẽ không bao giờ gặp nó.

Vì ở cuối cái List, Spring đặt sẵn hai quầy nhận-tất. Một quầy vét mọi kiểu đơn giản còn sót. Và một quầy - ModelAttributeMethodProcessor ở chế độ dễ tính - vét mọi kiểu phức tạp không annotation. UserForm rơi đúng vào đó.

Quầy này làm gì? Nó làm đúng nghề của nó: xử lý form HTML kiểu cũ. Nó new UserForm(), rồi lấy query parameter và form field đắp vào qua setter. Request của thằng em có body JSON, nhưng không có query param nào. Nên nó đắp… không gì cả, và trả về một object rỗng, sạch sẽ, hợp lệ.

Không có bug ở đâu cả. Không ai đọc JSON, vì người duy nhất biết đọc JSON - RequestResponseBodyMethodProcessor - chỉ giơ tay khi thấy chữ @RequestBody. Không có chữ đó, vòng for lướt qua mặt nó, và trôi xuống quầy nhận-tất ở đáy List. Body request nằm nguyên trong socket, không ai mở.

Và đây là chỗ hai câu chuyện tách đôi. Bài trước, @Value bằng 0 vì cái List chưa đầy - người phụ trách chưa vào danh sách, nên không ai giơ tay. Lần này cái List đầy ắp, người phụ trách có mặt đầy đủ. Nhưng ở đáy List có một quầy nhận-tất. Trong một dãy quầy có quầy nhận-tất ở cuối, không tồn tại lỗi “không ai phục vụ”. Chỉ tồn tại phục vụ sai. Đó là lý do triệu chứng này im lặng tuyệt đối - và là lý do nó tệ hơn: List chưa đầy thì bạn nhận một giá trị mặc định trông rõ ràng là sai; List có đáy thì bạn nhận một kết quả trông hoàn toàn hợp lệ.

Fix thì bạn biết rồi: thêm @RequestBody. Nhưng giờ bạn cũng biết tại sao fix chạy: annotation đó không phải lời gợi ý cho Spring “cố đọc JSON nhé” - nó là điều kiện giơ tay của đúng một thằng trong List, kéo tham số của bạn dừng lại ở quầy đúng, trước khi kịp trôi xuống đáy.

Nhớ lấy câu này:

annotation trong Spring MVC không phải trang trí - nó là lời khai để được xếp đúng quầy.

Vì bây giờ mình sẽ kể lại đúng câu chuyện ấy, ở đầu bên kia của method.

Thằng em sửa xong, chạy lại, và dính ngay chưởng thứ hai: return "ok" giờ nổ một lỗi 500 nhìn rất siêu thực - Circular view path [ok]: would dispatch back to the current handler URL. Nó chỉ muốn trả chữ “ok” về cho client. Spring thì đi tìm một cái view tên là “ok”.

Trông chẳng liên quan gì tới cái form null. Nhưng bạn đoán được rồi đấy: nhịp bốn, cái List thứ hai, cùng một luật chơi.

Giá trị trả về là một String, và method không có @ResponseBody. Vòng for duyệt List return-value-handler: người phụ trách @ResponseBody nhìn qua - không thấy annotation - lắc đầu. Trôi xuống, và ViewNameMethodReturnValueHandler giơ tay: “String không annotation hả? Chuyên môn của tôi: đó là tên view.” Với @Controller truyền thống - sinh ra cho thời render HTML server-side - đó là hành vi mặc định hoàn toàn có lý. Spring cầm chữ “ok” đi tìm template, không thấy, và phát hiện tên view trùng đường dẫn có thể dispatch ngược về chính handler - nên nó chặn lại bằng cái lỗi circular kia.

Thêm @ResponseBody lên method, thằng phụ trách nó giơ tay trước, cầm chữ “ok” ghi thẳng vào response body. Xong. Và đến đây bạn cũng tự giải được câu đố cuối: @RestController là gì? Mở source ra xem - nó chỉ là @Controller dán kèm sẵn @ResponseBody lên toàn bộ method. Không có class “RestController engine” nào riêng cả. Chỉ là một lời khai được điền sẵn, để mọi giá trị trả về dừng ở quầy đúng.

Hai triệu chứng. Một cái là object rỗng lặng lẽ chui vào database, một cái là lỗi 500 đi tìm view không ai nhờ tìm. Nhưng cùng một nguyên nhân, nằm ở đúng một chỗ: hai cái List đối xứng trong RequestMappingHandlerAdapter, cùng luật “ai giơ tay trước thắng”, cùng có quầy mặc định hứng mọi thứ không lời khai. Thiếu @RequestBody, tham số trôi về quầy form. Thiếu @ResponseBody, giá trị trả về trôi về quầy view. Bạn không rơi vào lỗi - bạn rơi vào quầy mặc định.

Cái giá của thiết kế này đáng để nói thẳng. Cơ chế List-resolver cho Spring MVC sự mềm dẻo hiếm có: bạn có thể tự viết một HandlerMethodArgumentResolver, nhét vào List, và thế là method controller nhận thẳng @CurrentUser User user - nhiều team làm vậy thật. Nhưng đổi lại, hệ thống không bao giờ nói “tôi không hiểu tham số này”. Nó luôn tìm được một cách hiểu - kể cả cách hiểu sai. First-match-wins cộng catch-all nghĩa là bạn đổi lỗi ồn ào lấy hành vi sai im lặng. Đó là loại đánh đổi bạn sẽ gặp lại ở mọi hệ thống dispatch có fallback: middleware chain, pattern matching có nhánh wildcard, hay chuỗi catch bắt Exception ở cuối.

Lớp trung gian hôm nay không nằm ở lúc khởi động. Nó nằm trên đường đi của từng request, chạy đi chạy lại vài nghìn lần một giây, và bạn vẫn không nhìn thấy nó. Vẫn là một cái List. Vẫn là một vòng for. Chỉ khác: bài trước nó hỏng vì danh sách chưa có ai; lần này nó hỏng vì danh sách có sẵn một người nhận tất.

Nếu bạn muốn tự kiểm chứng toàn bộ bài này, chỉ cần đặt một breakpoint trong HandlerMethodArgumentResolverComposite.getArgumentResolver() rồi gửi một request. Bạn sẽ nhìn thấy tận mắt vòng for, thấy từng resolver được hỏi, thấy khoảnh khắc ModelAttributeMethodProcessor giơ tay nhận nhầm tham số của bạn. Mười phút debug đó đáng giá hơn mười bài blog - kể cả bài này.

Thằng em, sau khi sửa xong cả hai chỗ, nhắn lại một câu làm mình khá ưng:

thang em Vậy giờ em nhìn annotation kiểu khác rồi. Không phải em bật tính năng - em đang khai báo để được xếp hàng đúng chỗ.

Đúng rồi đó. Xếp đúng quầy thì mọi thứ tự nó chạy. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 0.2

    JDK 17. mvn spring-boot:run rồi POST /users kèm name và email; UserController log [TEST 2] Received form: để xem UserForm được dựng bằng gì.

    https://caltalys.dev/downloads/book1/0.2-spring-deep-dive-demo.zip

https://caltalys.dev/spring-controller-deep-dive

Caltalys · 18/08/2026 · Spring · REST API