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

569次阅读
没有评论

为何强大

  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));

正文完
可以使用微信扫码关注公众号(ID:xzluomor)
post-qrcode
 
评论(没有评论)