基于Laravel封装一个强大的请求响应日志记录中间件

为何强大

  1. 记录全面: 包含请求路径、请求方法、客户端IP、设备标识、荷载数据、文件上传、请求头、业务逻辑处理时间、业务逻辑所耗内存、用户id、以及响应数据。
  2. 配置简单: 默认不需要写任何逻辑可开箱即用,靠前4个方法,就可指定某些url不记录日志,或不记录某些请求头,不记录某些荷载数据,或决定是否返回非json类型的相应数据。
  3. 清晰简洁: 返回的每项数据都是json或者字符串,一行一项数据,且缩进一致,清晰明了。该有的展示项都有,该忽略的展示项已经被忽略。
  4. 规范统一: 无论请求数据是什么格式,最后到日志的数据之有字符串或json两种格式,避免五花八门的数据造成日志格式混乱。
  5. 强兼容性: 无论是什么请求方式(GET、POST、DELETE、PATCH、PUT、OPTIONS等),或者传递什么内容类型(x-www-form-urlencoded、multipart/form-data、json、xml、纯文本),只要通过路由,上游无断点或死循环,日志都可记录,适用于任何项目的场景。
  6. 灵活扩展: 对中间件前4个配置相关的方法,引入了Request对象,方便根据此对象实现更复杂的逻辑。
  7. 方便调试: 当项目出问题时,有日志参考是必须的,结合"tail -f",或者日志查看器插件更是如虎添翼。
  8. 日志隔离: 利用laravel强大的日志渠道隔离和按天切割功能,使得记录日志过程更加强大。

效果示例

text 复制代码
[2023-10-18 18:14:48] local.INFO:
url      : http://xxx/api?framework=laravel&language=php
method   : POST
ip       : 127.0.0.1
ua       : PostmanRuntime-ApipostRuntime/1.1.0
payload  : {"key":"val","k":"v"}
file     : []
header   : {"content-type":"application\/x-www-form-urlencoded"}
time     : 16.90
mem      : 19.16 MB
user_id  : 0
response : {"code":0,"msg":"","data":[]}

部署

bash 复制代码
#在config/logging.php中的channels项添加如下配置
'req' => [
    'driver' => 'daily',
    'path' => storage_path('logs/request.log'),
    'level' => env('LOG_LEVEL', 'debug'),
    'days' => 3,
    'permission' => 0777
],

#进入laravel所在目录,用artisan命令创建中间件
php artisan make:middleware RequestMiddleware
php 复制代码
//在app/Http/Kernel.php文件的protected $middleware数组中追加一行,用于注册全局中间件
\App\Http\Middleware\RequestMiddleware::class

编写

php 复制代码
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

/**
 * @Class   RequestMiddleware 记录请求日志,方便开发者调试
 * @package App\Http\Middleware
 */
class RequestMiddleware {
    /**
     * @function 设置不记录日志的url
     * @param    \Illuminate\Http\Request  $request
     * @return   array
     * @other    排除规则依照request()->is()方法
     */
    private function setExceptUrl($request) {
        return [
            'admin/logs*', 'admin/logs/*',
        ];
    }


    /**
     * @function 设置不记录的荷载项
     * @param    \Illuminate\Http\Request  $request
     * @return   array
     * @other    比如防止CSRF的_token
     */
    private function setExceptPayload($request) {
        return [
            '_token'
        ];
    }


    /**
     * @function 设置不记录日志的请求头
     * @param    \Illuminate\Http\Request  $request
     * @return   array
     */
    private function setExceptHeader($request) {
        return [
            //官方
            'accept', 'accept-encoding', 'accept-language', 'authorization',
            'cache-control', 'charset', 'connection', 'content-length', 'content-type_except', 'cookie',
            'host', 'origin', 'pragma', 'referer',
            'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site',
            'upgrade-insecure-requests', 'user-agent', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto', 'x-requested-with',

            //自定义
            'encrypteddata', 'ivstr',
        ];
    }


