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 @Controller trả view với @RestController trả JSON, và dùng ResponseEntity để đặt mã trạng thái.
  • Biết vì sao cờ -parameters ảnh hưởng tới @PathVariable và @RequestParam khô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ầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Framework7.0.9khai báo trong pom.xml
Tomcat nhúng11.0.27khai báo trong pom.xml
Jackson3.2.3 (tools.jackson)chuyển đối tượng thành JSON
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
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
<?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

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

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

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

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

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

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

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

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

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

html
<%@ 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

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

text
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=2 cho q=java page=2. Với defaultValue = "1", khi thiếu page giá trị là 1 (q=java page=1). Tham số bắt buộc mà thiếu thì dừng ở 400 (GET /pages/search không có q).
  • @RequestHeader: một header: X-Trace: abc-123 cho trace=abc-123, và có mặc định none khi 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=20 thành Registration với name và 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/search không có q): MissingServletRequestParameterException.
  • Sai kiểu (page=abc, hoặc /pages/students/abc với int id): MethodArgumentTypeMismatchException.
  • Form không ép được vào đối tượng (age=abc gử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:

text
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ốngMãNgoại lệ (đo được)Phương thức của bạn chạy
Thiếu tham số bắt buộc400MissingServletRequestParameterExceptionKhông
Sai kiểu tham số400MethodArgumentTypeMismatchExceptionKhông
Form không ép được400MethodArgumentNotValidExceptionKhông
Thân sai kiểu nội dung415HttpMediaTypeNotSupportedExceptionKhông
Accept không có kiểu nào sinh được406HttpMediaTypeNotAcceptableExceptionKhông
Không có tài nguyên (do bạn trả)404không cóCó

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Lấy dữ liệuAnnotation theo nguồn, không tự đọc requestPhải biết annotation nào cho nguồn nào
Tên tham sốGhi tường minh hoặc bật -parametersDài dòng hơn, đổi lấy an toàn khi cấu hình build đổi
Dữ liệu tùy chọndefaultValue hoặc required = falseGiá trị mặc định ẩn trong annotation
Trả dữ liệuResponseEntity khi cần mã hoặc headerNhiều code hơn trả đối tượng thẳng
Giới hạn kiểu nội dungconsumes và producesClient 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ứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
400 trước khi phương thức chạyTên ngoại lệ ghi bởi một resolver như ExceptionSpyThiếu tham số, sai kiểu, form không ép đượcSửa client, hoặc đặt defaultValue, hoặc xử lý lỗi (bài sau)
500 Name for argument of typeCờ -parametersDự án không giữ tên tham sốBật cờ hoặc ghi tên trong annotation
415Header Content-Type của clientGửi sai kiểu tới endpoint consumes JSONGửi application/json
406Header Accept của clientClient chỉ nhận kiểu mà endpoint không sinhSửa Accept hoặc mở thêm produces
Endpoint bị "nuốt" bởi mẫu có biếnCó mẫu {id} cùng cấp khôngMẫu có biến khớp mà bạn muốn mẫu cố địnhSpring 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ườngGetter hoặc kiểu của DTOTrường không có accessorDùng record hoặc thêm getter

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
mvn không tải được tools.jacksonChưa có mạng hoặc kho bị chặnChạy lại với mạng
Tên tham số không đọc được (500)Thiếu cờ -parametersGiữ 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.

  1. 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.
  2. Thêm tham số tùy chọn. Thêm vào PageController một endpoint @GetMapping("/lang") với @ResponseBody nhận @RequestParam(required = false) String lang và trả "lang=" + lang, rồi gọi nó trong ParamsDemo hai lần (không có và có ?lang=vi). Tiêu chí: lang=null rồi lang=vi, cả hai mã 200.
  3. Tháo cờ -parameters. Chạy mvn clean compile exec:exec -Dmaven.compiler.parameters=false -Dmain.class=dev.canhdominich.spring.mvc.ParamsDemo. Tiêu chí: GET /pages/students/5 trả 500 và có thông báo về cờ -parameters.
  4. Ghi tên tường minh. Đổi student(@PathVariable int id) thành student(@PathVariable("id") int id) và chạy lại với cờ vẫn tắt. Tiêu chí: GET /pages/students/5 trả 200 student 5.
  5. Giá trị rỗng. Thêm vào ParamsDemo hai lời gọi GET /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 ra q=java page=1 (giá trị rỗng được coi như thiếu nên dùng defaultValue) và lần hai ra q= page=1 (q rỗ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 coi required là kiểm tra dữ liệu.
  6. 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 test cho Tests 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).
  • "@RestController chỉ 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

  1. /pages/students/new khớp hai mẫu: Spring chọn mẫu nào và vì sao?
  2. page=abc gửi tới @RequestParam int page cho mã nào, ngoại lệ nào, và phương thức có chạy không?
  3. Khác nhau giữa 415 và 406 là gì?
  4. Vì sao @PathVariable int id khô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?
  5. 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.
  • @RestController ghi dữ liệu thẳng (JSON); ResponseEntity đặt mã và header; consumes và produces cho ra 415 và 406.
  • Bật -parameters hoặc ghi tên tường minh trong annotation.

Đọc thêm

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.