99 Ngày Spring — Ngày 16: Xử lý lỗi toàn cục
Ngày 15 chốt lại một nguyên tắc: lỗi thì ném exception, không tự dựng response lỗi trong từng controller. Hôm nay ta xây phần còn thiếu — điểm xử lý tập trung dịch mọi exception thành HTTP response cho toàn bộ ứng dụng: @RestControllerAdvice kết hợp @ExceptionHandler, cùng chuẩn body lỗi ProblemDetail (RFC 7807) được Spring Boot 3 hỗ trợ sẵn. Kết quả: controller chỉ còn luồng thành công, và mọi lỗi — từ 404 đến lỗi validation — đều trả về một định dạng thống nhất.

Vấn đề: xử lý lỗi phân tán trong từng controller
// Cách tiếp cận phân tán — try-catch lặp lại trong từng controller:
@GetMapping("/{id}")
public ResponseEntity<?> detail(@PathVariable Long id) {
try {
return ResponseEntity.ok(postService.get(id));
} catch (PostNotFoundException e) {
return ResponseEntity.status(404)
.body(Map.of("error", "Không tìm thấy")); // format A
} catch (Exception e) {
return ResponseEntity.status(500)
.body(Map.of("message", e.getMessage())); // format B — và có nguy cơ
} // lộ chi tiết nội bộ!
}
// 30 controller = 30 biến thể body lỗi; thay đổi một quy ước đồng nghĩa
// với việc sửa 30 vị trí — client không có một cấu trúc lỗi ổn định để bám vào.Ba chi phí của cách tiếp cận này: trùng lặp code (mỗi endpoint một bộ catch), thiếu nhất quán (mỗi nơi một cấu trúc body lỗi), và rò rỉ thông tin —
e.getMessage()của một lỗi 500 có thể chứa tên bảng, câu SQL hoặc đường dẫn nội bộ.Ngoài ra controller phình to vì phần xử lý lỗi lấn át logic nghiệp vụ — phần duy nhất thực sự cần được đọc và review.
@RestControllerAdvice — điểm xử lý lỗi tập trung
// 1. Exception nghiệp vụ — đặt tên theo nghiệp vụ, không theo mã HTTP
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(Long id) {
super("Không tìm thấy bài viết id=" + id);
}
}
// 2. Service chỉ ném exception — không phụ thuộc vào tầng HTTP
public Post get(Long id) {
return repository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
}
// 3. Một điểm duy nhất dịch exception → HTTP cho toàn bộ ứng dụng
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PostNotFoundException.class)
public ProblemDetail handleNotFound(PostNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
}
// 4. Controller trở về dạng tối giản — chỉ còn luồng thành công:
@GetMapping("/{id}")
public PostResponse detail(@PathVariable Long id) {
return PostResponse.from(postService.get(id));
}@RestControllerAdviceáp dụng cho mọi controller trong ứng dụng; mỗi@ExceptionHandlerkhai báo cách chuyển một loại exception thành HTTP response. Spring chọn handler khớp nhất theo cây kế thừa của exception được ném.Phân tầng đúng trách nhiệm: service ném exception nghiệp vụ (không cần biết đến HTTP), advice đảm nhận việc dịch sang HTTP — nhất quán với nguyên tắc tách lớp đã theo từ đầu series.
Đặt tên exception theo nghiệp vụ (
PostNotFoundException,SeatAlreadyBookedException) thay vì theo mã HTTP — mã trả về là quyết định của advice, có thể thay đổi mà không ảnh hưởng service.
ProblemDetail — chuẩn hóa body lỗi theo RFC 7807
// Body lỗi theo RFC 7807 — cấu trúc mà mọi client đều dự đoán được:
// {
// "type": "https://api.example.com/errors/not-found",
// "title": "Không tìm thấy tài nguyên",
// "status": 404,
// "detail": "Không tìm thấy bài viết id=42",
// "instance": "/api/posts/42"
// }
@ExceptionHandler(PostNotFoundException.class)
public ProblemDetail handleNotFound(PostNotFoundException e) {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setTitle("Không tìm thấy tài nguyên");
pd.setType(URI.create("https://api.example.com/errors/not-found"));
pd.setProperty("timestamp", Instant.now()); // bổ sung field tùy biến khi cần
return pd;
}
// Content-Type của response tự động là application/problem+jsonThay vì mỗi hệ thống tự định nghĩa một cấu trúc lỗi, RFC 7807 (phiên bản cập nhật là RFC 9457, nội dung không đổi) chuẩn hóa 5 field:
type,title,status,detail,instance— Spring Boot 3 cung cấp sẵn classProblemDetail, trả trực tiếp từ handler.Để các lỗi do chính Spring sinh ra (404 sai đường dẫn, 405 sai method…) cũng theo chuẩn này: bật
spring.mvc.problemdetails.enabled=truetrongapplication.yml(Ngày 08).Phương án thay thế: kế thừa
ResponseEntityExceptionHandler— class có sẵn của Spring đã xử lý khoảng 20 exception phổ biến của web layer, chỉ cần override những điểm muốn tùy biến.
Lỗi validation & handler dự phòng
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log =
LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Lỗi validation của @Valid (Ngày 14) — 400 kèm danh sách field không hợp lệ
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Dữ liệu gửi lên không hợp lệ");
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(err -> errors.put(err.getField(), err.getDefaultMessage()));
pd.setProperty("errors", errors); // { "title": "không được để trống", ... }
return pd;
}
// Handler dự phòng — mọi exception chưa được xử lý ở trên
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnknown(Exception e) {
log.error("Lỗi không lường trước", e); // log đầy đủ stack trace ở server...
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"Đã có lỗi xảy ra, vui lòng thử lại sau"); // ...trả về thông điệp chung
}
}
// Nguyên tắc của 500: log chi tiết ở phía server, không trả stack trace,
// câu SQL hay message của exception nội bộ về cho client.Ngày 14 ta chấp nhận response 400 mặc định khi
@Validthất bại — giờ bạn kiểm soát hoàn toàn body đó: 400 kèm mapfield → thông điệpđể frontend hiển thị lỗi ngay dưới từng ô nhập liệu.Nguyên tắc cố định của 500: log chi tiết ở server, trả thông điệp chung cho client. Stack trace, câu SQL, message của exception nội bộ ở lại trong log — client chỉ cần biết hệ thống đang gặp sự cố.
Handler
Exception.classlà lưới an toàn cuối cùng, không phải nơi xử lý chính — nếu nó bắt cùng một loại lỗi nhiều lần, đó là tín hiệu cần bổ sung handler chuyên biệt cho loại lỗi đó.
Bài tập nhỏ
Tạo
BookNotFoundExceptionvà handler trảProblemDetail404 — gọicurl -ivới một id không tồn tại và phân tích body trả về.Viết handler cho
MethodArgumentNotValidException— gửi lại request không hợp lệ của bài Ngày 14 và so sánh body trước/sau.Thêm handler
Exception.class: chủ động némRuntimeExceptiontrong service, xác nhận client chỉ nhận thông điệp chung trong khi console ghi đầy đủ stack trace.Bật
spring.mvc.problemdetails.enabled=true— gọi một đường dẫn không tồn tại và một method sai (POST vào endpoint GET), quan sát body lỗi mặc định thay đổi thế nào.
Kết luận
Xử lý lỗi đã được tập trung về một điểm: service ném exception nghiệp vụ, @RestControllerAdvice là nơi duy nhất dịch chúng thành HTTP, ProblemDetail chuẩn hóa mọi body lỗi theo RFC 7807, và handler dự phòng bảo đảm thông tin nội bộ không rời khỏi server. Controller từ nay chỉ mô tả luồng thành công. Request chuẩn, response chuẩn, lỗi chuẩn — Ngày 17 ta quay lại một vấn đề còn bỏ ngỏ từ Ngày 13: chuyển đổi giữa entity và DTO một cách có hệ thống với MapStruct. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


