PHP核心基础模块设计之不可轻视的日志服务

可以说,日志服务是系统中不可或缺的基础模块之一,从小型网站,到大型企业级网站系统,都是如此。
但问题是,很多时候,由于考虑不周,日志服务是缺失的,或者是不全面的。由于激发的矛盾点是,日志服务不能满足日益增长的纪录需求,或者因为日志本身设计的不合理性影响了系统的正常运转,损害了主营业务,甚至还对日后升级切换统一的日志服务埋下了沉重的技术痛点。这些,皆因对日志服务没有清晰的理解与定位,也许是尚未对其引起关注。
需要纪录日志的场景可谓有很多,有用于开发调试排查问题用的,有用于关键业务节点的场景纪录,有对系统异常的监控,也有用于大数据分析的埋点和统计。除了要考虑日志服务的统一接入、统一格式、统一管理外,还要考虑到日志服务在高并发、高流量时不会对系统造成额外的损害。

1 PSR-3中的日志规范

在PHP开源社区里,已经存在很多成熟的日志服务解决方案。作为软件开发工程师,我们没有必要再去重复造轮子。如果确实有需要,也可以自建日志服务,但首先要熟悉PHP关于日志的约定。
摘自PSR-3的日志接口,日志的种类可分为8类,如下面代码所示:

<?php
namespace Psr\Log;

/**
 * Describes log levels
 */
class LogLevel
{
    const EMERGENCY = 'emergency';
    const ALERT     = 'alert';
    const CRITICAL  = 'critical';
    const ERROR     = 'error';
    const WARNING   = 'warning';
    const NOTICE    = 'notice';
    const INFO      = 'info';
    const DEBUG     = 'debug';
}

对于日志服务的选择,有四种方案。

2 自建简易日志服务

第一种,自建简易日志服务。简单的做法,利用文件写入file_put_contents()就能快速实现一个日志服务了。日志信息的基本组成有:时间,日志级别或类型,日志内容,以及上下文场景信息。这些组成要素通常以竖线分割,每一条日志为一行,最后的上下文场景信息是一个数组,需要进行序列化。例如,如果需要纪录新客注册的情况,日志类似如下:

2018-06-18 02:21:31|INFO|新客注册|{"nickname":"张三"}

一个快速简单实现的参考日志类如下:

<?php
class Logger {
    function log($level, $message, array $context = array()) {
        $file = '/path/to/application.log';

        $logData = array(
            date('Y-m-d H:i:s', time()),
            strtoupper($level),
            $message,
            json_encode($context, JSON_UNESCAPED_UNICODE),
        );
        $logStr = implode('|', $logData) . PHP_EOL;

        file_put_contents($file, $logStr, FILE_APPEND);
    }
}

注意到,在实现上面日志服务时,需要指定日志文件的存放路径,在对上下文场景数组信息进行JSON编码时要注意传递JSON_UNESCAPED_UNICODE选项以字面编码多字节 Unicode 字符,在写入日志文件时要采用追加的方式而非覆盖,日志内容最后要添加换行。这些都是实现时的注意点。
实现日志类后,写入日志就非常简单了,只需要一行代码即可。

Logger::log('INFO', '新客注册', array('nickname' => '张三'));

但若想实现一个完备的日志服务,还有很长的路要走,除了上面这些基本的注意点外,还有日志文件的分割、备份,对日志内容特殊字符的处理,PHP代码执行的调用栈信息纪录,权限的判断,允许日志写入级别的配置,和日志纪录的性能提升。
可以说,自建日志服务,只能解决前期的小问题,小需求,以及是作为技术上的一个小小尝试。自己实现过日志服务,才能更好明白日志服务组件的重要性和其原理、约束和规范。下面我们转入开源社区是如何提供日志服务的。

3 使用开源框架提供的日志服务

第二种,使用开源框架提供的日志服务。在我们开发人员熟悉的PHP开源框架中,都会提供日志服务,例如Yii、CakePHP、Laravel、Symfony、ThinkPHP等。我们以CakePHP为例,简单看下如何使用CakePHP提供的日志,以及其内部是如何实现的。
根据CakePHP官方文档的说明,可通过Cake\Core\Log进行日志配置。你可以为不同级别指定日志存储的方式,以文件日志为例,可以指定日志文件的目录保存路径、日志文件名,以及允许的日志级别。

use Cake\Log\Log;

