FastAdmin Hook 机制与插件错误透传:$result 之外的信封
基于 FastAdmin(ThinkPHP 5.0)+ PHP 7.3 的真实项目经验。本文以短信网关"发送失败时拿到底层错误信息"为例,拆解
Hook::listen的引用传参机制,以及如何利用 Model 的ArrayAccess实现错误信息的 in/out 信封传递,最后讨论多层日志的冗余治理。
0. 从一个困惑说起
给短信网关加错误透传时,遇到一段"看不懂"的代码:
php
$sms = \app\common\model\Sms::create([...]);
$result = Hook::listen('sms_send', $sms, null, true);
if (!$result) {
// 为什么错误信息从 $sms 上读,而不是从 $result 读?
$error = isset($sms['error']) ? $sms['error'] : '未知错误';
}
两个疑问:
$sms明明是个模型对象,插件发短信的失败信息怎么跑到它身上去了?- 失败结果不应该在
$result里吗?
答案藏在 TP5 的 Hook 机制里。把 thinkphp/library/think/Hook.php(file:///E:/www/hnnonggou/thinkphp/library/think/Hook.php) 拆开看就明白了。
1. Hook::listen 源码拆解
TP5 的 Hook 是"标签 → 行为"的事件机制。FastAdmin 的插件(addons)就是通过它挂载的:sms_send 这个标签上挂着 alisms 插件的 smsSend 方法。
1.1 入口:listen
php
public static function listen($tag, &$params = null, $extra = null, $once = false)
{
$results = [];
foreach (static::get($tag) as $key => $name) {
$results[$key] = self::exec($name, $tag, $params, $extra);
// 如果返回 false,或者仅获取一个有效返回则中断行为执行
if (false === $results[$key] || (!is_null($results[$key]) && $once)) {
break;
}
}
return $once ? end($results) : $results;
}
三个关键点:
&$params是引用参数------这是整个信封机制的地基$once = true时只执行第一个行为并返回其返回值,所以$result只有插件的 return 值- 返回
false会中断后续行为(失败短路语义)
1.2 分发:exec
php
public static function exec($class, $tag = '', &$params = null, $extra = null)
{
$method = Loader::parseName($tag, 1, false); // sms_send → smsSend
// ...
$obj = new $class();
$result = $obj->$method($params, $extra); // 调用插件方法
return $result;
}
exec 的 $params 同样是引用,一路原样传给插件的 smsSend(&$params)。
2. 核心知识点一:引用传参 = 共享同一个对象
调用链路:
SmsExt::send
$sms = Sms::create(...) // 模型对象,落库
$result = Hook::listen('sms_send', $sms, null, true)
└→ exec('addons\alisms\Alisms', 'sms_send', $sms)
└→ Alisms::smsSend(&$params) // $params 与 $sms 是同一个对象
PHP 的对象变量存的是句柄 ,&$params 让插件拿到的句柄与调用方的 $sms 指向同一份内存。插件里写:
php
public function smsSend(&$params)
{
$alisms = new \addons\alisms\library\Alisms();
$result = $alisms->mobile($params['mobile'])->template(...)->send();
if (!$result) {
$params['error'] = $alisms->getError(); // 写进的是调用方那个对象
}
return $result;
}
插件执行完,SmsExt::send 里的 $sms 就"多"出了一个 error 属性。
3. 核心知识点二:Model 是天然信封(ArrayAccess)
为什么能往模型上 $params['error'] = 'xxx' 这样赋值?因为 TP5 的 Model 实现了 ArrayAccess 接口:
offsetSet→ 写入模型内存中的data数组offsetExists/offsetGet→ 从data数组读
关键:这只改内存,不会自动写库 。只有调用 save() 才会落库。所以模型对象天然就是一个"信封"------插件往里塞附加信息,调用方原样取回,数据库表结构完全不用动。
4. 为什么错误不在 $result 里
$result 是 listen 的返回值,它只取插件方法的 return 值,而插件约定只返回成败布尔:
php
return false; // 失败 → $result = false
return true; // 成功 → $result = true
布尔里装不下"阿里云为什么拒绝你"(签名错误?模板不存在?余额不足?)。所以:
| 通道 | 携带什么 | 用途 |
|---|---|---|
return 值($result) |
成败布尔 | 流程控制(if 判断) |
$params 引用 |
错误详情 Message | 排查、审计、透传给上层 |
$result 告诉你成没成,$sms['error'] 告诉你为什么没成。 这是 FastAdmin 插件机制的既有约定------插件从 $params['mobile'] 取输入(入参信封),也可以往 $params 写输出(出参信封),in/out 两用。
5. 实战:让错误穿透三层
完整的错误透传链路:
阿里云返回 {Code: "isv.xxx", Message: "InvalidResult"}
→ alisms 插件:$params['error'] = $alisms->getError() [源头捕获]
→ SmsExt::send:self::$lastError = $sms['error'] [库层缓存]
→ 网关控制器:Smslib::getLastError() [业务读取]
├→ \think\Log::record("sms_send_fail app=... error=...")
└→ fa_sms_gateway_log 表 fail_reason 字段 [审计流水]
各层职责:
- 插件层:只负责"捕获 + 回写",是错误的最源头
- 库层(SmsExt) :只负责"缓存 + 透传",通过
getLastError()暴露 - 网关层:决定"记到哪里"------文件日志给运维 grep,数据库流水给审计
6. 日志分层与冗余治理
链路里一度有三层文件日志 + 一层数据库流水:
| 层 | 日志内容 | 用途 |
|---|---|---|
| alisms 插件 | alisms send fail mobile=... event=... error=... |
源头,覆盖后台测试短信等非网关场景 |
| SmsExt 库 | sms_send fail mobile=... event=... error=... |
与插件层内容几乎完全重复 |
| 网关控制器 | sms_send_fail app=... scene=... error=... |
带 app_id/scene 业务维度,运维排查 |
| fa_sms_gateway_log | fail_reason 字段 | 审计留痕,业务可查可统计 |
治理结论:
- SmsExt 中间层是冗余------同一失败写两遍日志文件,且没有增量信息(app_id/scene 在网关层才有)。库层应该只透传错误,不记日志,记什么由调用方决定。
- 插件层保留------它是唯一覆盖"非网关直接调插件"场景的日志,且是错误源头。
- 网关日志 + DB 流水不算冗余------文件日志用于运维排查/告警,数据库流水用于审计追溯,载体和用途不同,是生产级的正常分层。
7. 要点回顾
Hook::listen($tag, &$params, ...)的第二个参数是引用传参,插件和调用方共享同一个对象- TP5 Model 的
ArrayAccess让模型对象天然可作为 in/out 信封 ,写入只进内存data数组,不落库 $result只携带成败布尔,详情走信封,成败走返回值------这是 FastAdmin 插件的既有约定- 错误透传的分层原则:源头捕获、库层透传、调用方决定如何记录
- 多层日志要按"增量信息"审视:内容重复且无新维度的中间层日志就是冗余