例外處理是 Java 程式健壯性的重要保障。正確的例外處理策略可以幫助我們及時發現問題、優雅降級,並為排錯提供關鍵資訊。

例外體系結構#

Throwable
├── Error(不應捕獲)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── ...
└── Exception
    ├── RuntimeException(非檢查例外)
    │   ├── NullPointerException
    │   ├── IndexOutOfBoundsException
    │   ├── IllegalArgumentException
    │   └── ...
    └── 其他 Exception(檢查例外)
        ├── IOException
        ├── SQLException
        └── ...

例外分類#

類型說明是否必須處理
Error嚴重錯誤,JVM 無法恢復不應捕獲
Checked Exception編譯器強制處理必須 try-catch 或 throws
Unchecked ExceptionRuntimeException 及其子類非強制

Error 表示 JVM 級別的嚴重問題(如 OOM),程式通常無法恢復,不應該捕獲。Exception 才是程式應該處理的例外。


例外處理最佳實踐#

1. 精確捕獲例外#

// 錯誤:過於寬泛
try {
    doSomething();
} catch (Exception e) {  // 不好!
    log.error("Error", e);
}

// 正確:精確捕獲
try {
    doSomething();
} catch (FileNotFoundException e) {
    // 處理文件不存在
} catch (IOException e) {
    // 處理其他 IO 例外
}

捕獲過於寬泛的例外會掩蓋真正的問題,使除錯變得困難。

2. 不要忽略例外#

// 錯誤:吞掉例外
try {
    doSomething();
} catch (Exception e) {
    // 什麼都不做 - 絕對禁止!
}

// 正確:至少記錄日誌
try {
    doSomething();
} catch (Exception e) {
    log.error("Failed to do something", e);
    // 或者重新拋出
    throw new ServiceException("Operation failed", e);
}

3. 使用 try-with-resources#

// 老式寫法(容易遺漏關閉)
InputStream is = null;
try {
    is = new FileInputStream("file.txt");
    // 使用
} finally {
    if (is != null) {
        try {
            is.close();
        } catch (IOException e) {
            // 又一個需要處理的例外
        }
    }
}

// 正確:try-with-resources(Java 7+)
try (InputStream is = new FileInputStream("file.txt")) {
    // 使用
}  // 自動關閉例外也會正確處理

4. 例外資訊要有意義#

// 不好
throw new IllegalArgumentException("參數錯誤");

// 好
throw new IllegalArgumentException(
    String.format("用戶 ID 必須為正數,當前值: %d", userId));

5. 保留原始例外鏈#

// 錯誤:丟失原始例外
try {
    parseFile(file);
} catch (IOException e) {
    throw new BusinessException("解析失敗");  // 丟失了 e!
}

// 正確:保留例外鏈
try {
    parseFile(file);
} catch (IOException e) {
    throw new BusinessException("解析失敗", e);  // 保留原因
}

常見陷阱#

1. finally 中的 return#

// 這段程式碼回傳什麼?
public int test() {
    try {
        return 1;
    } finally {
        return 2;  // 這個 return 會覆蓋 try 中的
    }
}
// 答案回傳 2

永遠不要在 finally 中使用 return。這會覆蓋 try 或 catch 中的回傳值,甚至會吞掉例外。

2. finally 中的例外#

try {
    throw new RuntimeException("Original");
} finally {
    throw new RuntimeException("Finally");  // 覆蓋了原始例外!
}
// 只會看到 "Finally" 例外

3. 循環中的 try-catch#

// 不好:在循環內 try-catch
for (String item : items) {
    try {
        process(item);
    } catch (Exception e) {
        log.error("Error", e);
    }
}

// 根據業務決定:是否需要繼續處理其他項?
// 如果一個失敗就全部停止:
try {
    for (String item : items) {
        process(item);
    }
} catch (Exception e) {
    log.error("Error", e);
}

4. 例外與事務#

// Spring 預設只對 RuntimeException 回滾
@Transactional
public void createUser(User user) throws BusinessException {
    userRepo.save(user);
    if (invalid) {
        throw new BusinessException("Invalid");  // 不會回滾!
    }
}

// 正確:指定回滾的例外
@Transactional(rollbackFor = Exception.class)
public void createUser(User user) throws BusinessException {
    // ...
}
檢查例外 vs 非檢查例外的選擇

使用檢查例外(Checked Exception)當

  • 呼叫方可以合理地處理這個例外
  • 例如:文件不存在時可以創建、網路失敗時可以重試

使用非檢查例外(RuntimeException)當

  • 例外是程式設計錯誤導致(如 NPE、參數非法)
  • 呼叫方通常無法恢復
  • 例如:傳入了 null 參數、索引越界

現代趨勢:越來越多的專案(包括 Spring)傾向於使用非檢查例外,因為:

  1. 程式碼更簡潔
  2. 避免例外聲明污染介面
  3. 讓呼叫方自己決定是否處理

自定義例外#

例外層次設計#

// 基礎業務例外
public class BusinessException extends RuntimeException {
    private final String errorCode;

    public BusinessException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public BusinessException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

// 具體業務例外
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super("USER_NOT_FOUND", "User not found: " + userId);
    }
}

public class InsufficientBalanceException extends BusinessException {
    public InsufficientBalanceException(BigDecimal required, BigDecimal available) {
        super("INSUFFICIENT_BALANCE",
              String.format("Insufficient balance. Required: %s, Available: %s",
                           required, available));
    }
}

統一例外處理(Spring)#

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        log.warn("Business exception: {}", e.getMessage());
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unexpected error", e);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "Internal server error"));
    }
}

例外處理檢查清單#

  1. 不要捕獲 Throwable 或 Error
  2. 不要忽略例外(空 catch 塊)
  3. 不要在 finally 中 return 或 throw
  4. 使用 try-with-resources 管理資源
  5. 保留例外鏈(傳遞 cause)
  6. 提供有意義的例外資訊
  7. 精確捕獲例外類型
  8. 根據場景選擇 Checked/Unchecked
  9. 統一例外處理策略
  10. 記錄例外日誌時包含堆疊資訊

實踐建議#

  1. 早拋出,晚捕獲:在發現問題的地方拋出,在能夠處理的地方捕獲
  2. 為運維提供足夠資訊:例外資訊應包含上下文(用戶 ID、操作類型等)
  3. 區分業務例外和技術例外:業務例外給用戶友好提示,技術例外記錄詳細日誌
  4. 考慮重試機制:對於暫時性故障(網路抖動),可以設計重試策略
  5. 監控例外:將例外資料納入監控系統,及時發現問題