monolog是记录日志的php框架,应用广泛。
github地址:https://github.com/Seldaek/monolog
composer地址:monolog/monolog - Packagist
之前一直用tp,tp保存日志时若是数组或对象,会保留格式输出,大概是每个数据一行,容易看。用原生的monolog的话,会被变成json放到文件中,没有格式,看起来困难。又因为它输入到文件的内容是通过字符串替换实现,所以即使设置var_export()也会变成一行,不是很符合要求。
#Monolog\Logger
public function __construct(string $name, array $handlers = [], array $processors = [], ?DateTimeZone $timezone = null) {
$this->name = $name;
$this->setHandlers($handlers);
$this->processors = $processors;
$this->timezone = $timezone ?: new DateTimeZone(date_default_timezone_get() ?: 'UTC');
if (\PHP_VERSION_ID >= 80100) {
// Local variable for phpstan, see https://github.com/phpstan/phpstan/issues/6732#issuecomment-1111118412
/** @var \WeakMap<\Fiber, int> $fiberLogDepth */
$fiberLogDepth = new \WeakMap();
$this->fiberLogDepth = $fiberLogDepth;
}
}
public function pushHandler(HandlerInterface $handler): self{
array_unshift($this->handlers, $handler);
return $this;
}
public function pushProcessor(callable $callback): self{
array_unshift($this->processors, $callback);
return $this;
}
public function addRecord(int $level, string $message, array $context = [], DateTimeImmutable $datetime = null): bool {
……
foreach ($this->handlers as $handler) {
if (null === $record) {
#判断level是否可被记录
if (!$handler->isHandling(['level' => $level])) {
continue;
}
}
}
……
#记录日志
if (true === $handler->handle($record)) {
break;
}
……
}
所以根据Logger类,可以在构造设置handler或者单独设置,并且没有handler不能记录日志。
$log = new Logger('test1');
$logfile = "logs/20230718.log";
$sream = new StreamHandler($logfile, Logger::DEBUG);
$log->pushHandler($sream);
$log->warning('test1', ['qwe' => '123']);
#记录结果
[2023-07-19T10:53:21.235082+08:00] test1.WARNING: test1 {"qwe":"123"} []
这是经过格式化后的效果。
Monolog\Handler\StreamHandler继承Monolog\Handler\AbstractProcessingHandler,AbstractProcessingHandler继承Monolog\Handler\AbstractHandler。
#Monolog\Handler\AbstractProcessingHandler
public function handle(array $record): bool
{
if (!$this->isHandling($record)) {
return false;
}
if ($this->processors) {
/** @var Record $record */
$record = $this->processRecord($record);
}
$record['formatted'] = $this->getFormatter()->format($record);
$this->write($record);
return false === $this->bubble;
}
#Monolog\Handler\AbstractHandler
public function isHandling(array $record): bool
{
return $record['level'] >= $this->level;
}
#Monolog\Handler\StreamHandler
protected function write(array $record): void {
if (!is_resource($this->stream)) {
$url = $this->url;
if (null === $url || '' === $url) {
throw new \LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' . Utils::getRecordMessageForException($record));
}
$this->createDir($url);
$this->errorMessage = null;
set_error_handler([$this, 'customErrorHandler']);
$stream = fopen($url, 'a');
if ($this->filePermission !== null) {
@chmod($url, $this->filePermission);
}
restore_error_handler();
if (!is_resource($stream)) {
$this->stream = null;
throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened in append mode: ' . $this->errorMessage, $url) . Utils::getRecordMessageForException($record));
}
stream_set_chunk_size($stream, $this->streamChunkSize);
$this->stream = $stream;
}
$stream = $this->stream;
if (!is_resource($stream)) {
throw new \LogicException('No stream was opened yet' . Utils::getRecordMessageForException($record));
}
if ($this->useLocking) {
// ignoring errors here, there's not much we can do about them
flock($stream, LOCK_EX);
}
$this->streamWrite($stream, $record);
if ($this->useLocking) {
flock($stream, LOCK_UN);
}
}
protected function streamWrite($stream, array $record): void{
fwrite($stream, (string) $record['formatted']);
}
#Monolog\Logger
public const DEBUG = 100;
public const INFO = 200;
public const NOTICE = 250;
public const WARNING = 300;
public const ERROR = 400;
public const CRITICAL = 500;
public const ALERT = 550;
public const EMERGENCY = 600;
由代码可见,AbstractProcessingHandler::handle()处理格式化并调用父类测level判断,和子类的实际写入,也就是例子中的StreamHandler::write()->streamWrite()。
level判断根据level值的大小判断。所以设置debug都会记录,而设置emergency都不会被记录。
所以改下StreamHandler::streamWrite()应该能改变保存的格式,但是最后输出的内容仍然涉及格式化。
#Monolog\Handler\AbstractProcessingHandler
abstract class AbstractProcessingHandler extends AbstractHandler implements ProcessableHandlerInterface, FormattableHandlerInterface
{
use ProcessableHandlerTrait;
use FormattableHandlerTrait;
}
#Monolog\Handler\FormattableHandlerTrait
trait FormattableHandlerTrait
{
/**
* @var ?FormatterInterface
*/
protected $formatter;
/**
* {@inheritDoc}
*/
public function setFormatter(FormatterInterface $formatter): HandlerInterface
{
$this->formatter = $formatter;
return $this;
}
/**
* {@inheritDoc}
*/
public function getFormatter(): FormatterInterface
{
if (!$this->formatter) {
$this->formatter = $this->getDefaultFormatter();
}
return $this->formatter;
}
/**
* Gets the default formatter.
*
* Overwrite this if the LineFormatter is not a good default for your handler.
*/
protected function getDefaultFormatter(): FormatterInterface
{
return new LineFormatter();
}
}
#Monolog\Handler\ProcessableHandlerTrait
trait ProcessableHandlerTrait
{
/**
* @var callable[]
* @phpstan-var array
*/
protected $processors = [];
/**
* {@inheritDoc}
*/
public function pushProcessor(callable $callback): HandlerInterface
{
array_unshift($this->processors, $callback);
return $this;
}
/**
* {@inheritDoc}
*/
public function popProcessor(): callable
{
if (!$this->processors) {
throw new \LogicException('You tried to pop from an empty processor stack.');
}
return array_shift($this->processors);
}
/**
* Processes a record.
*
* @phpstan-param Record $record
* @phpstan-return Record
*/
protected function processRecord(array $record): array
{
foreach ($this->processors as $processor) {
$record = $processor($record);
}
return $record;
}
protected function resetProcessors(): void
{
foreach ($this->processors as $processor) {
if ($processor instanceof ResettableInterface) {
$processor->reset();
}
}
}
AbstractProcessingHandler使用ProcessableHandlerTrait和FormattableHandlerTrait。因为handler和process都是数组,这两者都是对数组的操作,handler会设置默认Monolog\Formatter\LineFormatter类。
#Monolog\Formatter\LineFormatter
class LineFormatter extends NormalizerFormatter
{
public const SIMPLE_FORMAT = "[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n";
……
public function __construct(?string $format = null, ?string $dateFormat = null, bool $allowInlineLineBreaks = false, bool $ignoreEmptyContextAndExtra = false, bool $includeStacktraces = false)
{
$this->format = $format === null ? static::SIMPLE_FORMAT : $format;
……
parent::__construct($dateFormat);
}
public function format(array $record): string
{
$vars = parent::format($record);
$output = $this->format;
foreach ($vars['extra'] as $var => $val) {
if (false !== strpos($output, '%extra.'.$var.'%')) {
$output = str_replace('%extra.'.$var.'%', $this->stringify($val), $output);
unset($vars['extra'][$var]);
}
}
foreach ($vars['context'] as $var => $val) {
if (false !== strpos($output, '%context.'.$var.'%')) {
$output = str_replace('%context.'.$var.'%', $this->stringify($val), $output);
unset($vars['context'][$var]);
}
}
if ($this->ignoreEmptyContextAndExtra) {
if (empty($vars['context'])) {
unset($vars['context']);
$output = str_replace('%context%', '', $output);
}
if (empty($vars['extra'])) {
unset($vars['extra']);
$output = str_replace('%extra%', '', $output);
}
}
foreach ($vars as $var => $val) {
if (false !== strpos($output, '%'.$var.'%')) {
$output = str_replace('%'.$var.'%', $this->stringify($val), $output);
}
}
// remove leftover %extra.xxx% and %context.xxx% if any
if (false !== strpos($output, '%')) {
$output = preg_replace('/%(?:extra|context)\..+?%/', '', $output);
if (null === $output) {
$pcreErrorCode = preg_last_error();
throw new \RuntimeException('Failed to run preg_replace: ' . $pcreErrorCode . ' / ' . Utils::pcreLastErrorMessage($pcreErrorCode));
}
}
return $output;
}
}
#Monolog\Formatter\NormalizerFormatter
class NormalizerFormatter implements FormatterInterface
{
public const SIMPLE_DATE = "Y-m-d\TH:i:sP";
……
public function __construct(?string $dateFormat = null)
{
$this->dateFormat = null === $dateFormat ? static::SIMPLE_DATE : $dateFormat;
if (!function_exists('json_encode')) {
throw new \RuntimeException('PHP\'s json extension is required to use Monolog\'s NormalizerFormatter');
}
}
public function format(array $record)
{
return $this->normalize($record);
}
protected function normalize($data, int $depth = 0)
{
if ($depth > $this->maxNormalizeDepth) {
return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization';
}
if (null === $data || is_scalar($data)) {
if (is_float($data)) {
if (is_infinite($data)) {
return ($data > 0 ? '' : '-') . 'INF';
}
if (is_nan($data)) {
return 'NaN';
}
}
return $data;
}
if (is_array($data)) {
$normalized = [];
$count = 1;
foreach ($data as $key => $value) {
if ($count++ > $this->maxNormalizeItemCount) {
$normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items ('.count($data).' total), aborting normalization';
break;
}
$normalized[$key] = $this->normalize($value, $depth + 1);
}
return $normalized;
}
……
}
}
LineFormatter设置默认格式,先调用父类格式化,之后再根据格式替换字符。时间格式是通过父类格式化。
父类NormalizerFormatter,设置默认时间格式。通过normalize函数递归调用格式化数据。大于最大层数maxNormalizeItemCount=1000,可能会显示异常。
$autoloadfile = "vendor/autoload.php";
require_once $autoloadfile;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Formatter\LineFormatter;
class myStreamHandler extends StreamHandler {
protected function streamWrite($stream, array $record): void{
$str = (string) $record['formatted'] . PHP_EOL . var_export($record['context'], true). PHP_EOL;
fwrite($stream, $str);
}
}
$logfile = "logs/20230718.log";
$log = new Logger('test1');
$dateFormat = "Y-m-d H:i:s";
$output = "%datetime%||%channel%||%level_name%\n%message%\n%extra%";
$formatter = new LineFormatter($output, $dateFormat);
//$sream = new StreamHandler($logfile, Logger::DEBUG);
$sream = new myStreamHandler($logfile, Logger::DEBUG);
$sream->setFormatter($formatter);
$log->pushHandler($sream);
$log->pushProcessor(function ($record) {
var_dump($record);
$record['extra']['test'] ='test' ;
return $record;
});
#执行输出
array(7) {
'message' =>
string(5) "test1"
'context' =>
array(1) {
'qwe' =>
string(3) "123"
}
'level' =>
int(300)
'level_name' =>
string(7) "WARNING"
'channel' =>
string(5) "test1"
'datetime' =>
class Monolog\DateTimeImmutable#7 (4) {
private $useMicroseconds =>
bool(true)
public $date =>
string(26) "2023-07-19 15:15:31.218972"
public $timezone_type =>
int(3)
public $timezone =>
string(13) "Asia/Shanghai"
}
'extra' =>
array(1) {
'test' =>
string(4) "test"
}
}
#日志写入
2023-07-19 15:18:09||test1||WARNING
test1
{"test":"test"}
array (
'qwe' => '123',
)
monolog设计采用多层继承,将统一使用的方法抽象出类,其子类负责呈上启下的流程调用,其子类的子类则通过方法重写负责具体实现。
数据格式化可以使用HtmlFormatter或者JsonFormatter可以自己定义。
handler可以使用RedisHandler、PHPConsoleHandler等,根据构造不同穿的参数不同,功能很多,还能发送email。
比如process可以在write调用之前处理缓存数据,例如 MemoryPeakUsageProcessor设置内存用量 ,可以对extra参数设置逻辑数据。