一个成熟的Web应用,请求从抵达路由到最终返回响应之间,要经过身份校验、参数清洗、频率控制、日志记录等一系列横切关注点。如果把这些逻辑全部散落在控制器里,代码复用性基本为零。ThinkPHP 8 的中间件机制正是为此而生——它采用标准的管道模式,让你可以把每一道拦截逻辑封装成独立的“切面”,按需装配到不同的路由组上。这篇文章会从零开始,带你实现认证、限流、日志三个完整的中间件,并展示如何将它们组合成一个可配置的全链路拦截体系。
一、中间件在ThinkPHP 8中的位置
一次完整的请求周期是这样的:请求进入应用 → 全局中间件 → 应用中间件 → 路由匹配 → 路由中间件 → 控制器执行 → 响应返回。每一步中间件都可以拿到请求和响应对象,并在执行下一个中间件前后插入自己的逻辑。这个“包裹洋葱”的模型在框架内部通过 thinkmiddleware 下的管道类实现,我们自定义的中间件只需要实现一个 handle 方法。
ThinkPHP 8 的全局中间件配置在 app/middleware.php,应用级中间件在 app/应用名/middleware.php,也可以在路由定义中直接用 ->middleware() 链式绑定。本文重点讲后两种,因为实际业务中很少所有接口都用同一个大杂烩中间件,分组绑定才是主流。
二、起步:一个基础的请求计时中间件
我们先写一个最简单的中间件来熟悉流程。这个中间件记录每个请求的处理耗时,并添加到响应头里。
在 app/common/middleware 下创建 RequestTimer.php(多应用项目建议放公共目录):
<?php
namespace appcommonmiddleware;
use thinkRequest;
use thinkResponse;
class RequestTimer
{
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
// 执行后续中间件和控制器
$response = $next($request);
$elapsed = round((microtime(true) - $start) * 1000, 2);
$response->header(['X-Request-Time' => $elapsed . 'ms']);
return $response;
}
}
然后在 app/api/route/route.php 里给需要监控的路由组挂上它:
use appcommonmiddlewareRequestTimer;
Route::group('v1', function () {
Route::get('data', 'DataController/index');
})->middleware(RequestTimer::class);
现在访问接口,响应头里就会出现 X-Request-Time。这个简单的包裹模式,就是后面所有复杂中间件的基础。
三、实战案例一:JWT认证中间件(无状态身份校验)
对于纯API应用,JWT是最常见的认证方式。我们可以把token解析和用户身份注入封装在一个中间件里。
在 app/api/middleware 下创建 JwtAuth.php:
<?php
namespace appapimiddleware;
use thinkRequest;
use thinkResponse;
use FirebaseJWTJWT;
use FirebaseJWTKey;
use FirebaseJWTExpiredException;
use thinkexceptionHttpResponseException;
class JwtAuth
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->header('Authorization');
if (empty($token)) {
throw new HttpResponseException(json([
'code' => 401,
'message' => '缺少认证令牌',
], 401));
}
// 去掉 Bearer 前缀
$token = str_replace('Bearer ', '', $token);
try {
$decoded = JWT::decode($token, new Key(config('jwt.secret'), 'HS256'));
// 将用户ID注入请求对象,供后续使用
$request->userId = $decoded->uid;
} catch (ExpiredException $e) {
throw new HttpResponseException(json([
'code' => 401,
'message' => '令牌已过期,请重新登录',
], 401));
} catch (Exception $e) {
throw new HttpResponseException(json([
'code' => 401,
'message' => '无效的认证令牌',
], 401));
}
return $next($request);
}
}
这里用 HttpResponseException 直接终止请求并返回401响应,后续的控制器和中间件都不会再执行。认证通过后,$request->userId 里就有了当前用户ID,控制器里直接用 $request->userId 即可,不需要再重复解析token。
绑定到需要登录的路由组:
Route::group('v1', function () {
Route::get('user/profile', 'UserController/profile');
Route::post('order/create', 'OrderController/create');
})->middleware([RequestTimer::class, JwtAuth::class]);
注意中间件执行的顺序就是数组的顺序,先计时(包裹在最外层),再认证(内层)。如果认证失败抛出异常,计时器也会记录耗时——这正是我们想要的。
四、实战案例二:基于Redis的接口限流中间件
对外的API需要防刷。我们可以利用Redis的滑动窗口或者固定窗口算法,实现一个简单的限流中间件。
创建 app/api/middleware/RateLimiter.php:
<?php
namespace appapimiddleware;
use thinkRequest;
use thinkResponse;
use thinkfacadeCache;
use thinkexceptionHttpResponseException;
class RateLimiter
{
/**
* @param int $maxPerMinute 每分钟最大请求数
*/
public function handle(Request $request, Closure $next, int $maxPerMinute = 60): Response
{
$key = 'rate:' . $request->ip() . ':' . $request->pathinfo();
$current = Cache::get($key, 0);
if ($current >= $maxPerMinute) {
throw new HttpResponseException(json([
'code' => 429,
'message' => '请求过于频繁,请稍后再试',
], 429));
}
// 自增计数,有效期设置为60秒
Cache::set($key, $current + 1, 60);
return $next($request);
}
}
这个中间件可以接受一个参数 $maxPerMinute,通过路由绑定时传递:
// 公开接口,限流每分钟30次
Route::group('v1', function () {
Route::get('news', 'NewsController/index');
})->middleware([RequestTimer::class, RateLimiter::class . ':30']);
中间件类名后面用冒号传递参数,多个参数用逗号分隔。这种参数化的设计让同一个中间件可以服务于不同的限流策略,比写死数字灵活得多。
五、实战案例三:请求与响应日志中间件
调试和审计时需要记录完整的请求和响应信息。我们可以写一个日志中间件,把有用信息存到数据库或文件。
创建 app/api/middleware/RequestLogger.php:
<?php
namespace appapimiddleware;
use thinkRequest;
use thinkResponse;
use thinkfacadeLog;
class RequestLogger
{
public function handle(Request $request, Closure $next): Response
{
$startTime = microtime(true);
$requestData = [
'method' => $request->method(),
'url' => $request->url(true),
'params' => $request->param(),
'ip' => $request->ip(),
'header' => $request->header('authorization') ? '***' : '',
];
// 执行后续
$response = $next($request);
$executionTime = round((microtime(true) - $startTime) * 1000, 2);
$logData = array_merge($requestData, [
'status' => $response->getCode(),
'time' => $executionTime . 'ms',
'date' => date('Y-m-d H:i:s'),
]);
// 异步写入日志,避免阻塞响应
Log::info(json_encode($logData, JSON_UNESCAPED_UNICODE));
return $response;
}
}
这里把认证头做了脱敏处理,避免token泄入日志文件。日志记录可以进一步通过队列异步写入数据库,但直接用 Log::info 写入文件已经满足大部分场景。
将这个中间件挂到全局作用域,或只放在需要审计的路由组上:
Route::group('v1', function () {
// 所有v1接口都记日志
})->middleware([RequestTimer::class, RequestLogger::class, RateLimiter::class . ':60']);
六、管道的组合艺术:中间件顺序为何至关重要
从上面的装配可以看出,中间件的顺序决定了请求穿过层层的次序:
- 最先执行计时器(包裹所有),记录总耗时。
- 然后是限流器,在认证之前就可以拒绝高频请求,减少不必要的密码验证开销。
- 接着是日志,此时还没有认证信息,只记录基础请求数据。
- 最后是认证,认证通过后控制器执行,响应再原路返回。
如果你的日志需要记录用户ID,那就应该把认证中间件放在日志前面。这种排列组合的自由度,正是管道模式相比于写在控制器 __construct 里的最大优势。
七、中间件的注册与全局配置
除了路由绑定,你还可以在应用的 middleware.php 中注册中间件。例如在 app/api/middleware.php 中:
<?php
return [
appcommonmiddlewareRequestTimer::class,
appapimiddlewareRequestLogger::class,
];
这样所有进入该应用的请求都会经过计时和日志中间件,而认证和限流仍保留在路由级绑定,因为它们需要选择性使用。这种分层注册的方式让配置更加合理。
八、总结
ThinkPHP 8 的中间件设计干净且强大,它把每一个横切关注点变成了可复用、可组合的小块。本文的三个实战案例覆盖了认证、限流、日志三个最常见的中间件需求,其中的参数传递和顺序控制经验可以直接用于生产环境。当你把业务逻辑中所有“非核心”的部分都抽离成中间件后,控制器会变得极其薄——只负责协调领域模型并返回结果,这才是框架该有的模样。

