ThinkPHP 6 自定义日志格式需继承 think\log\driver\File 并重写 write 方法,组装含时间、级别、消息及结构化上下文的单行字符串;JSON 日志须确保每条严格单行、关键字段前置、避免嵌套换行,配合 Log::setContext() 统一注入请求上下文,且不同 channel 应配置独立驱动类而非仅依赖 format 参数。

ThinkPHP 6 如何自定义日志格式(非 JSON)
默认日志是纯文本,带时间、级别、消息和上下文,但字段顺序固定、不可控,不适合 ELK 或日志服务解析。要改格式,核心不是重写 think\log\driver\File,而是替换日志处理器(think\log\handler\HandlerInterface)的 write 方法行为。
实际做法是继承 think\log\driver\File,覆盖 write 方法,在写入前把 $message 和 $context 组装成你想要的结构:
use think\log\driver\File;
class CustomFileLog extends File
{
protected function write($level, $message, $context = [])
{
// 构造自定义字符串:可加 trace_id、server IP、请求路径等
$logLine = sprintf(
'[%s] [%s] %s | %s',
date('Y-m-d H:i:s'),
strtoupper($level),
$message,
json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
parent::write($level, $logLine, []);
}
}
- 必须调用
parent::write()时传空[]作为第三个参数,否则会二次格式化 - 不要在
$context里塞资源句柄、闭包或大对象,序列化会失败或拖慢性能 - 如果项目用了 Swoole 长连接,注意
date()在协程中可能不准,建议用Carbon::now()->toDateTimeString()
ThinkPHP 6 输出 JSON 日志(结构化日志)
JSON 日志不是简单地把整条日志 json_encode 一遍——关键是要让每个字段可提取、可过滤。ThinkPHP 默认日志上下文($context)已支持传入数组,比如 Log::info('user login', ['uid' => 123, 'ip' => '192.168.1.1']),这部分天然适合转 JSON。
推荐方式:不修改驱动,而是在中间件或全局异常处理中统一注入结构化字段,并用 Log::record() 手动构造完整日志项:
立即学习“PHP免费学习笔记(深入)”;
// 在 App\Middleware\LogContextMiddleware 中
public function handle($request, \Closure $next)
{
$context = [
'request_id' => $request->header('x-request-id', uniqid('req_')),
'method' => $request->method(),
'path' => $request->url(true),
'ip' => $request->ip(),
];
// 注入到 Log 的上下文池(需配合自定义驱动读取)
\think\Log::setContext($context);
return $next($request);
}
- ThinkPHP 6.1+ 支持
Log::setContext(),后续所有日志自动合并该上下文 - 若用的是旧版 TP6.0,需自己维护一个静态上下文容器,在自定义驱动的
write中手动合并 - 避免在
$context中传$_SERVER全量数组——体积大、含敏感信息、JSON 化易出错
为什么不能直接用 json_encode($log) 替换日志内容?
直接对整个日志对象或原始数组做 json_encode,会导致日志文件变成多行 JSON(每条日志一行),看似结构化,实则埋坑:
- 日志轮转(
max_files)失效:TP 日志按行切分,而 JSON 字符串含换行符时会被截断 - grep / awk 失效:无法用
grep "error"快速筛选,因为关键字可能在 JSON 值里,也可能在 key 名里 - ELK 解析失败:Filebeat 默认按行读取,若某条 JSON 跨行,会当成两条日志丢弃或错位
- 错误堆栈被扁平化:原生
Exception::getTraceAsString()是多行文本,JSON 化后变成带\n的字符串,失去可读性
真正可靠的结构化日志,应确保单条日志严格单行、无嵌套换行、关键字段(level/time/trace_id)前置且无转义。
Log::channel() 切换日志实例时的格式陷阱
很多人想为 API 接口单独开一个 JSON 日志 channel,于是配置 'json' => ['type' => 'File', 'format' => ...],但发现没生效——因为 format 配置项在 TP6 中**仅对内置的 think\log\driver\File 有效,且只控制时间戳和级别前缀,不接管消息体组装逻辑。
正确做法是:为不同 channel 指定不同驱动类:
'channels' => [
'api_json' => [
'type' => 'CustomJsonFile', // 自定义类名
'path' => __DIR__ . '/../runtime/log/api/',
],
]
- 类名必须在
config/log.php加载前已定义,或通过 Composer autoload 可查找到 - 别在
CustomJsonFile中重复实现日志滚动、锁机制——应继承File并只改write - 多个 channel 共享同一份
setContext()数据,如需隔离,得在中间件里按 channel 名做分支处理
结构化日志最难的不是拼 JSON,而是让字段稳定、语义明确、生命周期可控。一旦 context 注入点分散在控制器、中间件、事件监听器里,就很容易漏字段或冲突。建议把日志上下文收敛到一个地方初始化,比如请求进入时的首个中间件。



