    /**
     * @function 是否记录非json格式的响应的数据
     * @param    \Illuminate\Http\Request  $request
     * @return   bool
     */
    private function isRecordHttpResponseData($request) {
        return false;
    }
//------------------------------------------------此分割线以下代码无需修改------------------------------------------------
    /**
     * @function 请求日志中间件
     * @param    \Illuminate\Http\Request  $request
     * @param    \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return   \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next) {
        if($this->hasExceptUrl($request)) {
            return $next($request);
        }

        $start     = microtime(true);
        $response  = $next($request);
        $end       = microtime(true);

        $res = $this->requestDataFormat([
            'url'      => $this->getFullUrl($request),
            'method'   => $this->getRequestMethod($request),
            'ip'       => $this->getClientIp($request),
            'ua'       => $this->getUa($request),
            'payload'  => $this->getRequestPayload($request),
            'file'     => $this->getClearRequestFile($request),
            'header'   => $this->getClearRequestHeader($request),
            'time'     => bcmul(bcsub($end, $start, 6), 1000, 2),
            'mem'      => $this->getUsageMemory(),
            'user_id'  => $this->getIdWithToken($request),
            'response' => $this->responseFormat($request, $response),
        ]);

        Log::channel('req')->info($res);

        return $response;
    }


    /**
     * @function 请求记录黑名单,在黑名单内的规则不记录日志
     * @param    \Illuminate\Http\Request  $request
     * @return   bool
     */
    private function hasExceptUrl($request) {
        $blacklist = array_filter($this->setExceptUrl($request));
        if(! $blacklist) {
            return false;
        }

        foreach($blacklist as $every_blacklist) {
            if($request->is($every_blacklist)) {
                return true;
            }
        }

        return false;
    }


    /**
     * @function 获取全路径
     * @param    \Illuminate\Http\Request  $request
     * @return   string
     */
    private function getFullUrl($request) {
        return urldecode($request->fullUrl());
    }


    /**
     * @function 获取请求方式
     * @param    \Illuminate\Http\Request  $request
     * @return   string
     * @other    由于laravel存在_method覆盖机制,若有括号,则括号内的为真正的请求方式
     */
    private function getRequestMethod($request) {
        $real_method = $_SERVER['REQUEST_METHOD'];

        //防止乱传参导致的错误
        try{
            $laravel_method = $request->method();
        } catch (\Exception $exception) {
            $laravel_method = $real_method;
        }

        if($real_method === $laravel_method) {
            return $real_method;
        }

        return "{$laravel_method}({$real_method})";
    }


    /**
     * @function 获取客户端的IP
     * @param    \Illuminate\Http\Request  $request
     * @return   string
     */
    private function getClientIp($request) {
        return $request->getClientIp();
    }


    /**
     * @function 获取用户代理
     * @param    \Illuminate\Http\Request  $request
     * @return   string
     */
    private function getUa($request) {
        return $request->header('user-agent') ?? '""';
    }


    /**
     * @function 获取请求荷载,包含x-www-form-urlencoded、multipart/form-data、json、xml等纯文本荷载数据
     * @param    \Illuminate\Http\Request  $request
     * @return   array
     */
      private function getRequestPayload($request) {
        if($request->method() === 'GET') {
            return [];
        }
        $except = collect($request->query())->keys()->merge($this->setExceptPayload($request))->filter();
        $input  = collect($request->input())->except($except)->map(function ($val) {
            if (is_null($val)) {
                return '';
            }
            return $val;
        })->toArray();

        if($input) {
            return $input;
        }

        $raw = $request->getContent();
        if($request->header('content-type') === 'application/xml') {
            if(! $raw) {
                return [];
            }
            if(! $this->isXml($raw)) {
                return [$raw];
            }
            return json_decode(json_encode(simplexml_load_string(str_replace(["\r", "\n"], '', $raw))), true);
        }

        return array_filter([$raw]);
    }


    /**
     * @function 获取简洁的文件上传数据
     * @param    \Illuminate\Http\Request  $request
     * @return   array
     */
    private function getClearRequestFile($request) {
        return collect($request->allFiles())->map(function($val) {
            if(is_array($val)) {
                $res = collect($val)->map(function($v) {
                    return $v->getClientOriginalName();
                });
            } else {
                $res = $val->getClientOriginalName();
            }
            return $res;
        })->toArray();
    }


    /**
     * @function 获取干净的请求头
     * @param    \Illuminate\Http\Request  $request
     * @return   array
     */
    private function getClearRequestHeader($request) {
        $except_header = array_filter($this->setExceptHeader($request));
        return collect($request->header())->except($except_header)->toArray();
    }


    /**
     * @function 获取脚本使用的内存
     * @return   string
     * @other    void
     */
    function getUsageMemory() {
        $bytes = memory_get_usage();
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $bytes /= pow(1024, ($i = floor(log($bytes, 1024))));
        return round($bytes, 2) . ' ' . $units[$i];
    }


