Command Palette

Search for a command to run...

[Java Basics] Custom Exception trong Java: tự viết exception type của bạn

Bạn đã biết try/catch/finally, throw, throws, và khác biệt giữa checked với unchecked exception. Bài này là bước tiếp theo: tự định nghĩa các exception type mà code của bạn ném ra, để một lỗi khi xảy ra mang đúng tên của thứ đã hỏng, thay vì tên của thư viện tình cờ phát hiện ra nó.

Viết class thì chỉ mất bốn dòng. Phần còn lại của bài mới là những thứ khiến nó đáng viết — chọn supertype nào, khai báo constructor nào, gắn dữ liệu gì lên object, wrap lỗi gốc thế nào để không xóa mất nó, và những trường hợp mà JDK đã có sẵn đúng cái type bạn định tự tạo.

Exception class của bạn ở ngoài và exception gốc của JDK vẫn nằm bên trong dưới dạng cause

Mọi stack trace, output và thông báo của compiler bên dưới đều lấy từ việc compile và chạy code thật trên OpenJDK 21.0.6. Các lỗi javac được trích nguyên văn, kèm cả dòng dấu mũ.

Vì sao nên tự viết exception type?

Một custom exception đáng tồn tại khi nó làm được ít nhất một trong ba việc mà type có sẵn của JDK không làm được:

  • Gọi đúng tên lỗi theo ngôn ngữ nghiệp vụ của bạn. InsufficientStockException cho người đọc biết chuyện gì đã hỏng. IllegalStateException chỉ nói rằng có cái gì đó đang ở trạng thái sai.
  • Cho caller một thứ cụ thể để catch. Một khối catch mang đúng một ý nghĩa, không vô tình nuốt luôn một lỗi khác chỉ vì nó tình cờ trùng type của JDK.
  • Mang theo dữ liệu có cấu trúc. SKU, số hàng còn thiếu, config key, giá trị input sai — dưới dạng field đọc được, chứ không phải một câu chữ nhét trong message mà caller phải parse ngược ra.

Nếu không rơi vào trường hợp nào ở trên, hãy đọc thẳng phần cuối bài: khả năng cao JDK đã có sẵn type bạn cần.

Custom exception class nhỏ nhất

Exception class là một class bình thường. Yêu cầu duy nhất là nó phải kế thừa từ java.lang.Throwable, và trên thực tế bạn extend Exception hoặc RuntimeException — không bao giờ extend thẳng Throwable hay Error.

public class OrderException extends Exception {
    public OrderException(String message) {
        super(message);
    }
}

Cả class chỉ có thế. Không interface nào phải implement, không annotation nào phải thêm, không registry nào phải cập nhật.

Constructor mới là chỗ hay sai, vì constructor không được kế thừa. Exception có bốn constructor; class của bạn chỉ có đúng những cái bạn khai báo. Nếu chỉ viết mỗi cái ở trên, mọi call site muốn truyền thêm cause đều hỏng ngay lúc compile:

throw new OrderException("order #5 not found", new IllegalStateException("db down"));
NoCtor.java:9: error: constructor OrderException in class OrderException cannot be applied to given types;
        throw new OrderException("order #5 not found", new IllegalStateException("db down"));
              ^
  required: String
  found:    String,IllegalStateException
  reason: actual and formal argument lists differ in length
1 error

Quy tắc đó còn cắn thêm một tầng nữa. Nếu bạn subclass chính exception của mình mà superclass không có constructor không tham số, câu super() mà compiler tự chèn vào sẽ không gọi được gì cả, và lỗi rơi vào dòng khai báo class chứ không phải vào constructor nào bạn viết:

class BaseEx extends Exception {
    BaseEx(String message) {
        super(message);
    }
}

