FastAdmin Hook 机制与插件错误透传:`$result` 之外的信封

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'] : '未知错误';
}

两个疑问:

  1. $sms 明明是个模型对象,插件发短信的失败信息怎么跑到它身上去了?
  2. 失败结果不应该在 $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 字段 审计留痕,业务可查可统计

治理结论:

  1. SmsExt 中间层是冗余------同一失败写两遍日志文件,且没有增量信息(app_id/scene 在网关层才有)。库层应该只透传错误,不记日志,记什么由调用方决定。
  2. 插件层保留------它是唯一覆盖"非网关直接调插件"场景的日志,且是错误源头。
  3. 网关日志 + DB 流水不算冗余------文件日志用于运维排查/告警,数据库流水用于审计追溯,载体和用途不同,是生产级的正常分层。

7. 要点回顾

  1. Hook::listen($tag, &$params, ...) 的第二个参数是引用传参,插件和调用方共享同一个对象
  2. TP5 Model 的 ArrayAccess 让模型对象天然可作为 in/out 信封 ,写入只进内存 data 数组,不落库
  3. $result 只携带成败布尔,详情走信封,成败走返回值------这是 FastAdmin 插件的既有约定
  4. 错误透传的分层原则:源头捕获、库层透传、调用方决定如何记录
  5. 多层日志要按"增量信息"审视:内容重复且无新维度的中间层日志就是冗余
相关推荐
小马过河R1 小时前
EdgeOne深度解析:从原理、适用场景到COS接入的技术逻辑
java·开发语言·前端·spring·cos
多弗朗皮卡丘1 小时前
C++ 红黑树
开发语言·c++·红黑树
ZoeLandia2 小时前
TypeScript 04:类型断言与类型守卫
开发语言·前端·typescript
geovindu2 小时前
rust: pool Pattern
开发语言·后端·设计模式·rust·对象池简写模式
jimy12 小时前
标准的动态类型定义与“多态”无关,非多态对象同样会出现动态类型不同于静态类型的情形
开发语言·c++
冬、不眠的夜2 小时前
Nuitka 将 python 程序编译成 exe 指南
开发语言·python
CVer儿2 小时前
起源1引擎相关知识点2--World Brush+func_detail
开发语言
薛之谦_2 小时前
JAVA 多线程编程:从基础原理到实战应用
java·开发语言
LMT_Lanmantech2 小时前
Creo许可降本增效:不增购,也能让现有许可发挥更大价值
java·大数据·开发语言