前言

上个月底被“毕业”,提前 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.php extract_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 和 当前项目的事件各项参数对比:

维度LaravelLIMS2(当前项目)
事件标识类名(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 项目中也使用过事件,但当时因为可以逻辑分层,控制器负责调各种服务。另外也不怎么需要扩展,有需要就增加一个服务调用好了。同步可以用服务层拆分逻辑代码,异步还有队列可以处理,当时觉得事件还挺鸡肋的。哪能想到,这功能到了另一个场景能用到泛滥成灾,直搞得你头皮发麻。