Backend

99 Ngày Spring — Ngày 16: Xử lý lỗi toàn cục

SSite Admin
12 tháng 08, 2026 6 phút đọc 0 lượt xem
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.

Sketchnote Ngày 16: xử lý lỗi toàn cục — @RestControllerAdvice, @ExceptionHandler, ProblemDetail RFC 7807, handler dự phòng cho 500

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 tine.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 @ExceptionHandler khai 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+json
  • Thay 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 class ProblemDetail, 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=true trong application.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.

Trái Đất nhìn từ không gian với mạng lưới kết nối — xử lý lỗi toàn cục là một điểm quan sát duy nhất bao quát mọi luồng của ứng dụng

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 @Valid thất bại — giờ bạn kiểm soát hoàn toàn body đó: 400 kèm map field → 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ướ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 BookNotFoundException và handler trả ProblemDetail 404 — gọi curl -i vớ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ém RuntimeException trong 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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 16: Abstract class

Ngày 16 của 99 Ngày Java: abstract class và abstract method — chuyển quy ước override thành ràng buộc biên dịch, khi nào nên dùng, mẫu template method (lớp cha kiểm soát trình tự, lớp con cung cấp chi tiết), và bảng so sánh với interface trước khi tìm hiểu nó ở Ngày 17.

12 thg 8, 20266 phút0
99 Ngày Spring — Ngày 15: ResponseEntity & status code

Ngày 15 của 99 Ngày Spring: bảng status code tối thiểu (200/201/204/400/404/409/500) với ranh giới 4xx vs 5xx, ResponseEntity điều khiển status–header–body, nghi thức 201 Created + Location cho POST, 204 cho DELETE, và nguyên tắc "lỗi thì ném, đừng tự build".

11 thg 8, 20265 phút18
99 Ngày Java — Ngày 15: Đa hình (Polymorphism)

Ngày 15 của 99 Ngày Java: upcasting và dynamic dispatch — kiểu biến vs kiểu đối tượng thật, một vòng lặp tính lương cho mọi loại nhân viên, downcasting với instanceof & pattern matching, lập trình theo hợp đồng (nền móng của DI), và toString — đa hình bạn dùng mỗi ngày.

11 thg 8, 20266 phút15