日志模块

从零开始编写一个PHP框架 系列的《日志模块》

项目地址:terse

前言

在一个系统中,日志模块会记录系统的运行情况,可能是异常,也可能是我们的一些调试信息。

需求分析

  • 可以实现记录功能
  • 可以指定记录级别
  • 可能有多种日志记录方式
  • 可以指定记录方式
  • 可以指定记录格式

目录结构

这个模块需要实现的东西比较多,涉及多个文件,索性先将目录结构抛出来。

.
├── Adapter
│   └── File.php      文件日志类
├── Adapter.php       日志构抽象类
├── Formatter
│   └── Line.php      行格式化类
└── Formatter.php     格式化抽象类

级别定义及释义

名称 类型 释义
DEBUG 调试 调试信息
INFO 信息 程序输出信息
NOTICE 通知 程序可以运行,但是还不够完美的错误
WARNING 警告 需要发出警告的错误
ERROR 一般错误 一般性错误
ALERT 警戒性错误 必须被立即修改的错误
CRITICAL 临界值错误 超过临界值的错误
EMERG 严重错误 导致系统崩溃无法使用

日志抽象类

考虑会有多种记录方式,所以我们需要一个抽象类,来定义一些基本操作。

<?php
abstract class Adapter
{
}

抽象方法

由于日志的多样化,我们需要将数据拼凑的操作放在抽象类里,关于构造函数、写日志和关闭连接的动作要在子类里去操作。

<?php
abstract class Logger
{
    /**
     * 构造函数
     * 
     * @param string $name
     * @param mixed  $options
     */
    abstract function __construct(string $name, array $options = []);

    /**
     * 写入日志
     * 
     * @param  array  $data
     * @return void
     */
    abstract public function save(array $data);

    /**
     * 关闭连接
     * 
     * @return void
     */
    abstract public function close();
}

属性初始化

上面的表格已经定义了各个级别,并加了相应的释义,在类初始化的时候,会默认初始化各个级别。

<?php
abstract class Adapter
{
    /**
     * 调试
     * 调试信息
     */
    const DEBUG     = 'DEBUG';

    /**
     * 信息
     * 程序输出信息
     */
    const INFO      = 'INFO';

    /**
     * 通知
     * 程序可以运行,但是还不够完美的错误
     */
    const NOTICE    = 'NOTICE';

    /**
     * 警告
     * 需要发出警告的错误
     */
    const WARNING   = 'WARNING';

    /**
     * 一般错误
     * 一般性错误
     */
    const ERROR     = 'ERROR';

    /**
     * 警戒性错误
     * 必须被立即修改的错误
     */
    const ALERT     = 'ALERT';

    /**
     * 临界值错误
     * 超过临界值的错误
     */
    const CRITICAL  = 'CRITICAL';

    /**
     * 严重错误
     * 导致系统崩溃无法使用
     */
    const EMERG     = 'EMERG';
}

方法初始化

如果对外只提供一个方法,那我们每次都需要注明需要什么级别的日志,如下:

$logger->log(Logger::DEBUG, $message);

为了语义化和统一,每个类型都会提供一个方法供外界调用,而 log 方法会变为受保护的方法。

<?php
class Adapter
{
    ...

    /**
     * 调试
     * 
     * @param  string $message
     * @return void
     */
    public function debug($message) {
        $this->log(self::DEBUG, $message);
    }

    /**
     * 信息
     * 
     * @param  string $message
     * @return void
     */
    public function info($message) {
        $this->log(self::INFO, $message);
    }

    /**
     * 通知
     * 
     * @param  string $message
     * @return void
     */
    public function notice($message) {
        $this->log(self::NOTICE, $message);
    }

    /**
     * 警告
     * 
     * @param  string $message
     * @return void
     */
    public function warning($message) {
        $this->log(self::WARNING, $message);
    }

    /**
     * 一般错误
     * 
     * @param  string $message
     * @return void
     */
    public function error($message) {
        $this->log(self::ERROR, $message);
    }

    /**
     * 警戒性错误
     * 
     * @param  string $message
     * @return void
     */
    public function alert($message) {
        $this->log(self::ALERT, $message);
    }

    /**
     * 临界值错误
     * 
     * @param  string $message
     * @return void
     */
    public function critical($message) {
        $this->log(self::CRITICAL, $message);
    }

    /**
     * 严重错误
     * 
     * @param  string $message
     * @return void
     */
    public function emerge($message) {
        $this->log(self::EMERG, $message);
    }

    ...
}

事务

有时候,我们需要在一次操作中,写入多次日志。同时,如果出现异常,则回滚之前需要写入的内容。

针对这种情况,我们需要实现一种类似于数据库事务的功能。

然而,这里的事务和数据库的事务有所区别,主要是为了我们解决写入多行日志准备,不过在语义和使用上和数据库事务一致。

<?php
abstract class Logger
{
    ...

    /**
     * 日志信息
     * 
     * @var array
     */
    protected $_log = [];

    /**
     * 事务
     * 
     * @var boolean
     */
    protected $_transcation = false;

    /**
     * 开启事务
     * 
     * @return void
     */
    public function begin()
    {
        $transcation = $this->_transcation;

        if ($transcation) {
            throw new \Exception('不可重复开启事务', -__LINE__);
        }

        $this->_transcation = true;
    }

    /**
     * 提交事务
     * 
     * @return mixed
     */
    public function commit()
    {
        $transcation = $this->_transcation;
        $log = $this->_log;

        if (!$transcation || !$log) {
            return false;
        }

        $this->_transcation = false;

        foreach ($log as $row) {
            $this->save($row);
        }

        $this->_log = [];
    }

    /**
     * 回滚事务
     * 
     * @return void
     */
    public function rollback()
    {
        $this->_transcation = false;
        $this->_log = [];
    }

    ...
}

log 的实现

到这里,我们需要实现 log 方法了。

log 需要辨别两种情况:一种是,不存在事务的时候,直接写入文件。一种是,存在事务时,等 commit 再提交。

<?php
abstract class Logger
{
    ...

    /**
     * 记录日志
     * 
     * @param  string $level
     * @param  mixed  $message
     * @return void
     */
    protected function log($level, $message)
    {
        $data = [
            'level' => $level,
            'message' => $message,
            'time' => time()
        ];

        $transcation = $this->_transcation;

        if ($transcation) {
            $this->_log[] = $data;
        } else {
            $this->save($data);
        }
    }
}

设置格式化类

在写入日志的时候,我们总是需要一些漂亮统一的格式,这时,我们需要一个单独的类来进行处理。

<?php
abstract class Logger
{
    ...

    /**
     * 格式化类
     * 
     * @var Formatter
     */
    protected $_formatHandler;

    /**
     * 格式化类
     * 
     * @param Formatter $formatHandler
     */
    public function setFormatHandler(Formatter $formatHandler)
    {
        $this->_formatHandler = $formatHandler;
    }

    ...
}

至此,抽象类结束。

文件日志类

在一般系统日志中,文件日志是最为常见的,这里以文件日志为例,实现上述抽象类。

初始化类

按照日常习惯,我们这个类有打开、写入、关闭的功能,同时,我们需要指定日志地址,或者其它配置。

<?php
class File extends Adapter
{
    /**
     * 文件句柄
     * 
     * @var resource
     */
    protected $_fileHanlder;

    /**
     * 日志文件地址
     * 
     * @var string
     */
    protected $_filePath;

    /**
     * 额外配置
     * 
     * @var array
     */
    protected $_options = [];

    /**
     * 构造函数
     * 
     * @param string $name
     * @param array  $options
     */
    function __construct(string $name, array $options = [])
    {
        $this->_filePath = $name;
        $this->_options = $options;
        $this->open();
    }

    /**
     * 打开文件
     * 
     * @return void
     */
    protected function open()
    {
    }

    /**
     * 存储文件
     * 
     * @param  array  $data
     * @return mixed
     */
    protected function save(array $data)
    {
    }

    /**
     * 关闭文件
     * 
     * @return void
     */
    public function close()
    {
    }

    /**
     * 析构函数
     */
    function __destruct()
    {
        $this->close();
    }
}

完善打开文件

我们需要写入日志,当然,指定的文件地址可能不存在,目录也有可能不存在。

logs/test.log             # test.log 不存在
logs/2018/09/18/test.log  # 2018/09/18/test.log 不存在

所以我们需要考虑所有情况,不存在则创建。

<?php
/**
 * 打开文件
 * 
 * @return void
 */
protected function open()
{
    $filePath = $this->_filePath;

    // 获取文件目录
    $dir = dirname($filePath);

    // 检测创建目录
    if (!is_dir($dir)) {
        if (!mkdir($dir, 0666, true)) {
            throw new \Exception('文件目录创建失败,目录:' . $dir, -__LINE__);
        }
    }

    if (!is_writable($dir)) {
        throw new \Exception('不存在创建目录的权限,请分配后重试,目录:' . $dir, -__LINE__);
    }

    // 检测创建文件
    if (!is_file($filePath)) {
        if (!touch($filePath)) {
            throw new \Exception('文件创建失败,文件:' . $filePath, -__LINE__);
        }
    }

    // 开启文件,后续可以把 mode 交给配置参数
    $fileHanlder = @fopen($filePath, 'a');
    $this->_fileHanlder = $fileHanlder;
}

完善保存文件

到这里是真正写入了,上面我们为了能够让日志输出的更加漂亮,添加了一个 Formatter,它将在这里起作用。

<?php
/**
 * 存储文件
 * 
 * @param  array  $data
 * @return mixed
 */
protected function save(array $data)
{
    // 文件路径
    $filePath = $this->_filePath;

    // 格式化句柄
    $formatHandler = $this->_formatHandler;

    if (!$formatHandler) {
        throw new \Exception('未指定格式化类型', -__LINE__);
    }

    // 文件句柄
    $fileHanlder = $this->_fileHanlder;

    // 如果不是资源类型,可能在创建目录或者创建文件时失败,也有可能没有写权限
    if (!is_resource($fileHanlder)) {
        throw new \Exception('文件资源错误', -__LINE__);
    }

    // 写入文件
    fwrite($fileHanlder, $formatHandler->format($data['level'], $data['message'], $data['time']));
}

完善关闭文件

关闭什么面特别的,直接上代码。

<?php
/**
 * 关闭文件
 * 
 * @return void
 */
public function close()
{
    $fileHanlder = $this->_fileHanlder;
    fclose($fileHanlder);
}

格式化抽象类

相对于日志抽象类,格式化抽象类就简单多了。

我们需要让其按照我们想要的格式写入日志,那么我们就需要一个配置格式的参数。由于日期比较特殊,这里也给个配置的参数。

当然,既然是格式化,那当然少不了格式化的方法了。

<?php
abstract class Formatter
{
    /**
     * 消息体格式化
     * 
     * @var string
     */
    protected $_format = '[:date:] [:level:] :message:';

    /**
     * 日期格式化
     * 
     * @var string
     */
    protected $_dateFormat = 'Y-m-d H:i:s';

    /**
     * 构造函数
     * 
     * @param string $format
     * @param string $dateFormat
     */
    function __construct(string $format = '', string $dateFormat = '')
    {
        if ($format) {
            $this->_format = $format;
        }
        if ($dateFormat) {
            $this->_dateFormat = $dateFormat;
        }
    }

    /**
     * 格式化
     * 
     * @param  string $level
     * @param  string  $message
     * @param  string $time
     * @return mixed
     */
    abstract public function format(string $level, string $message, string $time);
}

行格式化类

这里以行格式化作为例子。

我们需要按照指定的格式来渲染每一行记录,我在设定配置的时候,个体每个类型,如:datelevelmessage,两边都添加了 :,主要用作区分。

这里使用 str_replace 可以批量操作。

<?php
class Line extends Formatter
{
    /**
     * 格式化
     * 
     * @param  string $level
     * @param  string $message
     * @param  string $time
     * @return string
     */
    public function format(string $level, string $message, string $time)
    {
        $format = $this->_format;
        $dateFormat = $this->_dateFormat;

        $date = date($dateFormat, $time);
        return str_replace([':date:', ':level:', ':message:'], [$date, $level, $message], $format) . PHP_EOL;
    }
}

到此,整个日志模块就告一段落了。

完整代码

本来准备放的,但是涉及四个文件,而且代码总数也蛮长的,这里就不放了,有需要的小伙伴可以去我的代码库里看。

总结

本来是想用一个类来解决掉的,也没有想写那么多,但是写写就发现需要更好的设计。

下一篇《事件管理模块》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容