Controller, Mapping và tham số request
Xem thửMột API tìm kiếm khóa học nhận ?page=2&size=20. Ở lần kiểm thử đầu mọi thứ đều ổn. Sau khi phát hành, log lỗi đầy các yêu cầu 400 và nhóm giao diện báo "API hỏng": bấm "trang cuối" thì mã JavaScript gửi ?page=undefined khi tổng số trang chưa tải xong, và undefined không phải số. Ở một chỗ khác, nhóm di động nhận 500 thay vì 400 trên một endpoint cũ, vì ở endpoint đó người viết quên khai báo tên cho @PathVariable và dự án không bật cờ biên dịch giữ tên tham số. Hai sự cố khác nhau, cùng gốc: không ai biết rõ Spring làm gì giữa lúc yêu cầu tới và lúc phương thức của họ chạy, và mỗi lỗi ứng với mã trạng thái nào.
Bài này đi vào khoảng giữa đó. Bạn sẽ gửi mười chín yêu cầu thật tới các controller: khớp đường dẫn, đọc tham số từ bốn nguồn, ép kiểu, form, JSON, và các lỗi 400, 404, 406, 415, rồi đọc tên ngoại lệ mà Spring ném ra ở từng lỗi.
Sau bài này, bạn có thể
- Chọn đúng annotation cho từng nguồn dữ liệu: đường dẫn, query, header, form, thân JSON.
- Dự đoán mapping nào thắng khi hai mẫu cùng khớp.
- Giải thích mỗi lỗi 400, 404, 405, 406, 415 tương ứng ngoại lệ nào và vì sao phương thức không được gọi.
- Phân biệt
@Controllertrả view với@RestControllertrả JSON, và dùngResponseEntityđể đặt mã trạng thái. - Biết vì sao cờ
-parametersảnh hưởng tới@PathVariablevà@RequestParamkhông ghi tên.
Chuẩn bị môi trường và dự án
Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Framework 7.0.9, Tomcat 11.0.27, Jackson 3.2.3). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 07-spring-mvc/02-controller-va-tham-so, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.
Yêu cầu môi trường
| Thành phần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Spring Framework | 7.0.9 | khai báo trong pom.xml |
| Tomcat nhúng | 11.0.27 | khai báo trong pom.xml |
| Jackson | 3.2.3 (tools.jackson) | chuyển đối tượng thành JSON |
| Docker | không cần | bài này không dùng cơ sở dữ liệu |
Cấu trúc
02-controller-va-tham-so/
pom.xml
src/main/java/dev/canhdominich/spring/mvc/
Trace.java, EmbeddedServer.java, ServerMain.java như bài trước (không còn filter)
Registration.java đối tượng nhận dữ liệu form
StudentDto.java record dùng cho JSON
PageController.java @Controller: đường dẫn, tham số, form, header, view
ApiController.java @RestController: JSON, ResponseEntity, consumes và produces
ExceptionSpy.java ghi tên ngoại lệ mà Spring ném ra, không xử lý nó
WebConfig.java @EnableWebMvc, view resolver, gắn ExceptionSpy
ParamsDemo.java gửi mười chín yêu cầu và in kết quả
src/main/webapp/WEB-INF/views/
students.jsp view của /pages/view
Khai báo thư viện: pom.xml
pom.xml
Dự án Maven Java 17 với spring-webmvc 7.0.9, Jackson 3.2.3 (tools.jackson.core, để chuyển đối tượng thành JSON), Tomcat nhúng 11.0.27 kèm Jasper và JSTL, cờ maven.compiler.parameters để Spring đọc được tên tham số, cùng JUnit và AssertJ cho test. Không cần cơ sở dữ liệu hay Docker.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.canhdominich.javabackend</groupId>
<artifactId>spring-mvc-controller-params</artifactId>
<version>1.0.0</version>
<name>Spring MVC - Controller va tham so</name>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<maven.compiler.parameters>true</maven.compiler.parameters>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.class>dev.canhdominich.spring.mvc.ServerMain</main.class>
<spring.version>7.0.9</spring.version>
<tomcat.version>11.0.27</tomcat.version>
<jackson.version>3.2.3</jackson.version>
<jstl.api.version>3.0.2</jstl.api.version>
<jstl.impl.version>3.0.1</jstl.impl.version>
<junit.version>6.0.3</junit.version>
<assertj.version>3.27.7</assertj.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
<version>${jstl.api.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
<version>${jstl.impl.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
Các thành phần của bài
Registration là đối tượng nhận dữ liệu của một form: hai trường có getter và setter, để Spring điền vào theo tên trường:
src/main/java/dev/canhdominich/spring/mvc/Registration.java
package dev.canhdominich.spring.mvc;
public class Registration {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
StudentDto là một record làm dữ liệu cho JSON:
src/main/java/dev/canhdominich/spring/mvc/StudentDto.java
package dev.canhdominich.spring.mvc;
public record StudentDto(Integer id, String name) {
}
PageController là controller kiểu truyền thống, gom mọi cách đọc tham số trong một lớp. Phần lớn endpoint dùng @ResponseBody để trả thẳng chuỗi (dễ thấy kết quả), riêng /pages/view trả tên view:
src/main/java/dev/canhdominich/spring/mvc/PageController.java
package dev.canhdominich.spring.mvc;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping("/pages")
public class PageController {
@GetMapping("/students/new")
@ResponseBody
public String form() {
return "form for a new student";
}
@GetMapping("/students/{id}")
@ResponseBody
public String student(@PathVariable int id) {
return "student " + id;
}
@GetMapping("/products/{sku}/reviews/{id}")
@ResponseBody
public String review(@PathVariable String sku, @PathVariable int id) {
return "review " + id + " of " + sku;
}
@GetMapping("/search")
@ResponseBody
public String search(@RequestParam String q, @RequestParam(defaultValue = "1") int page) {
return "q=" + q + " page=" + page;
}
@PostMapping("/register")
@ResponseBody
public String register(@ModelAttribute Registration registration) {
return "registered " + registration.getName() + " aged " + registration.getAge();
}
@GetMapping("/trace")
@ResponseBody
public String trace(@RequestHeader(value = "X-Trace", defaultValue = "none") String trace) {
return "trace=" + trace;
}
@GetMapping("/view")
public String view(Model model) {
model.addAttribute("title", "Danh sach sinh vien");
return "students";
}
}
ApiController là @RestController: giá trị trả về được ghi thẳng thành JSON, và ResponseEntity cho phép đặt mã trạng thái và header (201 kèm Location, 404):
src/main/java/dev/canhdominich/spring/mvc/ApiController.java
package dev.canhdominich.spring.mvc;
import java.net.URI;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/students")
public class ApiController {
@GetMapping
public List<StudentDto> all() {
return List.of(new StudentDto(1, "An"), new StudentDto(2, "Binh"));
}
@GetMapping("/{id}")
public ResponseEntity<StudentDto> one(@PathVariable int id) {
if (id > 2) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(new StudentDto(id, id == 1 ? "An" : "Binh"));
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<StudentDto> create(@RequestBody StudentDto student) {
StudentDto saved = new StudentDto(3, student.name());
return ResponseEntity.created(URI.create("/api/students/3")).body(saved);
}
}
ExceptionSpy là một HandlerExceptionResolver chỉ ghi lại tên ngoại lệ mà Spring ném ra rồi trả null (nghĩa là "tôi không xử lý nó", để các bộ xử lý mặc định tiếp tục). Nhờ nó, bài chứng minh được mỗi lỗi ứng với ngoại lệ nào thay vì phỏng đoán:
src/main/java/dev/canhdominich/spring/mvc/ExceptionSpy.java
package dev.canhdominich.spring.mvc;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
public class ExceptionSpy implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
Trace.record(ex.getClass().getSimpleName());
return null;
}
}
WebConfig cấu hình giống bài trước, thêm việc đặt ExceptionSpy lên đầu danh sách bộ xử lý ngoại lệ:
src/main/java/dev/canhdominich/spring/mvc/WebConfig.java
package dev.canhdominich.spring.mvc;
import java.util.List;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
@ComponentScan(basePackageClasses = WebConfig.class)
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.jsp("/WEB-INF/views/", ".jsp");
}
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(0, new ExceptionSpy());
}
}
Máy chủ nhúng và lớp ghi sự kiện giống bài trước:
src/main/java/dev/canhdominich/spring/mvc/Trace.java
package dev.canhdominich.spring.mvc;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class Trace {
private static final List<String> EVENTS = Collections.synchronizedList(new ArrayList<>());
private Trace() {
}
public static void record(String event) {
EVENTS.add(event);
}
public static void clear() {
EVENTS.clear();
}
public static List<String> all() {
synchronized (EVENTS) {
return new ArrayList<>(EVENTS);
}
}
}
src/main/java/dev/canhdominich/spring/mvc/EmbeddedServer.java
package dev.canhdominich.spring.mvc;
import java.io.File;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.Wrapper;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
public final class EmbeddedServer implements AutoCloseable {
private final Tomcat tomcat;
private final AnnotationConfigWebApplicationContext springContext;
private EmbeddedServer(Tomcat tomcat, AnnotationConfigWebApplicationContext springContext) {
this.tomcat = tomcat;
this.springContext = springContext;
}
public static EmbeddedServer start(int port, Class<?>... configurations) throws LifecycleException {
Logger root = Logger.getLogger("");
root.setLevel(Level.WARNING);
for (Handler handler : root.getHandlers()) {
handler.setLevel(Level.WARNING);
}
Tomcat tomcat = new Tomcat();
tomcat.setBaseDir(new File("target/tomcat").getAbsolutePath());
tomcat.setPort(port);
Connector connector = tomcat.getConnector();
connector.setProperty("address", "127.0.0.1");
Context context = tomcat.addContext("", new File("src/main/webapp").getAbsolutePath());
if (context instanceof org.apache.catalina.core.StandardContext standard) {
standard.setClearReferencesRmiTargets(false);
standard.setClearReferencesThreadLocals(false);
}
context.addLifecycleListener(new Tomcat.FixContextListener());
context.addServletContainerInitializer(new org.apache.jasper.servlet.JasperInitializer(), null);
Wrapper jsp = Tomcat.addServlet(context, "jsp", "org.apache.jasper.servlet.JspServlet");
jsp.setLoadOnStartup(3);
context.addServletMappingDecoded("*.jsp", "jsp");
AnnotationConfigWebApplicationContext springContext = new AnnotationConfigWebApplicationContext();
springContext.register(configurations);
Wrapper dispatcher = Tomcat.addServlet(context, "dispatcher", new DispatcherServlet(springContext));
dispatcher.setLoadOnStartup(1);
context.addServletMappingDecoded("/", "dispatcher");
tomcat.start();
return new EmbeddedServer(tomcat, springContext);
}
public int port() {
return tomcat.getConnector().getLocalPort();
}
public AnnotationConfigWebApplicationContext springContext() {
return springContext;
}
@Override
public void close() throws LifecycleException {
tomcat.stop();
tomcat.destroy();
}
}
src/main/java/dev/canhdominich/spring/mvc/ServerMain.java
package dev.canhdominich.spring.mvc;
public class ServerMain {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
EmbeddedServer server = EmbeddedServer.start(port, WebConfig.class);
System.out.println("listening on http://127.0.0.1:" + server.port() + "/hello");
Thread.currentThread().join();
}
}
views/students.jsp là view của /pages/view:
src/main/webapp/WEB-INF/views/students.jsp
<%@ page contentType="text/html;charset=UTF-8" %>
<html><body><h1>${title}</h1></body></html>
Cuối cùng, ParamsDemo khởi động máy chủ ở cổng ngẫu nhiên, gửi mười chín yêu cầu bằng HttpClient và in mã trạng thái, kiểu nội dung, nội dung (khi thành công) và ngoại lệ mà ExceptionSpy ghi được (khi lỗi):
src/main/java/dev/canhdominich/spring/mvc/ParamsDemo.java
package dev.canhdominich.spring.mvc;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
public class ParamsDemo {
public static void main(String[] args) throws Exception {
run();
}
public static List<String> run() throws Exception {
List<String> lines = new ArrayList<>();
try (EmbeddedServer server = EmbeddedServer.start(0, WebConfig.class)) {
Client client = new Client(server.port(), lines);
client.get("/pages/students/5");
client.get("/pages/students/new");
client.get("/pages/products/J-77/reviews/3");
client.get("/pages/search?q=java&page=2");
client.get("/pages/search?q=java");
client.get("/pages/search");
client.get("/pages/search?q=java&page=abc");
client.get("/pages/students/abc");
client.get("/pages/trace");
client.get("/pages/trace", "X-Trace", "abc-123");
client.post("/pages/register", "application/x-www-form-urlencoded", "name=An&age=20");
client.post("/pages/register", "application/x-www-form-urlencoded", "name=An&age=abc");
client.get("/pages/view");
client.get("/api/students");
client.get("/api/students/1");
client.get("/api/students/9");
client.post("/api/students", "application/json", "{\"name\":\"Chi\"}");
client.post("/api/students", "text/plain", "Chi");
client.get("/api/students/1", "Accept", "text/plain");
}
return lines;
}
static final class Client {
private final int port;
private final List<String> lines;
private final HttpClient http = HttpClient.newHttpClient();
Client(int port, List<String> lines) {
this.port = port;
this.lines = lines;
}
void get(String path, String... headers) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).GET();
for (int i = 0; i < headers.length; i += 2) {
builder.header(headers[i], headers[i + 1]);
}
send("GET " + path + (headers.length > 0 ? " [" + headers[0] + ": " + headers[1] + "]" : ""), builder.build());
}
void post(String path, String contentType, String body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
.header("Content-Type", contentType)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
send("POST " + path + " [" + contentType + "] " + body, request);
}
private void send(String label, HttpRequest request) throws Exception {
Trace.clear();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
StringBuilder out = new StringBuilder(label + " -> " + response.statusCode());
response.headers().firstValue("Content-Type").ifPresent(type -> out.append(" ").append(type.split(";")[0]));
response.headers().firstValue("Location").ifPresent(location -> out.append(" Location: ").append(location));
String body = response.body().replaceAll("\\s+", " ").trim();
if (response.statusCode() < 400 && !body.isEmpty()) {
out.append(" | ").append(body);
}
if (!Trace.all().isEmpty()) {
out.append(" | spring raised ").append(Trace.all().get(0));
}
System.out.println(out);
lines.add(out.toString());
}
}
}
Chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.spring.mvc.ParamsDemo. Kết quả (đã bỏ log ở stderr):
GET /pages/students/5 -> 200 text/plain | student 5
GET /pages/students/new -> 200 text/plain | form for a new student
GET /pages/products/J-77/reviews/3 -> 200 text/plain | review 3 of J-77
GET /pages/search?q=java&page=2 -> 200 text/plain | q=java page=2
GET /pages/search?q=java -> 200 text/plain | q=java page=1
GET /pages/search -> 400 text/html | spring raised MissingServletRequestParameterException
GET /pages/search?q=java&page=abc -> 400 text/html | spring raised MethodArgumentTypeMismatchException
GET /pages/students/abc -> 400 text/html | spring raised MethodArgumentTypeMismatchException
GET /pages/trace -> 200 text/plain | trace=none
GET /pages/trace [X-Trace: abc-123] -> 200 text/plain | trace=abc-123
POST /pages/register [application/x-www-form-urlencoded] name=An&age=20 -> 200 text/plain | registered An aged 20
POST /pages/register [application/x-www-form-urlencoded] name=An&age=abc -> 400 text/html | spring raised MethodArgumentNotValidException
GET /pages/view -> 200 text/html | <html><body><h1>Danh sach sinh vien</h1></body></html>
GET /api/students -> 200 application/json | [{"id":1,"name":"An"},{"id":2,"name":"Binh"}]
GET /api/students/1 -> 200 application/json | {"id":1,"name":"An"}
GET /api/students/9 -> 404
POST /api/students [application/json] {"name":"Chi"} -> 201 application/json Location: /api/students/3 | {"id":3,"name":"Chi"}
POST /api/students [text/plain] Chi -> 415 text/html | spring raised HttpMediaTypeNotSupportedException
GET /api/students/1 [Accept: text/plain] -> 406 text/html | spring raised HttpMediaTypeNotAcceptableException
Khớp đường dẫn: biến trong đường dẫn và mẫu cụ thể hơn
Ba dòng đầu về @PathVariable. /pages/students/{id} lấy 5 từ đường dẫn và ép sang int (dòng đầu: student 5). Một đường dẫn có thể có nhiều biến: /pages/products/J-77/reviews/3 điền sku = "J-77" và id = 3.
Dòng thứ hai nhắc một điều tinh tế: /pages/students/new khớp cả hai mẫu /pages/students/new (đường dẫn cố định) và /pages/students/{id} (biến). Spring chọn mẫu cụ thể hơn, tức đường dẫn cố định: kết quả là form for a new student, không phải một lỗi ép new sang int.
Cách đọc hình: hàng trên: /pages/students/5 chỉ khớp mẫu có biến. Hàng dưới: /pages/students/new khớp cả hai, và mẫu cố định được chọn vì cụ thể hơn.
Bốn nguồn dữ liệu và việc ép kiểu
Mỗi annotation chỉ ra một nguồn của một tham số, và Spring đọc nguồn đó rồi ép chuỗi sang kiểu của tham số trước khi gọi phương thức. Các dòng kết quả cho từng nguồn:
@PathVariable: phần của đường dẫn (ví dụ trên).@RequestParam: tham số truy vấn hoặc trường form.?q=java&page=2choq=java page=2. VớidefaultValue = "1", khi thiếupagegiá trị là 1 (q=java page=1). Tham số bắt buộc mà thiếu thì dừng ở 400 (GET /pages/searchkhông cóq).@RequestHeader: một header:X-Trace: abc-123chotrace=abc-123, và có mặc địnhnonekhi vắng.@ModelAttribute: gom nhiều trường form vào một đối tượng bằng cách gọi setter trùng tên:name=An&age=20thànhRegistrationvớinamevàage.@RequestBody: cả thân yêu cầu, ví dụ JSON, chuyển thành đối tượng (ởApiController.create).
Đang tải Animation…
Cách đọc hình: năm annotation ở trên đọc năm nguồn khác nhau; ô cuối nhắc việc ép kiểu chung: chuỗi "2" thành int 2 xảy ra trước khi phương thức chạy.
Khi dữ liệu sai: các lỗi 400, và các lỗi khác
Ba trường hợp trong kết quả cho 400 và phương thức của bạn không bao giờ được gọi. ExceptionSpy cho biết ngoại lệ cụ thể:
- Thiếu tham số bắt buộc (
GET /pages/searchkhông cóq):MissingServletRequestParameterException. - Sai kiểu (
page=abc, hoặc/pages/students/abcvớiint id):MethodArgumentTypeMismatchException. - Form không ép được vào đối tượng (
age=abcgửi tới@ModelAttribute Registration):MethodArgumentNotValidException.
Cách đọc hình: mỗi hàng là một yêu cầu sai và ngoại lệ tương ứng; cả ba kết thúc bằng 400. Lỗi nằm ở dữ liệu client gửi, vì vậy 400 chứ không phải 500.
Hai lỗi nữa xuất hiện ở phần JSON: thân JSON gửi tới endpoint chỉ nhận JSON bằng text/plain trả 415 (HttpMediaTypeNotSupportedException), và yêu cầu Accept: text/plain tới endpoint chỉ sinh JSON trả 406 (HttpMediaTypeNotAcceptableException). Cả hai là kết quả của consumes và produces mà ApiController.create khai báo.
Cờ -parameters và @PathVariable không ghi tên
Ở code, @PathVariable int id không ghi tên "id": Spring đọc tên tham số Java qua reflection, và điều đó chỉ có khi trình biên dịch giữ tên tham số bằng cờ -parameters. pom.xml của bài đặt maven.compiler.parameters bằng true. Nếu bạn biên dịch không có cờ (mvn clean compile exec:exec -Dmaven.compiler.parameters=false ...), kết quả là 500 với thông báo:
IllegalArgumentException: Name for argument of type [int] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.
Hai cách phòng: giữ cờ trong pom.xml (dự án tạo bằng Spring Initializr đã bật sẵn), hoặc ghi tên tường minh (@PathVariable("id")). Đo trong lab: khi không có cờ, endpoint ghi tên @PathVariable("id") vẫn trả 200, còn endpoint không ghi tên trả 500.
View hay JSON: @Controller và @RestController
GET /pages/view (từ @Controller) trả tên view students, và ViewResolver đổi nó thành HTML (text/html, <h1>Danh sach sinh vien</h1>). GET /api/students (từ @RestController) trả một danh sách StudentDto, được HttpMessageConverter ghi thành JSON (application/json):
Cách đọc hình: hàng trên: tên view qua ViewResolver thành HTML. Hàng dưới: đối tượng trả về qua HttpMessageConverter thành JSON và được ghi thẳng vào thân.
@RestController tương đương @Controller cộng @ResponseBody cho mọi phương thức. ResponseEntity thêm quyền kiểm soát mã trạng thái và header: GET /api/students/9 trả 404 (ResponseEntity.notFound()), và POST thành công trả 201 kèm header Location: /api/students/3 cùng đối tượng đã tạo.
Cách đọc hình: ba yêu cầu tới cùng một endpoint: JSON hợp lệ cho 201 kèm Location; thân text/plain cho 415 vì consumes chỉ nhận JSON; Accept: text/plain cho 406 vì produces chỉ sinh JSON.
| Tình huống | Mã | Ngoại lệ (đo được) | Phương thức của bạn chạy |
|---|---|---|---|
| Thiếu tham số bắt buộc | 400 | MissingServletRequestParameterException | Không |
| Sai kiểu tham số | 400 | MethodArgumentTypeMismatchException | Không |
| Form không ép được | 400 | MethodArgumentNotValidException | Không |
| Thân sai kiểu nội dung | 415 | HttpMediaTypeNotSupportedException | Không |
Accept không có kiểu nào sinh được | 406 | HttpMediaTypeNotAcceptableException | Không |
| Không có tài nguyên (do bạn trả) | 404 | không có | Có |
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Lấy dữ liệu | Annotation theo nguồn, không tự đọc request | Phải biết annotation nào cho nguồn nào |
| Tên tham số | Ghi tường minh hoặc bật -parameters | Dài dòng hơn, đổi lấy an toàn khi cấu hình build đổi |
| Dữ liệu tùy chọn | defaultValue hoặc required = false | Giá trị mặc định ẩn trong annotation |
| Trả dữ liệu | ResponseEntity khi cần mã hoặc header | Nhiều code hơn trả đối tượng thẳng |
| Giới hạn kiểu nội dung | consumes và produces | Client gửi sai kiểu nhận 415 hoặc 406 |
Playbook: gặp sự cố tham số và mapping thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
| 400 trước khi phương thức chạy | Tên ngoại lệ ghi bởi một resolver như ExceptionSpy | Thiếu tham số, sai kiểu, form không ép được | Sửa client, hoặc đặt defaultValue, hoặc xử lý lỗi (bài sau) |
500 Name for argument of type | Cờ -parameters | Dự án không giữ tên tham số | Bật cờ hoặc ghi tên trong annotation |
| 415 | Header Content-Type của client | Gửi sai kiểu tới endpoint consumes JSON | Gửi application/json |
| 406 | Header Accept của client | Client chỉ nhận kiểu mà endpoint không sinh | Sửa Accept hoặc mở thêm produces |
| Endpoint bị "nuốt" bởi mẫu có biến | Có mẫu {id} cùng cấp không | Mẫu có biến khớp mà bạn muốn mẫu cố định | Spring chọn mẫu cụ thể hơn; nếu vẫn sai, kiểm tra thứ tự khai báo và đường dẫn |
| JSON trả về thiếu trường | Getter hoặc kiểu của DTO | Trường không có accessor | Dùng record hoặc thêm getter |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
mvn không tải được tools.jackson | Chưa có mạng hoặc kho bị chặn | Chạy lại với mạng |
| Tên tham số không đọc được (500) | Thiếu cờ -parameters | Giữ maven.compiler.parameters=true trong pom.xml, hoặc ghi tên trong annotation |
Bài thực hành
Làm trong dự án của bài (thư mục 07-spring-mvc/02-controller-va-tham-so). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- Chạy thí nghiệm.
mvn -q compile exec:exec -Dmain.class=dev.canhdominich.spring.mvc.ParamsDemo. Tiêu chí: mười chín dòng khớp bài. - Thêm tham số tùy chọn. Thêm vào
PageControllermột endpoint@GetMapping("/lang")với@ResponseBodynhận@RequestParam(required = false) String langvà trả"lang=" + lang, rồi gọi nó trongParamsDemohai lần (không có và có?lang=vi). Tiêu chí:lang=nullrồilang=vi, cả hai mã 200. - Tháo cờ
-parameters. Chạymvn clean compile exec:exec -Dmaven.compiler.parameters=false -Dmain.class=dev.canhdominich.spring.mvc.ParamsDemo. Tiêu chí:GET /pages/students/5trả 500 và có thông báo về cờ-parameters. - Ghi tên tường minh. Đổi
student(@PathVariable int id)thànhstudent(@PathVariable("id") int id)và chạy lại với cờ vẫn tắt. Tiêu chí:GET /pages/students/5trả 200student 5. - Giá trị rỗng. Thêm vào
ParamsDemohai lời gọiGET /pages/search?q=java&page=vàGET /pages/search?q=. Hãy dự đoán trước, rồi so: tiêu chí là cả hai đều 200, lần đầu raq=java page=1(giá trị rỗng được coi như thiếu nên dùngdefaultValue) và lần hai raq= page=1(qrỗng vẫn thỏa yêu cầu "có mặt"). Hãy cân nhắc điều này khi bạn coirequiredlà kiểm tra dữ liệu. - Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone,
mvn testchoTests run: 5, Failures: 0, Errors: 0.
Hiểu lầm và giới hạn
- "Tham số sai thì phương thức của tôi nhận giá trị rỗng." Với các lỗi trên, phương thức không được gọi.
- "400 và 500 như nhau." 400 là lỗi do dữ liệu client gửi; 500 là lỗi của máy chủ (như thiếu cờ
-parameters). - "
@RestControllerchỉ khác ở tên." Nó đổi cách xử lý giá trị trả về: ghi vào thân thay vì chọn view. - Số đo thuộc về Spring MVC 7.0.9 với Jackson 3.2.3 trên Tomcat 11.0.27. Bài chưa đo
@RequestPart(tải tệp),@CookieValue,@SessionAttribute, mẫu đường dẫn có biểu thức chính quy, và thứ tự ưu tiên khi hai mẫu có biến cùng khớp.
Tự kiểm tra
/pages/students/newkhớp hai mẫu: Spring chọn mẫu nào và vì sao?page=abcgửi tới@RequestParam int pagecho mã nào, ngoại lệ nào, và phương thức có chạy không?- Khác nhau giữa 415 và 406 là gì?
- Vì sao
@PathVariable int idkhông ghi tên có thể trả 500 trên một dự án này nhưng chạy tốt ở dự án khác? ResponseEntity.created(...)thêm gì vào phản hồi so với trả thẳng đối tượng?
Gợi ý đáp án. (1) Mẫu cố định /pages/students/new, vì cụ thể hơn mẫu có biến. (2) 400, MethodArgumentTypeMismatchException, phương thức không chạy. (3) 415 là kiểu của thân yêu cầu không được nhận; 406 là kiểu mà client muốn nhận không sinh được. (4) Cờ -parameters quyết định tên tham số có đọc được qua reflection hay không. (5) Mã 201 và header Location.
Điều cần nhớ
- Chọn annotation theo nguồn:
@PathVariable,@RequestParam,@RequestHeader,@ModelAttribute,@RequestBody. - Mẫu cố định thắng mẫu có biến; Spring ép kiểu trước khi gọi phương thức và dừng ở 400 nếu thất bại.
@RestControllerghi dữ liệu thẳng (JSON);ResponseEntityđặt mã và header;consumesvàproducescho ra 415 và 406.- Bật
-parametershoặc ghi tên tường minh trong annotation.
Đọc thêm
- Spring Framework Reference: Mapping Requests
- Spring Framework Reference: Method Arguments
- Spring Framework Reference: Handler Methods
Bài tiếp theo hoàn thiện chương: view và form (mẫu Post/Redirect/Get), kiểm tra dữ liệu bằng Bean Validation và xử lý lỗi tập trung thay cho trang 400 và 500 mặc định.