// Short classname
Log::config('debug', [
    'className' => 'File',
    'path' => LOGS,
    'levels' => ['notice', 'info', 'debug'],
    'file' => 'debug',
]);

同样在CakePHP中,也是遵循了PSR-3的规范,支持POSIX系列的标准,日志级别分为以下八类。

    Emergency: system is unusable
    Alert: action must be taken immediately
    Critical: critical conditions
    Error: error conditions
    Warning: warning conditions
    Notice: normal but significant condition
    Info: informational messages
    Debug: debug-level messages

配置好日志后,就可以通过静态类方法`Cake\Log\Log::write()
或者使用特质LogTrait
来纪录日志。例如CakePHP官网提示的示例:

Log::write('debug', 'Something did not work');

让我们稍微来看下`Cake\Log\Log::write()
的背后做了什么事情,从而窥探开源框架对于日志的一般设计思路。从CakePHP摘取的相关源代码片段如下:

<?php
namespace Cake\Log;
class Log
{
    public static function write($level, $message, $context = [])
    {
        static::_init();
        if (is_int($level) && in_array($level, static::$_levelMap)) {
            $level = array_search($level, static::$_levelMap);
        }

        if (!in_array($level, static::$_levels)) {
            throw new InvalidArgumentException(sprintf('Invalid log level "%s"', $level));
        }

        $logged = false;
        $context = (array)$context;
        ……

        foreach (static::$_registry->loaded() as $streamName) {
            ……
        }

        return $logged;
    }
}

部分代码有省略,但也不难看出其处理的核心路径和思路。首先,使用static::_init()
进行必要的初始化,再继续深入进去可以找到在Cake\Log\Log::_loadConfig()
方法会对前面配置的信息进行委托解析和加载。然后,对传递的日志级别参数进行转换和检测。接下来,提取上下文相关信息。最后,遍历已注册且加载的日志处理器,进行日志写入的授理操作。处理完毕后,返回日志处理的结果。
通常,开源框架都会提供多套不同的日志流处理方式。CakePHP则内置提供了三种不同的日志引擎,它们分别是:控制台标准和错误输出、文件日志和PHP系统日志。

