你有没有遇到过这样的情况:代码被各种人拷来拷去,散落在不同的服务器上,它们运行着同样的代码,却各有各的脾气。A 服务器风平浪静,B 服务器炸成烟花,C 服务器似乎活着但又不太对劲……而你,每天都在面对来自四面八方的“XX功能炸了”“接口500了”“部署完直接寄了”的灵魂拷问。

最离谱的是,它们都会从你这同步最新的代码,但到底是代码问题还是服务器环境问题,你根本没办法第一时间知道。于是,问题就变成了:如何把这些分散的错误日志规范地收集起来,好让我在别人冲进来质问之前,提前找到问题所在?

于是,我不情不愿地搞了个日志收集方案,顺便写了个 Golang 脚本来专门接收远程日志。虽然我并不想管这些破事,但现实就是,我要是再不解决,估计下次见到我,老板已经换了个人。

先把实现好的仓库放在这里:点击前往GitHub

需求分析与设计目标

在开发PHP工具包时,我需要一个满足以下特性的日志组件:

  1. 多输出渠道:同时支持文件、控制台、远程API等输出方式
  2. 格式解耦:允许不同输出使用不同格式(如开发环境用文本,生产环境用JSON)
  3. 低耦合扩展:新增处理器或格式化器时无需修改现有代码

核心架构设计

1. 接口先行:定义规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 日志处理器
interface LogHandlerInterface {
public function handle(
string $level,
string $title,
string $message,
array $context = []
): void;
}

// 日志格式化
interface LogFormatterInterface {
public function format(
string $level,
string $message,
array $context = []
): string;
}

通过接口隔离了「日志处理」与「格式转换」两个关注点,为后续扩展打下基础。

2. 责任链模式实现多路输出

1
2
3
4
5
6
7
8
9
10
11
12
13
class Logger {
private array $handlers;

public function __construct(array $handlers) {
$this->handlers = $handlers;
}

public function log(...$params) {
foreach ($this->handlers as $handler) {
$handler->handle(...$params);
}
}
}

每个处理器独立处理日志,形成处理流水线。典型处理器实现:

文件处理器核心逻辑:

1
2
3
4
5
6
7
8
9
10
class FileHandler implements LogHandlerInterface {
// 自动滚动日志文件
private function rotateLogFiles(string $logFile) {
$index = 1;
while (file_exists("{$logFile}.{$index}")) {
$index++;
}
rename($logFile, "{$logFile}.{$index}");
}
}

远程API处理器:

1
2
3
4
5
6
class RemoteApiHandler implements LogHandlerInterface {
public function handle(...$params) {
// 实际应使用异步HTTP客户端
HttpClient::post($this->endpoint, $formattedData);
}
}

3. 策略模式实现格式切换

通过注入不同的格式化器实现格式策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 文本格式化
class DefaultFormatter implements LogFormatterInterface {
public function format(...) {
return "[{$time}] {$level}: {$message} " . json_encode($context);
}
}

// JSON格式化
class JsonFormatter implements LogFormatterInterface {
public function format(...) {
return json_encode([
'timestamp' => microtime(true),
'level' => $level,
// ...其他字段
]);
}
}

在处理器中组合使用:

1
2
3
4
5
$logger = new Logger([
new FileHandler('app.log'),
new ConsoleHandler(),
new RemoteApiHandler('https://log-server.com/api')
]);

关键实现细节

1. 文件处理优化

  • 自动分割:通过maxFileSize​控制单个文件大小
  • 滚动策略:采用file.log.1​递增命名方式,避免覆盖历史日志
  • 目录创建:在首次写入时自动创建日志目录

2. 远程传输设计

  • 格式要求:强制使用JSON格式确保数据可解析
  • 头信息配置:预设Content-Type: application/json
  • 解耦网络层:将具体HTTP实现隔离在处理器之外

3. 异常处理原则

  • 静默失败:单个处理器异常不影响其他处理器执行
  • 开发友好:控制台处理器直接输出原始错误信息
  • 生产安全:文件处理器避免抛出致命错误

扩展实践示例

场景:添加企业微信通知

  1. 实现新处理器:
1
2
3
4
5
6
class WeChatHandler implements LogHandlerInterface {
public function handle(...) {
$markdown = "## {$title}\n**级别**: {$level}\n".$this->formatContext($context);
$this->sendToWeChat($markdown);
}
}
  1. 组合使用:
1
2
3
4
5
6
$logger = new Logger([
new FileHandler(...),
new WeChatHandler(WEBHOOK_URL),
]);

$logger->log(...)

模式应用总结

  1. 责任链模式的价值

    • 符合单一职责原则:每个处理器只关注自己的输出方式
    • 动态组合:运行时自由搭配不同处理器
    • 可扩展性:新增处理器无需修改核心逻辑
  2. 策略模式的优势

    • 格式转换与业务逻辑解耦
    • 支持不同场景的格式策略快速切换
    • 便于进行格式验证和单元测试

这种设计模式组合特别适合需要灵活扩展的日志系统,在保持核心稳定的同时,为各种定制需求留出了足够的扩展空间。

反正这玩意儿是搞完了。现在项目的日志终于变得清爽了一点,该输出到文件的就乖乖写文件,该打印到控制台的就老实滚屏,至于那些紧急的、可能导致我或者老板跑路的错误,就直接远程通知到我的服务器上。这样一来,我至少能在被质问之前,先假装冷静地说:“哦,这个问题我已经在看了。”

今天先这样,日志收集算是有个着落了。明天再搞个 Go 小脚本,把这些错误信息整理整理,毕竟光收集还不够,还得方便查看,不然到时候一堆日志堆在那,和没收集有什么区别?算了,明天的事就留给明天的自己头疼吧。