生產環境關閉 display_errors 卻未記錄日誌,會導致隨機錯誤無法追蹤、付款回調丟失無感知、長時間腳本靜默中斷。本文提供一套完整的錯誤/異常/致命錯誤攔截機制,包含自定義異常類別、錯誤處理器、關閉函式、去重記錄、報警整合,已驗證於高併發線上環境。
error_get_last() 捕獲 E_ERROR、E_PARSE 等致命錯誤。<?php
/**
* 統一錯誤異常類別:封裝代碼、訊息、檔案、行號、嚴重度、本地代碼/名稱
*/
class ErrorHandlerException extends ErrorException {
// 本地錯誤代碼映射(便於資料庫索引、報警分類)
public static $localCode = [
E_ERROR => 4006,
E_WARNING => 4015,
E_PARSE => 4008,
E_NOTICE => 4007,
E_CORE_ERROR => 4003,
E_CORE_WARNING => 4004,
E_COMPILE_ERROR => 4001,
E_COMPILE_WARNING => 4002,
E_USER_ERROR => 4012,
E_USER_WARNING => 4014,
E_USER_NOTICE => 4013,
E_STRICT => 4010,
E_RECOVERABLE_ERROR => 4009,
E_DEPRECATED => 4005,
E_USER_DEPRECATED => 4011,
4016 => 4016, // 自定義錯誤
];
// 使用者友好名稱
public static $localName = [
E_ERROR => 'PHP Fatal Error',
E_WARNING => 'PHP Warning',
E_PARSE => 'PHP Parse Error',
E_NOTICE => 'PHP Notice',
E_CORE_ERROR => 'PHP Core Error',
E_CORE_WARNING => 'PHP Core Warning',
E_COMPILE_ERROR => 'PHP Compile Error',
E_COMPILE_WARNING => 'PHP Compile Warning',
E_USER_ERROR => 'PHP User Error',
E_USER_WARNING => 'PHP User Warning',
E_USER_NOTICE => 'PHP User Notice',
E_STRICT => 'PHP Strict Warning',
E_RECOVERABLE_ERROR => 'PHP Recoverable Error',
E_DEPRECATED => 'PHP Deprecated Warning',
E_USER_DEPRECATED => 'PHP User Deprecated Warning',
4016 => 'Custom Error',
];
public function __construct($message = '', $code = 0, $severity = 1, $filename = __FILE__, $line = __LINE__, Exception $previous = null) {
parent::__construct($message, $code, $severity, $filename, $line, $previous);
}
// 判斷是否為致命錯誤
public static function isFatalError(array $error): bool {
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING];
return isset($error['type']) && in_array($error['type'], $fatalTypes, true);
}
public static function getLocalCode(int $code): int {
return self::$localCode[$code] ?? self::$localCode[4016];
}
public static function getName(int $code): string {
return self::$localName[$code] ?? self::$localName[4016];
}
}
<?php
require_once 'ErrorHandlerException.php';
class ErrorHandler {
public $argvs = []; // CLI 參數
public $memoryReserveSize = 262144; // 256KB 備用記憶體
private $_memoryReserve = '';
// 註冊攔截器
public function register(): void {
ini_set('display_errors', '0');
set_exception_handler([$this, 'handleException']);
set_error_handler([$this, 'handleError']);
// 預留記憶體,確保致命錯誤處理有可用空間
if ($this->memoryReserveSize > 0) {
$this->_memoryReserve = str_repeat('x', $this->memoryReserveSize);
}
register_shutdown_function([$this, 'handleFatalError']);
}
// 取消註冊
public function unregister(): void {
restore_error_handler();
restore_exception_handler();
}
// 處理未捕獲異常
public function handleException(Throwable $exception): void {
$this->unregister();
try {
$this->logException($exception);
exit(1);
} catch (Throwable $e) {
exit(1);
}
}
// 處理一般錯誤 → 轉為異常
public function handleError(int $code, string $message, string $file, int $line): bool {
// 僅處理 error_reporting 範圍內且非輕微錯誤
if ((error_reporting() & $code) && !in_array($code, [E_NOTICE, E_WARNING, E_USER_NOTICE, E_USER_WARNING, E_DEPRECATED], true)) {
$exception = new ErrorHandlerException($message, $code, $code, $file, $line);
// __toString 中發生錯誤不可拋出異常,直接處理
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
array_shift($trace);
foreach ($trace as $frame) {
if (($frame['function'] ?? '') === '__toString') {
$this->handleException($exception);
exit(1);
}
}
throw $exception;
}
return false; // 繼續內建處理器
}
// 處理致命錯誤
public function handleFatalError(): void {
unset($this->_memoryReserve); // 釋放備用記憶體
$error = error_get_last();
if ($error && ErrorHandlerException::isFatalError($error)) {
$exception = new ErrorHandlerException(
$error['message'], $error['type'], $error['type'], $error['file'], $error['line']
);
$this->logException($exception);
exit(1);
}
}
// 取得伺服器 IP
final public function getServerIp(): string {
return $_SERVER['SERVER_ADDR']
?? $_SERVER['LOCAL_ADDR']
?? (isset($_SERVER['HOSTNAME']) ? gethostbyname($_SERVER['HOSTNAME']) : '')
?? getenv('SERVER_ADDR')
?? 'unknown';
}
// 取得當前請求 URI
public function getCurrentUri(): string {
if (isset($_SERVER['REMOTE_ADDR'])) { // Web 模式
return 'https://' . ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME']) . $_SERVER['REQUEST_URI'];
} else { // CLI 模式
$params = $this->argvs;
$uri = array_shift($params);
foreach ($params as $param) $uri .= ' ' . $param;
return $uri;
}
}
// 記錄異常(含歷史鏈)
final public function logException(Throwable $e): void {
$messages = [];
do {
$messages[] = (string)$e; // ErrorException::__toString 包含檔案、行號、代碼、訊息
} while ($e = $e->getPrevious());
$errorData = [
'add_time' => time(),
'title' => ErrorHandlerException::getName($e->getCode()),
'message' => implode("\r\n", $messages),
'server_ip' => $this->getServerIp(),
'code' => ErrorHandlerException::getLocalCode($e->getCode()),
'file' => $e->getFile(),
'line' => $e->getLine(),
'url' => $this->getCurrentUri(),
];
$this->logError($errorData);
}
// 實際記錄邏輯(需自行實作:資料庫、檔案、Syslog、Sentry 等)
public function logError(array $error): void {
// 範例:寫入檔案(生產環境建議異步寫入資料庫/日誌系統)
$logLine = json_encode($error, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
@file_put_contents('/var/log/php_errors.json', $logLine, FILE_APPEND | LOCK_EX);
// 進階:去重機制(MD5(code+file+line+message) + Redis 過期 1 小時)
// 進階:報警整合(郵件、Slack、DingTalk、Prometheus Alertmanager)
}
}
<?php
/**
* 入口檔案最早引入此檔案
* 定義 DEBUG_ERROR=1 開啟調試模式(顯示錯誤、不記錄)
* 定義 DEBUG_ERROR=0 生產模式(不顯示、記錄日誌、報警)
*/
define('DEBUG_ERROR', 0); // 生產環境設 0
require_once 'ErrorHandler.php';
class RegisterErrorHandler {
public static function register(): void {
global $argv;
if (DEBUG_ERROR) {
ini_set('display_errors', '1');
error_reporting(-1);
return;
}
ini_set('error_reporting', '-1'); // 記錄所有等級
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php_errors.log'); // PHP 內建錯誤日誌
$handler = new ErrorHandler();
$handler->argvs = $argv;
$handler->register();
}
}
RegisterErrorHandler::register();
<?php // public/index.php 或 CLI 入口 require_once __DIR__ . '/../bootstrap/registerErrorHandler.php'; // 最優先引入 // 其餘框架啟動代碼... $app = require_once __DIR__ . '/../bootstrap/app.php'; $app->run();
| 項目 | 說明 |
|---|---|
| set_error_handler 限制 | 無法攔截 E_ERROR、E_PARSE、E_CORE_ERROR、E_CORE_WARNING、E_COMPILE_ERROR、E_COMPILE_WARNING。但這些屬編譯/核心錯誤,上線前必須修復,不影響運行期監控。 |
| 致命錯誤記憶體不足 | 預留 memoryReserveSize (256KB) 確保 handleFatalError 有空間執行 error_get_last()、序列化、寫日誌。 |
| 錯誤去重 | 使用 md5($code.$file.$line.$message) 作為 Key,Redis SETEX key 3600 1,僅首次記錄並報警。 |
| CLI 相容性 | 儲存 $argv 供 getCurrentUri() 重建命令列。 |
| 錯誤日誌格式 | 建議 JSON Lines 格式,便於 ELK/Filebeat 採集、Kibana 分析。 |
| 報警整合 | logError() 中判斷 $error['code'] 為致命代碼 → 發送郵件/Slack/DingTalk Webhook。 |
logError() 為 $logger->error(),支援多 Handler(Stream、Slack、Syslog、Elasticsearch)。Sentry\init(['dsn' => '...']) 自動捕獲未處理異常、致命錯誤,提供堆疊追蹤、版本關聯、使用者上下文。swoole_async_writefile 或投遞至 Redis List,由獨立 Worker 消費寫入磁碟/資料庫,避免阻塞請求。logException 中注入 trace_id、span_id,串聯微服務調用鏈。本方案透過 set_exception_handler、set_error_handler、register_shutdown_function 三重保障,實現 PHP 所有錯誤類型的統一攔截、轉換、記錄與報警。核心優勢在於:區分調試/生產模式、預留記憶體處理致命錯誤、自定義本地代碼便於分類、支援 CLI/Web 雙模式、可擴展去重與報警。直接引入 registerErrorHandler.php 並實作 logError() 即可落地生產環境。