日誌是系統可觀測性的基石。本文從最簡單的 fwrite 封裝出發,逐步演進至單例模式、多級別、文件輪轉、PSR-3 標準介面、Monolog 庫集成,給出適合不同項目體量的日誌方案選型建議。
function writeLog(string $message, string $level = 'INFO'): void
{
$file = __DIR__ . '/logs/app-' . date('Y-m-d') . '.log';
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message);
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
// 使用
writeLog('用戶登錄成功', 'info');
writeLog('數據庫連接失敗', 'error');
class Logger
{
private static ?self $instance = null;
private string $logDir;
private int $maxFiles = 30;
private array $levelMap = [
'debug' => 100, 'info' => 200, 'notice' => 250,
'warning' => 300, 'error' => 400, 'critical' => 500,
'alert' => 550, 'emergency' => 600,
];
private int $minLevel = 200; // 默認 INFO
private function __construct(string $logDir = __DIR__ . '/logs')
{
$this->logDir = $logDir;
}
public static function getInstance(string $logDir = ''): self
{
if (self::$instance === null) {
self::$instance = new self($logDir);
}
return self::$instance;
}
public function setMinLevel(string $level): void
{
$this->minLevel = $this->levelMap[strtolower($level)] ?? 200;
}
public function log(string $level, string $message, array $context = []): void
{
$level = strtolower($level);
if (($this->levelMap[$level] ?? 200) < $this->minLevel) {
return; // 級別不足,丟棄
}
$file = $this->logDir . '/app-' . date('Y-m-d') . '.log';
$this->rotateIfNeeded();
$contextStr = $context ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = sprintf("[%s] %-8s: %s%s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, $contextStr);
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
// 魔術方法快捷調用
public function __call(string $name, array $args): void
{
if (isset($this->levelMap[$name])) {
$this->log($name, $args[0] ?? '', $args[1] ?? []);
}
}
private function rotateIfNeeded(): void
{
$files = glob($this->logDir . '/app-*.log');
if (count($files) > $this->maxFiles) {
usort($files, fn($a, $b) => filemtime($a) - filemtime($b));
for ($i = 0; $i < count($files) - $this->maxFiles; $i++) {
@unlink($files[$i]);
}
}
}
}
// 使用
$log = Logger::getInstance();
$log->setMinLevel('warning'); // 生產環境只記錄 WARNING 以上
$log->info('用戶登錄', ['uid' => 123, 'ip' => '1.2.3.4']);
$log->error('支付失敗', ['order_id' => 'OD123', 'code' => 'PAY_TIMEOUT']);
$log->debug('SQL', ['sql' => 'SELECT * FROM users WHERE id=1']);
// 等價於
$log->info('用戶登錄', ['uid' => 123]);
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
class PsrLogger implements LoggerInterface
{
private Logger $logger; // 組合上述單例
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function emergency($message, array $context = []): void { $this->logger->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->logger->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->logger->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->logger->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->logger->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->logger->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->logger->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->logger->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
$this->logger->log($level, $message, $context);
}
}
LoggerInterface 即可無縫接入遵循 PSR-3 的框架(Laravel、Symfony、Slim、Yii2 等)LoggerInterface,業務代碼依賴介面而非具體實現
composer require monolog/monolog
use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Formatter\LineFormatter;
use Monolog\Processor\IntrospectionProcessor;
$log = new Logger('app');
$handler = new RotatingFileHandler(__DIR__ . '/logs/app.log', 30, Logger::WARNING);
$handler->setFormatter(new LineFormatter(
"[%datetime%] %level_name%: %message% %context% %extra%\n",
"Y-m-d H:i:s"
));
$handler->pushProcessor(new IntrospectionProcessor(Logger::DEBUG, ['Monolog\\', 'Psr\\']));
$log->pushHandler($handler);
// 多 Handler 鏈:錯誤單獨文件、Slack 通知、Elasticsearch 索引
$log->pushHandler(new \Monolog\Handler\StreamHandler(__DIR__ . '/logs/error.log', Logger::ERROR));
$log->pushHandler(new \Monolog\Handler\SlackHandler('webhook_url', Logger::CRITICAL));
$log->info('訂單創建', ['order_id' => 'OD123']);
$log->error('支付回調失敗', ['exception' => $e]);
| Handler | 用途 |
|---|---|
RotatingFileHandler | 按日期輪轉,保留 N 天 |
StreamHandler | 單文件/錯誤專用文件 |
SyslogHandler | 系統日誌 |
SlackHandler | 異常告警推送 |
ElasticsearchHandler | 集中式日誌分析 |
FingersCrossedHandler | 緩衝日誌,觸發閾值才寫入(高性能) |
| 項目體量 | 推薦方案 | 理由 |
|---|---|---|
| 個人/微型項目 | 單例 Logger 類 | 零依賴、易部署、滿足基本級別/輪轉 |
| 中型項目/團隊協作 | PSR-3 + Monolog | 標準化、可測試、框架無縫集成、Handler 鏈靈活 |
| 大型/微服務 | Monolog + ELK/EFK + 鏈路追蹤 | 集中式存儲、全文檢索、關聯請求 ID、告警體系 |
$log->info('User login', ['uid'=>123, 'ip'=>'...']),而非字符串拼接password、token、credit_card 字段X-Request-ID,Processor 自動注入所有日誌行BufferHandler + SwiftMailer 異步落盤從 file_put_contents 到 Monolog,日誌演進的本質是「結構化、標準化、異步化、集中化」。小項目用單例類足夠,中型以上項目請直接上 Monolog 實現 PSR-3,享受生態紅利。記住:沒有日誌的系統是盲目的,沒有級別的日誌是噪音,沒有輪轉的日誌是炸彈。