PHP⾃定义业务⽇志(monolog源码学习)
聊聊⾃⼰从开始⼯作到如今,对⽇志的认识。
在离校实习前,也多多少少做过⼀些⾃个玩的项⽬,后台管理系统、都有整过,对所谓的⽇志没有什么概念,需要做什么记录都是操作数据库的⾃定义log表。
开始实习后,慢慢有接触到⼀些所谓⽇志的概念,最先接触的是ThinkPHP3.1的框架,不过有使⽤到的都是⾃定义打印⽇志,对想要记录的数组写⼊到⾃定义⽇志⽂件⾥。⽐如每次想要打印数据,调⽤⼀个公共函数,依赖系统函数fopen、fwrite、fclose,⽇志⽂件名以⽇期加调⽤⽅法名定义。
随着⼀天天的使⽤,到了某⼀⽇,也许是觉得这样的⽇志都集中在⼀个⽬录下,数量太过庞⼤;也许是觉得⼀个⽇志⽂件写⼊内容太多,如果是通过浏览器访问⽇志⽂件要等待特别久时间,甚⾄会卡死;也许是发现业务⽇志即调⽤即写⼊的⽅法,在并发量不低的情况下,导致不同⽤户的⽇志内容混杂,不利于排错;也许是发现了TP3.1项⽬Runtime内的代码执⾏LOG随时间、⼜随⽇志⼤⼩切割很好⽤;也许是看了TP3.1源码打印⽇志的部分,发现⽇志还能这么⽤。
也忘了具体是什么原因了,也许是⼀个个原因的积累,总之决定换个⽇志记录⽅式。
看了下TP3.1源码的⽇志实现部分,两个部分让我感觉像是发现了新⼤陆,⼀个是register_shutdown_fu
nction函数(细节不说明了,另外的博客⾥有详细解释),⼀个是每次调⽤⽇志,都将内容存在⼀个数组增量内,即保存在内存,在所有业务逻辑执⾏完成
后,register_shutdown_function函数注册的⽅法将被执⾏,在此处将⽇志刷新到⽇志⽂件内,同时具体输出到哪个⽇志⽂件呢?这个也有讲究,⾸先⽇志⽂件名也是按照年⽉⽇来命令,但是在输出前,会优先判断当前⽇志⽂件的⼤⼩,若超过预定义最⼤值,则将当前⽂件重命名,加上时间戳的前缀来标识,新⽣成⽇志⽂件来存储⽇志。这种实现⽅式的好处在于,⽇志⼤容量被切割成多个⽂件,避免⼀个⽂件过⼤。个⼈认为也有不好的地⽅,⽐如所有⽇志⽂件都存储⼀个⽬录下,随着时间增长这个⽬录的⽂件数会越来越多。
⽇志暂存储在内存,封装后⼀次调⽤error_log函数打印。现在说来可能只是个基础的概念,不过对当时的⾃⼰来说,确实觉得神奇了好久。
想⾃⼰重新实现⼀种打印⽇志的⽅式,⾸先要满⾜⼏点要求:
1、⽇志能满⾜⼤⼩超过设置最⼤值后进⾏切割;
2、单个⽤户的请求⽇志,不会被并发影响,保证每次的排错⽇志相邻内容都是⼀个⽤户的;
3、可以根据不同的业务模块打印⽇志;
4、⽇志⽂件存储⽬录与⽇期相关,保证⼀个⽬录⽂件数不会太多,同时想定位到指定⽇期⽇志⽅便查。
⽇志⼯具类实现源码:
<?php
class LogModel {
static $logArr = array();
static $logRootPath = '';
static $class = '';
static $method = '';
/**
* ⽇志⼯具初始化
* */
public static function init() {
//获取⽇志⽂件⽬录
if (empty(self::$logRootPath)) self::$logRootPath = realpath(APP_PATH.'Log') . DIRECTORY_SEPARATOR;
}
/**
* ⽇志内容保存
* @static
* @access public
* @param  $logName  ⽇志模块名称
* @param  $content  ⽇志内容
* @param  $logTitle ⽇志内容头部
* @return void
* */
public static function writeLog($content, $logName = 'Common', $logFile = 'common', $logTitle = '') {
//获取⽇志⽂件⽬录
if (empty(self::$logRootPath)) self::$logRootPath = realpath(APP_PATH.'Log') . DIRECTORY_SEPARATOR;
if (empty(self::$logArr[$logName][$logFile])) {
self::$logArr[$logName][$logFile] = '----------------start---------------------'.PHP_EOL;
}
if (is_array($content)) {
if(version_compare(PHP_VERSION,'5.4.0','<')){
$tempStr = json_encode($content);
$content = preg_replace_callback("#\\\u([0-9a-f]{4})#i",function($matchs)                            {
return iconv('UCS-2BE', 'UTF-8', pack('H4', $matchs[1]));
},$tempStr);
} else $content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if (!empty($logTitle)) $logTitle .= ':';
self::$logArr[$logName][$logFile] .= (date('Y-m-d H:i:s')."======>>>>>>>{$logTitle}{$content}".PHP_EOL.PHP_EOL);
}
public static function close() {
if (empty(self::$logArr)) return;
$logPath = self::$logRootPath.date('Y_m_d');
//创建⽇志⽂件⽬录
五大唱片if (!file_exists($logPath)) mkdir($logPath);
乞巧是什么意思
//逐个⽇志⽂件写⼊
foreach (self::$logArr as $key => $value) {
$logDir = $logPath . DIRECTORY_SEPARATOR . $key;
if (!file_exists($logDir)) mkdir($logDir);
self::logEnd($value, $logDir);
}
self::$logArr = null;
}
public static function logEnd($data, $logDir) {
foreach ($data as $k => $v) {
//获取⽇志⽂件名避免单个⽇志⽂件太⼤
$count = 1;
王大治和董洁while(true) {
//⽣成⽇志⽂件名
$logFile = "{$logDir}" . DIRECTORY_SEPARATOR . "{$k}_{$count}.log";
//第⼀次写⼊⽇志
if (!is_file($logFile)) break;
//⽇志⽂件200M切割 byte / 1024 / 1024 = kb / 1024 = mb
$file = filesize($logFile) / 1024 / 1024;
if ($file < 200) break;
$count++;
}
$v = rtrim($v);
$v .= PHP_EOL.'-----------------end----------------------'.PHP_EOL.PHP_EOL;
error_log($v, 3, $logFile);
}
}
}
调⽤writeLog()⽅法,传⼊⽇志内容、⽇志⽬录名、⽇志⽂件名、⽇志titile信息,内容存储在全局静态数组内,在业务代码执⾏结束后,系统调⽤close()⽅法,将不同⽇志⽬录下的⽇志逐个打印,写⼊过程中判断⽇志⽂件⼤⼩是否超过预定义最⼤值,超过则⽣成新⽂件,以后缀1、2、3递增。
打印内容格式:
writeLog(["xxx"=>"aaa"], "xxxx", "xxxx", "请求报⽂");
writeLog(["sss"=>"qqq"], "xxxx", "xxxx", "返回报⽂");
----------------start---------------------
2018-08-06 14:35:01======>>>>>>>请求报⽂:{"xxx":"aaa”}
2018-08-06 14:35:01======>>>>>>>返回报⽂:{"sss":"qqq”}
-----------------end----------------------
后续发现TP5的⽇志打印,对不同年⽉也做了⽬录分离的处理,在⽇志⽂件过⼤时做切割,保证⼀个⽬录内⽂件不过多,说明这个考虑还是有必要的。基于register_shutdown_function函数输出⽇志⽅式,在实践中发现确实不错,代码执⾏异常,仍能保证⽇志正常⽣成,对排错、查漏⽅⾯都有好处。
原本对业务⽇志⽣成⽅⾯减少了研究,源于学习composer基本使⽤过程中,发现monolog库被⽤的较多,不少好框架都直接依赖作为系统库。StreamHandler是输出到⽂件的处理器,也是基于调⽤即输
徐少华妻子出的⽅法;再有BufferHandler,不过暂时还不太满意,多条⽇志,基于循环分别执⾏fwrite,简单的并发测试,发现⽇志就会重合。暂时还没研究laravel是怎么来⽣成系统⽇志的,这个框架会是以后研究的⼀个主题。
⽇志打印到⾃定义⽬录暂时发现了两个Handler,好像不太满意,要不要⽤呢?答案是肯定的,主要原因是monolog遵循了PSR3规范,换个⾓度想,之前写的⽇志⼯具类,也许公司⾥其他⼈觉得可以,会沿⽤下。但是对外呢?不符合规范⾃成⼀套的东西,换我我也不⼀定乐意⽤,遵循规范,说不定哪天⾃⼰的⼩玩意⼉被很多⼈认同,那会是个很开⼼的事情。
况且,monoglog的多样性也吸引了我,不同的Handler输出⽇志到不同的位置,syslog、邮件、⽂件、数据库等等;基于多样化formatter规范⽇志内容模版;⽇志分成多个级别,实现了整个库的功能解耦。即使我对⽬前认知的Handler即调⽤即打印不满意,也有解决办法,⽐如转换⽇志打印思路、⽐如⾃定义⼀个Handler,可以充分发挥⾃⼰的想象。
这篇内容原本想说说monolog简单应⽤过程的源码⾛向,不知不觉总结过往写了好多,步⼊下正题。
就以这段代码做个测试
<?php
require 'vendor/autoload.php';
$post = ['age'=>'15', 'amount'=>'100', 'cardno'=>'11112222222'];
$monolog = new Monolog\Logger('Pay');
$monolog->pushHandler(new Monolog\Handler\StreamHandler('./logs/1.log'));
$monolog->warn(json_encode($post));
执⾏后,查看logs⽬录的1.log⽂件,内容如下:
[2018-08-06 23:54:18] Pay.WARNING: {"age":"15","amount":"100","cardno":"11112222222"} [] []
从这段代码中,拆解出来两块内容,
1. Logger是个核⼼控制类,push新的Handler,warn打印⽇志
2. StreamHandler是⽇志输出的实际操作类,定义输出的⽇志⽂件
先看下两个类的结构,Logger类实现了\Psr\Log\LoggerInterface接⼝,遵循PSR3规范。StreamHand
ler继承AbstractProcessingHandler,AbstractProcessingHandler继承AbstractHandler,AbstractHandler实现HandlerInterface接⼝,四层关系。
画图更直观,先参考下
Logger结构
StreamHandler结构
现在从⽇志打印开始到结束观察下代码的执⾏过程。
第⼀步:初始化Logger类,传⼊标识Logger的channel名称,例⼦中为Pay。Logger可以添加、删除Handler和Processor,Processor 其实就是触发⼀些⾃定义的闭包函数对⽇志内容渲染处理,看下源码就明⽩是啥⽤处了。⽇志打印调⽤⽅法有很多,⽐如addDebug、addInfo、debug、Info等等,最终是调⽤了类内部的addRecord⽅法,在这⾥对⽇志打印逻辑做了封装。
第⼆步:实例化handler类,传⼊不同Handler需要的参数
第三步:调⽤Logger的warn⽅法打印⽇志
handler实例化和Logger进⾏pushHandler,就是存储⼀些参数,直接来看warn的实现吧
/**
* Adds a log record at the WARNING level.
*
* This method allows for compatibility with common interfaces.
*
* @param  string  $message The log message
* @param  array  $context The log context
* @return Boolean Whether the record has been processed
*/
public function warn($message, array $context = array())
{
return $this->addRecord(static::WARNING, $message, $context);
}
转向到类内部的addRecord⽅法
来看下添加了注释的源码,
/**
* Adds a log record.
*
* @param  int    $level  The logging level
* @param  string  $message The log message
赵霁微博
* @param  array  $context The log context
* @return Boolean Whether the record has been processed
*/
public function addRecord($level, $message, array $context = array())
{
//如果未添加handler,默认添加⼀个stderr的StreamHandler
if (!$this->handlers) {
//stderr、stdin、stdout是cli模式下系统的⽂件句柄
快乐大本营范冰冰蒋劲夫//如使⽤: fopen('php://stderr', 'w');
//执⾏fwrite会将错误信息发送到⽤户终端
$this->pushHandler(new StreamHandler('php://stderr', static::DEBUG));
}
//根据level级别获取levelName 如:100 ===>>> DEBUG
$levelName = static::getLevelName($level);
// check if any handler will handle this message so we can return early and save cycles
//检查是否起码存在⼀个handle
$handlerKey = null;
//将内部指针指向数组中的第⼀个元素,并输出
reset($this->handlers);
while ($handler = current($this->handlers)) {  //返回当前数组指针指向的元素,若不存在则返回FALSE
//例如:level 200, 判断当前的handler 对应的level 是否⼩于200
/
/⽐如handler设置了DEBU => 200,可以输出任何level >= 200的⽇志,若level == 100,当前handler不能处理            if ($handler->isHandling(array('level' => $level))) {
//handler能够处理,将key值赋给handlerKey
$handlerKey = key($this->handlers);
break;
}
//当前handler不能处理此⽇志,将指针向后移动
next($this->handlers);
}
//未查到能执⾏的handler,return终结
if (null === $handlerKey) {
return false;
}
//根据时区初始化DateTimeZone对象
if (!static::$timezone) {
static::$timezone = new \DateTimeZone(date_default_timezone_get() ?: 'UTC');
}