日志

未匹配的标注

前言

开发了多个服务 A、B、C,希望有个服务,对全局的日志进行管理。
参照 session 共享机制,各服务将日志写入 redis,由日志服务进行持久化和管理。
感谢 教你更优雅地写API之「记录日志」 提供实现思路

配置

.env

// 服务标记,区分日志属于哪个服务
APP_NAME=log
// 统一前缀
REDIS_PREFIX=ms_database_

database.php
将日志写到 redis

    'redis' => [
        'log' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_QUEUE_DB', 3),
        ],
    ],

logging.php
自定义的 channels,主要是扩展 Handler,用于区分日志类别

use Extend\Logger\Handlers;
use Monolog\Formatter\JsonFormatter;

'channels' => [
  'debug' => [
    'name' => 'debug',
    'driver' => 'monolog',
    'level' => env('LOG_LEVEL', 'debug'),
    'handler' => Handlers\DebugHandler::class,
    'formatter' => JsonFormatter::class,
  ],
  'request' => [
    'driver' => 'monolog',
    'level' => env('LOG_LEVEL', 'debug'),
    'handler' => Handlers\RequestHandler::class,
    'formatter' => JsonFormatter::class,
  ],
  'exception' => [
    'driver' => 'monolog',
    'level' => env('LOG_LEVEL', 'debug'),
    'handler' => Handlers\ExceptionHandler::class,
    'formatter' => JsonFormatter::class,
  ],
]

文件

目录结构

根目录增加 Extend 目录,放置自己的类库(composer扩展包开发还没学会)
Logger 文件夹

  • Events
    • RequestHandledEvent.php
  • Handlers
    • RequestHandler.php
  • Listeners
    • RequestHandledListener.php
  • Schedules
    • RequestSchedule.php

流程

App\Providers\EventServiceProvider 注册事件和监听器

use Extend\Logger\Events;
use Extend\Logger\Listeners;

    protected $listen = [
        Events\RequestArrivedEvent::class => [Listeners\RequestArrivedListener::class],
        Events\RequestHandledEvent::class => [Listeners\RequestHandledListener::class],
        Events\DebugEvent::class          => [Listeners\DebugListener::class],
        Events\ExceptionEvent::class      => [Listeners\ExceptionListener::class],
    ];

App\Http\Kernel 埋点

protected $middlewareGroups = [
  'api' => [
    \Extend\Logger\Http\Middleware\RequestLog::class,
  ],
];

Extend\Logger\Http\Middleware\RequestLog 触发事件

declare(strict_types=1);

namespace Extend\Logger\Http\Middleware;

use Extend\Logger\Events\RequestArrivedEvent;
use Extend\Logger\Events\RequestHandledEvent;
use Closure;
use Illuminate\Http\Request;

class RequestLog
{
    public function handle(Request $request, Closure $next)
    {
        event(new RequestArrivedEvent($request));
        return $next($request);
    }

    public function terminate(Request $request, $response)
    {
        event(new RequestHandledEvent($request, $response));
    }
}

代码示例

event
主要是绑定运行时参数

declare(strict_types=1);

namespace Extend\Logger\Events;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;

class RequestHandledEvent extends Event
{
  private Request $request;
  public $response;

  public function __construct(Request $request, $response)
  {
    $this->request = $request;
    $this->response = $response;
  }

  public function getRequest(): Request
  {
    return $this->request;
  }

  public function getResponse()
  {
    return $this->response;
  }
}

listener
解析 event, 记录 log。其中 channel 对应 logging.php 里的配置

declare(strict_types=1);

namespace Extend\Logger\Listeners;

