Bài 3 dựng endpoint đầu tiên bằng ba annotation, mỗi annotation một câu giải thích: @RestController trên class, @GetMapping("/hello") trên method, @RequestParam trên argument. Chừng đó đủ để lấy JSON ra từ một application đang chạy. Nhưng chưa đủ để giải thích vì sao một @Controller trả về đúng chuỗi đó lại nhận 404, vì sao một request DELETE có thể rơi vào method viết cho GET, hay vì sao /api/products/ không tìm thấy gì trong khi /api/products chạy bình thường.
Bài này mở các annotation đó ra: @RestController thêm gì vào @Controller, request đi qua những component nào của Spring MVC trước và sau method của bạn, method được chọn ra sao khi nhiều mapping cùng khớp, và status code nào quay về khi không mapping nào khớp. Ví dụ xuyên suốt là danh mục sản phẩm mà Chương 3 xây dựng dưới /api/products.
![]()
Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24, Jackson 3.1.5) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web. Application chạy với --server.port=8116, nên các lệnh gọi port đó và thread của Tomcat trong log có tên nio-8116-exec-N; cấu hình mặc định sẽ nghe ở 8080. Log level cũng được truyền theo cách đó, dưới dạng argument --logging.level..., và header Date được lược khỏi mọi response.
@RestController là @Controller cộng @ResponseBody
Đây là @RestController đúng như trong spring-web 7.0.9, bỏ phần Javadoc:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}Annotation này không có hành vi riêng. Nó đại diện cho hai annotation đặt trên cùng một class:
@Controllerlà một stereotype, nên component scanning đăng ký class thành bean (bài 6). Nó cũng là dấu hiệu màRequestMappingHandlerMappingkiểm tra khi gom các method có mapping lúc khởi động: methodisHandlercủa nó xem bean class có@Controllerhay không.valuelà alias cho tên bean.@ResponseBodynói rằng giá trị trả về của method chính là body của response. Đặt trên class thì nó áp dụng cho mọi method bên trong.
Bỏ @ResponseBody đi sẽ thấy nó làm gì. HelloController của bài 3 trả về "Hello, Spring Boot!" từ /hello. Đây là cùng method đó, đặt trong một class chỉ có @Controller:
package com.example.demo;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PageController {
@GetMapping("/page")
public String page() {
return "Hello, Spring Boot!";
}
}curl -i http://localhost:8116/helloHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 19
Hello, Spring Boot!curl -i http://localhost:8116/pageHTTP/1.1 404
Content-Type: application/json
Content-Language: en-VN
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:01.768Z","status":404,"error":"Not Found","path":"/page"}Path đã được map, method trả về giá trị, vậy mà nhận 404. Ở mức INFO mặc định, log không có gì ngoài ba dòng Initializing mà request đầu tiên nào cũng in ra. Với logging.level.org.springframework.web=DEBUG thì lý do hiện rõ:
2026-09-12T14:22:08.020+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.web.servlet.DispatcherServlet : GET "/page", parameters={}
2026-09-12T14:22:08.023+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.PageController#page()
2026-09-12T14:22:08.029+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.w.s.v.ContentNegotiatingViewResolver : Selected '*/*' given [*/*]
2026-09-12T14:22:08.029+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.w.servlet.view.InternalResourceView : View name [Hello, Spring Boot!], model {}
2026-09-12T14:22:08.029+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.w.servlet.view.InternalResourceView : Forwarding to [Hello, Spring Boot!]
2026-09-12T14:22:08.030+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.web.servlet.DispatcherServlet : "FORWARD" dispatch for GET "/Hello, Spring Boot!", parameters={}
2026-09-12T14:22:08.031+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped to ResourceHttpRequestHandler [classpath [META-INF/resources/], classpath [resources/], classpath [static/], classpath [public/], ServletContext [/]]
2026-09-12T14:22:08.033+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.w.s.r.ResourceHttpRequestHandler : Resource not found for path [Hello, Spring Boot!]
2026-09-12T14:22:08.034+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.servlet.resource.NoResourceFoundException: No static resource Hello, Spring Boot! for request '/Hello, Spring Boot!'.]
2026-09-12T14:22:08.034+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.web.servlet.DispatcherServlet : Exiting from "FORWARD" dispatch, status 404
2026-09-12T14:22:08.035+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-1] o.s.web.servlet.DispatcherServlet : Completed 404 NOT_FOUNDMapping không có lỗi: Mapped to com.example.demo.PageController#page(). Chỗ hỏng nằm ở phía sau. Không có @ResponseBody, giá trị trả về kiểu String là tên của một view. Các view resolver của Boot đi tìm view tên Hello, Spring Boot!, và khi không có template engine nào trên classpath thì thứ chúng tìm được là một InternalResourceView, vốn forward request tới URL mang đúng tên đó. Lần forward đi tới /Hello, Spring Boot!, static resource handler không có file nào như vậy, và lỗi 404 của nó trở thành response.
Khi tên view trùng với chính path của method, lần forward sẽ lặp vô tận, và Spring chặn lại bằng lỗi 500:
@GetMapping("/page")
public String page() {
return "Hello, Spring Boot!";
}
@GetMapping("/catalogue")
public String catalogue() {
return "catalogue";
} curl -i http://localhost:8116/catalogue trả về HTTP/1.1 500 với "error":"Internal Server Error" trong body, còn log in ra dòng này rồi đến một stack trace:
2026-09-12T14:22:02.108+07:00 ERROR 41619 --- [demo] [nio-8116-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Circular view path [catalogue]: would dispatch back to the current handler URL [/catalogue] again. Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)] with root causeThêm @ResponseBody vào page(), hoặc đổi annotation của class thành @RestController, thì /page trả về y hệt /hello: 200, text/plain;charset=UTF-8, Content-Length: 19.

@Controller trả về tên view là công cụ đúng khi một template engine render HTML ở phía server, và đó là nội dung của bài 24. Với JSON API, mọi controller trong chương này đều là @RestController.
@RequestMapping ở class và các annotation tắt theo HTTP method
@RequestMapping là annotation tổng quát. Đặt trên class, nó quy định prefix cho path của mọi method bên trong. Đặt trên method, nó thêm phần còn lại của path và, qua attribute method, các HTTP method mà mapping chấp nhận. Các attribute còn lại — params, headers, consumes, produces — thu hẹp điều kiện khớp thêm nữa và có riêng một phần ở bên dưới.
Viết @RequestMapping(path = "/{id}", method = RequestMethod.GET) trên từng method thì rườm rà, nên Spring có năm annotation tắt: @GetMapping, @PostMapping, @PutMapping, @PatchMapping và @DeleteMapping. Chúng là composed annotation, và mã nguồn cho thấy điều đó theo nghĩa đen. @GetMapping trong spring-web 7.0.9, bỏ phần Javadoc:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
@AliasFor(annotation = RequestMapping.class)
String name() default "";
@AliasFor(annotation = RequestMapping.class)
String[] value() default {};
@AliasFor(annotation = RequestMapping.class)
String[] path() default {};
@AliasFor(annotation = RequestMapping.class)
String[] params() default {};
@AliasFor(annotation = RequestMapping.class)
String[] headers() default {};
@AliasFor(annotation = RequestMapping.class)
String[] consumes() default {};
@AliasFor(annotation = RequestMapping.class)
String[] produces() default {};
@AliasFor(annotation = RequestMapping.class)
String version() default "";
}@RequestMapping(method = RequestMethod.GET) nằm ngay trên chính annotation, và mọi attribute đều là @AliasFor chuyển giá trị sang @RequestMapping. @PostMapping, @PutMapping, @PatchMapping và @DeleteMapping có đúng tám attribute như vậy và chỉ khác nhau ở hằng RequestMethod, nên không annotation nào có attribute method để bạn điền sai. version là attribute mới của Framework 7, dành cho API versioning, thuộc về khóa Advanced.
Cả năm annotation tắt đều khai báo @Target(ElementType.METHOD): không đặt được lên class. Prefix ở cấp class luôn là một @RequestMapping thuần.
Controller CRUD cho /api/products
Danh mục cần một type cho sản phẩm. Một record trong package product dưới com.example.demo vừa sinh:
package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String name, BigDecimal price) {
}Và controller, với sáu method: liệt kê, đọc một sản phẩm, tạo mới, thay thế, cập nhật một phần và xóa. Sản phẩm nằm trong một ConcurrentHashMap ngay bên trong controller, với một AtomicLong cấp id, vì phải đến Chương 4 mới có database; bài 21 sẽ chuyển phần lưu trữ này sang service và repository.
package com.example.demo.product;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> products = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
@GetMapping
public List<Product> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
.toList();
}
@GetMapping("/{id}")
public Product findById(@PathVariable Long id) {
return products.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@RequestBody Product product) {
Long id = nextId.getAndIncrement();
Product saved = new Product(id, product.name(), product.price());
products.put(id, saved);
return saved;
}
@PutMapping("/{id}")
public Product replace(@PathVariable Long id, @RequestBody Product product) {
return products.computeIfPresent(id,
(key, current) -> new Product(id, product.name(), product.price()));
}
@PatchMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product changes) {
return products.computeIfPresent(id, (key, current) -> new Product(id,
changes.name() != null ? changes.name() : current.name(),
changes.price() != null ? changes.price() : current.price()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
}
}Mỗi annotation đóng góp gì:
@RequestMapping("/api/products")trên class được ghép vào trước path của mọi method. Vì vậy@GetMappingvà@PostMappingkhông có path riêng sẽ map chính/api/products.@PathVariable Long idlấy segment{id}trong path, còn@RequestBody Productđọc body JSON thành mộtProduct. Cả hai ở đây đều dùng dạng đơn giản nhất; các option, việc chuyển đổi type và các trường hợp lỗi thuộc về bài 17.@ResponseStatus(HttpStatus.CREATED)khiếncreatethành công trả về 201 thay vì 200, còn@ResponseStatus(HttpStatus.NO_CONTENT)khiếndeletetrả về 204.findById,replacevàupdatetrả vềnullvới id không tồn tại.nullbiến thành gì được trình bày ở phần cuối.
Tạo một sản phẩm:
curl -i -X POST http://localhost:8116/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Mechanical keyboard","price":89.90}'HTTP/1.1 201
Content-Type: application/json
Content-Length: 51
{"id":1,"name":"Mechanical keyboard","price":89.90}id đến từ AtomicLong, và BigDecimal giữ nguyên số 0 ở cuối 89.90. Lần POST thứ hai với {"name":"USB-C hub","price":35.50} trả về {"id":2,"name":"USB-C hub","price":35.50}. Liệt kê chúng:
curl -i http://localhost:8116/api/productsHTTP/1.1 200
Content-Type: application/json
Content-Length: 95
[{"id":1,"name":"Mechanical keyboard","price":89.90},{"id":2,"name":"USB-C hub","price":35.50}]Đọc một sản phẩm:
curl -i http://localhost:8116/api/products/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 51
{"id":1,"name":"Mechanical keyboard","price":89.90}Thay thế sản phẩm thứ hai:
curl -i -X PUT http://localhost:8116/api/products/2 \
-H "Content-Type: application/json" \
-d '{"name":"USB-C hub 7-in-1","price":42.00}'HTTP/1.1 200
Content-Type: application/json
Content-Length: 48
{"id":2,"name":"USB-C hub 7-in-1","price":42.00}Chỉ đổi giá của sản phẩm thứ nhất:
curl -i -X PATCH http://localhost:8116/api/products/1 \
-H "Content-Type: application/json" \
-d '{"price":79.90}'HTTP/1.1 200
Content-Type: application/json
Content-Length: 51
{"id":1,"name":"Mechanical keyboard","price":79.90}Body không có name, nên argument Product đến nơi với name là null và update giữ lại tên đang lưu. Xóa sản phẩm thứ hai:
curl -i -X DELETE http://localhost:8116/api/products/2HTTP/1.1 204Response 204 không có body, nên cũng không có Content-Type lẫn Content-Length. Liệt kê lại thì còn một sản phẩm:
curl -i http://localhost:8116/api/productsHTTP/1.1 200
Content-Type: application/json
Content-Length: 53
[{"id":1,"name":"Mechanical keyboard","price":79.90}]Annotation nào map HTTP method nào
| Annotation | HTTP method | Trong danh mục sản phẩm | Status khi thành công |
|---|---|---|---|
@GetMapping | GET | GET /api/products liệt kê, GET /api/products/{id} đọc một sản phẩm | 200 |
@PostMapping | POST | POST /api/products tạo sản phẩm | 201, do @ResponseStatus(HttpStatus.CREATED) |
@PutMapping | PUT | PUT /api/products/{id} thay thế sản phẩm | 200 |
@PatchMapping | PATCH | PATCH /api/products/{id} đổi một vài field | 200 |
@DeleteMapping | DELETE | DELETE /api/products/{id} xóa sản phẩm | 204, do @ResponseStatus(HttpStatus.NO_CONTENT) |
@RequestMapping | mọi method, trừ khi đặt method | prefix /api/products trên class | — |
@RequestMapping không có method khớp với mọi HTTP method
Annotation tắt ngắn hơn, và còn chặn được một lỗi mà @RequestMapping khiến bạn dễ mắc. Thay @GetMapping trên findAll bằng một @RequestMapping trống:
@GetMapping
@RequestMapping
public List<Product> findAll() {Application vẫn khởi động và GET /api/products vẫn chạy. Danh sách mapping ở mức TRACE lúc khởi động, trình bày ở phần sau, cho thấy thay đổi: dòng của findAll là { [/api/products]}: findAll(), chỗ trước kia ghi GET giờ trống. Với ba sản phẩm đang lưu, gửi một request DELETE tới collection:
curl -i -X DELETE http://localhost:8116/api/productsHTTP/1.1 200
Content-Type: application/json
Content-Length: 144
[{"id":1,"name":"Mechanical keyboard","price":89.90},{"id":2,"name":"USB-C hub","price":35.50},{"id":3,"name":"27-inch monitor","price":249.00}]2026-09-12T14:22:14.036+07:00 TRACE 41886 --- [demo] [nio-8116-exec-6] o.s.web.servlet.DispatcherServlet : DELETE "/api/products", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'
2026-09-12T14:22:14.036+07:00 TRACE 41886 --- [demo] [nio-8116-exec-6] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#findAll()Request DELETE rơi vào một method viết để đọc và nhận lại danh sách sản phẩm cùng status 200. Request PUT tới cùng URL cũng y như vậy. POST thì không bị ảnh hưởng, và log TRACE cho thấy lý do:
2026-09-12T14:22:14.704+07:00 TRACE 41886 --- [demo] [io-8116-exec-10] s.w.s.m.m.a.RequestMappingHandlerMapping : 2 matching mappings: [{POST [/api/products]}, { [/api/products]}]
2026-09-12T14:22:14.704+07:00 TRACE 41886 --- [demo] [io-8116-exec-10] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#create(Product)Có hai mapping khớp với request POST. Mapping ghi rõ POST cụ thể hơn mapping không ghi method nào, nên create thắng. Với DELETE và PUT, không mapping nào trên /api/products ghi method đó, nên mapping trống trả lời.
⚠️
@RequestMappingở cấp method mà không cómethodkhông phải cách viết tắt của GET. Nó nhận mọi HTTP method mà không mapping cụ thể hơn nào giành lấy. Dùng annotation tắt trên method, và chỉ để@RequestMappingcho prefix của class.
Request đi tới method của bạn như thế nào bên trong Spring MVC
Bài 3 theo dõi một request từ bên ngoài: curl, embedded Tomcat, controller, JSON. Bên trong, Tomcat giao mọi request cho một servlet duy nhất là DispatcherServlet, được đăng ký ở /; đó là front controller của Spring MVC. Giữa nó và method của bạn là một chuỗi component có thể thay thế, và application đang chạy cho biết Boot đã cấu hình những gì: sáu bean HandlerMapping, bốn bean HandlerAdapter và sáu HttpMessageConverter trên RequestMappingHandlerAdapter.

Với POST /api/products:
DispatcherServletnhận request và lần lượt hỏi các beanHandlerMappingxem ai có handler.RequestMappingHandlerMappingtrả lời. Lúc khởi động, nó đã gom các method có mapping của mọi bean@Controllervào một registry; giờ nó so request với registry đó và trả vềProductController#create(Product).DispatcherServlettìm mộtHandlerAdaptergọi được loại handler đó —RequestMappingHandlerAdaptercho method có annotation — và gọihandle(request, response, handler)của nó. Bước 3 đến 7 đều chạy bên trong đúng một lời gọi này.- Resolve argument. Adapter hỏi các argument resolver cho từng parameter.
@PathVariabledoPathVariableMethodArgumentResolverxử lý,@RequestBodydoRequestResponseBodyMethodProcessor, vốn đọc body qua mộtHttpMessageConverter. - Method của bạn chạy với các argument đã resolve.
- Xử lý return value. Các return value handler được hỏi theo thứ tự cố định, và
RequestResponseBodyMethodProcessorđứng trướcViewNameMethodReturnValueHandler. Nó nhận mọi method có@ResponseBodytrên method hoặc trên class, rồi chọn content type dựa vào headerAcceptvà những converter ghi được giá trị đó. Không có@ResponseBody, mộtStringrơi xuốngViewNameMethodReturnValueHandlervà trở thành tên view — chính là lỗi 404 ở phần đầu. HttpMessageConverterghi giá trị ra:JacksonJsonHttpMessageConverter(Jackson 3) choProduct,StringHttpMessageConverterchoString.- Adapter không trả về
ModelAndView, nênDispatcherServletkhông render view nào, và response quay về Tomcat.
Với logging.level.org.springframework.web=DEBUG, request POST ở phần trước ghi một dòng cho phần lớn các bước đó:
2026-09-12T14:22:08.389+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-2] o.s.web.servlet.DispatcherServlet : POST "/api/products", parameters={}
2026-09-12T14:22:08.389+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#create(Product)
2026-09-12T14:22:08.425+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Read "application/json;charset=UTF-8" to [Product[id=null, name=Mechanical keyboard, price=89.90]]
2026-09-12T14:22:08.440+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json]
2026-09-12T14:22:08.440+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Writing [Product[id=1, name=Mechanical keyboard, price=89.90]]
2026-09-12T14:22:08.452+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-2] o.s.web.servlet.DispatcherServlet : Completed 201 CREATEDTừng dòng một: request lúc vừa đến (bước 1), method mà mapping chọn (2), body JSON được đọc vào argument @RequestBody (4), content type được chọn từ Accept: */* mà curl gửi mặc định cùng những gì JSON converter hỗ trợ (6), giá trị đang được ghi (7), và status cuối cùng (8). Ở mức TRACE, cùng request đó có thêm ba dòng mà DEBUG bỏ qua — các argument ngay lúc gọi method (5), một dòng của RequestMappingHandlerAdapter sau khi method đã trả về, và xác nhận rằng không có view nào tham gia (8):
2026-09-12T14:22:10.259+07:00 TRACE 41799 --- [demo] [nio-8116-exec-1] o.s.web.method.HandlerMethod : Arguments: [Product[id=null, name=Mechanical keyboard, price=89.90]]
2026-09-12T14:22:10.268+07:00 TRACE 41799 --- [demo] [nio-8116-exec-1] s.w.s.m.m.a.RequestMappingHandlerAdapter : Applying default cacheSeconds=-1
2026-09-12T14:22:10.268+07:00 TRACE 41799 --- [demo] [nio-8116-exec-1] o.s.web.servlet.DispatcherServlet : No view rendering, null ModelAndView returned.In toàn bộ mapping lúc khởi động
RequestMappingHandlerMapping in cả registry lúc khởi động, mỗi controller một khối, khi logger của nó ở mức TRACE. logging.level.org.springframework.web=TRACE bật việc đó:
2026-09-12T14:22:10.128+07:00 TRACE 41799 --- [demo] [ main] s.w.s.m.m.a.RequestMappingHandlerMapping :
c.e.d.HelloController:
{GET [/greeting]}: greeting(String)
{GET [/hello]}: hello()
2026-09-12T14:22:10.129+07:00 TRACE 41799 --- [demo] [ main] s.w.s.m.m.a.RequestMappingHandlerMapping :
c.e.d.PageController:
{GET [/catalogue]}: catalogue()
{GET [/page]}: page()
2026-09-12T14:22:10.131+07:00 TRACE 41799 --- [demo] [ main] s.w.s.m.m.a.RequestMappingHandlerMapping :
c.e.d.p.ProductController:
{GET [/api/products/{id}]}: findById(Long)
{PATCH [/api/products/{id}]}: update(Long,Product)
{PUT [/api/products/{id}]}: replace(Long,Product)
{DELETE [/api/products/{id}]}: delete(Long)
{POST [/api/products]}: create(Product)
{GET [/api/products]}: findAll()
2026-09-12T14:22:10.133+07:00 TRACE 41799 --- [demo] [ main] s.w.s.m.m.a.RequestMappingHandlerMapping :
o.s.b.w.a.e.BasicErrorController:
{ [/error], produces [text/html]}: errorHtml(HttpServletRequest,HttpServletResponse)
{ [/error]}: error(HttpServletRequest)
2026-09-12T14:22:10.134+07:00 DEBUG 41799 --- [demo] [ main] s.w.s.m.m.a.RequestMappingHandlerMapping : 12 mappings in 'requestMappingHandlerMapping'Mỗi dòng là một mapping và method nó gọi: {GET [/api/products/{id}]} là HTTP method cùng pattern. BasicErrorController của chính Boot map /error mà không có method nào, cùng dạng với @RequestMapping trống ở phần trước. Dòng DEBUG đếm tổng: sáu mapping của sản phẩm, hai trong HelloController, hai trong PageController và hai cho /error.
TRACE cũng bật mọi thứ khác mà các package web ghi ở mức đó. Muốn chỉ có danh sách này, dùng logger riêng mà Spring dành cho nó, có tên bắt đầu bằng dấu gạch dưới:
logging.level._org.springframework.web.servlet.HandlerMapping.Mappings=DEBUGLần chạy đó in ra đúng bốn khối trên qua _.s.web.servlet.HandlerMapping.Mappings, tiếp theo là 'beanNameHandlerMapping' {} rỗng và các mapping static resource /webjars/** và /**, không có dòng DEBUG nào khác. Actuator cung cấp chính registry này qua HTTP ở /actuator/mappings; Actuator thuộc Chương 7.
Path pattern trong mapping của Spring MVC
Path của một mapping là một pattern, được PathPatternParser phân tích. Đó là mặc định của Boot 4.1.1: spring.mvc.pathmatch.matching-strategy có giá trị mặc định path-pattern-parser trong configuration metadata, và RequestMappingHandlerMapping.getPatternParser() trả về một PathPatternParser trong application đang chạy. Cú pháp:
| Thành phần pattern | Khớp với | Ví dụ |
|---|---|---|
| chữ cố định | đúng chuỗi đó | /api/products/featured |
{name} | nguyên một segment của path, lưu vào name | /api/categories/{name} |
{name:regex} | một segment khớp regex | /api/products/{id:\d+} |
* | ký tự bất kỳ bên trong một segment | /api/images/*.png |
** | không hoặc nhiều segment trọn vẹn | /api/docs/** |
{*name} | không hoặc nhiều segment, lưu vào name | /api/categories/{*path} |
Một controller dùng tạm cho thấy các wildcard hoạt động:
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PatternDemoController {
@GetMapping("/api/images/*.png")
public String image() {
return "*.png";
}
@GetMapping("/api/docs/**")
public String docs() {
return "docs/**";
}
@GetMapping("/api/categories/{name}")
public String category(@PathVariable String name) {
return "{name} = " + name;
}
@GetMapping("/api/categories/{*path}")
public String categoryPath(@PathVariable String path) {
return "{*path} = " + path;
}
}| Request | Status | Body |
|---|---|---|
/api/images/phone.png | 200 | *.png |
/api/images/2026/phone.png | 404 | error body mặc định |
/api/images/phone.jpg | 404 | error body mặc định |
/api/docs | 200 | docs/** |
/api/docs/ | 200 | docs/** |
/api/docs/v1/products.html | 200 | docs/** |
/api/categories/phones | 200 | {name} = phones |
/api/categories/electronics/phones | 200 | {*path} = /electronics/phones |
/api/categories/ | 200 | {*path} = / |
/api/categories | 200 | {*path} = rồi một giá trị rỗng |
* không bao giờ vượt qua dấu /. ** cũng khớp với không segment nào, vì vậy chính /api/docs cũng được trả lời. {*path} lấy các segment còn lại kèm dấu gạch chéo ở đầu, và là chuỗi rỗng khi không còn segment nào. /api/categories/phones khớp cả hai pattern category và đi vào {name}; phần tiếp theo giải thích lý do.
** và {*path} chỉ được phép ở đầu hoặc cuối pattern. Một mapping như @GetMapping("/api/**/reviews") làm application dừng ngay lúc khởi động:
***************************
APPLICATION FAILED TO START
***************************
Description:
Invalid mapping pattern detected:
/api/**/reviews
^
{*...} or ** pattern elements should be placed at the start or end of the pattern
Action:
Fix this pattern in your application or switch to the legacy parser implementation with 'spring.mvc.pathmatch.matching-strategy=ant_path_matcher'.Vị trí đầu pattern là điểm mới. PathPatternParser trong spring-web 6.2.11 từ chối /**/reviews với thông báo No more pattern data allowed after {*...} or ** pattern element, còn parser của 7.0.9 chấp nhận và khớp được /api/x/reviews.
Mapping nào thắng khi nhiều mapping cùng khớp
Thêm endpoint featured vào cuối ProductController, sau các method có {id}:
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
}
@GetMapping("/featured")
public List<Product> featured() {
return products.values().stream()
.sorted(Comparator.comparing(Product::price).reversed())
.limit(3)
.toList();
}
}/api/products/featured khớp với /api/products/{id} lẫn /api/products/featured:
curl -i http://localhost:8116/api/products/featuredHTTP/1.1 200
Content-Type: application/json
Content-Length: 144
[{"id":3,"name":"27-inch monitor","price":249.00},{"id":1,"name":"Mechanical keyboard","price":89.90},{"id":2,"name":"USB-C hub","price":35.50}]Pattern chữ cố định thắng, dù featured() được khai báo sau findById. Khi nhiều pattern cùng khớp, Spring sắp xếp chúng theo độ cụ thể và lấy cái đầu tiên; thứ tự khai báo không có vai trò gì. Nói chung, càng ít URI variable và wildcard thì pattern càng cụ thể, còn ** và {*path} là kém cụ thể nhất, vì vậy /api/categories/phones ở trên đi vào {name} chứ không phải {*path}.
Độ cụ thể không phân xử được hai pattern khác nhau nhưng khớp tốt ngang nhau. Một controller thứ hai với /{name} dưới cùng prefix vẫn khởi động bình thường:
package com.example.demo.product;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductLookupController {
@GetMapping("/{name}")
public String findByName(@PathVariable String name) {
return name;
}
}/api/products/featured vẫn trả về 200, vì chữ cố định thắng cả hai variable. /api/products/1 khớp {id} và {name} với độ cụ thể như nhau, và request thất bại với HTTP/1.1 500:
2026-09-12T14:29:26.349+07:00 ERROR 49308 --- [demo] [nio-8116-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: Ambiguous handler methods mapped for '/api/products/1': {public com.example.demo.product.Product com.example.demo.product.ProductController.findById(java.lang.Long), public java.lang.String com.example.demo.product.ProductLookupController.findByName(java.lang.String)}] with root causeLúc khởi động không có cảnh báo nào; xung đột chỉ lộ ra khi một request trúng cả hai pattern.
Hai mapping giống hệt nhau làm application không khởi động được
Bỏ controller tra cứu kia đi, và giả sử có người thêm một endpoint tìm kiếm với đúng annotation của findAll:
@GetMapping
public List<Product> findAll() {
return products.values().stream()
.sorted(Comparator.comparing(Product::id))
.toList();
}
@GetMapping
public List<Product> search(@RequestParam String name) {
return products.values().stream()
.filter(p -> p.name().toLowerCase().contains(name.toLowerCase()))
.toList();
} Code compile được. Nhưng không khởi động được: RequestMappingHandlerMapping kiểm tra trùng lặp trong lúc dựng registry, và lần chạy kết thúc với Application run failed cùng exception này:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource [org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Ambiguous mapping. Cannot map 'productController' method
com.example.demo.product.ProductController#findAll()
to {GET [/api/products]}: There is already 'productController' bean method
com.example.demo.product.ProductController#search(String) mapped.Cùng pattern, cùng HTTP method, không có điều kiện nào khác: không gì phân biệt được hai method, nên Spring từ chối đoán. Với @GetMapping(params = "name") trên search thì đó là hai mapping khác nhau; application khởi động, /api/products?name=hub đi vào search và /api/products đi vào findAll. params là một trong các điều kiện trình bày ở phần dưới.
Giới hạn path variable chỉ nhận chữ số
Variable nhận mọi thứ trong segment của nó, nên /api/products/abc cũng đi vào findById, và việc chuyển abc sang Long thất bại với HTTP/1.1 400; bài 17 giải thích lỗi đó. Đặt regex vào variable thì những giá trị không phải số không khớp ngay từ đầu:
@GetMapping("/{id}")
@GetMapping("/{id:\\d+}")
public Product findById(@PathVariable Long id) {
return products.get(id);
}
@PutMapping("/{id}")
@PutMapping("/{id:\\d+}")
public Product replace(@PathVariable Long id, @RequestBody Product product) {
// unchanged
}
@PatchMapping("/{id}")
@PatchMapping("/{id:\\d+}")
public Product update(@PathVariable Long id, @RequestBody Product changes) {
// unchanged
}
@DeleteMapping("/{id}")
@DeleteMapping("/{id:\\d+}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
}curl -i http://localhost:8116/api/products/abcHTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:23.390Z","status":404,"error":"Not Found","path":"/api/products/abc"}Giờ không mapping nào khớp, và đó là câu trả lời đúng cho một URL không thể là sản phẩm nào. Mã nguồn Java phải viết dấu backslash hai lần; bản thân pattern là {id:\d+}. Regex này còn quan trọng lại ở phần điều kiện và phần OPTIONS.
Dấu gạch chéo cuối URL và chữ hoa, chữ thường
Bài 15 khuyên mỗi URL chỉ dùng một dạng, không có dấu gạch chéo ở cuối; đây là điều Spring làm với dạng còn lại. /api/products/ là một URL khác với /api/products:
curl -i http://localhost:8116/api/products/HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:23.729Z","status":404,"error":"Not Found","path":"/api/products/"}/api/products/1/ cũng nhận 404 như vậy. Các tutorial viết cho những phiên bản Spring cũ thường cho thấy cả hai dạng cùng tới một method, thường là nhờ setUseTrailingSlashMatch. Option đó đã không còn: PathMatchConfigurer trong Framework 7.0.9 không có method này, trong khi class ở bản 6.2.11 vẫn còn.
Chữ hoa, chữ thường cũng có ý nghĩa, như bài 15 đã cho thấy. /API/products và /api/Products đều trả về 404, và PathPatternParser của application báo isCaseSensitive() là true.
Nếu một client mà bạn không kiểm soát được gửi kèm dấu gạch chéo ở cuối, hãy đăng ký UrlHandlerFilter.trailingSlashHandler("/api/**").wrapRequest().build() của spring-web thành một bean; có nó, /api/products/ trả về danh sách sản phẩm. Với chữ hoa, chữ thường, một WebMvcConfigurer có configurePathMatch đặt một PathPatternParser với setCaseSensitive(false) khiến /API/Products trả về 200.
Thu hẹp mapping bằng consumes, produces, params và headers
Path và HTTP method là hai điều kiện của một mapping. @RequestMapping và mọi annotation tắt nhận thêm bốn điều kiện nữa:
| Attribute | So với | Ví dụ | Status khi đây là lý do không mapping nào khớp |
|---|---|---|---|
consumes | Content-Type của request | consumes = MediaType.APPLICATION_JSON_VALUE | 415 |
produces | Accept của request | produces = "text/csv" | 406 |
params | query parameter | params = "confirm=true", hoặc params = "confirm" nếu chỉ cần có mặt | 400 |
headers | header của request | headers = "X-Import-Source=warehouse" | 404 |
Ba thay đổi đưa chúng vào danh mục: các method ghi chỉ nhận JSON, một bản export CSV nằm cạnh danh sách JSON, và muốn xóa sạch danh mục thì phải có ?confirm=true.
import org.springframework.http.MediaType;
@PostMapping
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public Product create(@RequestBody Product product) {
// unchanged
}
@PutMapping("/{id:\\d+}")
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public Product replace(@PathVariable Long id, @RequestBody Product product) {
// unchanged
}
@PatchMapping("/{id:\\d+}")
@PatchMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public Product update(@PathVariable Long id, @RequestBody Product changes) {
// unchanged
}
@GetMapping(path = "/export", produces = "text/csv")
public String exportCsv() {
StringBuilder csv = new StringBuilder("id,name,price\n");
for (Product p : findAll()) {
csv.append(p.id()).append(',').append(p.name()).append(',').append(p.price()).append('\n');
}
return csv.toString();
}
@DeleteMapping(params = "confirm=true")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteAll() {
products.clear();
} Với ba sản phẩm đang lưu, export chạy đúng như mong đợi:
curl -i http://localhost:8116/api/products/exportHTTP/1.1 200
Content-Type: text/csv;charset=UTF-8
Content-Length: 85
id,name,price
1,Mechanical keyboard,89.90
2,USB-C hub,35.50
3,27-inch monitor,249.00Giờ đến các trường hợp thất bại, lần lượt từng điều kiện. Một path không ai map:
curl -i http://localhost:8116/api/produktsHTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:25.072Z","status":404,"error":"Not Found","path":"/api/produkts"}Một path đã map nhưng dùng method mà không mapping nào của nó chấp nhận:
curl -i -X PUT http://localhost:8116/api/products -H "Content-Type: application/json" -d '{"name":"Desk lamp","price":19.90}'HTTP/1.1 405
Allow: DELETE, GET, POST
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:25.409Z","status":405,"error":"Method Not Allowed","path":"/api/products"}Một header Accept mà produces không đáp ứng được:
curl -i http://localhost:8116/api/products/export -H "Accept: application/json"HTTP/1.1 406
Accept: text/csv
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:25.747Z","status":406,"error":"Not Acceptable","path":"/api/products/export"}Một Content-Type bị consumes từ chối:
curl -i -X POST http://localhost:8116/api/products -H "Content-Type: text/plain" -d 'Desk lamp'HTTP/1.1 415
Accept: application/json
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:26.422Z","status":415,"error":"Unsupported Media Type","path":"/api/products"}Một request DELETE vào collection mà thiếu confirm=true:
curl -i -X DELETE http://localhost:8116/api/productsHTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Connection: close
{"timestamp":"2026-09-12T07:22:26.763Z","status":400,"error":"Bad Request","path":"/api/products"}Log ghi exception đứng sau bốn trường hợp cuối ở mức WARN, mỗi request một dòng; path không tồn tại thì không ghi gì:
2026-09-12T14:22:25.409+07:00 WARN 42012 --- [demo] [nio-8116-exec-6] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'PUT' is not supported]
2026-09-12T14:22:25.746+07:00 WARN 42012 --- [demo] [nio-8116-exec-8] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation]
2026-09-12T14:22:26.421+07:00 WARN 42012 --- [demo] [nio-8116-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'text/plain' is not supported]
2026-09-12T14:22:26.762+07:00 WARN 42012 --- [demo] [nio-8116-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.UnsatisfiedServletRequestParameterException: Parameter conditions "confirm=true" not met for actual request parameters:Mỗi response còn mang một header cho client biết điều gì lẽ ra sẽ chạy được: Allow trong response 405 liệt kê các method đã map cho path đó, Accept: text/csv trong 406 là thứ produces cung cấp, và Accept: application/json trong 415 là thứ consumes chấp nhận. Body JSON là định dạng lỗi mặc định của Boot, bài 20 sẽ phân tích nó. Một điều kiện headers không thỏa mãn thì không có status riêng: request GET với X-Import-Source: erp gửi tới mapping khai báo headers = "X-Import-Source=warehouse" nhận đúng lỗi 404 như một path không tồn tại.
Có hai chi tiết trong các lần chạy này đáng biết.
consumes từ chối trước khi method được chọn. Trước khi thêm consumes, cùng request POST text/plain đó cũng nhận 415, nhưng log DEBUG cho thấy create được chọn trước rồi mới thất bại, trong lúc đọc argument:
2026-09-12T14:22:08.779+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-4] o.s.web.servlet.DispatcherServlet : POST "/api/products", parameters={}
2026-09-12T14:22:08.780+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-4] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#create(Product)
2026-09-12T14:22:08.781+07:00 DEBUG 41745 --- [demo] [nio-8116-exec-4] o.s.web.method.HandlerMethod : Could not resolve parameter [0] in public com.example.demo.product.Product com.example.demo.product.ProductController.create(com.example.demo.product.Product): Content-Type 'text/plain;charset=UTF-8' is not supported
2026-09-12T14:22:08.784+07:00 WARN 41745 --- [demo] [nio-8116-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'text/plain;charset=UTF-8' is not supported]Có consumes, log đi thẳng từ request tới dòng WARN, không có Mapped to nào ở giữa; header Accept của response cũng đổi từ application/json, application/*+json thành application/json:
2026-09-12T14:29:24.166+07:00 DEBUG 48997 --- [demo] [nio-8116-exec-1] o.s.web.servlet.DispatcherServlet : POST "/api/products", parameters={}
2026-09-12T14:29:24.175+07:00 WARN 48997 --- [demo] [nio-8116-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'text/plain' is not supported]Điều kiện nằm trên mapping nghĩa là một mapping khác trên cùng path có thể nhận request thay, và các quy tắc ưu tiên bên dưới áp dụng cho nó.
Không có regex, lỗi 406 thành 400. Ở một bản build trước đó, khi findById vẫn map /{id} thuần, cùng request Accept: application/json tới /api/products/export nhận về 400: sau khi produces loại exportCsv, /{id} vẫn khớp path, và việc chuyển export sang Long thất bại:
2026-09-12T14:01:03.070+07:00 WARN 35152 --- [demo] [nio-8116-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; For input string: "export"]Status nào thắng khi nhiều điều kiện cùng sai
Một request có thể sai nhiều điều kiện cùng lúc. PATCH /api/products với Content-Type: text/plain dùng một method mà collection không map, và một content type không method ghi nào chấp nhận:
curl -i -X PATCH http://localhost:8116/api/products -H "Content-Type: text/plain" -d 'Desk lamp'HTTP/1.1 405
Allow: DELETE, GET, POST
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-12T07:22:27.102Z","status":405,"error":"Method Not Allowed","path":"/api/products"}Method thắng. Để thấy toàn bộ thứ tự, một mapping thử nghiệm mang cả bốn điều kiện — @PostMapping(value = "/all", consumes = "application/json", produces = "text/csv", params = "p=1", headers = "X-H=1") trong một controller map ở /lab — và mỗi request dưới đây làm hỏng một nhóm điều kiện khác nhau:
Request tới /lab/all | Điều kiện bị sai | Status |
|---|---|---|
GET với Content-Type: text/plain, Accept: application/json | method, consumes, produces, params, headers | 405 |
POST với Content-Type: text/plain, Accept: application/json | consumes, produces, params, headers | 415 |
POST với body JSON, Accept: application/json | produces, params, headers | 406 |
POST với body JSON, Accept: text/csv | params, headers | 400 |
POST ?p=1 với body JSON, Accept: text/csv | headers | 404 |
POST ?p=1 với body JSON, Accept: text/csv, X-H: 1 | không có | 200 |
Điều kiện sai đầu tiên theo thứ tự method, consumes, produces, params quyết định status, còn request chỉ sai headers thì kết thúc giống một path không ai map.

HEAD và OPTIONS mà bạn không hề map
ProductController không map HEAD cũng không map OPTIONS, vậy mà cả hai vẫn được trả lời. Bài 15 đã bắt cả hai trên đường truyền; đây là cách Spring tạo ra chúng. /api/products/featured chỉ có một @GetMapping:
curl -I http://localhost:8116/api/products/featuredHTTP/1.1 200
Content-Type: application/json
Content-Length: 144Đúng các header của response GET, kể cả Content-Length: 144, và không có body. Log DEBUG cho thấy request HEAD được map vào method GET, method đó chạy và serialize kết quả; body chỉ bị bỏ đi sau đó, nên HEAD rẻ trên đường truyền nhưng phía server vẫn tốn đúng bằng request GET mà nó phản chiếu:
2026-09-12T14:22:33.635+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-7] o.s.web.servlet.DispatcherServlet : HEAD "/api/products/featured", parameters={}
2026-09-12T14:22:33.635+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-7] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#featured()
2026-09-12T14:22:33.635+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-7] m.m.a.RequestResponseBodyMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json]
2026-09-12T14:22:33.636+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-7] m.m.a.RequestResponseBodyMethodProcessor : Writing [[Product[id=3, name=27-inch monitor, price=249.00], Product[id=1, name=Mechanical keyboard, price=89 (truncated)...]
2026-09-12T14:22:33.637+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-7] o.s.web.servlet.DispatcherServlet : Completed 200 OKOPTIONS do chính Spring trả lời, không gọi method nào của bạn:
curl -i -X OPTIONS http://localhost:8116/api/products/featuredHTTP/1.1 200
Allow: GET,HEAD,OPTIONS
Accept-Patch:
Content-Length: 02026-09-12T14:22:33.974+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-9] o.s.web.servlet.DispatcherServlet : OPTIONS "/api/products/featured", parameters={}
2026-09-12T14:22:33.975+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-9] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping$HttpOptionsHandler#handle()
2026-09-12T14:22:33.977+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-9] o.s.web.servlet.DispatcherServlet : Completed 200 OKAllow được dựng từ các mapping khớp với path, thêm HEAD cạnh GET và thêm OPTIONS ở cuối. Ở URL của một sản phẩm, nơi @PatchMapping khai báo consumes, header Accept-Patch cũng được điền:
curl -i -X OPTIONS http://localhost:8116/api/products/1HTTP/1.1 200
Allow: DELETE,PUT,GET,HEAD,PATCH,OPTIONS
Accept-Patch: application/json
Content-Length: 0OPTIONS /api/products trả về Allow: DELETE,GET,HEAD,POST,OPTIONS. Có ba chi tiết dễ bỏ sót. Allow này không có khoảng trắng và có cả HEAD lẫn OPTIONS, trong khi Allow của response 405 lúc trước là DELETE, GET, POST. Nó phụ thuộc vào mọi pattern khớp: trước khi có regex \d+, OPTIONS /api/products/featured trả về Allow: PATCH,GET,HEAD,PUT,DELETE,OPTIONS, vì {id} cũng khớp featured. Và, như bài 15 đã nêu, thứ tự các method trong đó không cố định.
Mỗi kiểu giá trị trả về thành response như thế nào
Khi @ResponseBody có hiệu lực, kiểu trả về quyết định converter nào ghi body, và converter quyết định Content-Type. Thêm một method nữa để danh mục có endpoint trả về String:
@GetMapping("/summary")
public String summary() {
return products.size() + " products";
} curl -i http://localhost:8116/api/products/summaryHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 10
3 productscurl -i http://localhost:8116/api/products/summary -H "Accept: application/json"HTTP/1.1 200
Content-Type: application/json
Content-Length: 10
3 products3 products không phải JSON, vậy mà vẫn đi ra với nhãn JSON. StringHttpMessageConverter có */* trong danh sách media type nó hỗ trợ, nên nó nhận bất cứ thứ gì client yêu cầu và ghi nguyên chuỗi ra: Accept: application/xml nhận về application/xml;charset=UTF-8 với đúng mười byte đó. Khi client chờ JSON, hãy trả về một object.
Mọi kiểu trả về trong bài này, lấy từ request thật:
| Method trả về | Request | Status | Content-Type | Body |
|---|---|---|---|---|
String | GET /api/products/summary | 200 | text/plain;charset=UTF-8 | 3 products |
String | như trên, kèm Accept: application/json | 200 | application/json | 3 products |
một record Product | GET /api/products/1 | 200 | application/json | {"id":1,"name":"Mechanical keyboard","price":89.90} |
một record Product | như trên, kèm Accept: application/xml | 406 | không có | rỗng, Content-Length: 0 |
List<Product> | GET /api/products | 200 | application/json | một mảng JSON |
void có @ResponseStatus(HttpStatus.NO_CONTENT) | DELETE /api/products/3 | 204 | không có | không có |
void không có @ResponseStatus | DELETE /api/products/1 | 200 | không có | rỗng, Content-Length: 0 |
null từ method khai báo trả về Product | GET /api/products/99 | 200 | không có | rỗng, Content-Length: 0 |
Lỗi 406 xảy ra sau khi method đã chạy: không converter nào trên classpath ghi được record ra XML, và header Accept của response liệt kê application/json, application/*+json.
Method void trả về 200 với body rỗng, trừ khi @ResponseStatus nói khác. Bỏ nó khỏi delete cho ra dòng void thứ hai:
@DeleteMapping("/{id:\\d+}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
products.remove(id);
}curl -i -X DELETE http://localhost:8116/api/products/1HTTP/1.1 200
Content-Length: 0null từ một method khai báo trả về Product cũng không phải lỗi. Hỏi một sản phẩm không tồn tại:
curl -i http://localhost:8116/api/products/99HTTP/1.1 200
Content-Length: 02026-09-12T14:22:34.661+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-3] o.s.web.servlet.DispatcherServlet : GET "/api/products/99", parameters={}
2026-09-12T14:22:34.661+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#findById(Long)
2026-09-12T14:22:34.662+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-3] m.m.a.RequestResponseBodyMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json]
2026-09-12T14:22:34.662+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-3] m.m.a.RequestResponseBodyMethodProcessor : Nothing to write: null body
2026-09-12T14:22:34.663+07:00 DEBUG 42283 --- [demo] [nio-8116-exec-3] o.s.web.servlet.DispatcherServlet : Completed 200 OKNothing to write: null body, và status 200 cho một sản phẩm không có thật. Controller muốn trả về 404 trong trường hợp đó thì dùng ResponseEntity, nội dung của bài tiếp theo.
FAQ
@Controller và @RestController khác nhau thế nào?
@RestController là @Controller cộng @ResponseBody, không hơn. Trong @Controller, giá trị trả về kiểu String là tên view: không có template engine, Spring forward tới URL mang tên đó, và GET /page kết thúc bằng 404, hoặc bằng 500 với Circular view path khi tên trùng path. Trong @RestController, cùng String đó được ghi vào body dưới dạng text/plain.
Vì sao endpoint Spring Boot trả về 404 dù mapping trông đúng?
Kiểm tra dấu gạch chéo ở cuối hoặc chữ hoa, chữ thường khác trong URL, vì Framework 7.0.9 không khớp cả hai; một điều kiện headers mà request không đáp ứng; một method @Controller trả về String, khi đó lỗi 404 đến từ lần forward tới view; và controller nằm ngoài package được scan, như bài 3. logging.level.org.springframework.web=DEBUG cho thấy request đã đi đâu: Mapped to một method của bạn, hay Mapped to ResourceHttpRequestHandler rồi No static resource khi không mapping nào của controller khớp.
Vì sao Spring trả về 405 Method Not Allowed?
Path đã khớp ít nhất một mapping, nhưng không mapping nào nhận HTTP method của request. Header Allow liệt kê các method đã map cho path đó — DELETE, GET, POST với /api/products — và log ghi HttpRequestMethodNotSupportedException: Request method 'PUT' is not supported. Khi request còn sai ở chỗ khác, chẳng hạn Content-Type, 405 vẫn thắng.
Spring Boot 4 có khớp URL có dấu gạch chéo ở cuối không?
Không. /api/products/ trả về 404 trong khi /api/products chạy bình thường, và PathMatchConfigurer trong Framework 7.0.9 không có option nào để đổi điều đó. Nếu client gửi cả hai dạng, hãy đăng ký UrlHandlerFilter của spring-web với một trailing slash handler cho các path liên quan.
Có đặt @GetMapping lên class được không?
Không. @GetMapping và bốn annotation tắt còn lại được khai báo @Target(ElementType.METHOD). Prefix path ở cấp class viết bằng @RequestMapping, còn HTTP method thuộc về từng method.
Kết luận
@RestController là hai annotation: @Controller biến class thành handler mà RequestMappingHandlerMapping đăng ký, còn @ResponseBody biến giá trị trả về thành body của response thay vì tên view. Bên trong Spring MVC, request đi từ DispatcherServlet tới RequestMappingHandlerMapping để chọn method, rồi tới RequestMappingHandlerAdapter để resolve argument, gọi method và chuyển kết quả cho một HttpMessageConverter. Mapping kết hợp prefix @RequestMapping ở cấp class với các annotation tắt trên method, và một @RequestMapping trống trên method lặng lẽ nhận mọi HTTP method. Khi nhiều pattern cùng khớp, pattern cụ thể nhất thắng; mapping giống hệt nhau làm application dừng lúc khởi động, còn các pattern cụ thể ngang nhau thì thất bại khi có request. Dấu gạch chéo ở cuối hay chữ hoa, chữ thường khác nhau là URL khác nhau. consumes, produces, params và headers thu hẹp mapping thêm nữa, và request không khớp nhận status theo thứ tự cố định: 405, rồi 415, 406 và 400, với 404 cho path và cho headers. HEAD và OPTIONS không cần viết code, còn void hay null vẫn trả về 200.
Điểm cuối cùng đó là chỗ bài tiếp theo bắt đầu: nhận và trả dữ liệu cho đúng cách, với @PathVariable, @RequestParam, @RequestBody và @RequestHeader đầy đủ, cùng ResponseEntity để kiểm soát status và header của từng response.