Phần lớn ngoại lệ tự viết mà tôi gặp trong mã thật đều có dạng này:
public class LoiNghiepVu extends RuntimeException {
public LoiNghiepVu(String message) { super(message); }
}
Một lớp, một hàm khởi tạo, chỉ chứa một chuỗi. Nó hoạt động, nhưng bỏ phí gần hết giá trị mà ngoại lệ có thể mang lại.
Vì ngoại lệ không chỉ là cách báo "có lỗi". Nó là một đối tượng — và đối tượng thì mang được dữ liệu.
Ngoại lệ mang dữ liệu
class KhongDuTonKho extends RuntimeException {
private final String maHang;
private final int canCo, dangCo;
KhongDuTonKho(String maHang, int canCo, int dangCo) {
super("Không đủ tồn kho cho %s: cần %d, chỉ còn %d".formatted(maHang, canCo, dangCo));
this.maHang = maHang;
this.canCo = canCo;
this.dangCo = dangCo;
}
String maHang() { return maHang; }
int thieu() { return canCo - dangCo; }
}
thông điệp : Không đủ tồn kho cho SP-001: cần 10, chỉ còn 3
mã hàng : SP-001
còn thiếu : 7
-> gợi ý cho người dùng: đặt tối đa 3
Khác biệt nằm ở dòng cuối. Với ngoại lệ chỉ có chuỗi, bên bắt được chỉ có thể ghi log rồi trả về "có lỗi". Với ngoại lệ mang dữ liệu, bên bắt được làm được việc: gợi ý số lượng đặt tối đa, tự đặt phần còn lại ở kho khác, hay hiển thị đúng ô nhập bị sai.
Nguyên tắc rút ra: thông điệp dành cho người đọc log, các trường dành cho mã xử lý. Đừng bắt mã phải phân tích chuỗi để lấy lại thông tin mà bạn vừa nhét vào đó.
Tôi đã thấy mã làm đúng chuyện này:
if (e.getMessage().contains("tồn kho")) { ... } // đừng bao giờ
Nó gãy ngay lần đầu tiên có người sửa lại thông điệp cho dễ đọc hơn.
Cây ngoại lệ cho một miền nghiệp vụ
Với dự án cỡ vừa, ba tầng là đủ:
// 1. Gốc chung — để bắt tất cả ở tầng ngoài cùng
public abstract class LoiNghiepVu extends RuntimeException {
protected LoiNghiepVu(String message) { super(message); }
protected LoiNghiepVu(String message, Throwable cause) { super(message, cause); }
public abstract String maLoi(); // để ánh xạ sang mã HTTP hoặc mã trả về
}
// 2. Nhóm theo loại
public class LoiDuLieuVao extends LoiNghiepVu { ... } // -> HTTP 400
public class KhongTimThay extends LoiNghiepVu { ... } // -> HTTP 404
public class ViPhamQuyTac extends LoiNghiepVu { ... } // -> HTTP 409
// 3. Cụ thể
public class KhongDuTonKho extends ViPhamQuyTac { ... }
Cây này cho phép bắt ở mức phù hợp: bộ xử lý HTTP bắt LoiNghiepVu và ánh xạ maLoi() sang mã trạng thái; còn một chỗ cụ thể trong mã nghiệp vụ có thể bắt riêng KhongDuTonKho để thử kho khác.
Đừng làm quá tay. Một lớp ngoại lệ cho mỗi thông điệp lỗi là dấu hiệu thiết kế sai — hãy gộp lại và phân biệt bằng dữ liệu bên trong.
Checked hay unchecked cho ngoại lệ nghiệp vụ
Tôi dùng unchecked cho gần như tất cả, vì hai lý do thực tế.
Thứ nhất, phần lớn lỗi nghiệp vụ không có phương án B ở chỗ ném ra. Hàm datHang không biết phải làm gì khi hết tồn kho; quyết định đó thuộc về tầng trên.
Thứ hai, checked exception không đi qua được lambda và Stream. Một list.forEach(this::xuLy) sẽ không biên dịch nếu xuLy khai throws.
Ngoại lệ của quy tắc: khi lỗi là một kết quả bình thường mà người gọi phải xử lý ngay tại chỗ, hãy cân nhắc không dùng ngoại lệ mà trả về một kiểu kết quả:
sealed interface KetQuaDatHang {
record ThanhCong(String maDon) implements KetQuaDatHang {}
record HetHang(int conLai) implements KetQuaDatHang {}
}
Đây chính là mẫu sealed của bài trước, và nó buộc người gọi xử lý cả hai nhánh — điều mà unchecked exception không làm được.
Chi phí: 110 ms so với 7 ms
Ngoại lệ đắt, nhưng phần đắt nằm ở chỗ nhiều người không ngờ. Tôi đo 200.000 lần ném và bắt:
200,000 lần, có stack trace : 110 ms
200,000 lần, không stack trace: 7 ms
Chênh nhau khoảng 15 lần, và toàn bộ khác biệt đến từ việc chụp stack trace trong hàm khởi tạo của Throwable — chứ không phải từ việc ném hay bắt.
Tắt nó bằng hàm khởi tạo bốn tham số:
class LoiNghiepVuNhe extends RuntimeException {
LoiNghiepVuNhe(String m) {
super(m, null, false, false); // không nén, KHÔNG chụp stack trace
}
}
số khung: 6
số khung (nhẹ): 0
Tham số thứ tư là writableStackTrace. Đặt false thì getStackTrace() trả về mảng rỗng.
Và đừng vội tối ưu: 110 ms cho 200.000 lần là 0,55 micro giây mỗi lần. Chỉ đáng quan tâm nếu ngoại lệ nằm trong đường đi nóng — mà nếu vậy thì vấn đề thật là bạn đang dùng ngoại lệ cho luồng điều khiển bình thường.
Bọc ngoại lệ ở ranh giới
Tầng nghiệp vụ không nên biết SQLException hay IOException là gì. Ở ranh giới hạ tầng, hãy chuyển đổi:
try {
return kho.tim(ma);
} catch (SQLException e) {
throw new LoiTruyCapDuLieu("không đọc được đơn hàng " + ma, e);
}
Ba điều trong ba dòng đó:
Luôn truyền nguyên nhân gốc — tham số thứ hai. Bỏ nó là mất toàn bộ dấu vết, như bài hôm kia đã nói.
Thêm ngữ cảnh mà tầng dưới không có. SQLException biết câu SQL nào hỏng; nó không biết đó là đơn hàng nào. Ngữ cảnh đó chỉ tầng này có.
Đổi sang từ vựng của miền nghiệp vụ. Người đọc log ở tầng trên không cần biết bạn dùng JDBC hay JPA.
Viết thông điệp lỗi
Một thông điệp tốt trả lời được ba câu hỏi mà không cần mở mã nguồn:
Cái gì sai? — "Không đủ tồn kho"
Mong đợi gì, thực tế thế nào? — "cần 10, chỉ còn 3"
Ở đâu? — "cho SP-001"
Ghép lại: "Không đủ tồn kho cho SP-001: cần 10, chỉ còn 3". Người trực đêm đọc dòng này là biết ngay phải làm gì, không cần gọi cho ai.
Vài thứ nên tránh:
Đừng viết hoa toàn bộ hay thêm dấu chấm than. Log không cần cảm xúc.
Đừng lặp lại tên lớp ngoại lệ. "KhongDuTonKho: không đủ tồn kho" là thừa — tên lớp đã hiện ra trong stack trace rồi.
Đừng đưa dữ liệu nhạy cảm vào. Mật khẩu, token, số thẻ, thông tin cá nhân. Nếu cần định danh một người dùng, dùng id chứ không dùng email.
Đừng dịch thông điệp lỗi kỹ thuật sang tiếng người dùng cuối ngay trong ngoại lệ. Ngoại lệ nói cho lập trình viên; việc hiển thị cho người dùng thuộc về tầng giao diện, và tầng đó dùng maLoi() để tra chuỗi hiển thị phù hợp.
Vài mẫu đáng dùng
Hàm nhà máy tĩnh thay cho nhiều hàm khởi tạo, để chỗ gọi đọc như câu văn:
throw KhongTimThay.donHang(ma);
throw KhongTimThay.khachHang(id);
Ngoại lệ mang gợi ý sửa lỗi, nhất là với lỗi cấu hình:
throw new LoiCauHinh("Thiếu biến môi trường DB_URL. "
+ "Ví dụ: DB_URL=jdbc:postgresql://localhost:5432/blogdb");
Một dòng ví dụ trong thông điệp tiết kiệm được rất nhiều thời gian cho người triển khai — kể cả khi người đó là chính bạn, sáu tháng sau.
Thử ba mươi giây
Tìm trong dự án của bạn một chỗ catch rồi đọc e.getMessage() để quyết định làm gì tiếp.
Nếu có, đó là chỗ ngoại lệ đang thiếu dữ liệu. Thêm một trường vào lớp ngoại lệ, và mã xử lý sẽ vừa ngắn hơn vừa không gãy khi ai đó sửa lại câu thông điệp.
Ngày mai: ghi log đúng cách — mức nào cho việc gì, vì sao không nên vừa log vừa ném lại, và tham số hoá thay cho nối chuỗi.