use Extend\Logger\Events\RequestHandledEvent;
use Illuminate\Support\Facades\Log;
use Monolog\Logger;
use Monolog\Processor\WebProcessor;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class RequestHandledListener
{
    public function handle(RequestHandledEvent $event)
    {
        $request_id = $event->getRequest()->server->get('UNIQUE_ID') ?: '';
        $start = $event->getRequest()->server('REQUEST_TIME_FLOAT');
        $end = microtime(true);

        $request = $event->getRequest()->all();
        if ($files = $event->getRequest()->allFiles()) {
            foreach ($files as $key => $uploadedFile) {
                $request[$key] = [
                    'originalName' => $uploadedFile->getClientOriginalName(),
                    'mimeType' => $uploadedFile->getClientMimeType(),
                ];
            }
        }
        $response = $event->response instanceof SymfonyResponse
            ? json_decode($event->response->getContent(), true)
            : (string) $event->response;

        $context = [
            'serverless' => env('APP_NAME', 'ms'),
            'request_id' => $request_id,
            'request' => $request,
            'response' => $response,
            'start' => $start,
            'end' => $end,
        ];

        $message = 'request';

        /** @var Logger $logger */
        $logger = Log::channel('request');
        $logger->pushProcessor(new WebProcessor(request()->server()));
        $logger->info($message, $context);
    }
}

handler
采用 redis 记录

declare(strict_types=1);

namespace Extend\Logger\Handlers;

use Illuminate\Support\Facades\Redis;
use Monolog\Handler\RedisHandler;
use Monolog\Logger;

class RequestHandler extends RedisHandler
{
    public function __construct($level = Logger::DEBUG, bool $bubble = true, int $capSize = 0)
    {
        $redis = Redis::connection('log')->client();
        parent::__construct($redis, 'request', $level, $bubble, $capSize);
    }
}

schedule
用于将 redis 数据保存到数据库

declare(strict_types=1);

namespace Extend\Logger\Schedules;

use Illuminate\Support\Facades\Redis;

abstract class BaseSchedule
{
    protected const KEY   = '';
    protected const LIMIT = '1';

    public function __invoke()
    {
        config(['logging.query_executed' => false]);
        $redis = Redis::connection('log')->client();
        $limit = static::LIMIT;
        do {
            $value = $redis->lPop(static::KEY);
            if (is_null($value)) {
                break;
            }
            if (!$info = json_decode($value, true)) {
                $message = json_last_error_msg();
                event(new DebugEvent($message, ['message' => $message, 'value' => $value]));
                continue;
            }
            DB::beginTransaction();
            $this->createInfo($info);
            DB::commit();
        } while ($limit--);

        config(['logging.query_executed' => true]);
    }

    abstract protected function createInfo($info);
}
<?php

declare(strict_types=1);

namespace Extend\Logger\Schedules;

use App\Models\Tables\RequestModel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

class RequestSchedule extends BaseSchedule
{
    protected const KEY = 'request';
    protected const LIMIT = '100';

    protected function createInfo($info)
    {
        $model = new RequestModel();
        $model->request_id = $info['message'];
        $model->level = $info['level_name'];

        $context = $info['context'];
        $model->serverless = $context['serverless'];
        $model->params = json_encode($context['params'], JSON_UNESCAPED_UNICODE);

        $extra = $info['extra'];
        $model->url = $extra['url'];
        $model->ip = $extra['ip'];
        $model->http_method = $extra['http_method'];
        $model->server = $extra['server'];
        $model->referrer = $extra['referrer'] ?: '';
        $model->save();
    }
}

后记

平时日志直接用 Log::info 记录到文件,使用起来不是很方便。接触了overtrue/laravel-query-logger才发现日志其实可以扩展和自定义,决定自己试试。
一边查资料,一边看monolog源码,断断续续总算是让服务跑起来了,大概是这么回事:

  • 运行时记录(不中断运行)
    // 旧
    $a->save();
    Log::info('a保存成功')$b->save();
    // 新
    $a->save();
    event(new DebugEvent('a保存成功'));
    $b->save();
  • 运行时记录(中断运行)
    // 旧
    if(!$request->id){
      Log::error('异常了')abort(500, '异常了');
    }
    // 新
    // App\Exceptions\Handler
    $this->renderable(function (\Exception $e, Request $request) {
       event(new ExceptionEvent($e, $request));
    });
  • 自定义事件
    参看上面的 reqeust
  • 系统事件绑定自定义监听
    // sql 记录
    QueryExecuted::class => [Listeners\QueryListener::class],

还不成熟,感谢阅读:smiley:

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
秦晓武
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 0
发起讨论 只看当前版本


暂无话题~