直接在控制器写Log::info()不优雅,因日志与业务强耦合、异常捕获不全、响应体错误难发现、客户端不统一、日志开关不灵活;应通过装饰器模式替换HTTP客户端,统一拦截请求响应,结构化记录脱敏参数与响应,并支持trace_id串联。

为什么直接在控制器里写 Log::info() 不算优雅
因为日志和业务逻辑耦合太紧,每次调用第三方接口都要手动加前后日志、捕获异常、记录响应体——不仅重复,还容易漏掉错误分支或超时场景。更麻烦的是,当接口返回 200 但业务字段缺失(比如 "code":500)时,Http::get() 默认不抛异常,日志里根本看不出失败。
- 真实报错常藏在响应体里,不是 HTTP 状态码决定的
- 不同接口可能用
think\facade\Http、GuzzleHttp\Client或自定义 cURL 封装,日志入口不统一 - 调试时想临时关闭日志?得全局搜
Log::改,而不是开关一个配置项
用中间件 + 装饰器模式拦截所有 Http:: 请求
ThinkPHP 本身没提供 HTTP 客户端装饰器机制,但可以利用其 think\facade\Http 的底层可替换性:把默认客户端换成带日志能力的代理实例。关键不是“重写 Http 类”,而是替换它的驱动实例。
- 在
app/bootstrap.php或服务提供者中,用Http::macro()注入带日志的封装方法,比如Http::loggedGet() - 更彻底的做法:通过容器绑定,把
think\http\ClientInterface绑定为你自己的LoggedHttpClient,它内部调用原生GuzzleHttp\Client并包裹日志逻辑 - 必须注意:不要在装饰器里记录原始
$response->getBody()->getContents(),它只能读一次,会导致后续业务拿不到响应体——要用stream_get_contents()后再fopen('php://temp', 'r+')重放流
LoggedHttpClient 必须捕获的三类错误场景
只记录 try/catch GuzzleException 远不够。第三方接口失败往往静默发生,靠状态码和业务字段双重判断才可靠。
-
4xx/5xx响应:Guzzle 默认不抛异常,需显式调用$response->getStatusCode() >= 400判断 - 响应体 JSON 解析失败:比如返回 HTML 错误页或空字符串,
json_decode($body, true)返回null,且json_last_error()非零 - 业务约定错误码:如
["code" => 1001, "msg" => "token expired"],这类必须由调用方传入校验回调,装饰器不能硬编码规则
示例:传入校验逻辑 fn($data) => $data['code'] === 0,失败时自动触发 Log::error() 并抛出 ThirdPartyApiException。
立即学习“PHP免费学习笔记(深入)”;
日志内容别只记 URL 和状态码
线上排查时,最常被问到的是:“当时传了什么参数?对方到底返回了啥?”但很多人只记 GET /api/user?id=123,却没记录实际发出的完整请求头、Query、Body(脱敏后),也没存原始响应头和 body 片段。
- 敏感字段(如
access_token、password)必须在写入日志前用正则或白名单 key 清洗,不能依赖“前端不传”这种假设 - 响应体超过 2KB 截断,但要保留开头和结尾各 200 字符,避免只截中间导致看不出结构
- 建议加一个
trace_id字段,和当前请求的think\Request::traceId()对齐,方便跨系统串联日志
真正难的从来不是记日志,是让日志在出问题那一刻,刚好有你需要的那一行。



















