Java OO & Functional
16/38
Bài 16 / 38~25 phútException HandlingLộ trình · chặng 04/21Miễn phí… lượt xem

Mini-challenge: Bộ kiểm thực mượn ấn phẩm với custom exception

Thực hành thiết kế bộ kiểm thực mượn sách thư viện, gom nhiều lỗi vào ValidationException và tích hợp xử lý lỗi tập trung.

TL;DR: Thử thách này tổng hợp toàn bộ kiến thức của Module 02: try-catch, phân loại checked/unchecked, cấu trúc custom exception, và thiết kế tầng bắt lỗi tập trung. Bạn sẽ xây dựng bộ kiểm thực yêu cầu mượn ấn phẩm (BorrowRequestValidator) áp dụng nguyên tắc gom lỗi toàn diện (fail-slow) thay vì ngắt ngay ở lỗi đầu tiên.

Ở bài trước, bạn đã học cách thiết kế custom exception mang theo dữ liệu ngữ cảnh (payload) để phục vụ cho việc ghi log và trả về thông tin chi tiết cho client. Trong bài thử thách này, ngoại lệ sẽ mang theo cả một danh sách các lỗi vi phạm dữ liệu.

🎯 Đề bài

Khi một bạn đọc gửi yêu cầu mượn ấn phẩm lên hệ thống thư viện trực tuyến, biểu mẫu gửi lên bao gồm mã thẻ bạn đọc, mã ấn phẩm, số ngày đăng ký mượn và ghi chú.

Nếu bạn đọc nhập sai nhiều trường cùng lúc (ví dụ: mã thẻ sai định dạng, số ngày mượn âm, ghi chú quá dài), người dùng sẽ cảm thấy rất phiền toái nếu hệ thống chỉ báo lỗi từng trường một (sửa xong trường này lại báo tiếp trường khác). Hệ thống chuyên nghiệp cần gom toàn bộ các lỗi vi phạm và trả về một lượt thông qua ngoại lệ ValidationException.

Validator kiểm tra toàn bộ request và gom danh sách lỗi vào ValidationException thay vì ngắt ở lỗi đầu tiên

1. record BorrowRequest(String memberId, String assetId, int durationDays, String note)

  • Không thực hiện validate trong compact constructor mà để BorrowRequestValidator kiểm tra riêng biệt (mô hình kiểm thực theo tầng).

2. record ValidationError(String field, String code, String message)

  • Đại diện cho một lỗi vi phạm cụ thể của từng trường dữ liệu.

3. class ValidationException extends RuntimeException

  • Trường dữ liệu: private final List<ValidationError> errors.
  • Constructor: ValidationException(List<ValidationError> errors).
  • Ghi đè phương thức getMessage() để nối toàn bộ các thông điệp lỗi thành một chuỗi tổng hợp dễ đọc.

4. class BorrowRequestValidator

  • Phương thức: public void validate(BorrowRequest request).
  • Quy tắc kiểm tra (Rules):
    • memberId: Không được null, không được rỗng, phải bắt đầu bằng tiền tố "MEM-" và có độ dài từ 6 đến 12 ký tự.
    • assetId: Không được null, không được rỗng, phải bắt đầu bằng tiền tố "ITEM-".
    • durationDays: Phải từ 1 đến 30 ngày (quy định thời hạn mượn của thư viện).
    • note: Nếu có giá trị thì độ dài không được vượt quá 100 ký tự.
  • Quy tắc thực thi: Gom tất cả lỗi vi phạm vào một danh sách. Chỉ ném ValidationException khi danh sách có từ 1 lỗi trở lên.

5. class BorrowController (Mô phỏng Global Handler)

  • Phương thức: public String submitBorrow(BorrowRequest request).
  • Sử dụng khối try-catch để bắt ValidationException và trả về chuỗi JSON thông báo lỗi có cấu trúc; nếu hợp lệ, trả về "SUCCESS".

Kết quả mong đợi khi dữ liệu vi phạm nhiều lỗi:

Plain Text
Kiem tra yeu cau: BorrowRequest[memberId=LIB12, assetId=B-01, durationDays=-3, note=Ghi chu...]
Phat hien 3 loi vi pham:
  - memberId [FORMAT]: Ma ban doc phai bat dau bang 'MEM-' va tu 6-12 ky tu
  - assetId [FORMAT]: Ma an pham phai bat dau bang 'ITEM-'
  - durationDays [RANGE]: So ngay muon phai tu 1 den 30 ngay, nhan duoc: -3

Ket qua phan hoi tu Controller:
{"status": "BAD_REQUEST", "errorCount": 3, "errors": [...]}

📦 Kiến thức trọng tâm áp dụng

Khái niệmBài học liên quanCách áp dụng trong thử thách
try-catch semanticBài 01Bắt ngoại lệ tại controller và xử lý phản hồi
Checked vs UncheckedBài 04ValidationException kế thừa RuntimeException
Custom Exception ContextBài 06Ngoại lệ chứa danh sách đối tượng ValidationError
Global Handler PatternBài 06Tách biệt tầng kiểm thực (Service) và tầng định dạng lỗi (Controller)

