ThinkPHP 钩子注册顺序写反了,我调试三小时才发现事件是"倒着长"出来的

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
站长杂谈 6 浏览 0 回复

昨晚给后台加操作日志,想在模型写入后自动记一笔。写了 Model::event('after_write', function($model){ ... }),日志倒是记了,但记进去的数据永远少一截——新插入记录的 ID 死活拿不到。

我第一反应是事务没提交,调了半小时数据库配置。后来打日志才发现,after_write 触发时数据确实已经落库,但我的闭包里又触发了另一个模型的 after_write,而那个钩子去查关联表,关联表这时候……还没数据。因为当前模型的写入事务,在嵌套钩子执行期间,对另一个连接里的查询不可见。

说白了,钩子不是"执行完这一步再继续",而是"这一步的某个瞬间突然插进来一段代码"。理解成回调容易栽,理解成"中断"更准确。

后来我把这类钩子分了三种活法,现在挂之前先对号入座:

一、模型事件:最隐蔽的"暗桩"

ThinkPHP 的模型事件分静态注册和实例注册。静态的写在模型类里 protected static function init(),跟着类走;实例的是 $model->event('after_write', ...),只影响当前这一次实例。我踩的坑是混着用——基类里静态注册了全局日志,子类实例注册了个别业务,结果同一个 after_write 触发了两遍,顺序还不可控。

现在我的规矩:全局通用的(如操作日志、缓存清理)放静态 init;业务耦合的(如发送通知、同步第三方)在控制器里显式触发,或者挂到自定义事件里,绝不往模型事件里塞。

二、应用事件:最容易"倒着长"

AppInit、HttpRun、RouteLoaded、ControllerBegin、ActionBegin……这串顺序我背错过三次。最坑的是 AppInit 里如果去读配置,而配置又是从数据库拉的——数据库连接还没初始化完,直接报 "driver not found"。

我现在的调试土办法:在 event.php 的每个监听闭包第一行写 trace('=== 钩子名 ' . microtime(true));,然后看 runtime 日志里的时间戳。别笑,比 Xdebug 跟流程快多了,尤其事件嵌套事件的时候,调用栈能把你绕晕。

有个冷知识:LogWrite 事件是在日志真正写入前触发的,你可以在这里改日志路径或者过滤敏感字段。但如果你在 LogWrite 里又打了一条日志……恭喜你,递归了。ThinkPHP 有层数限制,默认 100,超了直接抛异常,我本地压测时撞上过。

三、自定义事件:最该用却最少人用

我早期爱在控制器里直接调模型、调服务、发通知,一个方法 60 行。后来拆成 Event::trigger('UserUpgraded', $user),监听里再分会员服务发权益、消息服务推微信、统计服务记转化。看起来多了层转发,实际上排查问题爽多了——哪个环节崩了,看事件名就知道范围。

但自定义事件也有坑:Event::trigger 默认是同步顺序执行,第一个监听器抛异常,后面的全断。我想过改成异步,ThinkPHP 本身没内置队列事件,得自己接 Redis 或者用 think-queue。小站点没必要,我现在是用 try-catch 把每个监听器包起来,异常记日志但不阻断,宁可漏发一条通知,不能整个下单流程崩掉。

最后说个调试神器

我在 app/Event.php(应用事件定义文件)里加了个全局中间件式的钩子:

Event::listen('*', function($event, $params) {
    if (env('APP_DEBUG')) {
        trace("事件捕获: {$event} | 参数类型: " . gettype($params));
    }
});

通配符监听 * 会抓到所有事件触发,包括框架内部的。上线前关掉,调事件死循环的时候比断点好用——断点进去就出不来了,日志至少能看完生命周期全貌。

你们挂钩子有没有遇到过"明明写了监听,死活不触发"的情况?我有一次是 event.php 配置里监听器类名写错,框架静默忽略了,连警告都没有。现在我在部署脚本里加了行 php think event:listen 的自定义命令,遍历配置校验类是否存在,算是给自己加的保险。

评论0
回复 · 0
还没有回复
微信客服 微信客服