異常處理是 Java 程式健壯性的基石。雖然 JDK 提供了豐富的標準異常體系,但實際業務中往往需要表達特定業務錯誤語義。本文系統講解自定義異常的設計原則、實現模式與工程落地經驗。
建議:現代框架 (Spring、MyBatis) 傾向使用 RuntimeException 衍生類,減少樣板碼,配合全域異常處理器統一響應。
public class BizException extends RuntimeException {
private final String errorCode; // 業務錯誤碼:BIZ_1001
private final String userMessage; // 使用者友好提示:「餘額不足,請充值」
private final String devMessage; // 開發者除錯訊息:包含參數、堆疊等
private final Map context; // 擴充上下文:userId、orderId 等
// 多種建構子支援:錯誤碼、訊息、原因異常、上下文靈活組合
}
/**
* 統一業務異常基類
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class BizException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String errorCode;
private final String userMessage;
private final Map context = new HashMap<>();
public BizException(String errorCode, String userMessage) {
super(userMessage);
this.errorCode = errorCode;
this.userMessage = userMessage;
}
public BizException(String errorCode, String userMessage, Throwable cause) {
super(userMessage, cause);
this.errorCode = errorCode;
this.userMessage = userMessage;
}
// 鏈式新增上下文
public BizException addContext(String key, Object value) {
context.put(key, value);
return this;
}
// 常用錯誤碼常量介面
public interface ErrorCodes {
String VALIDATION_FAILED = "BIZ_1000";
String INSUFFICIENT_BALANCE = "BIZ_1001";
String ORDER_NOT_FOUND = "BIZ_2001";
// ...
}
}
@PostMapping("/transfer")
public Result transfer(@Valid @RequestBody TransferDTO dto) {
if (dto.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BizException(BizException.ErrorCodes.VALIDATION_FAILED,
"轉帳金額必須大於零").addContext("field", "amount");
}
// ...
}
public void deductBalance(Long userId, BigDecimal amount) {
UserAccount account = accountDao.lockForUpdate(userId);
if (account.getBalance().compareTo(amount) < 0) {
throw new BizException(BizException.ErrorCodes.INSUFFICIENT_BALANCE,
"帳戶餘額不足")
.addContext("userId", userId)
.addContext("available", account.getBalance())
.addContext("required", amount);
}
// ...
}
try {
PaymentResponse resp = paymentClient.pay(req);
} catch (PaymentGatewayException e) {
// 封裝第三方錯誤碼,不洩露內部細節
throw new BizException("BIZ_3001", "支付服務暫時不可用,請稍後重試", e)
.addContext("gatewayCode", e.getCode())
.addContext("orderNo", req.getOrderNo());
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result handleBizException(BizException e, HttpServletRequest request) {
log.warn("業務異常: code={}, msg={}, path={}, context={}",
e.getErrorCode(), e.getUserMessage(), request.getRequestURI(), e.getContext());
return Result.fail(e.getErrorCode(), e.getUserMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.fail(BizException.ErrorCodes.VALIDATION_FAILED, msg);
}
@ExceptionHandler(Exception.class)
public Result handleSystemException(Exception e, HttpServletRequest request) {
log.error("系統異常: path={}", request.getRequestURI(), e);
return Result.fail("SYS_9999", "系統繁忙,請稍後重試");
}
}
| 反模式 | 問題 | 正確做法 |
|---|---|---|
| 大量 catch 後 log 後拋新 RuntimeException | 丟失原始堆疊,難以排查 | 使用帶 cause 參數的建構子:new BizException(code, msg, cause) |
| 異常類爆炸:每個錯誤一個 Class | 類數量膨脹,維護困難 | 採用錯誤碼 + 參數化訊息,單一 BizException 覆蓋 90% 場景 |
| 在高頻路徑使用異常控制流程 | 效能極差 (填充堆疊追蹤開銷大) | 改用 Result/Optional 模式,異常僅用於真正的錯誤場景 |
| 錯誤訊息硬編碼中文 | 無法國際化,前端難以解析 | 錯誤碼 + 訊息鍵 (i18n key),前端根據語言包顯示 |
優秀的異常設計應具備:語義清晰 (錯誤碼體系)、資訊完整 (上下文、原因鏈)、處理統一 (全域攔截)、安全可控 (敏感資訊過濾)。採用「單一基類 + 錯誤碼枚舉 + 上下文擴充」模式,配合 Spring 全域異常處理,即可構建可維護、可擴充的企業級異常處理體系。