▶️ Khung mã khởi động (Starter Code)

Java
import java.util.*;
import java.util.stream.Collectors;

public class BorrowValidatorApp {

    // TODO 1: Khai bao record ValidationError(String field, String code, String message)

    // TODO 2: Khai bao class ValidationException extends RuntimeException
    //         Chua List<ValidationError> errors

    // TODO 3: Khai bao record BorrowRequest(String memberId, String assetId, int durationDays, String note)

    // TODO 4: Xay dung class BorrowRequestValidator gom loi toan dien
    //         public void validate(BorrowRequest request)

    // TODO 5: Xay dung class BorrowController bat ValidationException va tra ve JSON
    //         public String submitBorrow(BorrowRequest request)

    public static void main(String[] args) {
        BorrowController controller = new BorrowController();

        // Kich ban 1: Du lieu sai pham nhieu loi
        BorrowRequest invalidReq = new BorrowRequest("LIB-12", "BOOK-99", -5, "Muon gap");
        System.out.println("--- KICH BAN 1: DU LIEU KHONG HOP LE ---");
        System.out.println("Controller Response:\n" + controller.submitBorrow(invalidReq));

        System.out.println();

        // Kich ban 2: Du lieu hop le
        BorrowRequest validReq = new BorrowRequest("MEM-10023", "ITEM-5021", 14, "Nghien cuu khoa hoc");
        System.out.println("--- KICH BAN 2: DU LIEU HOP LE ---");
        System.out.println("Controller Response:\n" + controller.submitBorrow(validReq));
    }
}

Hãy dành khoảng 20 đến 25 phút tự viết mã trước khi xem lời giải chi tiết dưới đây.

💡 Gợi ý giải pháp

Gợi ý khi gặp khó khăn
  1. Gom lỗi vào List trong Validator:
    Java
    List<ValidationError> errors = new ArrayList<>();
    if (req.memberId() == null || !req.memberId().startsWith("MEM-")) {
        errors.add(new ValidationError("memberId", "FORMAT", "Ma ban doc phai bat dau bang 'MEM-'"));
    }
    if (!errors.isEmpty()) {
        throw new ValidationException(errors);
    }
    
  2. Nối thông điệp trong getMessage():
    Java
    @Override
    public String getMessage() {
        return errors.stream()
            .map(e -> e.field() + " [" + e.code() + "]: " + e.message())
            .collect(Collectors.joining("\n  - ", "Xac thuc that bai:\n  - ", ""));
    }
    

✅ Lời giải hoàn chỉnh

Lời giải mã nguồn chuẩn mực
Java
import java.util.*;
import java.util.stream.Collectors;

public class BorrowValidatorApp {

    public record ValidationError(String field, String code, String message) {
        public ValidationError {
            Objects.requireNonNull(field, "field must not be null");
            Objects.requireNonNull(code, "code must not be null");
            Objects.requireNonNull(message, "message must not be null");
        }
    }

    public static class ValidationException extends RuntimeException {
        private final List<ValidationError> errors;

        public ValidationException(List<ValidationError> errors) {
            super("Yeu cau chua du lieu khong hop le: " + errors.size() + " loi");
            this.errors = List.copyOf(errors); // Bao ve tinh bat bien
        }

        public List<ValidationError> getErrors() {
            return errors;
        }

        @Override
        public String getMessage() {
            return errors.stream()
                .map(err -> String.format("%s [%s]: %s", err.field(), err.code(), err.message()))
                .collect(Collectors.joining("\n  - ", "Kiem tra du lieu that bai:\n  - ", ""));
        }
    }

    public record BorrowRequest(String memberId, String assetId, int durationDays, String note) { }

    public static class BorrowRequestValidator {
        public void validate(BorrowRequest request) {
            Objects.requireNonNull(request, "request must not be null");
            List<ValidationError> errors = new ArrayList<>();

            // 1. Kiem tra memberId
            if (request.memberId() == null || request.memberId().isBlank()) {
                errors.add(new ValidationError("memberId", "REQUIRED", "Ma the ban doc khong duoc de trong"));
            } else if (!request.memberId().startsWith("MEM-") || request.memberId().length() < 6 || request.memberId().length() > 12) {
                errors.add(new ValidationError("memberId", "FORMAT", "Ma the ban doc phai bat dau bang 'MEM-' va dai 6-12 ky tu"));
            }

            // 2. Kiem tra assetId
            if (request.assetId() == null || request.assetId().isBlank()) {
                errors.add(new ValidationError("assetId", "REQUIRED", "Ma an pham khong duoc de trong"));
            } else if (!request.assetId().startsWith("ITEM-")) {
                errors.add(new ValidationError("assetId", "FORMAT", "Ma an pham phai bat dau bang tien to 'ITEM-'"));
            }

            // 3. Kiem tra durationDays
            if (request.durationDays() < 1 || request.durationDays() > 30) {
                errors.add(new ValidationError("durationDays", "RANGE", "Thoi gian muon phai tu 1 den 30 ngay, nhan duoc: " + request.durationDays()));
            }

            // 4. Kiem tra note
            if (request.note() != null && request.note().length() > 100) {
                errors.add(new ValidationError("note", "MAX_LENGTH", "Ghi chu khong duoc vuot qua 100 ky tu"));
            }

            // Neu co loi, nem ngoai le gom tat ca
            if (!errors.isEmpty()) {
                throw new ValidationException(errors);
            }
        }
    }

    public static class BorrowController {
        private final BorrowRequestValidator validator = new BorrowRequestValidator();

        public String submitBorrow(BorrowRequest request) {
            try {
                validator.validate(request);
                return "{\"status\": \"SUCCESS\", \"message\": \"Yeu cau muon sach da duoc ghi nhan thanh cong\"}";
            } catch (ValidationException ex) {
                // Mo phong format JSON tra ve cho client
                String errorsJson = ex.getErrors().stream()
                    .map(err -> String.format("{\"field\": \"%s\", \"code\": \"%s\", \"detail\": \"%s\"}",
                        err.field(), err.code(), err.message()))
                    .collect(Collectors.joining(",\n    ", "[\n    ", "\n  ]"));

                return String.format("{\n  \"status\": \"BAD_REQUEST\",\n  \"errorCount\": %d,\n  \"errors\": %s\n}",
                    ex.getErrors().size(), errorsJson);
            }
        }
    }

    public static void main(String[] args) {
        BorrowController controller = new BorrowController();

        BorrowRequest invalidReq = new BorrowRequest("LIB-12", "BOOK-99", -5, "Muon gap");
        System.out.println("--- KICH BAN 1: DU LIEU KHONG HOP LE ---");
        System.out.println("Controller Response:\n" + controller.submitBorrow(invalidReq));

        System.out.println();

        BorrowRequest validReq = new BorrowRequest("MEM-10023", "ITEM-5021", 14, "Nghien cuu khoa hoc");
        System.out.println("--- KICH BAN 2: DU LIEU HOP LE ---");
        System.out.println("Controller Response:\n" + controller.submitBorrow(validReq));
    }
}

🎓 Bài kiểm thử tự động với JUnit 5

Java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class BorrowRequestValidatorTest {
    private final BorrowValidatorApp.BorrowRequestValidator validator =
        new BorrowValidatorApp.BorrowRequestValidator();

    @Test
    void testValidRequestPassesWithoutException() {
        var req = new BorrowValidatorApp.BorrowRequest("MEM-12345", "ITEM-9901", 7, "Hop le");
        assertDoesNotThrow(() -> validator.validate(req));
    }

    @Test
    void testCollectsMultipleErrorsSimultaneously() {
        var req = new BorrowValidatorApp.BorrowRequest("BAD_ID", "BAD_ASSET", 50, "Ghi chu");
        
        var ex = assertThrows(BorrowValidatorApp.ValidationException.class, () ->
            validator.validate(req));

        assertEquals(3, ex.getErrors().size());
        assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("memberId")));
        assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("assetId")));
        assertTrue(ex.getErrors().stream().anyMatch(e -> e.field().equals("durationDays")));
    }

    @Test
    void testThrowsNullPointerWhenRequestIsNull() {
        assertThrows(NullPointerException.class, () -> validator.validate(null));
    }
}

✨ Tổng kết Module 02: Exception Handling

Bạn đã hoàn thành trọn vẹn Module 02. Nhìn lại kiến thức vừa chinh phục:

  • Cơ chế Stack Unwinding: JVM leo ngược Call Stack và thu dọn biến cục bộ khi tìm kiếm khối catch.
  • Bản đồ Throwable: Phân biệt bản chất giữa Error, Checked Exception và Unchecked Exception.
  • Stack Trace & Cause Chain: Đọc hiểu log trong 10 giây và kỹ thuật giữ gìn dấu vết lỗi gốc qua tham số cause.
  • Bytecode Exception Table: Hiểu nguyên lý Zero-cost try block và định lượng chi phí của fillInStackTrace().
  • try-with-resources: Tự động giải phóng tài nguyên theo thứ tự LIFO và bảo toàn suppressed exception.
  • Custom Exception & Validation Pattern: Thiết kế phân cấp ngoại lệ domain kết hợp gom lỗi có cấu trúc.

Bài tiếp theo: Generics cơ bản — kiểu tham số hoá và an toàn type-safe

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

✓ Bạn vừa đi hết chặng Exception handling (4/21)

Chặng tiếp: Generics & Collections — “Generics cơ bản — class và method nhận kiểu làm tham số”

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Generics cơ bản — class và method nhận kiểu làm tham số