@RestController & Request Mapping — controller annotation-driven
@RestController = @Controller + @ResponseBody. Bài này giải thích tại sao @ResponseBody tách ra thành meta-annotation, cách @RequestMapping + 7 shortcut map URL đến handler method, và PathPattern syntax Spring 6.
TL;DR: @RestController là meta-annotation gộp @Controller + @ResponseBody — áp @ResponseBody cho mọi method trong class, loại bỏ duplicate annotation từng method. @RequestMapping là annotation gốc 7 attribute (value, method, consumes, produces, headers, params, name); 5 shortcut @GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping thu gọn cho 99% case. Spring 6 dùng PathPatternParser thay AntPathMatcher — hỗ trợ path variable {id}, regex constraint {id:\d+}, capture-remaining {*path}. Pitfall cốt lõi: dùng @Controller thay @RestController cho REST API khiến return value bị lookup ViewResolver thay serialize JSON — 500 tại runtime.
Bài trước (URL routing & DispatcherServlet) bóc cơ sở hạ tầng DispatcherServlet + HandlerMapping. Bài này đi xuống một tầng ứng dụng: annotation mà bạn viết để khai báo controller và route.
1. Lịch sử controller annotation — 3 generation
Hiểu lịch sử giải thích vì sao @RestController tồn tại và tại sao nó không thể đơn giản hóa hơn nữa.
Generation 1 — Spring 1.x/2.x (XML mapping)
<bean name="/orders" class="com.olhub.OrderController"/>
Class implement interface Controller với method duy nhất handleRequest(HttpServletRequest, HttpServletResponse). Mapping URL trong XML — mọi thứ đều verbose. Legacy code 2003-2009; không còn gặp trong code mới.
Generation 2 — Spring 2.5+ (@Controller + @ResponseBody)
@Controller
@RequestMapping("/api/orders")
public class OrderController {
@RequestMapping(method = RequestMethod.GET, value = "/{id}")
@ResponseBody
public OrderDto getOrder(@PathVariable Long id) { ... }
@RequestMapping(method = RequestMethod.POST)
@ResponseBody
public OrderDto create(@RequestBody OrderRequest req) { ... }
}
Cải thiện lớn: annotation-driven, không XML. Nhưng pain point rõ ràng: @ResponseBody phải lặp ở mỗi method. Nếu class có 10 method, phải viết 10 lần.
Generation 3 — Spring 4+ (@RestController + shortcut)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderDto getOrder(@PathVariable Long id) { ... }
@PostMapping
public OrderDto create(@RequestBody OrderRequest req) { ... }
}
Đây là syntax chuẩn 2026. Hai thay đổi trọng tâm:
@RestControllergộp@ResponseBodyáp cho toàn bộ class — không còn lặp.- Shortcut
@GetMapping,@PostMapping,... thay thế@RequestMapping(method = ...)dài dòng.
flowchart LR G1["Generation 1<br/>XML bean + Controller interface"] -->|"Spring 2.5"| G2 G2["Generation 2<br/>@Controller + @ResponseBody moi method"] -->|"Spring 4"| G3 G3["Generation 3<br/>@RestController + shortcut annotation"]
2. @RestController — tại sao cần @ResponseBody
Để hiểu @RestController, cần hiểu trước tại sao @ResponseBody tồn tại và bản chất của nó.
@ResponseBody làm gì
Khi một handler method trả về object Java (vd OrderDto), Spring MVC có hai lựa chọn:
- Không có
@ResponseBody: Spring xem return value là tên view — tìm templateOrderDto.html,OrderDto.jspquaViewResolver. Đây là hành vi mặc định cho server-side rendering (Thymeleaf, JSP). - Có
@ResponseBody: Spring bypass ViewResolver, gọiHttpMessageConverter(thường là Jackson) để serialize object thành JSON/XML rồi viết thẳng vào HTTP response body.
Tóm gọn: @ResponseBody = "serialize trực tiếp vào body, không qua view".
Tại sao chọn serialize trực tiếp
Trước khi REST API phổ biến (pre-2010), Spring MVC dùng chủ yếu để render HTML qua ViewResolver. @ResponseBody được thêm vào sau khi developer cần trả JSON/XML cho AJAX và mobile client — một "escape hatch" khỏi View layer.
Khi REST API trở thành default (post-2015), cần thiết kế annotation thể hiện intent toàn class: "class này là REST controller — mọi method đều serialize". Đó là lý do @RestController ra đời ở Spring 4.
@RestController source
// org/springframework/web/bind/annotation/RestController.java (rut gon)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
String value() default "";
}
@RestController là meta-annotation — annotation tổng hợp từ 2 annotation khác. Spring xử lý meta-annotation theo chain:
@RestControllerkế thừa@Controller, vốn kế thừa@Component— nhờ vậy component scan tìm thấy class và đăng ký bean.@RestControllerkế thừa@ResponseBody, và annotation này áp cho mọi method trong class.
Không có "magic" nào khác. Bổ sung @ResponseBody một lần trên class thay vì N lần trên N method — đó là toàn bộ giá trị.
flowchart TB RC["@RestController tren class"] RC -->|"ke thua"| CTRL["@Controller<br/>(extends @Component)"] RC -->|"ke thua"| RB["@ResponseBody<br/>ap cho moi method"] CTRL -->|"component scan"| BEAN["Bean dang ky trong container"] RB -->|"khi method return"| CONV["HttpMessageConverter serialize JSON"] CONV --> RESP["HTTP Response body"]
Khi nào dùng @Controller vs @RestController
| Scenario | Annotation | Lý do |
|---|---|---|
| REST API trả JSON/XML | @RestController | @ResponseBody áp tất cả method |
| Server-side render HTML (Thymeleaf) | @Controller | Method trả String = view name |
| Mix REST method + view method trong 1 class | @Controller + @ResponseBody từng method | Hiếm, tránh mix nếu được |
3. @RequestMapping — annotation gốc
@RequestMapping là annotation tổng quát, có thể đặt trên class (class-level prefix) hoặc method (method-level pattern).
@RequestMapping(
value = "/orders", // URL pattern; alias: path
method = RequestMethod.GET, // HTTP method filter
consumes = MediaType.APPLICATION_JSON_VALUE, // Content-Type filter
produces = MediaType.APPLICATION_JSON_VALUE, // Accept header filter
headers = "X-API-Version=2", // request header constraint
params = "filter=active" // query param constraint
)
public List<OrderDto> list() { ... }
7 attribute chính và hành vi của từng attribute:
| Attribute | Mô tả | Mismatch HTTP status |
|---|---|---|
value (alias path) | URL pattern, có thể là array | — |
method | Array RequestMethod | 405 Method Not Allowed |
consumes | Filter theo Content-Type request | 415 Unsupported Media Type |
produces | Filter theo Accept header request | 406 Not Acceptable |
headers | Header constraint Key=Value hoặc !Key | 400 / no match |
params | Query param constraint key=value hoặc !key | no match |
name | Tên để lookup URL qua MvcUriComponentsBuilder | — |
Class-level + method-level concat
@RestController
@RequestMapping("/api/v1/orders") // class-level prefix
public class OrderController {
@GetMapping("/{id}") // concat -> /api/v1/orders/{id}
public OrderDto get(@PathVariable Long id) { ... }
@PostMapping // concat -> /api/v1/orders
public OrderDto create(@RequestBody OrderRequest req) { ... }
@GetMapping // concat -> /api/v1/orders
public List<OrderDto> list() { ... }
}
Class-level và method-level cộng lại thành URL cuối. Khi method-level để rỗng, URL cuối chính là class-level prefix.
4. 7 shortcut annotation — tại sao cần
Spring 4.3 thêm 5 shortcut tương đương @RequestMapping(method = ...):
| Shortcut | Tương đương | HTTP method | Safe | Idempotent |
|---|---|---|---|---|
@GetMapping | @RequestMapping(method = GET) | GET | Có | Có |
@PostMapping | @RequestMapping(method = POST) | POST | Không | Không |
@PutMapping | @RequestMapping(method = PUT) | PUT | Không | Có |
@DeleteMapping | @RequestMapping(method = DELETE) | DELETE | Không | Có |
@PatchMapping | @RequestMapping(method = PATCH) | PATCH | Không | Không |
Safe = không thay đổi state server. Idempotent = gọi nhiều lần kết quả giống gọi một lần.
Tại sao shortcut cần thiết (không chỉ là syntactic sugar)
Ngoài việc giảm verbosity, shortcut annotation ràng buộc intent tại compile time. Khi viết @GetMapping, không thể vô tình truyền method = RequestMethod.POST vào — attribute method không tồn tại trong shortcut. Lỗi này được bắt bởi compiler, không phải runtime.
// Compile error -- @GetMapping khong co attribute method:
@GetMapping(value = "/orders", method = RequestMethod.POST)
// OK -- @RequestMapping dung khi can nhieu HTTP method:
@RequestMapping(value = "/orders", method = {RequestMethod.GET, RequestMethod.HEAD})
Shortcut accept tất cả attribute @RequestMapping trừ method:
@PostMapping(
value = "/orders",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public OrderDto create(@RequestBody OrderRequest req) { ... }
Khuyến nghị: dùng shortcut cho 99% case. Dùng @RequestMapping trực tiếp chỉ khi cần match nhiều HTTP method cùng URL.
5. PathPattern syntax — URL matching Spring 6
Spring 6 thay AntPathMatcher bằng PathPatternParser làm default. PathPatternParser parse pattern tại startup (compile-time), không phải per-request — nhanh hơn và strict hơn về cú pháp.
Các pattern cơ bản
@GetMapping("/orders") // exact -- chi match /orders
@GetMapping("/orders/{id}") // path variable -- /orders/42, /orders/abc
@GetMapping("/orders/{id:\\d+}") // path variable + regex -- chi digits: /orders/42
@GetMapping("/orders/{*path}") // capture remaining -- /orders/a/b/c
@GetMapping("/api/v?/orders") // single char wildcard -- /api/v1/orders, /api/v2/orders
@GetMapping("/api/*/orders") // single segment wildcard -- /api/v1/orders (1 segment)
@GetMapping("/files/**") // multi-segment wildcard -- /files/a/b/c
| Pattern | Matches | Khong match |
|---|---|---|
"/orders/{id}" | /orders/42, /orders/abc | /orders/42/items |
"/orders/{id:\\d+}" | /orders/42 | /orders/abc |
"/orders/{*path}" | /orders/a, /orders/a/b/c | /orders |
"/api/v?/orders" | /api/v1/orders, /api/v2/orders | /api/v10/orders |
Most-specific match khi nhiều pattern trùng
@GetMapping("/orders/latest") // (A) -- exact
@GetMapping("/orders/{id}") // (B) -- path variable
@GetMapping("/orders/{id:\\d+}") // (C) -- regex constraint
Request GET /orders/latest:
- (A) match exact.
- (B) match với
id = "latest".
Spring chọn most specific = (A). Thứ tự specificity (cao xuống thấp): literal segment > regex constraint > path variable > single wildcard > multi wildcard.
flowchart TB
REQ["Request: GET /orders/latest"]
L["Literal: /orders/latest<br/>do cu the nhat -- WINS"]
RX["Regex: /orders/{id:\\d+}<br/>chi digits -- khong match 'latest'"]
PV["Path var: /orders/{id}<br/>match nhung it cu the"]
WS["Wildcard: /orders/*<br/>match nhung it cu the nhat"]
REQ --> L
REQ --> RX
REQ --> PV
REQ --> WS
L -->|"selected"| H["handler method chay"]Spring sort theo specificity tại startup, không phụ thuộc thứ tự khai báo trong code.
6. Cơ chế bên dưới — request đến handler
Để hiểu toàn bộ luồng, cần biết HandlerMapping đóng vai trò gì khi matching annotation.
Khi @RestController được scan vào container, RequestMappingHandlerMapping đọc tất cả annotation trên class và method, build một registry — map từ RequestMappingInfo (URL pattern + method + consumes + produces + headers + params) đến handler method. Đây là bước startup, không phải per-request.
Khi request đến:
DispatcherServlethỏiRequestMappingHandlerMapping: "request này match handler nào?"HandlerMappingtra registry, chạy specificity ranking nếu nhiều handler match.- Handler method được invoke, return value đi qua
HttpMessageConverternếu có@ResponseBody.
flowchart LR HTTP["HTTP Request<br/>GET /api/orders/42"] DS["DispatcherServlet"] HM["RequestMappingHandlerMapping<br/>tra registry built at startup"] METHOD["@GetMapping method<br/>OrderController.get(42)"] CONV["HttpMessageConverter<br/>Jackson serialize OrderDto -> JSON"] RESP["HTTP Response<br/>200 + JSON body"] HTTP --> DS DS -->|"lookup handler"| HM HM -->|"handler found"| METHOD METHOD -->|"return OrderDto"| CONV CONV --> RESP
Registry được build một lần tại startup — đây là lý do trùng URL throw IllegalStateException lúc khởi động, không phải lúc request đến.
7. Pitfall
❌ Nhầm 1 — Dùng @Controller cho REST API:
@Controller
public class OrderController {
@GetMapping("/api/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id); // BUG: Spring tim view "OrderDto"
}
}
Spring xem OrderDto là view name và đem hỏi ViewResolver; không template nào tên đó tồn tại nên request kết thúc bằng 500. Fix: đổi @Controller thành @RestController.
❌ Nhầm 2 — Trùng URL giữa controller:
@RestController @RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}") public OrderDto get(...) { ... }
}
@RestController @RequestMapping("/api")
public class ApiController {
@GetMapping("/orders/{id}") public Object alt(...) { ... } // trung URL!
}
Spring throw IllegalStateException tại startup. Không có rule "first wins". Fix: refactor URL space hoặc phân biệt qua produces/consumes.
❌ Nhầm 3 — Sai type cho path variable:
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable String id) { ... } // String thay Long
Code chạy nhưng cần convert manual. Nếu id không phải số, convert thủ công throw NumberFormatException và client nhận 400. Fix: khai đúng type @PathVariable Long id để Spring auto-convert.
❌ Nhầm 4 — Trailing slash trong class-level mapping:
@RequestMapping("/api/orders/") // trailing slash
public class OrderController {
@GetMapping("/{id}") // -> /api/orders//{id} double slash
Spring 6 strict hơn với double slash. Fix: bỏ trailing slash ở class-level: @RequestMapping("/api/orders").
❌ Nhầm 5 — Truyền method vào shortcut annotation:
@GetMapping(value = "/orders", method = RequestMethod.POST) // compile error
Shortcut không có attribute method. Dùng @RequestMapping nếu cần nhiều method.
Liên hệ các bài khác
- URL routing & DispatcherServlet: bài trước giải thích
DispatcherServlet+HandlerMappingnhận request và tra cứu handler — bài này đi vào annotation bạn dùng để khai báo handler trong registry đó. - Content negotiation & versioning: bài tiếp theo đào sâu
produces/consumes, chiến lược versioning (path/header/media type), vàAcceptnegotiation — bài này chỉ giới thiệu attribute, bài sau giải thích cơ chế chi tiết.
Tóm tắt
@ResponseBodybáo Spring serialize return value trực tiếp vào HTTP response body quaHttpMessageConverter, bypass ViewResolver. Thiếu nó, Spring xem return value là view name và request fail 500.@RestController=@Controller+@ResponseBodyáp toàn class — meta-annotation loại bỏ duplicate@ResponseBodymỗi method. Standard cho REST API từ Spring 4.@RequestMappingcó 7 attribute;@GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMappinglà shortcut fix HTTP method tại compile time.- Class-level
@RequestMappingđặt prefix; method-level concat thêm suffix. PathPatternParser(Spring 6 default):{id}path variable,{id:\d+}regex,{*path}capture-remaining,**multi-segment wildcard.- Khi nhiều handler match cùng URL, Spring chọn most specific (literal trước regex, regex trước path var, path var trước wildcard). Literal segment luôn thắng.
- Registry build tại startup, nên trùng URL gây
IllegalStateExceptionngay khi boot, không phải lúc request đến.
Tự kiểm tra
Q1Vì sao Spring 4 đưa ra @RestController thay vì để developer tiếp tục dùng @Controller + @ResponseBody mỗi method? Cơ chế bên dưới thay đổi gì?▸
@RestController thay vì để developer tiếp tục dùng @Controller + @ResponseBody mỗi method? Cơ chế bên dưới thay đổi gì?Trước Spring 4, developer phải đặt @ResponseBody trên mỗi handler method trong REST controller. Class có 10 method → 10 lần lặp annotation giống nhau. Đây là noise thuần túy: annotation không thêm thông tin, chỉ lặp intent của class.
@RestController là meta-annotation gộp @Controller + @ResponseBody. Spring đọc meta-annotation theo chain: khi thấy @RestController trên class, nó kế thừa @ResponseBody và áp cho mọi method trong class — hành vi tương đương viết @ResponseBody từng method nhưng chỉ khai báo một lần trên class.
Cơ chế runtime không thay đổi: HttpMessageConverter (thường Jackson) vẫn serialize return value thành JSON. Thay đổi duy nhất là nơi annotation đặt — class thay vì method. Đây là vấn đề DRY (Don't Repeat Yourself), không phải tính năng mới.
Hệ quả thực tế: nếu dùng @Controller mà quên @ResponseBody một method, Spring sẽ lookup ViewResolver với return value làm view name — runtime error khó debug. @RestController loại bỏ hoàn toàn khả năng quên này ở cấp class.
Q2Đoạn sau có gì sai? Output khi gọi GET /api/orders/42 là gì?@Controller
public class OrderController {
@GetMapping("/api/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id);
}
}
▸
GET /api/orders/42 là gì?@Controller
public class OrderController {
@GetMapping("/api/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id);
}
}Sai: dùng @Controller thay @RestController. Method trả OrderDto không có @ResponseBody, nên Spring xem OrderDto là tên view.
DispatcherServlet gọi ViewResolver để tìm template tên OrderDto. Với Thymeleaf: tìm classpath:/templates/OrderDto.html. File không tồn tại nên Thymeleaf throw TemplateInputException, client nhận 500 Internal Server Error.
Client nhận 500, không nhận JSON. Log server chứa "Could not resolve view with name 'com.olhub.dto.OrderDto'" (hoặc tương tự tùy ViewResolver).
Hai cách fix:
1. Đổi @Controller thành @RestController — áp @ResponseBody toàn class.
2. Giữ @Controller nhưng thêm @ResponseBody trên method — verbose nhưng đúng. Dùng khi class cần mix REST method và view method.
Quy tắc rõ: REST API → @RestController. Server-side HTML → @Controller + return String view name.
Q3App đã có GET /orders/{id}. Team muốn thêm GET /orders/latest trả order mới nhất. Hai route có conflict không? Spring chọn handler theo cơ chế nào?▸
GET /orders/{id}. Team muốn thêm GET /orders/latest trả order mới nhất. Hai route có conflict không? Spring chọn handler theo cơ chế nào?Không conflict. Spring dùng most-specific match để phân giải khi nhiều handler cùng match một request.
Request GET /orders/latest match cả hai: /orders/latest (literal exact) và /orders/{id} (path variable, id = "latest"). Spring chọn literal segment vì độ đặc hiệu cao hơn path variable.
Thứ tự specificity từ cao xuống thấp: literal segment, regex constraint {id:\d+}, path variable {id}, single-segment wildcard *, multi-segment wildcard **. Specificity tính tại startup khi build registry, không phụ thuộc thứ tự khai báo trong class.
Thực hành tốt: thêm regex constraint cho id nếu nó luôn là số nguyên — /orders/{id:\d+}. Khi đó /orders/latest không match {id:\d+} (vì "latest" không phải digits), Spring chỉ còn một handler match là literal. Sạch hơn về semantics và tránh edge case khi id là string ngẫu nhiên.
Q4Vì sao Spring throw IllegalStateException tại startup khi có 2 controller map cùng URL + method, thay vì throw lúc request đến URL đó lần đầu?▸
IllegalStateException tại startup khi có 2 controller map cùng URL + method, thay vì throw lúc request đến URL đó lần đầu?RequestMappingHandlerMapping build registry tại startup — scan toàn bộ @Controller/@RestController bean, đọc annotation, đăng ký RequestMappingInfo → handler. Bước này xảy ra trong afterPropertiesSet() khi container khởi tạo.
Khi phát hiện 2 handler cùng RequestMappingInfo (cùng URL + method + không phân biệt qua produces/consumes/headers), registry không thể có 2 entry cho cùng key — Spring throw IllegalStateException ngay tại đây, trước khi app nhận request đầu tiên.
Đây là lựa chọn thiết kế fail-fast có chủ đích: lỗi cấu hình (ambiguous mapping) lộ ra ngay khi deploy, không phải vào lúc request đầu tiên hit URL đó. Nếu Spring chọn lazy resolution (throw lúc request đến), bug chỉ xuất hiện khi đúng URL đó được gọi — có thể là URL ít dùng, bug ẩn mãi đến production.
Fix: phân biệt bằng produces/consumes/headers/params (Spring coi đây là 2 mapping khác nhau), hoặc refactor URL space (cách tốt hơn — clean URL, không trùng).
Q5Giải thích sự khác nhau giữa consumes và produces trong @RequestMapping. Mỗi attribute filter dựa trên header nào của request và trả HTTP status gì khi mismatch?▸
consumes và produces trong @RequestMapping. Mỗi attribute filter dựa trên header nào của request và trả HTTP status gì khi mismatch?consumes và produces là hai chiều của content negotiation — filter request đến theo format data.
consumes: filter theo header Content-Type của request — request body được encode theo format nào. Ví dụ consumes = "application/json" chỉ match request có Content-Type: application/json. Client gửi Content-Type: text/plain → không match → 415 Unsupported Media Type.
produces: filter theo header Accept của request — client chấp nhận response theo format nào. Ví dụ produces = "application/json" chỉ match request có Accept: application/json (hoặc Accept: */*). Client gửi Accept: application/xml khi handler chỉ produce JSON → không match → 406 Not Acceptable.
Ứng dụng thực tế: cùng URL, cùng HTTP method, 2 handler khác nhau cho 2 format. Ví dụ GET /orders/{id} với produces = "application/json" và GET /orders/{id} với produces = "application/xml" — Spring route theo Accept header. Đây là nền tảng của content negotiation và một pattern versioning (media type versioning) được trình bày chi tiết trong bài [Content negotiation & versioning](./04-content-negotiation-versioning).
Bài tiếp theo: Content negotiation, versioning & CORS
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên