把鉴权、限流、日志这些横切逻辑从控制器里搬出来——中间件管道的设计哲学一次讲透
一个让我印象深刻的线上事故
去年我接手了一个用ThinkPHP做后端API的项目。那个项目里有一个获取商品详情的接口,本身没什么复杂的逻辑——查数据库、拼数据、返回JSON。但奇怪的是,每隔一两周就会收到几条告警,说这个接口响应时间突然飙升到好几秒。排查了好几次才发现,有个第三方爬虫每隔一段时间就用同一组参数疯狂请求这个接口,几百个请求砸过来,数据库连接池直接被打满。
当时临时的解决方案是在控制器方法开头加了一段手动判断:从Redis里取当前IP的请求次数,超过了就返回429。代码大概长这样:
public function detail($id)
{
$ip = request()->ip();
$count = Redis::get("rate_limit:{$ip}");
if ($count > 100) {
return json(['code' => 429, 'msg' => '请求太频繁']);
}
Redis::incr("rate_limit:{$ip}");
// 后面才是真正的业务逻辑...
}
这段代码的问题一眼就能看出来:限流逻辑和业务逻辑搅在一起。更麻烦的是,后来产品经理要求给另一个接口也加上同样的限流,于是同样的代码又被复制粘贴了一次。再后来需求变成了“普通用户每分钟最多50次,VIP用户每分钟200次”,两个地方的判断条件都得改,而且必须保持一模一样。这种重复的、跟业务无关的“横切逻辑”,一旦四处散落在控制器里,维护成本就会像滚雪球一样越来越大。
解决这类问题的标准方案就是中间件。ThinkPHP 8的中间件系统允许你在请求到达控制器之前、响应返回给客户端之前,插入一层或多层处理逻辑。鉴权、限流、日志记录、跨域处理——这些跟具体业务无关但又每个接口都可能用到的功能,天生就适合写成中间件。
先理解中间件的执行流程
如果把一个HTTP请求的生命周期画成一条线,中间件就像是这条线上的若干个检查站。请求从最外层中间件开始,一层一层往里走,直到抵达控制器;控制器返回响应后,响应再沿着原路一层一层往外走,经过相同的中间件,最终发给客户端。
这种“洋葱模型”有一个巨大的好处:你可以在请求阶段做前置处理(比如校验Token),在响应阶段做后置处理(比如统一添加响应头)。而且中间件的执行顺序完全由你来控制,哪个先执行、哪个后执行,在配置文件里排好就行。
ThinkPHP 8支持两种中间件注册方式:全局中间件和路由中间件。全局中间件对每一个请求都生效,适合放日志记录、全局CORS处理这类“一个都不能少”的逻辑;路由中间件则绑定到特定的路由或路由分组上,适合放权限校验、接口限流这种“只对某些接口生效”的逻辑。
动手写第一个中间件:请求日志记录
我们从最简单的开始——写一个记录每个API请求日志的中间件。在项目根目录执行:
php think make:middleware RequestLog
这会在app/middleware/目录下生成RequestLog.php,骨架如下:
namespace appmiddleware;
class RequestLog
{
public function handle($request, Closure $next)
{
// 前置操作:记录请求信息
$startTime = microtime(true);
// 把请求交给下一层(可能是下一个中间件,也可能是控制器)
$response = $next($request);
// 后置操作:记录响应信息
$duration = round((microtime(true) - $startTime) * 1000, 2);
// 这里把日志写进文件或数据库
trace("API请求: {$request->url()} 耗时: {$duration}ms", 'api_log');
return $response;
}
}
这里的关键是$next($request)这行调用。它就像一个接力棒,把请求传递给下一层。在调用之前写的代码属于前置处理,调用之后写的代码属于后置处理。如果你在调用之前就return了一个响应,那请求根本不会到达控制器——这个机制在后面做权限校验时非常有用。
要让这个中间件对所有请求生效,打开app/middleware.php,把类名加进去:
return [
appmiddlewareRequestLog::class,
];
现在随便访问一个接口,日志文件里就会多出一条记录。这个中间件没有改变任何业务逻辑,却给整个系统增加了一层可观测性。用同样的思路,你还可以记录更详细的信息,比如请求参数、用户ID、响应状态码等。
核心实战:构建API限流中间件
有了上面的基础,我们来实现一个真正实用的限流中间件。需求很明确:
- 基于用户IP或者用户ID来限制请求频率。
- 支持配置不同的时间窗口和最大请求数。
- 超出限制时返回429状态码和友好的提示信息。
- 限流计数器用Redis存储,保证分布式环境下的一致性。
创建中间件:
php think make:middleware RateLimit
编辑app/middleware/RateLimit.php:
namespace appmiddleware;
use thinkfacadeCache;
class RateLimit
{
// 默认配置:每分钟最多60次
protected int $maxRequests = 60;
protected int $windowSeconds = 60;
public function handle($request, Closure $next, int $maxRequests = 60, int $windowSeconds = 60)
{
$this->maxRequests = $maxRequests;
$this->windowSeconds = $windowSeconds;
// 构建唯一的限流标识:优先使用用户ID,其次使用IP
$identifier = $this->getIdentifier($request);
$cacheKey = "rate_limit:{$identifier}";
// 从Redis获取当前窗口内的请求次数
$currentCount = Cache::get($cacheKey, 0);
if ($currentCount >= $this->maxRequests) {
// 超出限制,直接返回429响应,请求不会进入控制器
return json([
'code' => 429,
'msg' => "请求过于频繁,请{$this->windowSeconds}秒后再试",
'data' => null
])->code(429);
}
// 未超出限制:计数器加1,并设置过期时间
if ($currentCount === 0) {
Cache::set($cacheKey, 1, $this->windowSeconds);
} else {
Cache::inc($cacheKey);
}
// 放行请求
$response = $next($request);
// 在响应头里告诉客户端剩余的请求次数
$remaining = $this->maxRequests - ($currentCount + 1);
return $response->header([
'X-RateLimit-Limit' => $this->maxRequests,
'X-RateLimit-Remaining' => max(0, $remaining),
'X-RateLimit-Reset' => time() + $this->windowSeconds,
]);
}
private function getIdentifier($request): string
{
// 如果用户已登录,用用户ID;否则用客户端IP
$userId = $request->middleware('user_id') ?? null;
if ($userId) {
return "user:{$userId}";
}
return "ip:" . $request->ip();
}
}
这段代码展示了中间件的完整能力:
- 前置拦截:在请求到达控制器之前判断是否超限,超限则直接返回429响应。
- 参数接收:
handle方法的第三个参数开始,可以接收路由配置中传入的自定义参数。 - 后置增强:请求正常处理后,在响应头里塞入限流相关的元信息,客户端可以根据这些信息调整自己的请求频率。
最妙的是,这段限流代码和任何具体的业务逻辑都没有耦合。它可以贴在任何一个接口上,也可以随时从某个接口上摘下来。跟本文开头的那个“控制器内嵌限流”比起来,高下立判。
把限流中间件绑定到路由上
限流通常不需要对所有接口生效(登录、注册这类接口可能频率很低),所以我们把它做成路由中间件。打开app/middleware.php,给中间件注册一个别名:
return [
// 全局中间件
appmiddlewareRequestLog::class,
// 别名定义
'rate_limit' => appmiddlewareRateLimit::class,
];
然后在路由定义中使用它。打开route/app.php:
use thinkfacadeRoute;
// 不需要限流的路由
Route::post('login', 'Auth/login');
Route::post('register', 'Auth/register');
// 需要限流的路由分组:商品和订单相关接口
Route::group(function () {
Route::get('goods/:id', 'Goods/detail');
Route::get('goods/list', 'Goods/list');
Route::post('order/create', 'Order/create');
Route::get('order/:id', 'Order/detail');
})->middleware('rate_limit:100,120'); // 每120秒最多100次
看到rate_limit:100,120这种写法了吗?冒号后面的参数会按顺序传给中间件的handle方法。这样一来,不同路由分组可以有不同的限流策略——普通接口宽松一些,敏感接口严格一些,完全由配置决定。
再进一步:权限校验中间件
限流搞定了,我们趁热打铁再写一个权限校验中间件。需求是:某些接口只允许管理员角色访问,普通用户返回403。
创建中间件:
php think make:middleware CheckRole
实现逻辑:
namespace appmiddleware;
use thinkfacadeSession;
class CheckRole
{
public function handle($request, Closure $next, string $requiredRole = 'admin')
{
// 从Session或JWT中获取当前用户角色
$currentUserRole = Session::get('user_role') ?? 'guest';
if ($currentUserRole !== $requiredRole) {
return json([
'code' => 403,
'msg' => '权限不足,需要' . $requiredRole . '角色',
'data' => null
])->code(403);
}
// 把用户信息挂在请求上,方便后续的控制器使用
$request->middleware('user_role', $currentUserRole);
return $next($request);
}
}
注册别名:
'check_role' => appmiddlewareCheckRole::class,
路由中使用:
// 管理员专属接口
Route::group(function () {
Route::get('admin/users', 'Admin/userList');
Route::delete('admin/user/:id', 'Admin/deleteUser');
})->middleware('check_role:admin');
现在你可以在一个路由上叠加多个中间件。比如某个接口既要限流又要校验角色:
Route::group(function () {
Route::post('admin/batch-send', 'Admin/batchSend');
})->middleware(['check_role:admin', 'rate_limit:20,300']);
ThinkPHP会按照数组顺序依次执行中间件:先检查角色,角色不通过直接返回403,根本不会走到限流逻辑;角色通过后,再检查请求频率。这种“层层过滤”的模式,让接口的安全防护变得层次分明,每一层只关注一件事情。
中间件之间如何传递数据
你可能注意到在CheckRole中间件里有一行:
$request->middleware('user_role', $currentUserRole);
这是ThinkPHP 8提供的一个轻量级数据传递机制。你可以在上游中间件里通过$request->middleware('key', 'value')设置数据,在下游的中间件或控制器里通过$request->middleware('key')读取数据。
举个例子,如果我们想在日志里记录当前请求是由哪个用户发起的,就可以在RequestLog中间件的后置处理中读取:
$userId = $request->middleware('user_id') ?? 'anonymous';
$role = $request->middleware('user_role') ?? 'unknown';
trace("用户[{$userId}, {$role}] 请求 {$request->url()}", 'api_log');
这种机制避免了使用全局变量或ThreadLocal,数据的作用域严格限制在当前请求的生命周期内,不会造成内存泄漏或跨请求污染。在微服务或Swoole环境下尤其安全。
中间件的优先级:当多个中间件同时生效时谁先执行
这个问题在实际项目里经常遇到。假设你同时配置了全局中间件、路由中间件和应用中间件,它们的执行顺序是怎样的?
ThinkPHP 8的规则很清晰:全局中间件最先执行,接着是应用中间件,最后是路由中间件。在同一层级内,按照数组的排列顺序从前往后执行。前置处理按这个顺序,后置处理则反向——最后执行的中间件,它的后置处理最先执行。
拿我们上面配置的例子来说:
// 全局中间件
return [
appmiddlewareRequestLog::class, // ① 最先执行前置,最后执行后置
];
// 路由中间件
->middleware(['check_role:admin', 'rate_limit:20,300'])
// check_role ② 第二个执行前置
// rate_limit ③ 第三个执行前置
// rate_limit ③' 倒数第二个执行后置
// check_role ②' 倒数第三个执行后置
// RequestLog ①' 最后执行后置(记录完整耗时)
这个顺序设计有一个很自然的推论:日志记录通常应该放在最外层,这样才能准确记录整个请求的处理耗时。如果把它放在内层,外层的中间件耗时就不会被统计进去了。
让你的中间件更健壮:几个容易被忽略的细节
中间件写得多了,有些小细节会逐渐浮出水面。我把自己踩过的坑整理如下:
不要在中间件里吞掉异常。如果中间件里出现了意料之外的错误,最好让它抛出去,交给全局异常处理器统一处理。吞掉异常会让排查问题变得极其困难。
尽量保持中间件的无状态性。中间件最好是“拿来即用”的,不要在内部缓存跟具体请求相关的数据。需要共享数据就用$request->middleware()传递,需要持久化数据就存Redis或数据库。
注意中间件对文件上传请求的影响。如果你写的中间件尝试读取请求体(比如$request->post()),可能会影响后续的文件上传处理。对于文件上传类的接口,限流可以只基于IP,不要尝试解析请求体。
利用中间件做API版本控制。如果你维护着多版本的API,可以在中间件里解析请求头中的版本号,然后动态切换路由或者添加兼容处理。这比在控制器里写一堆if (version >= 2)优雅得多。
回过头看,中间件给我们带来了什么
动手实现完这几个中间件之后,你会发现一个明显的转变:控制器的职责重新变得纯粹了。它只需要关心“这个接口要返回什么数据”,至于谁可以访问、访问频率有没有超标、请求日志怎么记,这些横切关注点全部被剥离到了中间件层。
这种架构下,新增一个安全策略的成本低得惊人。比如哪天CTO说“所有涉及资金操作的接口必须加上二次验证”,你只需要写一个TwoFactorAuth中间件,然后在路由分组里把它加上去。业务代码纹丝不动,回归测试的范围也大大缩小。
我在那个遭遇爬虫的项目里最终就是用这套方案彻底解决了限流问题。不仅代码量少了一大半,更重要的是,以后再遇到类似的需求,只需要动一行路由配置,再也不用满世界找散落在各处的限流代码了。
动手试一下
如果你手头正好有一个ThinkPHP 8项目,不妨按下面的步骤实际感受一下:
- 找一个目前把鉴权逻辑写在控制器里的接口,把鉴权部分抽出来写成一个中间件。
- 用Redis实现一个简单的限流中间件,设置每分钟10次的限制,然后用Postman连续发11次请求看看效果。
- 在路由里尝试把多个中间件叠加到一个接口上,观察执行顺序是否符合预期。
- 在响应头里检查
X-RateLimit-Remaining这类信息,确认中间件的后置处理确实生效了。
当你看到原本挤在控制器里的几十行代码被拆成几个各司其职的中间件时,那种“代码终于回归它本来面目”的感觉,会让你觉得花在理解中间件上的时间全都值了。