$ tree ./Log/Engine/
./Log/Engine/
|-- BaseLog.php
|-- ConsoleLog.php
|-- FileLog.php
`-- SyslogLog.php

0 directories, 4 files

就Cake\Log\Engine\FileLog日志引擎而言,它的内部实现和我们前面介绍的简易文件日志基本类似。通过构造函数完成了基本的初始化工作后,在Cake\Log\Engine\FileLog::log()
方法内,就可以看到与前面例子的相似之处。不过,它的实现更为严谨,考虑的因素更多,实现更为细致。
作为完整的解说,这里再稍微粘贴一下Cake\Log\Engine\FileLog::log()
的相关代码片段,让大家有一个更全面的认识。

<?php
namespace Cake\Log\Engine;

class FileLog extends BaseLog
{
    public function log($level, $message, array $context = [])
    {
        $message = $this->_format($message, $context);
        $output = date('Y-m-d H:i:s') . ' ' . ucfirst($level) . ': ' . $message . "\n";
        $filename = $this->_getFilename($level);
        ……

        $pathname = $this->_path . $filename;
        ……

        $exists = file_exists($pathname);
        $result = file_put_contents($pathname, $output, FILE_APPEND);
        static $selfError = false;
        ……

        return $result;
    }
}

在一开始,FileLog也会对传递进来的参数进行格式化,然后在前面添加当前时间,在行末追加换行。关键部分也是使用file_put_contents()函数把新的日志追加到日志文件。
小结一下,开源框架提供的日志组件,在使用前一般都可以进行配置,指定使用的日志流引擎、允许的日志级别和个性化的配置。在实现的内部,开源框架会提供统一的日志控件入口,并内置多种日志存储或处理方式。顺着这个思路,再去使用开源框架的日志就会更加得心应手了。

4 使用log4php日志组件

第三种,使用log4php日志组件。与前面开源框架内的日志不同的是,log4php是一个独立的、针对PHP、多功能日志框架。正如其官网介绍的,它的主要特性有:

第1级无序列表”>可通过XML文件、INI属性文件或者PHP文件进行配置
支持多种日志存储方式,包括:控制台的标准输出和错误输出、文件、邮件、数据库、Socket和系统日志
多套内置日志消息格式,包括:HTML、XML、自定义格式

在项目中引入log4php,然后进行使用,其过程是非常简单的,因为它已经把强大的封装好简单易用的接口。下面快速来领略一下它的魅力。下载log4php并且解压后,通过XML文件进行相关配置,参考log4php官网说明,但把最低日志级别设置为DEBUG。与此同时,为了接近前面的日志格式,我们还通过conversionPattern选项配置了日志的内容格式。创建config.xml文件,并放置以下内容:

    <configuration xmlns="http://logging.apache.org/log4php/">
        <appender name="myAppender" class="LoggerAppenderFile">
            <param name="file" value="myLog.log" />
            <layout class="LoggerLayoutPattern">
                <param name="conversionPattern" value="%date{Y-m-d H:i:s}|%level|%msg%n" />
            </layout>
        </appender>
        <root>
            <level value="DEBUG" />
            <appender_ref ref="myAppender" />
        </root>
    </configuration>

日志文件为当前目录的myLog.log文件,然后编写客户端使用代码,其实现逻辑是:加载log4php,加载xml配置文件,获取日志实例,最后写日志。

<?php
// 根据解压的位置,加载log4php,也可以使用composer方式进行加载
include('./src/main/php/Logger.php');

// 加载xml配置文件
Logger::configure('config.xml');

// 获取日志实例
$log = Logger::getLogger('myLogger');

// 写日志
$log->trace("我的第一条日志");   // 不会纪录,因为:TRACE < WARN
$log->debug("我的第二条日志");
$log->info("我的第三条日志");
$log->warn("我的第四条日志");
$log->error("我的第五条日志");
$log->fatal("我的第六条日志");

把上面的PHP代码保存到test.php文件,然后通过控制台命令$php test.php
执行,最后在myLog.log文件中,可以看到类似以下的日志内容。

$ cat ./myLog.log 
2018-06-18 07:11:18|DEBUG|我的第二条日志
2018-06-18 07:11:18|INFO|我的第三条日志
2018-06-18 07:11:18|WARN|我的第四条日志
2018-06-18 07:11:18|ERROR|我的第五条日志
2018-06-18 07:11:18|FATAL|我的第六条日志

与PSR-3约定的规范不同的是,log4php只提供了6种日志级别,如上所示,日志级别从最高到最低依次是:FATAL、ERROR、WARN、INFO、DEBUG、TRACE。相比之下少了2种日志级别,但对于大部分的项目开发已经是适用的了。此外,log4php值得称赞的是,它提供了更为丰富的Appender,包括有文件、控制台、数据库、邮件、MongoDB、Socket、系统日志等。
只需要简单集成整合,就能在项目中使用功能强大的log4php。

5 使用日志生态系统

日志服务,从小的角度实现,可以是一个函数,或者一个日记类;而从大的层面来宏观看待,可以把日记的纪录、收集、分析等各个环节作为了一个子系统来看待,进而演变成由异构系统组成的更大的日志生态系统。当诸如log4php这样的日志类库无法满足日益庞大的企业级系统对于日志的需求时,可以考虑搭建更完备的日志系统。当下,比较流行的是ELK架构,由ElasticSearch 、Logstash 、Kibana三个系统组成。关于ELK的介绍,超出了本书的范畴,感兴趣的同学可以继续深入研究。
顺便也可以从日志这一点看出,任何一个基础模块,最开始都是以一个函数或者一个类的形式简单实现,其次到了框架这一层,则封装成通用的组件、包、类库或者插件。再往上,可以单独抽离出来,形成一个工具,一个子系统,一套模板。最后,独立的工具在大型企业级系统中无法满足苛刻的需求时,会催生更系统化、具备一定生态圈的产品、解决方案。
与日志服务相似的另一个案例则是消息队列。我们可以简单地使用数据库实现一个消息队列,也可以使用开源框架比如PhalApi的Task计划任务扩展类库,也可以使用类似RabbitMQ这样独立的消息队列类库,甚至还可以使用异构系统搭建一套企业级消息中心系统。
所以,我们平时在开发过程中,也可以顺着这个思维方向走。在开发功能,使用组件时,结合当前项目、公司和团队的情况,选择配备的对策。小团队用大系统,会成本高昂且收益不明显;大公司用小工具,就会步履维艰,捉衣见肘。
不管是使用何种日志服务,回到我们这一节的课题,在接入日志时我们都应尽量统一客户端使用的方式。不管是使用同时阻塞式写入,还是采用异步定时入库的策略,日志服务背后的实现应该对客户端开发人中是透明的。尽早考虑日志服务的设计,收益颇丰。

发表评论