在一个API项目里,我遇到了一个让人头疼的问题:每个接口的控制器方法里,前几行几乎都是重复代码——检查token有效性、校验请求频率、记录操作日志。这些逻辑散落在几十个方法中,改一处得全局搜索替换。后来把这些横切关注点全部抽到中间件里,控制器一下子清爽了,每个方法只剩下纯粹的业务逻辑。
ThinkPHP 8的中间件机制比我预想的要灵活得多。它不仅支持前置和后置两种执行阶段,还能控制中间件的执行顺序、分组管理,甚至可以动态地向管道中插入新的中间件。这篇文章从管道模式说起,把中间件的执行流掰开揉碎,然后配合三个递进式的实战案例,让你能立刻在项目里用起来。
用生活中的例子理解管道模式
把一次HTTP请求想象成一个包裹,中间件就是物流链条上的各个检查站。包裹从发件人出发,先经过安检(校验token),再过秤(记录请求参数),然后到达分拣中心(控制器处理业务),返回时反向经过同样的站点,最后回到发件人手中。
这个结构在软件工程里有个形象的名称——洋葱模型。请求从外层一层层剥进去,到达核心后再一层层穿出来。每一层都可以在请求到达核心之前做点什么(前置操作),也可以在核心处理完之后再做点什么(后置操作),甚至可以直接在半路把包裹打回去,不让它到达核心。
ThinkPHP 8的中间件正是按照这个模型来设计的。框架收到请求后,不会直接调控制器,而是先把请求塞进一个由多个中间件串联而成的管道。管道里的每个中间件都有权利在执行控制器之前拦截请求,或者在控制器返回响应之后修改响应。
中间件的执行顺序:先注册的先包在最外层
这个点如果不搞清楚,中间件就容易写出反效果。假设我们注册了三个中间件:A、B、C,顺序是A最先、B其次、C最后。那么实际执行流是这样的:
A 前置 → B 前置 → C 前置 → 控制器 → C 后置 → B 后置 → A 后置
A是最外层,C是最内层。A的前置操作最先执行,但A的后置操作最后执行。这就像一个套娃,A套着B,B套着C,C套着控制器。理解这个顺序对于设计中间件职责非常重要。比如操作日志通常要记录完整的请求处理时间,那就应该放在最外层(最先开始计时,最后结束计时)。而权限校验应该尽量靠前,越早拦截无效请求越好。
在ThinkPHP 8中,全局中间件的执行顺序由app/middleware.php文件中的数组顺序决定。数组索引越小,中间件越靠外。路由中间件和控制器中间件的执行顺序则遵循“全局 → 路由 → 控制器”的层级,同一层级内按注册顺序排列。
创建第一个中间件:操作日志记录
先从最简单的开始。创建一个中间件,记录每个请求的方法、路径、IP和耗时。在项目根目录执行:
php think make:middleware RequestLog
这会在app/middleware目录下生成一个RequestLog.php文件。框架生成的模板包含一个handle方法,签名为:
public function handle(Request $request, Closure $next): Response
两个参数中,$request是当前请求对象,$next是一个闭包,调用它就会把请求传递给管道中的下一个中间件(或者最终到达控制器)。如果不调用$next,请求就在这里终止,后续的中间件和控制器都不会执行。
把日志记录逻辑填进去:
namespace appmiddleware;
use thinkRequest;
use thinkfacadeLog;
class RequestLog
{
public function handle(Request $request, Closure $next): thinkResponse
{
// 前置:记录请求开始时间和基本信息
$start = microtime(true);
$method = $request->method();
$path = $request->pathinfo();
$ip = $request->ip();
// 调用下一个中间件,拿到响应
$response = $next($request);
// 后置:计算耗时并写入日志
$elapsed = round((microtime(true) - $start) * 1000, 2);
Log::info("{$method} {$path} | IP: {$ip} | 耗时: {$elapsed}ms | 状态码: {$response->getCode()}");
return $response;
}
}
这里$next($request)返回的是Response对象。在调用$next之前写的代码就是前置操作,调用之后写的代码就是后置操作。前后之间隔着的就是整个后续中间件链和控制器执行的全过程。
要让这个中间件生效,需要在app/middleware.php中注册:
return [
appmiddlewareRequestLog::class,
];
现在访问任意接口,日志文件里就会多出一条记录。这个中间件放在全局层,所有请求都会被记录。如果只想对特定路由生效,可以在路由定义中单独指定,而不是注册为全局中间件。
进阶案例:Token校验中间件
日志记录是“旁观者”型中间件,只看不管。Token校验则需要在请求到达控制器之前做出判断——验证不通过就直接返回401,根本不让请求进入控制器。
创建TokenAuth中间件:
php think make:middleware TokenAuth
namespace appmiddleware;
use thinkRequest;
use thinkfacadeCache;
class TokenAuth
{
public function handle(Request $request, Closure $next)
{
$token = $request->header('Authorization');
if (empty($token)) {
return json([
'code' => 401,
'msg' => '缺少认证令牌'
])->code(401);
}
// 去掉Bearer前缀
$token = str_replace('Bearer ', '', $token);
// 从缓存中验证token是否有效
$userId = Cache::get('token:' . $token);
if (!$userId) {
return json([
'code' => 401,
'msg' => '令牌无效或已过期'
])->code(401);
}
// 把用户ID注入到请求中,方便控制器直接取用
$request->userId = $userId;
return $next($request);
}
}
这个中间件的特点是,它在不满足条件时直接return了一个Response,根本没有调用$next($request)。请求在这里就被拦截了,后续中间件和控制器完全没有机会执行。这是一种常见的守卫模式。
注意$request->userId = $userId这行。控制器里可以通过$request->userId直接拿到当前登录用户的ID,不需要在控制器里再解析一次token。中间件起到了“数据预加工”的作用,把原始请求转换成了携带业务上下文信息的请求。这种模式在实际项目中极其好用,能大幅减少控制器里的重复代码。
如果只希望部分接口走这个校验(比如登录接口和注册接口显然不能要求token),路由分组加中间件是最灵活的方案:
// route/app.php
Route::group('api', function () {
Route::post('login', 'Auth/login');
Route::post('register', 'Auth/register');
})->middleware([]); // 不需要认证的接口
Route::group('api', function () {
Route::get('user/profile', 'User/profile');
Route::post('order/create', 'Order/create');
})->middleware([appmiddlewareTokenAuth::class]); // 需要认证的接口
这样分组之后,认证中间件只作用在第二个路由组上,第一个组不受影响。比在控制器里挨个判断省心得多。
高级应用:API频率限制中间件
这个案例稍微复杂一些,涉及外部参数传递。需求是:对某个IP在一定时间窗口内的请求次数做限制,超过阈值就返回429。限制规则最好能灵活配置,比如不同接口可以有不同的频率上限。
中间件本身不支持构造函数传参——在路由里注册中间件时,你不能直接new RateLimit(60, 120)传进去。ThinkPHP 8的做法是通过中间件的$request获取路由参数,或者在中间件类上定义属性,然后在路由注册时用:分隔传递参数。
实际上ThinkPHP支持在路由中向中间件传参。写法是在中间件类名后面跟:参数1,参数2:
Route::post('sms/send', 'Sms/send')
->middleware([appmiddlewareRateLimit::class . ':1,60']);
这表示每分钟最多1次。中间件类里需要定义一个$rate和$window属性来接收,但框架对这个的支持在不同版本里略有差异。更稳妥的做法是直接读取路由的额外参数,或者在中间件里写死默认值、通过配置文件覆盖。这里展示一种比较通用的方案——从配置中读取规则,按路由名称匹配:
namespace appmiddleware;
use thinkRequest;
use thinkfacadeCache;
use thinkfacadeConfig;
class RateLimit
{
public function handle(Request $request, Closure $next)
{
$ip = $request->ip();
$path = $request->pathinfo();
// 从配置文件读取限流规则
$rules = Config::get('ratelimit.rules', []);
$limit = $rules[$path]['limit'] ?? 60; // 默认每分钟60次
$window = $rules[$path]['window'] ?? 60; // 默认窗口60秒
$cacheKey = 'rate_limit:' . $ip . ':' . $path;
$current = Cache::get($cacheKey, 0);
if ($current >= $limit) {
return json([
'code' => 429,
'msg' => "请求过于频繁,请{$window}秒后重试"
])->code(429);
}
// 递增计数,并设置过期时间
Cache::set($cacheKey, $current + 1, $window);
$response = $next($request);
// 在响应头中告知客户端剩余配额
$response->header([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => $limit - ($current + 1),
]);
return $response;
}
}
对应的配置文件config/ratelimit.php:
return [
'rules' => [
'api/sms/send' => [
'limit' => 1,
'window' => 60,
],
'api/upload' => [
'limit' => 10,
'window' => 60,
],
],
];
现在把限流中间件注册到需要保护的接口上就行。不同的接口可以在配置文件里指定不同的限制,不需要动中间件代码。
这里有一个需要掂量的设计选择:限流状态存在缓存里,如果服务器重启或缓存清理,计数会归零。对于绝大多数业务场景来说这是可接受的,但如果需要严格的限流(比如支付接口),应该考虑用Redis的原子计数或者专门的令牌桶算法来保证准确性。
中间件分组:把多个中间件打包复用
当某个业务场景需要同时应用多个中间件时,每次都在路由定义里写一长串数组很不方便。ThinkPHP 8支持在app/middleware.php中定义中间件分组:
return [
// 别名 => 中间件数组
'api' => [
appmiddlewareRequestLog::class,
appmiddlewareTokenAuth::class,
appmiddlewareRateLimit::class,
],
'web' => [
appmiddlewareRequestLog::class,
],
];
路由里直接用别名引用:
Route::group('api', function () {
// 接口定义
})->middleware('api');
分组里中间件的执行顺序和数组顺序一致。需要注意,别名的解析是在框架启动阶段完成的,如果中间件类不存在或者写错了命名空间,会在请求进来之前就报错,不会等到运行时才发现。
动态修改中间件执行链
有些场景下,你需要在中间件内部根据条件决定是否跳过后续中间件,或者动态插入一个新的中间件到管道中。ThinkPHP的中间件是基于闭包串联的,每个中间件拿到一个$next闭包,调用它就继续往下走。
如果你在某个中间件中不想继续执行后续中间件,直接不调$next就行。但如果你想在中间件内部再包裹一层逻辑,比如“仅在特定条件下才执行后续中间件”,可以这样写:
public function handle(Request $request, Closure $next)
{
if ($request->param('debug') == '1') {
// debug模式下跳过后续中间件,直接返回一个mock响应
return response('debug mode, skip pipeline');
}
return $next($request);
}
这个写法在调试时特别方便。生产环境里则可以用类似逻辑做A/B测试——根据请求头中的实验标识,把请求导向不同版本的控制器。
一个综合实例:把三个中间件串起来
假设我们有一个订单创建接口,要求:记录请求日志、校验用户token、限制每个用户每分钟最多创建3个订单。三个中间件我们都已经写好了,现在只需要在路由中按正确顺序注册:
Route::post('api/order/create', 'Order/create')
->middleware([
appmiddlewareRequestLog::class, // 最外层:记录日志
appmiddlewareTokenAuth::class, // 中间层:校验身份
appmiddlewareRateLimit::class, // 最内层:频率限制
]);
执行顺序是:RequestLog前置 → TokenAuth前置 → RateLimit前置 → 控制器 → RateLimit后置 → TokenAuth后置 → RequestLog后置。
为什么RateLimit放在最内层?因为只有当请求通过了token校验,我们才需要关心这个用户的频率。如果token都不合法,限流检查就是在浪费资源。把守卫型中间件放在外层,把业务相关的中间件放在内层,是设计中间件执行顺序的一个通用原则。
反过来,RequestLog放在最外层,它计算的时间跨度包含了所有中间件和控制器执行的总耗时。如果把它放在内层,日志里记录的就只是控制器执行时间,中间件的开销被遗漏了。对于性能监控来说,外层计时更有参考价值。
中间件里能做什么,不该做什么
中间件擅长处理的是横切关注点——那些横跨多个控制器、与具体业务逻辑无关但又必须执行的通用操作。权限校验、日志记录、请求参数预处理、响应格式统一、CORS头设置、缓存读取、请求频率限制,这些都是中间件的典型应用场景。
但不应该把业务逻辑塞进中间件。比如在中间件里查询数据库订单表、计算折扣金额、生成支付单号——这些属于业务层的操作。一旦把这些东西放进中间件,中间件就不再是“横切”的,而是和特定业务强绑定了,维护成本会急剧上升,而且会导致一些接口莫名其妙地执行了不该执行的业务代码。
一个简单的判断标准是:如果你准备在中间件里写的代码,在超过一半的控制器里也需要用到,那它就适合放在中间件里。如果只是某两三个接口才需要,用Trait或者在控制器基类里封装更合适。
中间件调试:当管道卡住时怎么排查
中间件链一旦拉长,出问题时不容易定位是哪个中间件把请求吞了。最直接的排查手段是在每个中间件的handle入口处加一行日志,记录当前中间件名和请求路径。如果某个中间件入口日志有、但下一个中间件入口日志没有,说明问题出在这个中间件——要么它直接返回了响应,要么抛了异常。
另一个常见的坑是中间件里return $next($request)写成$next($request)忘了return。这种情况下,当前中间件的后置代码不会执行,而且返回给上一层的Response可能是null,导致后续报错。PHP不会对缺少return语句发出警告,所以这种bug通常要到运行时才能暴露。
还有一个值得注意的细节是异常处理。如果中间件里抛出了未捕获的异常,管道执行会中断,后续中间件和控制器都不会执行。框架的全局异常处理器会接管并返回错误响应,但已经执行过的中间件的后置代码也不会再执行了——这可能导致日志丢失或数据不一致。如果你的中间件在后置阶段做了重要的收尾工作,最好用try-finally包裹$next调用:
public function handle(Request $request, Closure $next)
{
try {
// 前置操作
return $next($request);
} finally {
// 后置操作放在finally里,确保即使发生异常也能执行
}
}
这种写法牺牲了一些简洁性,但在关键业务链路中值得采用。
中间件和控制器数据传递的几种方式
前面TokenAuth例子中用了$request->userId = $userId来传递数据。这是最直接的方式,但不够规范——往Request对象上动态挂属性,IDE无法提示,容易拼写错误。
更规范的做法是通过$request->withAttribute系列方法:
// 中间件中设置
$request = $request->withAttribute('user_id', $userId);
// 控制器中获取
$userId = $request->getAttribute('user_id');
不过withAttribute返回的是一个新实例(PSR-7风格),需要把新的$request传给$next。如果直接修改原$request而不更新引用,控制器里拿不到值。ThinkPHP的Request对象是可变风格,所以直接赋值属性也能工作,但为了代码的可维护性,建议在团队内部约定一种统一的数据传递方式并坚持使用。
总结
中间件的本质是把请求处理流程中的通用逻辑从控制器中剥离出来,以洋葱模型串联执行。ThinkPHP 8的中间件体系提供了全局注册、路由绑定、控制器绑定和分组管理四种组织方式,配合前置后置的双向执行能力,足以覆盖绝大多数的横切需求。
用好中间件的关键是两条:一是搞清楚执行顺序(外层先入后出),二是明确中间件的职责边界(只做横切关注点,不侵入业务)。守住这两条,中间件就能成为项目中解耦最干净、复用最高的代码层。