class ChildEx extends BaseEx {
}
SubNoSuper.java:7: error: constructor BaseEx in class BaseEx cannot be applied to given types;
class ChildEx extends BaseEx {
^
  required: String
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error

Còn một chi tiết nên biết trước khi commit file. Throwable implement Serializable, nên mọi exception class bạn viết đều là serializable class, và javac sẽ nói ra điều đó nếu bạn hỏi:

Lint.java:1: warning: [serial] serializable class OrderException has no definition of serialVersionUID
class OrderException extends Exception {
^
1 warning

Đây là warning chứ không phải error, và build mặc định thì im lặng. Thêm private static final long serialVersionUID = 1L; nếu project của bạn build với -Xlint:serial hoặc -Werror, hoặc nếu exception thật sự đi qua một serialization boundary. Ngoài ra thì nó chỉ là rác.

extends Exception hay extends RuntimeException?

Đây là quyết định duy nhất trong class mà caller cảm nhận được, và nó nằm gọn trong một từ.

Cùng một call site được compile hai lần, một lần extends Exception và một lần extends RuntimeException, cùng bốn constructor của Throwable và thứ mà mỗi cái để lại trong object

Dưới đây là cùng một chương trình viết hai lần. Khác biệt duy nhất là supertype của OrderException:

class OrderException extends Exception {
    OrderException(String message) {
        super(message);
    }
}

public class Orders {
    static void loadOrder(int id) throws OrderException {
        throw new OrderException("order #" + id + " not found");
    }

    public static void main(String[] args) {
        loadOrder(5);
    }
}

Với extends Exception, file này không compile nổi:

Orders.java:13: error: unreported exception OrderException; must be caught or declared to be thrown
        loadOrder(5);
                 ^
1 error

Đổi supertype sang RuntimeException, bỏ luôn mệnh đề throws giờ đã thừa, thì đúng file đó compile không một lời phàn nàn và chỉ hỏng lúc chạy:

Exception in thread "main" OrderException: order #5 not found
	at Orders.loadOrder(Orders.java:9)
	at Orders.main(Orders.java:13)

Khác biệt chỉ có vậy, và nó lớn hơn vẻ ngoài rất nhiều:

extends Exception (checked)extends RuntimeException (unchecked)
Compiler bắt buộcmọi caller phải catch hoặc khai báokhông
Xuất hiện trong signaturecó, qua throwskhông
Đi qua một caller quên xử lýkhông thể — code sẽ không compilelặng lẽ đi thẳng lên trên cùng
Chi phí khi thêm vào sauphải sửa lại toàn bộ chuỗi callerkhông
Dùng được trong lambda của Stream.mapkhông, functional interface không khai báo nó
Trường hợp điển hìnhlỗi mà caller có phương án thay thế thật sựbug, hoặc lỗi không ai cứu được

Quy tắc đứng vững trong thực tế: chọn checked khi một caller hợp lý có việc gì đó tốt hơn là chết. Một order không tìm thấy thì có thể retry, có thể ghi log, có thể trả về 404; bắt caller phải thừa nhận nó là một sự giúp đỡ. Một internal identifier sai định dạng là bug trong code của bạn, và bắt mọi caller viết catch cho một bug chỉ sinh ra hàng loạt khối catch rỗng.

Hai hệ quả mà người ta thường phát hiện muộn. Checked exception không thoát ra được khỏi lambda truyền cho map, forEach hay bất kỳ functional interface chuẩn nào, vì các interface đó không khai báo nó — chỉ riêng điều này đã đẩy nhiều tác giả thư viện sang unchecked. Và đổi một method từ unchecked sang checked là thay đổi phá vỡ code của mọi caller đang compile được, còn chiều ngược lại thì miễn phí.

Mỗi constructor để lại gì trong object?

Throwable định nghĩa bốn constructor, và thông lệ là làm đủ cả bốn trong class của bạn, mỗi cái chỉ delegate thẳng xuống super:

public class OrderException extends Exception {
    public OrderException() {
        super();
    }

    public OrderException(String message) {
        super(message);
    }

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }

    public OrderException(Throwable cause) {
        super(cause);
    }
}

Chúng không thay thế cho nhau được. Tạo mỗi loại một object rồi in ra thứ nằm bên trong:

new OrderException()
  getMessage() = null
  getCause()   = null
  toString()   = OrderException
new OrderException(msg)
  getMessage() = order #5 not found
  getCause()   = null
  toString()   = OrderException: order #5 not found
new OrderException(msg, cause)
  getMessage() = order #5 not found
  getCause()   = java.lang.IllegalStateException: connection pool exhausted
  toString()   = OrderException: order #5 not found
new OrderException(cause)
  getMessage() = java.lang.IllegalStateException: connection pool exhausted
  getCause()   = java.lang.IllegalStateException: connection pool exhausted
  toString()   = OrderException: java.lang.IllegalStateException: connection pool exhausted
ConstructorgetMessage()getCause()
()nullnull
(String message)giá trị bạn truyềnnull
(String message, Throwable cause)giá trị bạn truyềngiá trị bạn truyền
(Throwable cause)cause.toString()giá trị bạn truyền

Dòng cuối là dòng cần nhớ. Constructor chỉ nhận cause không để message rỗng — nó suy message ra từ cause.toString(), nên lần chạy trên mới in nguyên chuỗi java.lang.IllegalStateException: connection pool exhausted làm message của một OrderException. Thỉnh thoảng đó đúng là thứ bạn muốn, nhưng thường thì không: nó đưa cho caller một message viết bằng ngôn ngữ của đúng tầng mà bạn đang cố che đi.

Viết đủ bốn constructor là thông lệ chứ không phải luật. Hãy viết những cái code bạn thật sự dùng — nhưng hãy viết (String, Throwable) trong mọi exception class sẽ có lúc wrap một exception khác, vì đó là cái giữ lại bằng chứng, và thêm nó về sau đồng nghĩa với việc sửa lại mọi chỗ throw.

Mang dữ liệu có cấu trúc thay vì ghép chuỗi

Ngay khi message của một exception chứa một giá trị mà caller có thể cần đến, giá trị đó nên là một field. Message dành cho con người đọc log; field dành cho code quyết định làm gì tiếp theo.

class InsufficientStockException extends Exception {
    private final String sku;
    private final int requested;
    private final int available;

    InsufficientStockException(String sku, int requested, int available) {
        super("cannot ship " + requested + " x " + sku + ": only " + available + " in stock");
        this.sku = sku;
        this.requested = requested;
        this.available = available;
    }

    public String getSku() {
        return sku;
    }

    public int getAvailable() {
        return available;
    }

    public int getShortfall() {
        return requested - available;
    }
}

Caller nhờ đó có thứ để làm việc, thay vì một câu chữ phải đem đi regex:

try {
    ship("KB-100", 5, 2);
} catch (InsufficientStockException e) {
    System.out.println("caught  : " + e.getMessage());
    System.out.println("sku     : " + e.getSku());
    System.out.println("short by: " + e.getShortfall());
    System.out.println("action  : back-order " + e.getShortfall()
            + ", ship " + e.getAvailable() + " now");
}
caught  : cannot ship 5 x KB-100: only 2 in stock
sku     : KB-100
short by: 3
action  : back-order 3, ship 2 now

Bốn thói quen làm cho cách này chạy tốt:

  • Ghép message ngay trong constructor, từ chính các giá trị bạn lưu lại. Một nơi duy nhất quyết định câu chữ, nên mọi chỗ throw đều diễn đạt giống hệt nhau và không thể lệch dần.
  • Để các field là final. Khi ai đó nhìn thấy exception thì nó đã đang bay rồi; không gì được phép sửa lại nội dung nó khai báo.
  • Thêm accessor suy ra như getShortfall() khi đó mới là thứ caller thật sự cần. Nó vẫn là một class bình thường.
  • Đừng override getMessage() để nhét field vào. getMessage() bị gọi bởi toString(), bởi printStackTrace() và bởi mọi logging framework, đôi khi trên một object mới dựng dở trong lúc deserialize. Hãy ghép chuỗi một lần, trong constructor.

Một exception class với ba field tốt hơn ba exception class không có field nào. Ngay khi bạn thấy mình đang viết OrderNotFoundException, OrderExpiredExceptionOrderLockedException mà không caller nào phân biệt, hãy gộp chúng thành một class với một field reason hoặc một enum nhỏ.

Chaining: cause, getCause() và phần Caused by

Wrap nghĩa là ném type của bạn ra ngoài trong khi vẫn giữ exception gốc bên trong. Cause chỉ là một reference bình thường lưu trên Throwable và đọc lại bằng getCause() — không có gì bí ẩn hơn thế. Điều làm nó giá trị là printStackTrace() đi hết chuỗi và in ra mọi link.

Ba exception được chain trên cùng một lưới frame, cho thấy mỗi link in ra frame nào và frame nào bị gộp vào dòng ... N more

Đây là một chuỗi ba link: một truy cập array hỏng, tầng storage wrap nó lại, tầng order wrap tiếp.

class StorageException extends Exception {
    StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

class OrderException extends Exception {
    OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class DeepChain {
    static final String[] ROWS = { "1,keyboard", "2,mouse" };

    static String readRow(int index) throws StorageException {
        try {
            return ROWS[index];
        } catch (ArrayIndexOutOfBoundsException e) {
            throw new StorageException("no row at index " + index, e);
        }
    }

    static String loadOrder(int id) throws OrderException {
        try {
            return readRow(id);
        } catch (StorageException e) {
            throw new OrderException("order #" + id + " could not be loaded", e);
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println(loadOrder(5));
    }
}

Chạy lên, nó in ra cả ba link, ngoài cùng trước:

Exception in thread "main" OrderException: order #5 could not be loaded
	at DeepChain.loadOrder(DeepChain.java:28)
	at DeepChain.main(DeepChain.java:33)
Caused by: StorageException: no row at index 5
	at DeepChain.readRow(DeepChain.java:20)
	at DeepChain.loadOrder(DeepChain.java:26)
	... 1 more
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 2
	at DeepChain.readRow(DeepChain.java:18)
	... 2 more

Đọc từ dưới lên, bạn có câu chuyện theo đúng thứ tự nguyên nhân: index 5 vượt giới hạn của một array dài 2, nên không có dòng số 5, nên order số 5 không load được. Đọc từ trên xuống, bạn có nó theo thứ tự tầng: tầng order hỏng, vì tầng storage hỏng, vì truy cập array hỏng.

Dòng ... N more đến từ đâu. Mỗi link chỉ in những frame nó không dùng chung với link đã bọc nó. StorageExceptionOrderException đều được ném khi main đang nằm trên stack, nên frame cuối giống hệt nhau là at DeepChain.main(DeepChain.java:33) bị thay bằng ... 1 more. Link trong cùng dùng chung hai frame cuối với link phía trên, nên nó in ... 2 more. Không mất gì cả: những frame bị lược đi đúng là những frame đã in ở trên. Đây là khử trùng lặp, không phải cắt bớt.

Dòng 26 và dòng 28. Nhìn kỹ hai frame loadOrder. OrderException được tạo ở dòng 28, tại câu throw bên trong khối catch; còn StorageException thì đang đi ngang qua dòng 26, chỗ gọi readRow. Một frame ghi lại vị trí của chính exception đó, nên một chuỗi được wrap cho bạn cả điểm hỏng lẫn mọi tầng đã ném lại nó.

initCause là cách thay thế, và nó chỉ bắn được đúng một lần. Nếu constructor không nhận cause được — thường vì bạn đang wrap một exception class không do bạn kiểm soát — initCause gán cause sau khi object đã dựng xong. Bốn lần thử, mỗi lần chạy qua một helper in ra -> ok hoặc getMessage() của IllegalStateException nhận về:

Throwable root = new java.io.IOException("socket closed");

attempt("initCause once                        ", () -> {
    Exception e = new IllegalStateException("wrapped");
    e.initCause(root);
});
attempt("initCause twice                       ", () -> {
    Exception e = new IllegalStateException("wrapped");
    e.initCause(root);
    e.initCause(root);
});
attempt("initCause after super(message, cause) ", () -> {
    Exception e = new IllegalStateException("wrapped", root);
    e.initCause(root);
});
attempt("initCause(null), then initCause(root) ", () -> {
    Exception e = new IllegalStateException("wrapped");
    e.initCause(null);
    e.initCause(root);
});
initCause once                         -> ok
initCause twice                        -> Can't overwrite cause with java.io.IOException: socket closed
initCause after super(message, cause)  -> Can't overwrite cause with java.io.IOException: socket closed
initCause(null), then initCause(root)  -> Can't overwrite cause with java.io.IOException: socket closed

Chỉ lần đầu tiên chạy được. Ô cause là loại một lần: hoặc constructor điền vào, hoặc initCause điền, đúng một lần. Dòng thứ hai là trường hợp hiển nhiên; dòng thứ ba cho thấy constructor đã nhận cause thì cũng tính là đã điền; và dòng thứ tư mới là cạnh sắc — initCause(null) vẫn tính là đã điền dù sau đó getCause() vẫn trả về null, nên exception đó vĩnh viễn không thể nhận một cause thật nữa. Hãy ưu tiên constructor khi class của bạn có; chỉ dùng initCause ở chỗ không có.

Translate exception tại ranh giới API

Chaining là cơ chế. Translation là lý do bạn dùng đến nó: tại rìa của một module, exception do implementation của bạn ném ra được thay bằng một exception mà API của bạn khai báo, còn bản gốc thì giữ lại làm cause.

Caller, API của bạn và JDK với một đường ranh giới, cùng một lỗi được xử lý ba cách và thứ mà mỗi cách để lại cho caller

Một config reader parse số port có thể hỏng theo ba cách. Cách thứ nhất, để exception tầng thấp đi thẳng qua:

static int readPort(String raw) {
    return Integer.parseInt(raw);
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "8o80"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Leak.readPort(Leak.java:3)
	at Leak.main(Leak.java:7)

Năm frame, ba trong số đó nằm trong java.base, và một message nhắc đến chuỗi input nhưng không nhắc đến setting mà nó đến từ đó. Caller bây giờ phải catch NumberFormatException — một type rò rỉ ra từ việc bạn chọn dùng Integer.parseInt. Đổi sang parser tự viết là mọi caller vỡ.

Cách thứ hai, wrap nhưng bỏ mất cause:

} catch (NumberFormatException e) {
    throw new ConfigException("invalid value for 'server.port': " + raw);
}
Exception in thread "main" ConfigException: invalid value for 'server.port': 8o80
	at LostCause.readPort(LostCause.java:12)
	at LostCause.main(LostCause.java:17)

Type thì đúng còn bằng chứng thì mất. Hai frame, không có phần Caused by:, và không cách nào nhìn trace mà biết được là parse hỏng, index vượt giới hạn, hay chuyện gì khác hẳn. Đây chính là phiên bản biến một bug năm phút thành một bug hai tiếng.

Cách thứ ba, wrap kèm cause — và vì dù sao cũng đang viết class, giữ luôn dữ liệu có cấu trúc:

class ConfigException extends Exception {
    private final String key;
    private final String rawValue;

    ConfigException(String key, String rawValue, Throwable cause) {
        super("invalid value for '" + key + "': " + rawValue, cause);
        this.key = key;
        this.rawValue = rawValue;
    }

    public String getKey() {
        return key;
    }

    public String getRawValue() {
        return rawValue;
    }
}

public class Translate {
    static int readPort(String raw) throws ConfigException {
        try {
            return Integer.parseInt(raw);
        } catch (NumberFormatException e) {
            throw new ConfigException("server.port", raw, e);
        }
    }
}
message : invalid value for 'server.port': 8o80
key     : server.port
raw     : 8o80
cause   : java.lang.NumberFormatException: For input string: "8o80"
--- full trace ---
ConfigException: invalid value for 'server.port': 8o80
	at Translate.readPort(Translate.java:25)
	at Translate.main(Translate.java:31)
Caused by: java.lang.NumberFormatException: For input string: "8o80"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Translate.readPort(Translate.java:23)
	... 1 more

Caller catch đúng một type nó hiểu được, có getKey()getRawValue() để dựng trang báo lỗi, còn toàn bộ lỗi parse vẫn nằm nguyên dưới Caused by: cho người đọc log. Hai việc cùng lúc, và cái giá chỉ là một tham số thêm ở chỗ throw.

Vạch ranh giới ở đâu: ở đúng chỗ mà một caller ngoài module của bạn sẽ buộc phải biết implementation của bạn làm bằng gì. Bên trong một module, cứ để exception đi — wrap mọi lời gọi thành một type mới chỉ sinh ra những chuỗi mười link chẳng nói lên điều gì. Ở ranh giới thì translate. Và khi translate, luôn luôn truyền cause.

Khi nào không nên viết custom exception

Phần lớn exception type mà người ta viết ra thì đã tồn tại sẵn. Nhóm unchecked exception của JDK phủ hết các dạng lỗi phổ biến, và dùng chúng nghĩa là mọi người đọc code đều đã hiểu ý bạn.

Tình huốngDùng cái nàyVì sao không cần type riêng
Caller truyền vào giá trị method không chấp nhậnIllegalArgumentExceptionai cũng hiểu; subclass chỉ thêm một cái tên chứ không thêm gì
Object không ở trạng thái cho phép gọi việc nàyIllegalStateExceptiontương tự, và caller hiếm khi catch riêng loại này
Một tham số bắt buộc bị nullNullPointerException qua Objects.requireNonNull(x, "...")JDK đã ném sẵn kèm message của bạn
Không còn gì để trả vềNoSuchElementExceptionIterator, OptionalScanner đều ném nó — dùng chung từ vựng
Implementation này không hỗ trợ thao tác đóUnsupportedOperationExceptionimmutable collection dùng nó; người đọc nhận ra ngay
Không parse được chuỗi thành sốNumberFormatExceptionvốn là subclass của IllegalArgumentException, đã đủ cụ thể

Hai trong số đó mặc định không mang message nào cả. Iterator của một ArrayList rỗng ném NoSuchElementException với getMessage() trả về null, và List.of("a").add("b") ném UnsupportedOperationException cũng với getMessage() trả về null — nên khi bạn ném chúng, hãy tự đưa vào một câu, vì bản thân cái type sẽ không nói giúp.

Phép thử chỉ gồm một câu hỏi: có caller nào sẽ catch riêng nó, hoặc đọc dữ liệu từ nó không? Nếu mọi caller đều đối xử với nó y như mọi lỗi khác — log rồi bỏ cuộc — thì một type của JDK là đủ và một class riêng chỉ là file không ai được lợi. Nếu có caller muốn retry riêng lỗi này, hoặc cần lấy SKU từ nó để dựng đơn back-order, hãy viết class.

Còn một đường ở giữa đáng biết: subclass một exception của JDK thay vì subclass Exception. class InvalidSkuException extends IllegalArgumentException cho caller một type cụ thể để catch mà vẫn bị bắt bởi bất kỳ ai catch IllegalArgumentException. Cách này hợp với validation, nơi một handler chung ở trên cùng là trường hợp bình thường còn một handler riêng mới là ngoại lệ.

Một custom exception tốn những gì

Dựng một Throwable sẽ capture stack trace. Việc đó không miễn phí, và chi phí mang tính cấu trúc: nó duyệt qua đúng những frame đang nằm trên stack tại thời điểm đó, nên nó tỉ lệ với việc bạn đang ở sâu bao nhiêu, chứ không phải một khoản cố định cho mỗi lần throw.

Đo bằng cấu trúc thay vì bằng thời gian. deep đệ quy 60 lần rồi throw; report bắt lại và đếm số phần tử getStackTrace() trả về:

static void deep(int n, int kind) {
    if (n == 0) {
        if (kind == 0) throw new Plain("boom");
        if (kind == 1) throw new NoTrace("boom");
        throw new NoFill("boom");
    }
    deep(n - 1, kind);
}

static void report(String label, int kind) {
    try {
        deep(60, kind);
    } catch (RuntimeException e) {
        System.out.printf("%-38s: frames = %d, message = %s%n",
                          label, e.getStackTrace().length, e.getMessage());
    }
}
plain RuntimeException subclass       : frames = 63, message = boom
writableStackTrace = false            : frames = 0, message = boom
fillInStackTrace() overridden to no-op: frames = 0, message = boom

63 frame đó là 61 lời gọi deep cộng reportmain — tổng chính xác phụ thuộc vào lúc throw bạn đang ở sâu bao nhiêu, và đó chính là điều cần thấy.

Có hai cách được hỗ trợ để tắt nó đi. Constructor bốn tham số của Throwable, có từ Java 7, là cách hiện đại:

class Lite extends RuntimeException {
    Lite(String message) {
        super(message, null, false, false);
    }
}

Tham số thứ ba tắt suppression, tham số thứ tư tắt writable stack trace. Cách cũ hơn là override fillInStackTrace() để trả về this mà không làm gì cả:

class NoFill extends RuntimeException {
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

Cả hai đều cho trace không frame nào. Cả hai đều nên hiếm. Đây là công cụ cho một exception thật sự được dùng như control flow trên đường đi nóng — một parser báo hiệu hết input vài nghìn lần mỗi giây — và cái giá là khi nó thật sự thoát ra chỗ không ai ngờ, log chẳng nói cho bạn biết nó đến từ đâu. Với một exception mang nghĩa "request này hỏng rồi", stack trace mới chính là điểm mấu chốt. Cứ để nguyên.

Những lỗi thường gặp

Viết mỗi message một class. OrderNotFoundException, OrderExpiredExceptionOrderLockedException là ba file, ba import và ba khối catch cùng làm một việc. Một class với field reason nói đúng chừng đó và còn cho caller switch trên nó.

Extend Exception cho một bug lập trình. Nếu phản ứng đúng duy nhất là sửa code, thì bắt mọi caller viết catch chỉ sinh ra các khối catch rỗng, tệ hơn hẳn cái crash mà chúng thay thế.

Extend thẳng Error hoặc Throwable. Error nghĩa là JVM đang gặp chuyện — OutOfMemoryError, StackOverflowError — và catch nó gần như luôn sai. Extend thẳng Throwable cho bạn một checked exception mà không catch (Exception e) nào bắt được, khiến ai cũng bất ngờ.

Catch Exception phía trên type của mình. Quy tắc thứ tự catch cắn ngay khi bạn đưa một custom class vào, vì khối nhắc tên nó trở nên không bao giờ chạy tới và compiler từ chối compile file:

CatchOrder.java:11: error: exception OrderException has already been caught
        } catch (OrderException e) {
          ^
1 error

Type hẹp nhất đứng trước — và một khi bạn đã tự viết một cái, hẹp nhất thường chính là của bạn.

Làm rơi mất cause. throw new ConfigException(msg) bên trong catch (NumberFormatException e) vẫn compile, vẫn chạy, và lặng lẽ xóa mất bản ghi duy nhất về thứ thật sự đã hỏng. Nếu biến e đang trong scope mà bạn không truyền nó đi, đó là một bug.

Ghép message ở ngoài constructor. Hai chỗ throw, hai cách diễn đạt lệch nhau một chút, và một đống log không ai grep nổi. Truyền giá trị vào rồi để constructor lo câu chữ.

Thêm serialVersionUID theo phản xạ — hoặc bỏ qua warning theo phản xạ. Thêm nếu build coi warning là error hoặc exception thật sự được serialize. Ngoài ra thì đó là một dòng nghi thức cho mỗi class.

FAQ

Custom exception nên extends Exception hay RuntimeException?

Extend Exception khi một caller hợp lý có việc gì đó tốt hơn là chết — retry, fallback, trả về 404 — và bạn muốn compiler bảo đảm họ phải cân nhắc. Extend RuntimeException khi exception báo hiệu một bug, hoặc khi không caller nào trong chuỗi làm được gì có ích. Hai tiêu chí phân định trong thực tế: checked exception không thoát ra được khỏi lambda truyền cho map hay forEach, và đổi một method từ unchecked sang checked sẽ phá vỡ mọi caller đang compile được.

Có bắt buộc viết đủ bốn constructor của Throwable không?

Không. Constructor không được kế thừa, nên bạn chỉ có đúng những cái bạn khai báo, và nên khai báo những cái code bạn thật sự dùng. Cái luôn đáng viết là (String message, Throwable cause), vì đó là cái giữ lại lỗi gốc — thêm nó về sau đồng nghĩa với sửa mọi chỗ throw. Cái nên cân nhắc kỹ là constructor chỉ nhận (Throwable): nó suy message ra từ cause.toString(), đẩy thẳng từ vựng của tầng dưới vào message của exception bạn.

Khác nhau giữa constructor nhận cause và initCause là gì?

Cả hai gán cùng một field. super(message, cause) gán lúc dựng object; initCause(cause) gán sau đó, và chỉ một lần — lần gọi thứ hai ném IllegalStateException: Can't overwrite cause with ..., gọi trên object đã nhận cause từ constructor cũng vậy. Chỉ dùng initCause khi bạn wrap một exception class không do bạn kiểm soát và constructor của nó không nhận cause.

Vì sao stack trace của tôi kết thúc bằng "... 2 more"?

Vì những frame đó đã được in rồi. Khi printStackTrace() đi hết một chuỗi, mỗi link chỉ in những frame nó không dùng chung với link đã bọc nó, còn các frame cuối giống hệt nhau bị gộp thành một dòng ... N more. Không có gì bị giấu — những frame bị lược đúng là những frame đã liệt kê ở link bên trên. Đọc các phần Caused by: từ dưới lên để có lỗi theo thứ tự nguyên nhân.

Custom exception có cần serialVersionUID không?

Chỉ khi nó thật sự được serialize hoặc build của bạn coi warning là lỗi. Throwable implement Serializable nên mọi exception class đều thừa hưởng điều đó, và javac -Xlint:serial sẽ báo serializable class OrderException has no definition of serialVersionUID. Lần compile mặc định thì im lặng. Thêm private static final long serialVersionUID = 1L; khi warning đó có ý nghĩa với project của bạn.

Khi nào custom exception là thừa?

Khi không caller nào catch riêng nó và không caller nào đọc dữ liệu từ nó. Nếu câu trả lời thật lòng là "ai cũng chỉ log thôi", thì IllegalArgumentException, IllegalStateException, NoSuchElementException hoặc UnsupportedOperationException đã nói đúng điều đó bằng một cái tên mọi developer Java đều biết. Lưu ý hai cái cuối mặc định mang message null, nên hãy tự truyền message khi bạn ném chúng.

Kết luận

Một custom exception class là bốn dòng code và hai quyết định thật sự. Supertype quyết định ai bị buộc phải xử lý nó: extends Exception khiến loadOrder(5); không compile được nếu thiếu catch hoặc throws, còn extends RuntimeException thì compile im lặng và chỉ hỏng lúc chạy. Các constructor quyết định object mang theo gì, và (String, Throwable) là cái quan trọng nhất, vì nó là khác biệt giữa một trace dừng lại ở lớp wrapper của bạn và một trace còn hiện Caused by: java.lang.NumberFormatException: For input string: "8o80" ở bốn frame phía dưới.

Mọi thứ còn lại đến từ việc coi nó như một class bình thường. Lưu các giá trị gây ra lỗi thành field final rồi ghép message từ chúng ngay trong constructor, để caller nhận được getShortfall() thay vì một câu chữ phải parse. Translate ở ranh giới module và luôn truyền cause, để chuỗi nguyên vẹn và ... N more chỉ gộp đúng những gì đã in. Và trước khi viết class, hãy kiểm tra xem IllegalArgumentException đã nói đúng ý bạn chưa — custom exception tốt nhất thường là cái bạn không cần viết.

Bài tiếp theo trong series: ArrayListLinkedList. Array có độ dài cố định quyết định ngay lúc tạo; collection thì không. Bài sau nói về interface List và hai implementation chủ lực của nó, cách mỗi cái thật sự nằm trong bộ nhớ, và vì sao lựa chọn giữa chúng làm thay đổi chi phí của việc thêm, xóa và truy cập ngẫu nhiên.

Bài viết liên quan

[Java Basics] Xử lý exception trong Java: try, catch, finally, throw và throws

Xử lý exception trong Java trên OpenJDK 21: cây Throwable, try-catch-finally và điều finally thực sự bảo đảm, multi-catch và thứ tự các block catch, throw khác throws, checked khác unchecked, stack unwinding và cách đọc stack trace, cùng những cái bẫy — catch rỗng, catch (Exception e), và return trong finally.

[Java Basics] Câu lệnh điều kiện trong Java: if, else if, else và switch

Câu lệnh điều kiện trong Java: chuỗi if / else if / else, vì sao phải có ngoặc nhọn, bẫy dangling else, điều kiện chỉ nhận boolean, switch với break và fall-through, label mũi tên, switch expression cùng yield và pattern matching của Java 21.

[Java Basics] Phạm vi biến trong Java: biến cục bộ, field và static

Scope của variable trong Java: local variable, parameter, instance field và static field, block scope, shadowing, definite assignment và lifetime, cùng mọi lỗi cannot find symbol chạy thật trên JDK 21.

[Java Basics] Class và Object trong Java: nền tảng của lập trình hướng đối tượng

Class và object trong Java, chứng minh bằng cách chạy code thật: class định nghĩa cái gì, new thực sự làm gì theo từng bước, field được zero-init, reference và aliasing, null cùng helpful NullPointerException, instance method, toString, equals so với ==, và quy tắc một public class một file, tất cả trên OpenJDK 21.