異常體系基礎
Throwable (PHP 7+)
├── Error (引擎錯誤:TypeError, ParseError, ArithmeticError...)
└── Exception (使用者代碼拋出)
├── LogicException (邏輯錯誤:InvalidArgument, DomainException...)
└── RuntimeException (執行期錯誤:OutOfBounds, UnexpectedValue...)
自訂異常類別設計
<?php
// 基礎業務異常
class BusinessException extends Exception {
protected int $code = 400; // HTTP 狀態碼
public function __construct(string $message = '', int $code = 0, Throwable $previous = null) {
parent::__construct($message, $code ?: $this->code, $previous);
}
}
// 具體業務異常
class UserNotFoundException extends BusinessException { protected $code = 404; }
class InsufficientBalanceException extends BusinessException { protected $code = 402; }
class ValidationException extends BusinessException {
private array $errors;
public function __construct(array $errors, string $message = '驗證失敗') {
$this->errors = $errors;
parent::__construct($message, 422);
}
public function getErrors(): array { return $this->errors; }
}
// 系統層異常
class DatabaseException extends RuntimeException { protected $code = 500; }
class ExternalApiException extends RuntimeException { protected $code = 502; }
拋出與捕獲基本模式
<?php
function transfer(int $from, int $to, float $amount): void {
$db = getDb();
$db->beginTransaction();
try {
$fromUser = $db->query("SELECT * FROM users WHERE id=$for UPDATE")->fetch();
if (!$fromUser) throw new UserNotFoundException("轉出用戶 $from 不存在");
if ($fromUser['balance'] < $amount) throw new InsufficientBalanceException("餘額不足");
$db->exec("UPDATE users SET balance=balance-$amount WHERE id=$from");
$db->exec("UPDATE users SET balance=balance+$amount WHERE id=$to");
$db->commit();
} catch (BusinessException $e) {
$db->rollBack();
throw $e; // 向上拋出,讓上層決定如何回應
} catch (Throwable $e) {
$db->rollBack();
throw new DatabaseException('轉帳失敗: ' . $e->getMessage(), 0, $e);
}
}
// 控制器層統一處理
try {
transfer(1, 2, 100.50);
apiResponse(0, '成功');
} catch (UserNotFoundException $e) {
apiResponse(404, $e->getMessage());
} catch (InsufficientBalanceException $e) {
apiResponse(402, $e->getMessage());
} catch (ValidationException $e) {
apiResponse(422, $e->getMessage(), ['errors' => $e->getErrors()]);
} catch (BusinessException $e) {
apiResponse($e->getCode(), $e->getMessage());
} catch (Throwable $e) {
logError($e);
apiResponse(500, '系統錯誤');
}
全域異常處理器(捕獲未捕獲異常)
<?php
function globalExceptionHandler(Throwable $e): void {
// 記錄完整堆疊
logError([
'message' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'request' => [
'uri' => $_SERVER['REQUEST_URI'],
'method' => $_SERVER['REQUEST_METHOD'],
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? ''
]
]);
// 區分環境輸出
if (isCli()) {
fwrite(STDERR, "FATAL: {$e->getMessage()}\n");
exit(1);
}
http_response_code($e->getCode() ?: 500);
header('Content-Type: application/json; charset=utf-8');
if (DEBUG_MODE) {
echo json_encode([
'error' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => explode("\n", $e->getTraceAsString())
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} else {
echo json_encode(['error' => 'Internal Server Error', 'message' => '系統暫時不可用,請稍後再試'], JSON_UNESCAPED_UNICODE);
}
}
set_exception_handler('globalExceptionHandler');
// 將 Error 也轉為異常捕獲 (PHP 7+)
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) return false; // @ 抑制的錯誤
throw new ErrorException($message, 0, $severity, $file, $line);
});
// 嚴重錯誤 (E_ERROR 等) 無法由 error_handler 捕獲,需 register_shutdown_function
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
globalExceptionHandler(new ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line']));
}
});
?>
錯誤轉異常最佳實踐
| 場景 | 做法 |
| 資料庫 PDO | $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); |
| 檔案操作 | 使用 SplFileObject 或封裝檢查回傳值拋異常 |
| 外部 API | 檢查 HTTP 碼非 2xx 拋 ExternalApiException |
| JSON 解析 | json_decode(...); if (json_last_error()) throw new ValidationException(...) |
異常處理原則(SOLID 風格)
- 單一職責:每個異常類別代表一種具體失敗原因。
- 開放封閉:新增業務錯誤只需新增異常類,不修改現有捕獲邏輯(利用多型)。
- 里氏替換:捕獲
BusinessException 可處理所有子類。
- 介面隔離:
ValidationException 提供 getErrors(),呼叫端依介面而非實作。
- 依賴倒置:高層模組依賴抽象異常介面,不依賴具體 PDOException。
常見錯誤與修正
| 錯誤做法 | 問題 | 正確做法 |
catch (Exception $e) {} 吞掉所有異常 | 隱藏 Bug、難除錯 | 精確捕獲具體異常,最後再 catch Throwable 記錄並回應 500 |
在 catch 中 throw $e 丟失堆疊 | PHP 7+ 保留堆疊,但舊版本會丟失 | 用 throw new NewException($msg, 0, $e) 串聯 previous |
業務邏輯用 die()/exit() | 無法被上層捕獲、測試困難 | 拋出異常,讓控制器/處理器決定輸出 |
| 異常訊息洩露敏感資訊 | SQL、路徑、堆疊暴露給用戶 | 生產環境只回應通用訊息,詳細記錄到日誌 |
測試異常流程
<?php
// PHPUnit 範例
public function testTransferInsufficientBalance() {
$this->expectException(InsufficientBalanceException::class);
$this->expectExceptionMessage('餘額不足');
transfer(1, 2, 999999);
}
?>
建立清晰的異常體系、統一全域處理、將錯誤轉為異常、精確捕獲並記錄,讓 PHP 應用具備企業級的錯誤韌性與可維護性。