feat(nginx-log): NginxLogParser 解析器 service(Base/App 双层)

This commit is contained in:
augushong
2026-07-28 00:38:25 +08:00
parent c4b72913df
commit 7d707c2150
2 changed files with 253 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<?php
namespace app\common\service;
use base\common\service\NginxLogParserBase;
/**
* Nginx access log 解析器App 入口类).
*
* 业务侧可在此覆盖 NginxLogParserBase 的方法实现定制逻辑.
*/
class NginxLogParser extends NginxLogParserBase
{
}

View File

@@ -0,0 +1,239 @@
<?php
namespace base\common\service;
use DateTime;
/**
* Nginx access log 解析器Base 层).
*
* 匹配 analytics log_format11 字段):
* remote_addr - remote_user [time_local] "request" status body_bytes_sent "referer" "ua" request_time upstream_response_time bytes_sent
*
* 示例行:
* 192.168.32.1 - - [27/Jul/2026:16:20:15 +0000] "GET / HTTP/1.1" 499 0 "-" "Mozilla/5.0 (...)" 5.085 5.086 0
*
* 业务侧通过 app/common/service/NginxLogParser 覆盖本类方法.
*/
class NginxLogParserBase
{
/** 爬虫 UA 关键词(大小写不敏感匹配) */
public const SPIDER_KEYWORDS = [
'Googlebot', 'Baiduspider', 'bingbot', 'Sogou', '360Spider',
'YisouSpider', 'PetalBot', 'AhrefsBot', 'semrushbot', 'DotBot',
];
/** 静态资源扩展名 */
public const STATIC_EXTS = [
'css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'ico',
'woff', 'woff2', 'ttf', 'svg', 'map',
];
/** URI 最大存储长度 */
protected const URI_MAX_LENGTH = 255;
/** UA 最大存储长度 */
protected const UA_MAX_LENGTH = 500;
/** analytics log_format 正则11 捕获组) */
protected const LINE_PATTERN = '/^(\S+) - (\S+) \[(.+?)\] "([^"]*)" (\d+) (\d+) "([^"]*)" "([^"]*)" (\S+) (\S+) (\d+)$/';
/** $request 拆分正则METHOD URI HTTP/version */
protected const REQUEST_PATTERN = '/^(\S+)\s+(\S+)\s+(HTTP\/\S+)$/';
/**
* 解析单行 nginx access log.
*
* @param string $line 原始日志行
*
* @return array|null 解析成功返回字段数组,正则不匹配或 request 畸形返回 null
*/
public function parse(string $line): ?array
{
$line = trim($line);
if ($line === '') {
return null;
}
if (!preg_match(self::LINE_PATTERN, $line, $m)) {
return null;
}
// $m[1..11] = 11 个捕获组
$remoteAddr = $m[1];
$remoteUser = $m[2];
$timeLocalStr = $m[3];
$request = $m[4];
$status = (int) $m[5];
$bodyBytes = (int) $m[6];
$referer = $m[7];
$userAgent = $m[8];
$requestTime = $m[9];
$upstreamTime = $m[10];
$bytesSent = (int) $m[11];
// time_local 字符串 → Unix 时间戳DateTime 自动转业务时区)
$timeLocal = $this->parseTimeLocal($timeLocalStr);
if ($timeLocal === null) {
return null;
}
// 拆分 request → method / uri / http_version
[$method, $uri, $httpVersion] = $this->splitRequest($request);
if ($method === null) {
// 畸形 request含空格等无法提取核心字段
return null;
}
// URI 截断 + 拆 query_string
$uri = substr($uri, 0, self::URI_MAX_LENGTH);
$queryString = '';
$qsPos = strpos($uri, '?');
if ($qsPos !== false) {
$queryString = substr($uri, $qsPos + 1);
$uri = substr($uri, 0, $qsPos);
}
// UA 截断
$userAgent = substr($userAgent, 0, self::UA_MAX_LENGTH);
// referer 域名提取
$refererDomain = $this->extractRefererDomain($referer);
// UA 分类
[$uaType, $uaName] = $this->classifyUa($userAgent);
return [
'remote_addr' => $remoteAddr,
'remote_user' => $remoteUser,
'time_local' => $timeLocal,
'method' => $method,
'uri' => $uri,
'query_string' => $queryString,
'http_version' => $httpVersion,
'status' => $status,
'body_bytes_sent' => $bodyBytes,
'http_referer' => $referer,
'referer_domain' => $refererDomain,
'http_user_agent' => $userAgent,
'ua_type' => $uaType,
'ua_name' => $uaName,
'request_time' => $requestTime !== '-' ? (float) $requestTime : 0.0,
'upstream_response_time' => $upstreamTime !== '-' ? (float) $upstreamTime : null,
'bytes_sent' => $bytesSent,
];
}
/**
* 首行格式自检:判断给定行是否匹配 analytics log_format.
*/
public function isFormatMatch(string $sampleLine): bool
{
return preg_match(self::LINE_PATTERN, trim($sampleLine)) === 1;
}
/**
* UA 分类.
*
* @param string $ua User-Agent 字符串
*
* @return array{0:string,1:string} [ua_type, ua_name]spider 时 ua_name = 匹配的关键词
*/
public function classifyUa(string $ua): array
{
if ($ua === '' || $ua === '-') {
return ['unknown', ''];
}
foreach (self::SPIDER_KEYWORDS as $keyword) {
if (stripos($ua, $keyword) !== false) {
return ['spider', $keyword];
}
}
return ['browser', ''];
}
/**
* 提取 referer 域名.
*
* @param string $referer 原始 referer 值
*
* @return string 域名;空或 "-" 返回 'direct';解析失败返回 'unknown'
*/
public function extractRefererDomain(string $referer): string
{
if ($referer === '' || $referer === '-') {
return 'direct';
}
$host = parse_url($referer, PHP_URL_HOST);
if ($host === false || $host === null || $host === '') {
return 'unknown';
}
return $host;
}
/**
* 拆分 request → [method, uri, http_version].
*
* @param string $request 原始 request 字段("GET /path HTTP/1.1"
*
* @return array{0:?string,1:?string,2:?string} 匹配失败三项均为 null
*/
public function splitRequest(string $request): array
{
if (preg_match(self::REQUEST_PATTERN, $request, $m)) {
return [$m[1], $m[2], $m[3]];
}
return [null, null, null];
}
/**
* 判断 URI 是否为静态资源.
*
* 取 URI 路径部分的扩展名,命中 STATIC_EXTS 返回 true.
*/
public function isStaticResource(string $uri): bool
{
// 去掉 query string
$path = $uri;
$qsPos = strpos($path, '?');
if ($qsPos !== false) {
$path = substr($path, 0, $qsPos);
}
$dotPos = strrpos($path, '.');
if ($dotPos === false) {
return false;
}
$ext = strtolower(substr($path, $dotPos + 1));
return in_array($ext, self::STATIC_EXTS, true);
}
/**
* time_local 字符串 → Unix 时间戳.
*
* 格式示例27/Jul/2026:16:20:15 +0000
* DateTime::createFromFormat 自动处理时区偏移,
* getTimestamp() 返回 UTC Unix 时间戳,
* 后续 date() 按业务时区Asia/Shanghai格式化.
*
* @param string $timeLocalStr nginx time_local 字段值
*
* @return int|null Unix 时间戳;解析失败返回 null
*/
protected function parseTimeLocal(string $timeLocalStr): ?int
{
$dt = DateTime::createFromFormat('d/M/Y:H:i:s O', $timeLocalStr);
if ($dt === false) {
return null;
}
return $dt->getTimestamp();
}
}