PHP:代码审查 - 改进糟糕的 PHP 日志记录代码
注意:这篇文章发布已超过两年,因此其中包含的信息可能已过时。如果您发现了问题,请留言,我公司会尽力更正。
2023 年 8 月 6 日 - 阅读时长 18 分钟
我时不时会阅读《每日代码吐槽》(The Daily WTF),其中一篇关于糟糕的 PHP 日志记录代码的故事吸引了我的注意。这篇文章分析了一段用于将字符串记录到文件的 PHP 代码,但每生成一条日志,应用程序的运行速度就会逐渐变慢。
PHP 是一门很棒的语言,它能让我们轻松编写代码且不易出错。不过,这种便捷性也有一个缺点,它可能会让初学者编写出能运行但最终会长期引发问题的代码。文章中给出的 PHP 代码就是一个典型例子。
我知道《每日代码吐槽》的目的不是解决代码中发现的问题,而是以讽刺的视角看待糟糕的编码示例。但我觉得研究这段代码并解决其中的问题是件好事。人们很容易嘲笑代码,但我总是努力寻找这些问题的解决方案,以起到教育作用。在 Drupal 开发、Drupal 模块开发和 Drupal 升级等工作中,也会遇到类似需要解决代码问题的情况,尤其是在 Drupal11 版本中,提升代码质量至关重要。
以下是文章中的代码。这是一个类中日志函数的相关部分,该函数假定接收一个字符串类型的 $message 参数,并将其添加到日志文件末尾(日志文件路径由 getPath() 方法提供)。
$path = self::getPath();
if (file_exists($path)) {
$content = file_get_contents($path);
} else {
$content = "";
}
$content .= "\n" . date('Y-m-d H:i') . " | " . $message;
file_put_contents($path, $content);
不用说,你不应该在生产环境中使用这段代码。
这段代码的工作原理是将日志文件的内容加载到内存中,在文件内容末尾追加一行,然后将内容写回文件。file_get_contents() 函数是 PHP 的内置函数,用于将文件加载到内存中,因此这段代码的根本问题是日志文件越长,将其加载到内存中所需的时间就越长。
在 PHP 中,字符串被归类为可变类型,这意味着它们可以被自由修改。在内部,这是通过写时复制系统实现的,即对字符串的任何更改都是通过将字符串复制到一个新的内存地址并进行更改,然后丢弃旧的副本。对于上面的代码,这意味着不仅从文件中读取数据时会有性能损失,而且在将数据连接到字符串末尾时也会有性能损失。
在一个日志记录量极少的小应用程序中,这种日志记录方法最终可能会引发问题;但在这个例子中并非如此。显然,这段代码被用在一个活跃的应用程序中,很快就导致日志文件达到了数 GB,使整个服务器出现了严重的性能问题。
总结一下,这里存在两个问题:
- 加载整个日志文件。
- 将日志条目连接到日志末尾。
一、简单修复方案
解决这些问题的直接方法是移除 file_get_contents() 调用,仅使用带 FILE_APPEND 参数的 file_put_contents() 函数。这样可以将日志条目追加到日志文件中,而无需先从文件中加载信息。我们还将换行符放在日志条目的末尾,以便每条新日志都为下一条日志条目留出空行。
$path = self::getPath();
$content = date('Y-m-d H:i') . " | " . $message . "\n";
file_put_contents($path, $content, FILE_APPEND);
然而,这样做会让我们面临两个问题,因为在尝试写入文件之前,我们没有确保文件可写。
另一种方法是使用其他 PHP 文件函数来确保文件存在且可写。这可以使用 fopen() 函数来实现,该函数的第一个参数是要打开的文件,第二个参数是模式设置。有几种模式可供选择,但由于我们要打开文件并将指针定位到文件末尾,所以需要使用 "a" 文件模式。
$fp = fopen($path, 'a');
打开文件句柄后,我们可以使用 fwrite() 函数向其中写入数据。该函数只需要文件句柄变量(我们通过 fopen() 函数创建的)和要写入的数据。我没有在 date() 函数中使用 "Y-m-d H:i" 这样的日期格式化字符串,而是使用了 "c",它会以 ISO 8601 格式输出当前时间,这是一种更符合标准的日期表示方式。最后,我们使用 PHP_EOL 常量而不是 "\n" 来输出换行符,这样更具跨平台兼容性。这个换行符放在日志条目的末尾,效果相同,但会在日志文件末尾留下一个空行。
fwrite($fp, date('c') . "|" . $message . PHP_EOL);
以这种方式添加日志消息,我们无需先检查日志文件中是否已有内容,就可以直接添加日志。
以下是修改后的完整代码,其中还包含了一些错误检查代码,以防止在日志目录不可写或文件无法打开时日志记录产生错误。
$path = self::getPath();
if (is_writable(dirname($path)) === false) {
// 日志目录不可写。
return;
}
$fp = fopen($path, 'a');
if ($fp === false) {
// 无法打开文件句柄。
return;
}
fwrite($fp, date('c') . " | " . $message . PHP_EOL);
使用这种方法,我们将日志写入时间缩短到了约 0.00001 秒,即使对于数 GB 的日志文件,这个时间也不会改变。
二、使用 PSR - 3
到目前为止,我们只是修复了底层代码。我们可以(而且应该)更进一步,使用符合 PSR - 3 标准的日志记录系统。在 Drupal 开发特别是 Drupal11 的开发过程中,遵循标准的日志记录系统能极大提升系统的可维护性。
PHP 标准建议(PSR)是一套旨在解决 PHP 开发者面临的许多常见挑战的标准。其中包括编码标准(PSR - 1)、自动加载(PSR - 4)、缓存接口(PSR - 6)、事件调度器(PSR - 14)和日志接口(PSR - 3)等。
我不会在这里详细介绍 PSR - 3 涵盖的所有内容,但关键部分是它应该支持不同的日志级别(调试、信息、通知、警告、错误、严重、警报、紧急),并且在日志系统中有统一的消息创建接口。
PHP 库 Monolog 是一个符合 PSR - 3 标准的日志记录包,是为系统引入日志记录的优秀选择。它可以将日志写入文件(就像我们的示例一样),但你也可以选择将日志记录到系统日志,或者引入自己的日志处理程序,将日志推送到外部系统甚至数据库。
Monolog 是一个 Composer 包,因此可以使用 composer require 命令轻松安装。
composer require monolog/monolog
要使用该系统,我们需要创建一个日志通道,系统的主要功能将通过这个对象来实现。
$log = new Monolog\Logger('MyWebsite');
然后,我们需要向日志记录器添加一个处理程序,用于将日志记录到我们想要的位置。由于我们要将日志写入文件,所以使用 Monolog\Handler\StreamHandler 类,它允许我们将日志写入任意文件。StreamHandler 接收第二个参数来指定级别,即记录日志的最低级别。
$log->pushHandler(new Monolog\Handler\StreamHandler('./mono.log', Monolog\Level::Warning));
完成这些设置后,我们现在可以开始编写日志了。我们可以使用 warning() 或 error() 等方法创建不同级别的日志。
$log->warning('Some log message.');
$log->error('Something bad happened');
我们甚至可以在日志中注入参数,以便更详细地描述发生的事情。
$log->warning('The action {action} was performed.', ['action' => 'event']);
这将在名为 "mono.log" 的文件中创建一条日志,包含日志消息和 ISO 8601 格式的日期。
使用上述方法,我们将原来的日志代码修改如下。
$log = new Logger('MyWebsite');
$log->pushHandler(new StreamHandler(self::getPath(), Level::Warning));
$log->warning($message);
当然,我们在这里将日志级别始终设置为 "警告",但至少我们以更统一的方式进行日志记录。现在日志以符合标准的方式写入日志文件。
我们原来的代码现在通过添加一个强大且经过充分测试的日志框架得到了改进。这个框架可以直接集成到现有的类中,而无需对应用程序的其他部分进行大规模重写。通过将 Logger 对象注入到现有类中,我们可以进一步改进它,这样就不必每次都实例化它并添加处理程序。
当面对一个存在此类问题的遗留应用程序时,修复基本问题对于确保平台的稳定性至关重要。人们往往会想把所有东西都扔掉重新开始,但通过解决这样的基本问题,你可以在不花费巨额资金进行重写的情况下逐步改进应用程序。
你是否有内容想提交给 PHP:代码审查(PHP:CSI)?如果有,请通过填写我们的联系表单或留言与我们联系。


