从事件机制分析公司自研 Q 框架优劣性
前言
上个月底被“毕业”,提前 30 天通知到个人,因为就待了一年左右所以有补偿就顺坡下驴同意了。虽然现在的工作确实很难找,尤其是还是 IT 行业。没办法,有补偿总比上家公司打官司拖了一年,强制执行也什么都没有强。
对于公司第一印象是破旧。之前办公点在苏大北校区内,楼不高,所有地方都是破破的,包括路面,下雨必淹的那种。苏州这边属于分部,主要就三、四个开发。面试的时候没什么感觉,就觉得远程面试的领导技术一般,没什么深层次的沟通,大概聊了一下物联网相关的项目技术细节。
进来以后,一切都是乱糟糟的。各种乱七八糟的东西填报,还要寄回到总部去。核心的系统是公司自研的框架,扩展项目也是自研的,没有文档,全靠自己摸索和询问。公司培训拿 ppt 过了一遍,然后还被威胁考不过可能会影响实习转正。实习期每个月也都是惊吓,绩效是按照上线任务等级转化为有效工作天数/月天数,实习期 6 个月,要最后达到 0.8 才会合格(正式员工是 1,但不及格的多的是)。
后面这边的组长莫名其妙安排了一个扩展项目的任务,不熟悉业务,也不熟悉代码,第一次见识到了 A 级任务的工作量和难度,文档还看不懂(你不知道是要实现的功能,还是对已有功能的阐述)。显而易见的那个月不合格,到了后面主动拿一些简单任务勉强过了 6 个月的实习期。是的,我以为的实习期是两个月就结束了,结果硬是要考核满 6 个月,每天日报不停的(因为前面考核没过)。到了今年说是下半年业务调整,应该是项目订单少了,然后末位淘汰,降本增效。项目维护能力上,得承认新员工确实跟老员工没法比。
回过头来,除了复杂的业务逻辑,各种乱七八糟的配置和嵌入式代码,最让我恶心的就是框架内的事件机制了。因为用的太多了,简直在滥用了,逻辑方面的、页面构建方面全是,几乎每个请求都能给整一个或多个。第一次感觉嵌入式代码的混乱,view 里面套一个 trigger,trigger 通过 hooks 绑定的事件名,映射到对应的工厂类执行,执行里面再返回 view...相当于是把事件作为切蛋糕的工具了,需要的地方就来一刀,项目整体比散装江苏还散。
项目分了 N 多个(这里的 N 大于 100)模块,每个模块下都可以添加 hooks 配置项,然后每个站点都可以覆盖重写这么多的模块,结果就是每次查询事件(字符串)都要全项目搜索,然后一搜索就是几十条。一旦因为某个绑定的事件出问题,就得按条一个个去排查。遇到过这种情况,一个一个去添加日志,确认执行到那里了。
不熟悉某块业务代码,就得被这个 trigger 搞疯掉,因为你不知道实际的代码写在那里了,只能按照文件去找。记得之前还统计对哪些主要模块不熟悉的,说是要培训,结果入职以来从来没有培训过。在这样的基础上去工作,新员工简直就是耗材。
这次也是用 AI 插件做本地代码分析。AI 真是太强大了(尤其 API 付费版本),什么特性功能都懂,还能给你分析用法。自己看半天还不一定看得懂,AI 刷刷两下出结果。古法编程的程序员和使用 AI 的程序员真的是两个物种了。
以下来自 Qoder CN 本地代码分析:
Q 框架的事件机制
事件这个功能做出来,肯定是其卓著的优越性的。
不是"链式调用",而是"发布-订阅"(观察者模式)——只要绑定了,触发时必定全部执行
事件是继依赖注入之后,实现代码解耦的另一种最核心的武器。
简单来说,事件的作用就是:“有件事发生了,但我不用管谁来处理它,我只管广播出去。”
1. 核心机制
Event::trigger('xx.yy', $form, $columns, $obj) 是一个事件广播:
- 第一个参数是事件名,其余参数(
$form, $columns, $obj)作为相同参数广播给所有绑定的回调; - 只要该事件名被绑定过回调,trigger 执行时所有回调都会被依次调用,每个回调收到的都是同一组参数;
- 如果从未绑定任何回调,
Event::factory($name, false)返回null,直接跳过,空操作不报错(event.php L136-137)。
这不同于链式调用(a()->b()->c(),上一个的返回值传给下一个)。这里每个回调独立处理,通过修改传入的引用参数(如 $columns 数组)或 $e->return_value 来交互。
2. 绑定来源:hooks.php 配置文件(启动时统一注册)
Core::bind_events()(system/core/core.php:216)在系统启动时遍历 Config::get('hooks'),把各模块 config/hooks.php 中的配置统一执行 Event::bind()。
以 xx.yy 为例,本仓库中已绑定的回调有(见 modules/*/config/hooks.php):
$config['xx.yy'][] = 'xx::yy';所以你在视图里调用这一行,实际上这些模块的回调会全部依次执行,往 $columns 里追加自己的列定义。
3. 触发执行顺序与短路规则(event.php 关键逻辑)
// _trigger(): 排序后逐个调用
foreach ($this->queue as &$hook) {
$success = call_user_func_array($hook['callback'], $args); // $args[0] 是事件对象 $e
if (false === $success) { // 回调返回 false(严格 ===)
$this->stop_propagation = true;
break; // 短路:中断后续所有回调
}
}- 执行顺序:按
weight升序(weight 相同按绑定先后),所以你可以用 weight 控制优先级; - 短路例外:如果某个回调
return false;,会立即中断,后续回调不再执行,此时trigger()返回false; - 正常情况:全部执行完,
trigger()返回最后一个回调设置的$e->return_value。
4. 回调签名(重要)
trigger 内部会 array_unshift($args, $this),把事件对象塞到参数最前面,所以回调第一个参数永远是事件对象 $e:
// modules/eq_charge/libraries/eq_charge.php:3508
static function charge_table_list_columns($e, $form, $columns)
{
$equipment = O('equipment', $form['equipment']);
if ($equipment->require_dteacher) {
$columns['duty_teacher'] = ['title' => ..., 'weight' => 45];
}
}5. 其他细节
- key 的作用:hooks.php 中
$config['事件名']['key']可防止重复绑定,相同 key 后绑定的会覆盖前面的; - selector 支持多个事件:
Event::trigger(['a', 'b'], ...)或空格分隔字符串会逐个触发(event.phpextract_names); - 调试:配置
debug.event开启后,每次调用会在 error_log 打印事件名、回调、执行结果,排查绑定是否生效很有用。
为什么事件发布前后要使用 ArrayObject
调用实例场景:
$columns = new ArrayObject($columns);
Event::trigger('xx.yy', $form, $columns, $obj);
$table->add_columns((array)$columns);$columns 不是普通数组,而是被包装成了 ArrayObject 对象
这段代码的核心目的,是为了绕过 PHP 中数组的“值传递”特性。
在编程模式中,这种操作叫做“将数据包装成可变的引用传递容器”。
为什么我印象中在之前 Laravel 项目的事件调用没用过这样的封包、解包呢?
Laravel 和 当前项目的事件各项参数对比:
| 维度 | Laravel | LIMS2(当前项目) |
|---|---|---|
| 事件标识 | 类名(FQCN),类型安全 | 字符串(如 xx.yy) |
| 监听器形态 | 类/闭包,构造函数可 DI | 'Class::method' 静态方法或 [$obj, 'method'] |
| 数据传递 | 数据封装在事件对象属性里,监听器只收 $event 一个参数 | 事件名只是标识,原始参数直接广播,回调签名 ($e, $form, $columns, $obj) 多参数 |
| 注册方式 | ServiceProvider $listen 数组 / 运行时 listen() / 订阅者 | 各模块 config/hooks.php 配置文件 + 启动时 Core::bind_events() 统一注册 / 运行时 Event::bind() |
| 排序 | 监听器数组顺序(可 PriorityQueue) | 显式 weight 升序 + 绑定顺序 |
| 短路 | 监听器返回 false 停止传播 | 回调返回 false(严格 ===)→ stop_propagation,中断后续 |
| 返回值 | dispatch() 返回数组(各监听器返回值);halt 模式返回首个非 null | 通过 $e->return_value 传递,trigger() 返回最后一个设置的值;短路返回 false |
| 参数修改 | 事件对象是类实例,天然引用共享;监听器改 $event->order 外部可见 | 数组参数需手动包装 ArrayObject/ArrayIterator 获得引用语义,或用 $e->return_value 回传 |
| 异步 | ✅ ShouldQueue | ❌ 纯同步 |
| DI | ✅ 容器自动解析 | ❌ 静态方法字符串调用 |
所以,是之前一般只传值,或者使用 $order 之类的 Eloquent Model 对象,而对象属于引用类型。
事件机制的副作用和解决
这就是观察者模式的固有代价,业界叫 "幽灵般的远距离作用"(spooky action at a distance)——数据在你看不见的地方被改了。
这是"可读性/可追踪性"与"可扩展性/解耦"的此消彼长。
业界接受这个权衡,是因为它换来了"核心模块不用改一行代码就能被任意模块扩展"(开闭原则)。
让我意想不到的是,AI 还分析出来框架自带调试器,这是我之前不知道的。
// call_wrapper 里(event.php L140):
$e->debug = !!Config::get('debug.event:' . $name, Config::get('debug.event'));
// _trigger 里(event.php L46-65):
if ($this->debug) {
error_log($prefix . $this->name . ' > ' . @json_encode($hook['callback'])); // 谁被调了
...
error_log($indent . ' done.' / ' aborted.'); // 执行结果、是否短路
}开运行时日志
在配置里加 'debug.event' => true(或精确到 'debug.event:xx.yy' => true),error_log 会按实际执行顺序打印每个回调名和结果——不用挨个看代码,直接看日志就知道谁改了、执行顺序、有没有被短路。
结论
你指出的复杂度真实存在,是观察者模式的固有代价(隐式控制流),不是你的错觉;
但正确解法不是"全项目搜索挨个看",而是 hooks.php 清单 + debug.event 日志 + 二分禁用 + 追加约定——这套组合把排查从"考古"变成"查台账";最终是权衡问题:如果你的功能永远不会被其他模块扩展,直接显式调用更简单(我们前面讨论过);只要你的系统需要"多模块可插拔"(LIMS2 的核心诉求——25+ 站点共享一套核心代码),这个代价就必须付,而 hooks + debug 机制就是它的"付账单"。
所以,为了多模块可扩展性,框架这样搞没有问题。
替换“事件”,高可用且可读功能特性
问了 deepseek 是否有保持系统的可扩展性的同时,又能直观可读的功能特性:
- 命令模式 (Command Pattern)
- 拦截器模式 (Interceptor Pattern)
- 插件/中间件系统 (Plugin/Middleware System)
- 响应式编程 (Reactive Programming) 与信号 (Signals)
| 方案 | 核心思想 | 直观可读性 | 可扩展性 | 适用场景 |
|---|---|---|---|---|
| 命令模式 | 将请求封装为对象 | 高(意图明确) | 高(符合开闭原则) | 业务操作、事务处理、需要支持撤销/重做的场景 |
| 拦截器模式 | 在方法调用前后插入逻辑 | 中高(职责分离) | 中高(可插拔) | 日志、权限、性能监控等横切关注点 |
| 插件系统 | 通过责任链组织处理单元 | 高(流程清晰) | 极高(动态加载/排序) | 需要高度模块化和动态扩展能力的复杂系统 |
| 响应式编程 | 声明数据流与依赖关系 | 高(声明式) | 中高(易于组合) | UI状态管理、复杂异步数据流处理 |
拦截器在 Java 里有使用过,中间件在 Laravel 中使用过,功能确实与事件相似,但这两者都是对于请求进行截取处理。跟事件主要区别在于,拦截器/中间件会有明确的处理目的,通过定义的名称就知道干嘛的。但总感觉颗粒度不太对齐,没有办法替换事件本身。
之前 Laravel 项目中也使用过事件,但当时因为可以逻辑分层,控制器负责调各种服务。另外也不怎么需要扩展,有需要就增加一个服务调用好了。同步可以用服务层拆分逻辑代码,异步还有队列可以处理,当时觉得事件还挺鸡肋的。哪能想到,这功能到了另一个场景能用到泛滥成灾,直搞得你头皮发麻。
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。
海滨擎蟹
微信
支付宝