    /**
     * @function 通过token获取user_id,这个有伪造的风险
     * @param    \Illuminate\Http\Request  $request
     * @return   int
     */
    private function getIdWithToken($request) {
        $token = $request->header('authorization');
        if(! $token) {
            return 0;
        }

        $payload = (explode('.', $token)[1]) ?? null;
        if(is_null($payload)) {
            return 0;
        }

        $json = base64_decode($payload);
        $arr = json_decode($json, true);
        if(is_null($arr)) {
            return 0;
        }

        return $arr['sub'] ?? 0;
    }


    /**
     * @function 格式化响应数据
     * @param    \Illuminate\Http\Request  $request
     * @param    \Illuminate\Http\JsonResponse|\Illuminate\Http\Response $response
     * @return   string|array
     */
    private function responseFormat($request, $response) {
        if($response instanceof \Illuminate\Http\JsonResponse){
            return collect($response->getData())->toArray();
        }

        if(! $this->isRecordHttpResponseData($request)) {
            return '""';
        }

        if($response instanceof \Illuminate\Http\Response) {
            return $response->getContent();
        }

        return '""';
    }


    /**
     * @function 格式化数组并转换为字符串
     * @param    $request_data array
     * @return   string
     */
    private function requestDataFormat($request_data) {
        $str = "\n";
        foreach($request_data as $k => $v) {
            //格式化请求头
            if(($k == 'header') && $v) {
                foreach($v as $key => $val) {
                    if(count($val) == 1) {
                        $v[$key] = collect($val)->values()->first();
                    } else {
                        $v[$key] = $val;
                    }
                }
            }

            //格式化数据
            $v = is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : $v;
            $k = str_pad($k, 9, ' ', STR_PAD_RIGHT);
            $str .= "{$k}: {$v}\n";
        }

        return $str;
    }


    /**
     * @function 判断是否是xml
     * @param    $str string 要判断的xml数据
     * @return   bool
     */
    private function isXml($str) {
        libxml_use_internal_errors(true);
        simplexml_load_string($str);
        $errors = libxml_get_errors();
        libxml_clear_errors();
        return ! $errors;
    }
}

说明

  1. 文章的每个方法都加了清晰的注释,且拆分的非常详细,便于二次开发,像是getUsageMemory(),isXml(),方法都可以封装到公共的工具库中。
  2. user_id项是因为项目使用jwt,为了方便调试,临时加的。考虑到性能问题没做验签,所以user_id有被篡改的可能。
  3. 此模块经受过时间的考验,目前没有因为不兼容导致此中间件报错的情况,每个项目值得拥有。
  4. json精度问题: 前后端分离的架构,大数据在传参时时使用json会产生精度误差问题,导致日志记录不精确,如下:
php 复制代码
// [1.2345678912345678e+17]
echo json_encode([123456789123456789.123456789123456789]);
//Array ( [0] => 1.2345678912346E+17 )
print_r(json_decode('[123456789123456789.123456789123456789]', true));

这个是编程语言层面的问题,所以在传输大数据时一定要转化为字符串去解决精度问题。

php 复制代码
// ["123456789123456789.123456789123456789"]
echo json_encode(["123456789123456789.123456789123456789"]);
//Array ( [0] => 123456789123456789.123456789123456789)
print_r(json_decode('["123456789123456789.123456789123456789"]', true));
相关推荐
Asthenia04126 分钟前
什么是语法分析 - 编译原理基础
后端
Asthenia041219 分钟前
理解词法分析与LEX:编译器的守门人
后端
uhakadotcom21 分钟前
视频直播与视频点播:基础知识与应用场景
后端·面试·架构
Asthenia04121 小时前
Spring扩展点与工具类获取容器Bean-基于ApplicationContextAware实现非IOC容器中调用IOC的Bean
后端
bobz9652 小时前
ovs patch port 对比 veth pair
后端
Asthenia04122 小时前
Java受检异常与非受检异常分析
后端
uhakadotcom2 小时前
快速开始使用 n8n
后端·面试·github
JavaGuide2 小时前
公司来的新人用字符串存储日期,被组长怒怼了...
后端·mysql
bobz9652 小时前
qemu 网络使用基础
后端
Asthenia04123 小时前
面试攻略:如何应对 Spring 启动流程的层层追问
后端