Xây REST API với Spring Boot
Xem thửMột API đặt lịch có hai ứng dụng khách: web và di động. Nhóm di động báo "API trả lỗi lung tung". Khi kiểm tra, các lỗi có ba hình dạng khác nhau: dữ liệu sai trả {"errors": [...]}, không tìm thấy trả một trang HTML của Tomcat, và JSON sai cú pháp trả {"timestamp":..., "error":"Bad Request"} kiểu mặc định. Mỗi hình dạng cần một đoạn mã xử lý riêng ở phía client, và mỗi lần backend thêm một loại lỗi mới, ứng dụng di động lại vỡ. Không ai quyết định hình dạng lỗi: nó là kết quả của những mặc định khác nhau ở những chỗ khác nhau.
Bài này xây một API REST với Spring Boot theo một hợp đồng thống nhất: tài nguyên và động từ HTTP, mã trạng thái có nghĩa, và một dạng lỗi duy nhất (RFC 9457, ProblemDetail) cho mọi lỗi từ kiểm tra dữ liệu tới JSON hỏng. Mọi mã và nội dung dưới đây được đo bằng yêu cầu HTTP thật tới ứng dụng đang chạy.
Sau bài này, bạn có thể
- Thiết kế các endpoint CRUD cho một tài nguyên và chọn đúng mã trạng thái (200, 201, 204, 404, 405, 406, 415, 400).
- Kiểm tra dữ liệu vào bằng
@Validvà trả lỗi theo từng trường. - Dùng
ProblemDetailvàResponseEntityExceptionHandlerđể mọi lỗi có cùng hình dạng. - Phân trang an toàn với giới hạn kích thước trang.
- Biết các mặc định của Jackson trên Boot (ví dụ trường lạ bị bỏ qua) và đổi chúng khi cần hợp đồng chặt.
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 Boot 4.1.1, Spring Framework 7.0.9, Jackson 3.1.5). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 08-spring-boot/03-rest-api, 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 Boot | 4.1.1 | khai báo ở parent trong pom.xml |
| Docker | không cần | bài này không dùng cơ sở dữ liệu |
Cấu trúc
03-rest-api/
pom.xml
src/main/java/dev/canhdominich/boot/rest/
RestApplication.java @SpringBootApplication
Course.java record tài nguyên (id, tiêu đề, giá)
CourseRequest.java record dữ liệu vào, có ràng buộc Bean Validation
PageResponse.java record một trang kết quả
CourseNotFoundException.java ngoại lệ nghiệp vụ
CourseStore.java kho dữ liệu trong bộ nhớ
CourseController.java các endpoint /api/courses
ApiExceptionHandler.java @RestControllerAdvice trả ProblemDetail
RestDemo.java khởi động ứng dụng, gửi yêu cầu HTTP và in kết quả
Khai báo thư viện: pom.xml
pom.xml
Dự án Maven dùng spring-boot-starter-parent 4.1.1 với spring-boot-starter-webmvc (Spring MVC, Tomcat nhúng, Jackson), spring-boot-starter-validation (Bean Validation cho @Valid) và spring-boot-starter-test. Plugin exec-maven-plugin chạy RestDemo bằng mvn -q compile exec:exec; spring-boot-maven-plugin đóng gói ứng dụng.
<?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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>dev.canhdominich.javabackend</groupId>
<artifactId>boot-rest-api</artifactId>
<version>1.0.0</version>
<name>Spring Boot - REST API</name>
<properties>
<java.version>17</java.version>
<main.class>dev.canhdominich.boot.rest.RestDemo</main.class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
</project>
Tài nguyên và dữ liệu
API quản lý một tài nguyên Course (khóa học). Course là một record gồm id, tiêu đề và giá, và cũng là hình dạng trả về trong JSON:
src/main/java/dev/canhdominich/boot/rest/Course.java
package dev.canhdominich.boot.rest;
public record Course(Long id, String title, int price) {
}
Dữ liệu client gửi lên khi tạo hoặc sửa là một kiểu khác, CourseRequest: nó không có id (do server cấp), và mang các ràng buộc @NotBlank, @Size(max = 60) cho tiêu đề và @Min(0) cho giá. Tách kiểu vào và kiểu ra giúp client không tự đặt id:
src/main/java/dev/canhdominich/boot/rest/CourseRequest.java
package dev.canhdominich.boot.rest;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CourseRequest(
@NotBlank @Size(max = 60) String title,
@Min(0) int price) {
}
PageResponse là cách trả một trang kết quả: các phần tử, số trang, kích thước và tổng số phần tử, để client biết còn bao nhiêu trang:
src/main/java/dev/canhdominich/boot/rest/PageResponse.java
package dev.canhdominich.boot.rest;
import java.util.List;
public record PageResponse<T>(List<T> items, int page, int size, long total) {
}
CourseNotFoundException là ngoại lệ nghiệp vụ khi không có tài nguyên:
src/main/java/dev/canhdominich/boot/rest/CourseNotFoundException.java
package dev.canhdominich.boot.rest;
public class CourseNotFoundException extends RuntimeException {
public CourseNotFoundException(long id) {
super("Course " + id + " does not exist");
}
}
CourseStore là nơi lưu dữ liệu, ở đây chỉ là một bản đồ trong bộ nhớ có bộ đếm id (bài Spring Data JPA sẽ thay nó bằng cơ sở dữ liệu). Phương thức page cắt một trang từ danh sách theo số trang và kích thước:
src/main/java/dev/canhdominich/boot/rest/CourseStore.java
package dev.canhdominich.boot.rest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
@Component
public class CourseStore {
private final Map<Long, Course> courses = new ConcurrentSkipListMap<>();
private final AtomicLong ids = new AtomicLong();
public Course create(CourseRequest request) {
Course course = new Course(ids.incrementAndGet(), request.title(), request.price());
courses.put(course.id(), course);
return course;
}
public Optional<Course> find(long id) {
return Optional.ofNullable(courses.get(id));
}
public Optional<Course> replace(long id, CourseRequest request) {
if (!courses.containsKey(id)) {
return Optional.empty();
}
Course course = new Course(id, request.title(), request.price());
courses.put(id, course);
return Optional.of(course);
}
public boolean delete(long id) {
return courses.remove(id) != null;
}
public PageResponse<Course> page(int page, int size) {
List<Course> all = new ArrayList<>(courses.values());
int from = Math.min(page * size, all.size());
int to = Math.min(from + size, all.size());
return new PageResponse<>(all.subList(from, to), page, size, all.size());
}
public void clear() {
courses.clear();
ids.set(0);
}
}
Các endpoint
CourseController là lõi: năm endpoint cho một tài nguyên. Chú ý mã trả về của từng cái và hai ràng buộc trên tham số phân trang của list (@Min(0) cho page, @Min(1) @Max(50) cho size):
src/main/java/dev/canhdominich/boot/rest/CourseController.java
package dev.canhdominich.boot.rest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/courses")
public class CourseController {
private final CourseStore store;
public CourseController(CourseStore store) {
this.store = store;
}
@GetMapping
public PageResponse<Course> list(@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "5") @Min(1) @Max(50) int size) {
return store.page(page, size);
}
@GetMapping("/{id}")
public Course one(@PathVariable long id) {
return store.find(id).orElseThrow(() -> new CourseNotFoundException(id));
}
@PostMapping
public ResponseEntity<Course> create(@Valid @RequestBody CourseRequest request) {
Course created = store.create(request);
return ResponseEntity.created(URI.create("/api/courses/" + created.id())).body(created);
}
@PutMapping("/{id}")
public Course replace(@PathVariable long id, @Valid @RequestBody CourseRequest request) {
return store.replace(id, request).orElseThrow(() -> new CourseNotFoundException(id));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
if (!store.delete(id)) {
throw new CourseNotFoundException(id);
}
return ResponseEntity.noContent().build();
}
}
ApiExceptionHandler đưa mọi lỗi về ProblemDetail. Nó kế thừa ResponseEntityExceptionHandler, lớp của Spring đã biết biến các ngoại lệ chuẩn của MVC (JSON hỏng, sai kiểu nội dung, phương thức không hỗ trợ, ...) thành ProblemDetail. Lớp của bài thêm hai thứ: notFound cho ngoại lệ nghiệp vụ, và một bản ghi đè handleMethodArgumentNotValid để thêm thuộc tính errors liệt kê lỗi theo từng trường:
src/main/java/dev/canhdominich/boot/rest/ApiExceptionHandler.java
package dev.canhdominich.boot.rest;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(CourseNotFoundException.class)
public ProblemDetail notFound(CourseNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed");
Map<String, String> errors = new TreeMap<>();
e.getBindingResult().getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
problem.setProperty("errors", errors);
return handleExceptionInternal(e, problem, headers, status, request);
}
}
RestApplication là lớp ứng dụng như các bài trước:
src/main/java/dev/canhdominich/boot/rest/RestApplication.java
package dev.canhdominich.boot.rest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RestApplication {
public static void main(String[] args) {
SpringApplication.run(RestApplication.class, args);
}
}
Chạy và đọc kết quả
RestDemo khởi động ứng dụng ở cổng ngẫu nhiên rồi gửi một chuỗi yêu cầu bằng HttpClient và in từng câu trả lời (mã trạng thái, kiểu nội dung, các header đáng chú ý và thân):
src/main/java/dev/canhdominich/boot/rest/RestDemo.java
package dev.canhdominich.boot.rest;
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;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
public class RestDemo {
public static void main(String[] args) throws Exception {
run();
}
public static List<String> run() throws Exception {
List<String> lines = new ArrayList<>();
SpringApplication application = new SpringApplication(RestApplication.class);
application.setDefaultProperties(Map.of("server.port", "0", "spring.main.banner-mode", "off", "logging.level.root", "OFF"));
try (ConfigurableApplicationContext context = application.run()) {
int port = ((WebServerApplicationContext) context).getWebServer().getPort();
Client client = new Client(port, lines);
client.send("POST", "/api/courses", "application/json", "{\"title\":\"Spring Boot\",\"price\":100}");
client.send("POST", "/api/courses", "application/json", "{\"title\":\"\",\"price\":-1}");
client.send("POST", "/api/courses", "application/json", "{\"title\":\"Extra field ignored\",\"price\":5,\"unknown\":true}");
client.send("POST", "/api/courses", "application/json", "{\"title\":");
client.send("POST", "/api/courses", "text/plain", "Spring Boot");
for (int i = 4; i <= 12; i++) {
client.quiet("POST", "/api/courses", "application/json", "{\"title\":\"Course " + i + "\",\"price\":" + i + "}");
}
client.send("GET", "/api/courses/1", null, null);
client.send("GET", "/api/courses/99", null, null);
client.send("GET", "/api/courses?page=1&size=5", null, null);
client.send("GET", "/api/courses?size=0", null, null);
client.send("GET", "/api/courses?size=abc", null, null);
client.send("PUT", "/api/courses/1", "application/json", "{\"title\":\"Spring Boot 4\",\"price\":150}");
client.send("PUT", "/api/courses/99", "application/json", "{\"title\":\"x\",\"price\":1}");
client.send("DELETE", "/api/courses/1", null, null);
client.send("DELETE", "/api/courses/1", null, null);
client.send("PATCH", "/api/courses/2", "application/json", "{}");
client.send("GET", "/api/courses/2", "Accept: application/xml", null);
}
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 quiet(String method, String path, String type, String body) throws Exception {
request(method, path, type, body);
}
void send(String method, String path, String type, String body) throws Exception {
HttpResponse<String> response = request(method, path, type, body);
StringBuilder out = new StringBuilder(method + " " + path + (body == null ? "" : " " + body) + " -> " + response.statusCode());
response.headers().firstValue("Content-Type").ifPresent(contentType -> out.append(" ").append(contentType.split(";")[0]));
response.headers().firstValue("Location").ifPresent(location -> out.append(" Location: ").append(location));
response.headers().firstValue("Allow").ifPresent(allow -> out.append(" Allow: ").append(String.join(", ", new java.util.TreeSet<>(List.of(allow.split(", "))))));
String text = response.body().replaceAll("\"instance\":\"[^\"]*\",?", "").replaceAll(",}", "}").replaceAll("\\s+", " ").trim();
if (!text.isEmpty()) {
out.append(" | ").append(text);
}
System.out.println(out);
lines.add(out.toString());
}
private HttpResponse<String> request(String method, String path, String type, String body) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path));
if (type != null && type.startsWith("Accept: ")) {
builder.header("Accept", type.substring("Accept: ".length()));
} else if (type != null) {
builder.header("Content-Type", type);
}
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
}
}
Chạy bằng mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr; header Allow được sắp xếp để ổn định):
POST /api/courses {"title":"Spring Boot","price":100} -> 201 application/json Location: /api/courses/1 | {"id":1,"title":"Spring Boot","price":100}
POST /api/courses {"title":"","price":-1} -> 400 application/problem+json | {"detail":"Request validation failed","status":400,"title":"Bad Request","errors":{"price":"must be greater than or equal to 0","title":"must not be blank"}}
POST /api/courses {"title":"Extra field ignored","price":5,"unknown":true} -> 201 application/json Location: /api/courses/2 | {"id":2,"title":"Extra field ignored","price":5}
POST /api/courses {"title": -> 400 application/problem+json | {"detail":"Failed to read request","status":400,"title":"Bad Request"}
POST /api/courses Spring Boot -> 415 application/problem+json | {"detail":"Content-Type 'text/plain;charset=UTF-8' is not supported.","status":415,"title":"Unsupported Media Type"}
GET /api/courses/1 -> 200 application/json | {"id":1,"title":"Spring Boot","price":100}
GET /api/courses/99 -> 404 application/problem+json | {"detail":"Course 99 does not exist","status":404,"title":"Not Found"}
GET /api/courses?page=1&size=5 -> 200 application/json | {"items":[{"id":6,"title":"Course 7","price":7},{"id":7,"title":"Course 8","price":8},{"id":8,"title":"Course 9","price":9},{"id":9,"title":"Course 10","price":10},{"id":10,"title":"Course 11","price":11}],"page":1,"size":5,"total":11}
GET /api/courses?size=0 -> 400 application/problem+json | {"detail":"Validation failure","status":400,"title":"Bad Request"}
GET /api/courses?size=abc -> 400 application/problem+json | {"detail":"Failed to convert 'size' with value: 'abc'","status":400,"title":"Bad Request"}
PUT /api/courses/1 {"title":"Spring Boot 4","price":150} -> 200 application/json | {"id":1,"title":"Spring Boot 4","price":150}
PUT /api/courses/99 {"title":"x","price":1} -> 404 application/problem+json | {"detail":"Course 99 does not exist","status":404,"title":"Not Found"}
DELETE /api/courses/1 -> 204
DELETE /api/courses/1 -> 404 application/problem+json | {"detail":"Course 1 does not exist","status":404,"title":"Not Found"}
PATCH /api/courses/2 {} -> 405 application/problem+json Allow: DELETE, GET, PUT | {"detail":"Method 'PATCH' is not supported.","status":405,"title":"Method Not Allowed"}
GET /api/courses/2 -> 406 application/problem+json | {"detail":"Acceptable representations: [application/json, application/*+json].","status":406,"title":"Not Acceptable"}
(Ở phần chạy thật, chín yêu cầu POST tạo thêm khóa học "Course 4" tới "Course 12" được gửi giữa dòng 5 và dòng 6 mà không in, để có đủ dữ liệu cho phân trang; tổng cộng 11 khóa học vì hai yêu cầu POST đầu bị từ chối.)
Mã trạng thái: mỗi mã nói điều gì
- 201 Created kèm
Location. Tạo thành công trả 201, headerLocation: /api/courses/1cho client biết địa chỉ tài nguyên mới, và thân là chính tài nguyên (kèmiddo server cấp). Client không cần gọiGETthêm. - 200 cho đọc và sửa, 204 cho xóa.
GETvàPUTthành công trả 200 kèm thân;DELETEthành công trả 204 No Content: thành công và không có gì để trả. - 404 khi không có tài nguyên.
GET,PUTvàDELETEtrên id không tồn tại đều trả 404.DELETEhai lần trên cùng id: lần đầu 204, lần hai 404. Kết quả trên máy chủ giống nhau (tài nguyên đã mất), nhưng mã trả về khác; đây là lựa chọn của bài, có API chọn trả 204 cho cả hai lần. - 405 kèm
Allow.PATCHkhông được hỗ trợ trả 405 và headerAllowliệt kê các phương thức hợp lệ cho đường dẫn đó (DELETE, GET, PUT). - 406 và 415 cho kiểu nội dung.
Accept: application/xmltrả 406 (không sinh được XML); thântext/plaintrả 415 (không đọc được). - 400 cho dữ liệu sai. Thân sai ràng buộc, JSON hỏng và tham số truy vấn sai đều trả 400.
Cách đọc hình: hình trên liệt kê phương thức và đường dẫn với câu trả lời đo được; hình dưới nhóm các mã trạng thái: 2xx là thành công, 4xx là lỗi do client (sai dữ liệu, sai đường dẫn, sai phương thức, sai kiểu nội dung).
Một dạng lỗi cho mọi trường hợp
Điều đáng nhìn nhất trong kết quả là mọi lỗi đều có cùng hình dạng: Content-Type: application/problem+json và các trường status, title, detail. Lỗi kiểm tra dữ liệu có thêm errors liệt kê từng trường (price và title) và thông báo của nó; 404 mang thông điệp nghiệp vụ Course 99 does not exist; JSON hỏng, sai kiểu nội dung, sai phương thức, sai Accept cũng cùng hình dạng. Với phần lớn các lỗi, bạn không viết mã nào (ResponseEntityExceptionHandler lo); bạn chỉ viết cho những lỗi nghiệp vụ của mình và những chỗ muốn bổ sung thông tin. Client chỉ cần một đoạn mã đọc lỗi.
Đang tải Animation…
Cách đọc hình: ba nhóm lỗi bên trái (kiểm tra dữ liệu, không tìm thấy, JSON hoặc kiểu nội dung sai) cùng đổ vào ProblemDetail rồi tới client với cùng các trường.
Phân trang và giới hạn kích thước
GET /api/courses?page=1&size=5 trả năm phần tử (id 6 đến 10) cùng "page":1,"size":5,"total":11: client tính được có 3 trang. Hai điều đáng chú ý: số trang bắt đầu từ 0, và size bị ràng buộc @Min(1) @Max(50) ngay trên tham số. size=0 trả 400 (Validation failure) và size=abc trả 400 (Failed to convert 'size' with value: 'abc'): một lời gọi không thể đòi "tất cả" hay một trang rỗng vô nghĩa, và không phải viết dòng kiểm tra nào trong phương thức.
Cách đọc hình: hàng trên là một yêu cầu phân trang hợp lệ và câu trả lời; hàng dưới là hai kiểu size sai và ràng buộc trong chữ ký phương thức chặn chúng.
Mặc định của Jackson: trường lạ bị bỏ qua
Yêu cầu thứ ba gửi thêm trường "unknown": true không có trong CourseRequest. Kết quả là 201: trường lạ bị bỏ qua lặng lẽ. Đó là mặc định của Boot (Jackson được cấu hình không báo lỗi trên thuộc tính lạ), tiện cho client có phiên bản mới hơn nhưng cũng che giấu lỗi chính tả như "prce". Với API cần hợp đồng chặt, bạn đặt spring.jackson.deserialization.fail-on-unknown-properties=true; bài thực hành đo hiệu quả của dòng này.
Cách đọc hình: trường lạ trong JSON đúng cú pháp bị bỏ qua và tạo thành công; JSON cụt trả 400. Ô cuối nhắc rằng muốn hợp đồng chặt phải đổi cấu hình có chủ đích.
| Tình huống | Mã | Content-Type | Do ai tạo ra |
|---|---|---|---|
| Tạo thành công | 201 | application/json | Controller |
| Dữ liệu vi phạm ràng buộc | 400 | application/problem+json | ApiExceptionHandler (bản ghi đè) |
| JSON cụt | 400 | application/problem+json | ResponseEntityExceptionHandler |
| Sai kiểu nội dung | 415 | application/problem+json | ResponseEntityExceptionHandler |
| Không có tài nguyên | 404 | application/problem+json | ApiExceptionHandler.notFound |
| Phương thức không hỗ trợ | 405 | application/problem+json | ResponseEntityExceptionHandler |
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Hình dạng lỗi | ProblemDetail (RFC 9457) cho mọi lỗi | Mọi client phải hiểu một chuẩn, đổi lấy một đoạn mã đọc lỗi |
| Kiểu vào và kiểu ra | CourseRequest tách khỏi Course | Thêm một lớp, đổi lấy việc client không đặt được id |
| Xóa lần hai | Trả 404 | Client phải chấp nhận 404 là bình thường khi thử lại |
| Phân trang | Giới hạn size ngay trên tham số | Giới hạn cố định; muốn đổi phải sửa mã |
| Trường JSON lạ | Mặc định bỏ qua | Che lỗi chính tả; đổi bằng thuộc tính nếu cần chặt |
| Lưu dữ liệu | Trong bộ nhớ ở bài này | Mất dữ liệu khi tắt; bài Spring Data JPA thay bằng cơ sở dữ liệu |
Playbook: gặp sự cố API 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ý |
|---|---|---|---|
| Client nhận 415 | Header Content-Type | Gửi text/plain hoặc quên header | Gửi application/json |
| 406 | Header Accept | Client chỉ nhận kiểu mà API không sinh | Sửa Accept |
| 400 nhưng không rõ trường nào | Thân phản hồi | Lỗi JSON cú pháp (không có errors) | Kiểm tra JSON; lỗi ràng buộc mới có errors |
| Lỗi mới có hình dạng khác | Handler cho lỗi đó | Ngoại lệ không đi qua ResponseEntityExceptionHandler | Thêm @ExceptionHandler trả ProblemDetail |
| Tạo thành công nhưng client không biết địa chỉ | Header Location | Trả 200 thay vì 201 | Dùng ResponseEntity.created(...) |
| Dữ liệu sai lọt qua | Có @Valid và ràng buộc không | Thiếu @Valid hoặc ràng buộc | Thêm vào tham số và record |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Số total khác bài | Bộ đếm id và kho dữ liệu dùng chung giữa các lần chạy trong cùng một tiến trình | Mỗi lần mvn compile exec:exec là một tiến trình mới; chạy đúng lệnh trong README |
Port ... already in use khi tự chạy RestApplication | Cổng 8080 đang bị chiếm | mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8081 |
Bài thực hành
Làm trong dự án của bài (thư mục 08-spring-boot/03-rest-api). 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. Tiêu chí: mười sáu dòng khớp bài. - Bắt trường lạ. Tạo
src/main/resources/application.propertiesvới một dòngspring.jackson.deserialization.fail-on-unknown-properties=truevà chạy lại. Tiêu chí: yêu cầu"unknown": trueđổi từ 201 thành400 application/problem+jsonvớiFailed to read request. - Thêm ràng buộc. Đổi
@Size(max = 60)thành@Size(min = 3, max = 60)và thêm một yêu cầu với"title":"ab". Tiêu chí: 400 vàerrorscó"title":"size must be between 3 and 60". - Đổi quy ước xóa. Sửa
deleteđể xóa id không tồn tại cũng trả 204. Tiêu chí: lầnDELETEthứ hai đổi từ 404 thành 204; viết một câu giải thích khi nào bạn chọn mỗi cách. - Thêm sắp xếp. Thêm tham số
sort(titlehoặcprice) cholistvà sắp xếp trongCourseStore.pagetrước khi cắt trang. Tiêu chí:GET /api/courses?sort=price&size=3trả ba khóa học có giá thấp nhất trước. - 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
- "REST là dùng JSON." REST là cách đặt tên tài nguyên và dùng động từ, mã trạng thái cho đúng nghĩa; JSON chỉ là định dạng.
- "Trả 200 cho mọi thứ và đặt lỗi trong thân." Mã trạng thái là phần của hợp đồng; client và proxy dựa vào nó.
- "
@Validkiểm tra cả tham số truy vấn." Ràng buộc trên tham số (như@Min) được kiểm tra, nhưng qua cơ chế khác và cho ra thông báo khác (Validation failure), như đã đo. - Số đo thuộc về Spring Boot 4.1.1 và Jackson 3.1.5 do Boot quản lý. Bài chưa nói về bảo mật, HATEOAS, phiên bản hóa API, ETag và điều kiện, tài liệu OpenAPI, tải tệp, và kiểm thử bằng
MockMvc.
Tự kiểm tra
- Tạo tài nguyên thành công trả mã nào, kèm header nào?
- Vì sao dùng
ProblemDetailcho mọi lỗi? - Phân biệt 405, 406 và 415.
- Vì sao tách
CourseRequestkhỏiCourse? - Yêu cầu có trường JSON lạ trả gì theo mặc định của Boot, và làm sao đổi?
Gợi ý đáp án. (1) 201 Created, header Location trỏ tới tài nguyên mới. (2) Client xử lý mọi lỗi bằng một đoạn mã; mã trạng thái cho loại, thân cho chi tiết. (3) 405: phương thức không hỗ trợ (kèm Allow); 406: không sinh được kiểu client muốn (Accept); 415: không đọc được kiểu của thân yêu cầu (Content-Type). (4) Client không nên đặt id do server cấp; kiểu vào chỉ chứa trường client được phép gửi. (5) 201: trường lạ bị bỏ qua; đổi bằng spring.jackson.deserialization.fail-on-unknown-properties=true để trả 400.
Điều cần nhớ
- Tài nguyên là danh từ trong URL, động từ HTTP là hành động; chọn mã trạng thái có nghĩa (201 kèm
Location, 204, 404). @Validcộng ràng buộc trên record kiểm tra dữ liệu vào; ràng buộc ngay trên tham số truy vấn giới hạn phân trang.- Một dạng lỗi
ProblemDetailcho mọi lỗi: kế thừaResponseEntityExceptionHandlervà bổ sung phần nghiệp vụ. - Biết mặc định của Jackson (trường lạ bị bỏ qua) và đổi có chủ đích.
Đọc thêm
- Spring Framework Reference: Error Responses (ProblemDetail)
- Spring Boot Reference: Developing Web Applications
- RFC 9457: Problem Details for HTTP APIs
Bài tiếp theo thêm khả năng quan sát cho ứng dụng: Actuator, log, và công cụ phát triển của Boot.