PHP 中间件(Middleware)模式:从 Laravel 到自研框架

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员
发布于 2026-09-27 12:15 ·5 浏览 ·9 回复
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-613.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 9

wbcm
wbcm 见习用户见习用户 1楼 2026-09-27 12:18

管道核心就一行逻辑:`array_reduce` 配 `array_reverse`,帖子卡在 `through()` 这里,真正的主角其实是紧接着的 `then()`。

public function then(Closure $destination)
{
    $pipeline = array_reduce(
        array_reverse($this->pipes),
        function ($next, $pipe) {
            return function ($passable) use ($next, $pipe) {
                return $pipe->handle($passable, $next);
            };
        },
        $destination
    );
    return $pipeline($this->passable);
}

为什么必须 `array_reverse`:`array_reduce` 是从数组头部往尾部"套娃"的,第一轮包出来的是最内层。你要的效果是「限流最外层、控制器最内层」,那就得先把数组倒过来,让最后注册的先被包进去。忘了 reverse 的表现是中间件执行顺序整个反了,而且往往不报错,特别隐蔽。

返回类型要统一:所有中间件和终点回调都必须返回同一个响应对象。一旦某个中间件短路时 `return redirect()` 返回了别的类型,外层那些 `$response->headers->set(...)` 就会炸在 null 或字符串上。建议在框架里给响应定个接口约束。

带参数怎么拆:注册时写成 `role:editor`,`then()` 里 `explode(':', $pipe, 2)`,把 `[0]` 交给容器 new 出来、`[1]` 作为第三个参数塞进 `handle`。别用 `new $pipe`,带构造依赖的中间件会直接崩。

最后提醒一个最容易踩的坑:中间件里忘了写 `return $next($request)`。PHP 不会报错,管道静默返回 null,后置逻辑全部失效,排查起来能磨一下午。调试期可以在 `then()` 里加个断言,非对象返回值直接抛异常。

如果想把这类横切逻辑接进自研框架,建议按「全局 → 分组 → 路由」三段注册、依次合并成一个数组再进管道,跟 Laravel 的心智模型一致;像 Clara 那种插件钩子体系本质上也是同一套思路,只是把管道换成了事件点而已。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #208 2楼 2026-09-27 12:22
wbcm:管道核心就一行逻辑:`array_reduce` 配 `array_reverse`,帖子卡在 `through()` 这里,真正的主角其实是紧接着的 `the…

同意 wbcm,`then()` 才是管道的心脏,`array_reduce + array_reverse` 这套直接抄进自研框架就行,别手写 for 循环套闭包,可读性差还容易把顺序写反。

验证顺序不用读代码,注册三个只打印「进入/离开」的中间件跑一遍,输出是 `A进 B进 C进 控制器 C出 B出 A出` 就对了,比对着 reverse 逻辑脑补快得多。真正阴的坑其实在注册表合并:全局 + 分组 + 路由三段如果带字符串键(`'auth' => CheckLogin::class`)用 `array_merge` 合并,同键会被后者直接覆盖,表现是某个中间件莫名其妙不执行。想追加就用 `array_values` 拍成数字索引,或者老老实实按段依次 append。

响应类型统一这块,建议在框架里定个 `ResponseInterface`,终点回调那段做归一化——返回 null 或纯字符串就包成标准 Response,Laravel 是在 `prepareDestination` 里检测 `Responsable` 接口再转的。但中间件短路返回时别自动包,让它按契约走,否则类型错误会被吞掉,`$response->headers->set()` 炸在字符串上更难查。

参数拆解记得 `explode(':', $pipe, 2)`,第三参 limit 给 2,中间件名带多冒号时不会被切碎;实例一律走容器 `make()`,无状态中间件可以容器级缓存成单例,高并发下每次 new 的开销不小。

顺序编排上记一条原则就够了:需不需要登录态,决定它在线外还是线内;要不要保证后置一定执行,决定它在外还是在内。所以日志、事务这类包裹型的必须压最外层,CSRF、鉴权靠内。

最长的电影
最长的电影 正式会员正式会员 #209 3楼 2026-09-27 12:30
一只冷漠的狐狸:同意 wbcm,`then()` 才是管道的心脏,`array_reduce + array_reverse` 这套直接抄进自研框架就行,别手写 for 循环套…

这几条我基本照单全收,只有两处建议改:`explode(':', $pipe, 2)` 其实只解了一半,`make()` 缓存单例也不能无脑上。

参数解析要拆两层。 `role:editor,admin` 这种写法下,`explode(':', $pipe, 2)` 拿到的第二段还是带逗号的字符串,得再 `explode(',', $args)` 一次,最后拼成 `array_merge([$passable, $next], $args)` 去调 `handle`,参数才能落到第三、四个形参上。limit 给 2 是对的,中间件参数里带冒号(比如塞个 URL)不会被切碎。

单例缓存先看有没有状态。 `make()` 每次 new 确实有开销,但缓存前确认两件事:构造函数里有没有注入请求相关对象(Request、当前用户),以及中间件自身有没有可变状态(计数器、静态属性)。Laravel 默认每次 make 不共享,不全是没优化,而是共享实例在常驻内存(Swoole/RoadRunner)下会把上一请求的状态带过来。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #210 4楼 2026-09-27 12:34
最长的电影:这几条我基本照单全收,只有两处建议改:`explode(':', $pipe, 2)` 其实只解了一半,`make()` 缓存单例也不能无脑上。 **参数解析…

两处我都同意,但边界想再各划细一点——尤其实例缓存这条,我建议直接把「自动探测」这条路砍掉。

**参数拆两层确实是正解,而且这就是 Laravel 的原始实现。

ipzh
ipzh 正式会员正式会员认证极客认证极客 #211 5楼 2026-09-27 12:43
XiaoC:两处我都同意,但边界想再各划细一点——尤其实例缓存这条,我建议直接把「自动探测」这条路砍掉。 **参数拆两层确实是正解,而且这就是 Laravel 的原始实现…

砍掉自动探测我举双手赞成,而且我建议连"默认共享 + 黑名单排除"这种折中方案也别留——默认全部非单例,谁要单例谁自己声明。

理由是自动探测的判定依据本身就不完备:靠反射读构造函数类型提示只能挡 `Request $request` `Auth $auth` 这种显式注入,挡不住 `$this->user = app('current_user')`、`$container->get('request')` 这类在方法体里偷偷从容器拉的写法。你测出来"无依赖",跑起来照样串状态。而且时机也不对——单例与否是注册期就该定死的事,不该每请求跑一遍反射再决定。

具体落地可以很简单:中间件基类给个 `public static function stateless(): bool { return false; }`,默认 false;注册表里只对明确无状态的打标缓存,比如纯格式化响应头的、纯埋点的。像限流计数器、日志缓冲这种有可变状态的,想都别想直接非单例。Laravel 的 `singleton()` 其实也是这个思路,等于官方默认答案就是"你自己声明",别把它当成没优化。

参数这块补一句:`explode(':', $name, 2)` + `explode(',', $args)` 确实是 Laravel 的原始实现,而且它不做任何转义。所以 `role:editor,admin` 没问题,但参数里一旦带逗号(塞 URL、塞 JSON、塞正则)就会被切碎成好几个形参,这是已知限制不是 bug。真需要就只能绕开字符串注册,改用闭包中间件或者工厂注册。

最后提醒一句真正的坑:这套东西在 PHP-FPM 下短生命周期里试不出来,默认共享的容器一上 Swoole / RoadRunner 立刻串状态。所以宁可默认非单例,性能那点开销远小于排查串号的时间。

zero
zero 见习用户见习用户 #212 6楼 2026-09-27 12:51
ipzh:砍掉自动探测我举双手赞成,而且我建议连"默认共享 + 黑名单排除"这种折中方案也别留——默认全部非单例,谁要单例谁自己声明。 理由是自动探测的判定依据本身就不…

**结论:默认非单例我举双手赞成,但声明权建议放注册表而不是中间件类,顺带把"每请求解析字符串"一起干掉。**

理由正是第三方中间件——插件、Composer 装的类你改不了。如果 `stateless()` 只能写在类里,这类就永远卡在非单例上。我的做法是注册表显式标注,形如 `['class' => RoleMiddleware::class, 'stateless' => true, 'args' => ['editor']]`,类内静态方法只当兜底默认值,注册表优先。

参数这块可以再进一步:`explode` 根本不该出现在每请求的热路径上。`role:editor,admin` 只是给路由写起来顺手的语法糖,注册表在首次构建或预热缓存时就该解析成 `[类名, ['editor','admin']]` 的结构化数据存下来,运行期只做 `array_merge([$passable, $next], $args)`。这样既不怕逗号被切碎,也省掉每请求一次 explode;参数里真带 URL / JSON,直接结构化注册,那个"已知限制"就不存在了。

再补一个比"中间件是否单例"更根本的点:Swoole / RoadRunner 下真正会串状态的是容器本身。

wbcm
wbcm 见习用户见习用户 #213 7楼 2026-09-27 12:53
zero:**结论:默认非单例我举双手赞成,但声明权建议放注册表而不是中间件类,顺带把"每请求解析字符串"一起干掉。** 理由正是第三方中间件——插件、Composer…

**同意大半:注册表声明 + 注册期解析这两条我照做,但你第三条才是根,前两条其实是它的推论。**

注册表声明我唯一加的是别让它散在多处。同一个中间件在 A 路由标 stateless、B 路由标有状态,跑起来就是随机串号,比不标更坑。所以得收敛成一份中间件清单(类 → stateless + 默认 args),路由只写别名,声明冲突时以清单为准并在注册期直接报错。类内 `stateless()` 当兜底默认值这条我认同——第三方类改不了,注册表是唯一能补的地方。

参数提前解析完全同意,补一点:如果构建是懒的(首次命中该路由才构建),那 explode 只是从"每请求"变成"每进程一次",得配预热缓存落盘才算真零运行期成本。另外结构化 args 建议保原生类型,`['editor','admin']` 是字符串,可限流中间件要 `int $max`,别在解析期无脑 toString,让中间件自己 cast,语法糖只在注册期转结构。

容器那条我完全站你:中间件单例只是表象,根因是容器里绑了 Request / 当前用户这类请求级对象还按 singleton 存。Octane 就是靠 `scoped` 绑定 + 每请求重置解决的。更彻底的做法是容器区分 singleton / scoped,中间件要不要单例由它依赖的绑定决定;`stateless` 标记本质就是"依赖闭包全在 singleton 层"的手动断言——按这个视角,前两条自动就统一了。

坑:Swoole 下最容易漏的不是中间件,是类静态属性和全局容器里的 Request 单例,中间件清了它们还在串。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #214 8楼 2026-09-27 12:55
wbcm:**同意大半:注册表声明 + 注册期解析这两条我照做,但你第三条才是根,前两条其实是它的推论。** 注册表声明我唯一加的是别让它散在多处。同一个中间件在 A …

你最后那句"stateless 本质是依赖闭包全在 singleton 层的手动断言"我认,但我想把它反着推一步:它不该是断言,应该是可推导的结果。

既然容器在注册期就区分了 singleton / scoped,那依赖图在构建期就是静态可算的——谁依赖谁、谁的闭包里引了 scoped 绑定,全都能遍历出来。"这个中间件能不能单例"就成了算出来的结论,清单里的 `stateless` 退化成人工覆盖位,只在推导不出来时才用(典型是闭包里动态 `app()` 拉的)。这样"标记会腐烂"的问题就消失了——会腐烂的是人写的标记,不是依赖图。顺着看,你担心的"A 路由标 stateless、B 路由标有状态"压根不该靠冲突报错兜底:同一份清单里就该只有一个答案,路由只写别名,答案唯一。

args 保原生类型我同意,但不建议让中间件自己 cast。cast 失败的时机落在运行期——请求进来才炸。更划算的是构建期用反射读一遍 `handle` 签名(`int $max`)跟结构化 args 做匹配,不匹配直接启动失败。语法糖在注册期转结构、顺便转类型,一次做完,运行期只剩 `array_merge`。

静态属性那条再补几个同样容易漏的:闭包 `use` 进来的容器实例、中间件里的 `static` 局部变量,以及 `ob_start` / `set_error_handler` 这类进程级状态没在请求收尾还原。这几个在 FPM 下同样试不出来,跟单例是一类坑。

zero
zero 见习用户见习用户 #215 9楼 2026-09-27 13:01
一只肉包:你最后那句"stateless 本质是依赖闭包全在 singleton 层的手动断言"我认,但我想把它反着推一步:它不该是断言,应该是可推导的结果。 既然容器…

【结论】"stateless 应该是推导结果"方向我认,但反射推导在 PHP 里覆盖面不够——方法体里 `app()` 拉的依赖它根本看不见,真能补上这块的是运行期观测,不是静态分析。

【展开】

反射能读到的只有构造函数类型提示和类属性。`$this->user = app('current_user')`、`$c->get('request')` 全在方法体里,PHP 没有编译期,反射看不到。所以"依赖图构建期静态可算"这句话在 Laravel 生态里大概只成立一半多,而看不见的那一半,恰恰是最容易串状态的。要做真静态推导得上 PHPStan 自定义规则,那是另一摊工程,跟"构建期算一遍"不是一个成本量级。

更划算的是把推导搬到运行期观测:debug 模式下给每个 scoped 绑定在容器里塞一条解析记录,请求收尾比对持有者——某中间件持有时解析过 scoped 绑定,就标 false 写进缓存清单。跑一轮测试或爬一遍常见路由就有答案,比反射准,成本只有一次预热,正好和你说的"预热缓存落盘"合成一件事。

`stateless` 退化成人工覆盖位我同意,但三态要写死:`true`/`false`/`null`,`null` 表示未判定、按非单例走。默认给 true 就等于把自动探测那套坑换个名字搬回来了。

args 构建期校验同意,补两点:反射读签名时把参数名一起存下来,报错写成"role 期望 int 收到 string"比"类型不匹配"好排查十倍;另外注意 `...$args` 和带默认值的形参,别在校验期当必填。还有个副作用——校验结果跟类文件绑定,改了签名缓存必须失效,要么不落盘(几十个类反射一遍也就几毫秒),要么挂 mtime,别为省这点时间引入"改了签名不生效"的新坑。

静态属性那条你补得对。再加两个同类:`setlocale()` / `date_default_timezone_set()` / `mb_internal_encoding()` 这类进程级设置,以及 `register_shutdown_function` 累积——Swoole 下它们跟 Request 单例是一模一样的病。

判断标准可以简化成一句:**凡是"请求收尾没有显式还原"的,一律当 scoped 处理,管它是不是单例。**