Spring REST API & Data JPA/@RestController & Request Mapping — controller annotation-driven
4/46
Bài 4 / 46~12 phútSpring MVC CoreMiễn phí lượt xem

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

  1. @RestController gộp @ResponseBody áp cho toàn bộ class — không còn lặp.
  2. 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:

  1. Không có @ResponseBody: Spring xem return value là tên view — tìm template OrderDto.html, OrderDto.jsp qua ViewResolver. Đây là hành vi mặc định cho server-side rendering (Thymeleaf, JSP).
  2. @ResponseBody: Spring bypass ViewResolver, gọi HttpMessageConverter (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 "";
}

@RestControllermeta-annotation — annotation tổng hợp từ 2 annotation khác. Spring xử lý meta-annotation theo chain:

  • @RestController kế thừa @Controller, vốn kế thừa @Component — nhờ vậy component scan tìm thấy class và đăng ký bean.
  • @RestController kế 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

ScenarioAnnotationLý do
REST API trả JSON/XML@RestController@ResponseBody áp tất cả method
Server-side render HTML (Thymeleaf)@ControllerMethod trả String = view name
Mix REST method + view method trong 1 class@Controller + @ResponseBody từng methodHiế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:

AttributeMô tảMismatch HTTP status
value (alias path)URL pattern, có thể là array
methodArray RequestMethod405 Method Not Allowed
consumesFilter theo Content-Type request415 Unsupported Media Type
producesFilter theo Accept header request406 Not Acceptable
headersHeader constraint Key=Value hoặc !Key400 / no match
paramsQuery param constraint key=value hoặc !keyno match
nameTê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 = ...):

ShortcutTương đươngHTTP methodSafeIdempotent
@GetMapping@RequestMapping(method = GET)GET
@PostMapping@RequestMapping(method = POST)POSTKhôngKhông
@PutMapping@RequestMapping(method = PUT)PUTKhông
@DeleteMapping@RequestMapping(method = DELETE)DELETEKhông
@PatchMapping@RequestMapping(method = PATCH)PATCHKhôngKhô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
PatternMatchesKhong 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:

  1. DispatcherServlet hỏi RequestMappingHandlerMapping: "request này match handler nào?"
  2. HandlerMapping tra registry, chạy specificity ranking nếu nhiều handler match.
  3. Handler method được invoke, return value đi qua HttpMessageConverter nế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 + HandlerMapping nhậ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à Accept negotiation — 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

  • @ResponseBody báo Spring serialize return value trực tiếp vào HTTP response body qua HttpMessageConverter, 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 @ResponseBody mỗi method. Standard cho REST API từ Spring 4.
  • @RequestMapping có 7 attribute; @GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping là 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 IllegalStateException ngay khi boot, không phải lúc request đến.

Tự kiểm tra

Tự kiểm tra
Q1
Vì 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ì?

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);
  }
}

Sai: dùng @Controller thay @RestController. Method trả OrderDto không có @ResponseBody, nên Spring xem OrderDtotê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.

Q3
App đã 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?

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.

Q4
Vì 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?

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

Q5
Giải thích sự khác nhau giữa consumesproduces trong @RequestMapping. Mỗi attribute filter dựa trên header nào của request và trả HTTP status gì khi mismatch?

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

Đặt 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