上个月接手了一个后台管理系统的重构任务,技术栈定的是ThinkPHP 8。说实话之前用TP 6比较多,对8版本的中间件机制只是粗略看过文档,并没有在实际项目里完整跑过一遍。这次借着重构的机会,把中间件这块从头到尾吃透了,从最基础的登录校验,到稍微复杂一些的RBAC权限控制,再到接口级别的令牌桶限流,全部用中间件来实现。这篇文章就是这次实战过程的完整记录,包含踩过的坑和最终落地的代码。
一、为什么要用中间件来解决这些问题
先交代一下项目背景。这个后台系统有三十多个接口,分散在七八个控制器里。原来的代码在每个控制器的每个需要校验的方法里都手动调了一个checkLogin()函数,权限判断的逻辑也散落在各处,有的地方用if-else硬编码,有的地方甚至直接把用户角色写死在前端判断。接口限流更是完全没有,曾经被一个写脚本的同事用循环请求把数据库连接池打满了。
重构的目标很明确:把横切关注点从业务代码里抽出来。登录校验、权限控制、请求限流这些逻辑跟具体的业务没有直接关系,它们属于”请求处理管道”上的切面,恰好就是中间件擅长做的事情。ThinkPHP 8的中间件机制支持三种粒度的挂载——全局中间件、路由中间件、控制器中间件,足够覆盖我们遇到的所有场景。
还有一个更实际的好处:中间件可以自由组合。比如某个接口既需要登录校验又需要限流,只需要在路由定义里把两个中间件都挂上,执行顺序按数组排列顺序来,逻辑清晰,不会像以前那样在方法开头写一堆前置判断。
二、ThinkPHP 8 中间件的运行机制
在正式动手之前,先把中间件的执行流程理清楚。ThinkPHP 8的中间件采用了”洋葱模型”——请求从外向内穿过一层层中间件,到达控制器处理完业务逻辑后,响应再从内向外穿过同一组中间件。每一层中间件都可以在请求进入时做一些前置处理,也可以在响应返回时做一些后置处理。
一个标准的中间件类需要实现thinkmiddlewareMiddlewareInterface接口,这个接口只定义了一个handle方法。方法签名如下:
public function handle(Request $request, Closure $next): Response
{
// 前置处理:在请求到达控制器之前执行
// 比如校验token、记录日志、检查权限等
// 调用$next把请求传递给下一层
$response = $next($request);
// 后置处理:在控制器返回响应之后执行
// 比如统一添加响应头、格式化输出等
return $response;
}
$next是一个闭包,调用它代表把请求交给下一层处理。如果你在handle方法里直接返回了一个Response对象而没有调用$next,那么请求就会被拦截在这一层,后面的中间件和控制器都不会执行。这个机制在登录校验场景里特别有用——校验不通过直接返回401响应,请求到此为止。
ThinkPHP 8还支持给中间件传参数。比如限流中间件需要知道每分钟允许的最大请求数,这个数字不适合写死在中间件类里,应该在挂载中间件时动态传入。参数通过路由定义或控制器注解来传递,中间件的handle方法可以通过第三个参数$params接收。
三、项目搭建与中间件目录规划
用Composer创建一个全新的ThinkPHP 8项目,命令行执行:
composer create-project topthink/think backend-system
cd backend-system
项目创建完成后,先规划中间件的目录结构。官方推荐的约定是把中间件类放在app/middleware目录下,但我习惯按功能模块再细分一层,方便后续维护:
app/
├── middleware/
│ ├── Auth.php # 登录校验中间件
│ ├── Rbac.php # 基于角色的权限控制中间件
│ ├── RateLimit.php # 令牌桶限流中间件
│ ├── RequestLog.php # 请求日志记录中间件
│ └── Cors.php # 跨域处理中间件
├── controller/
│ ├── Admin.php # 管理员相关接口
│ └── User.php # 普通用户相关接口
├── model/
└── service/
中间件目录建好之后,还需要在app/middleware.php文件里注册全局中间件。这个文件在TP 8安装后默认存在,里面返回一个数组,数组元素就是全局中间件的类名。全局中间件会对所有请求生效,适合放那些不需要区分路由的通用逻辑:
<?php
// app/middleware.php
return [
appmiddlewareCors::class, // 跨域处理,所有接口都需要
appmiddlewareRequestLog::class, // 请求日志,记录所有进出请求
];
全局中间件的执行顺序就是数组里的排列顺序,先注册的先执行。Cors中间件放在最前面是合理的,因为跨域预检请求(OPTIONS)需要在任何业务逻辑之前处理掉,否则后续的登录校验会因为缺少token而误拦截。
四、第一个中间件:跨域处理
跨域中间件虽然简单,但它是一个很好的热身练习,能帮我们快速熟悉中间件的编写范式。同时这个中间件在实际开发中几乎必用,先把它写好。
<?php
// app/middleware/Cors.php
namespace appmiddleware;
use thinkmiddlewareMiddlewareInterface;
use thinkRequest;
use thinkResponse;
use Closure;
class Cors implements MiddlewareInterface
{
public function handle(Request $request, Closure $next): Response
{
// 如果是OPTIONS预检请求,直接返回200响应,不再往后传递
if ($request->method() === 'OPTIONS') {
$response = Response::create();
$response->header('Access-Control-Allow-Origin', '*');
$response->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
$response->header('Access-Control-Max-Age', '86400');
return $response;
}
// 非OPTIONS请求正常放行,但在响应返回时统一追加跨域头
$response = $next($request);
$response->header('Access-Control-Allow-Origin', '*');
$response->header('Access-Control-Allow-Credentials', 'true');
return $response;
}
}
这段代码展示了中间件中”拦截”和”放行”两种处理方式。OPTIONS请求被直接拦截,返回一个构造好的响应对象,不再调用$next。普通的GET、POST请求则照常放行,但在$next($request)执行完之后,给响应对象追加上跨域头。这就是洋葱模型的后置处理——等控制器执行完了,响应在返回途中经过中间件时再附加上这些头部信息。
五、登录校验中间件:JWT Token的验证与刷新
登录校验是后台系统里最常见也最容易写出问题的环节。这个中间件需要完成三件事:从请求头里提取JWT Token、验证Token的有效性、把解析出来的用户信息注入到请求上下文中,方便后续的控制器和权限中间件使用。
先安装JWT扩展包。ThinkPHP 8没有内置JWT处理,我选用的是firebase/php-jwt,这个包稳定且文档齐全:
composer require firebase/php-jwt
然后在app/common.php(或一个独立的工具类)里封装JWT的生成和解析函数。这里不展开讲JWT的原理,只给出实际用到的代码:
<?php
// app/common.php
use FirebaseJWTJWT;
use FirebaseJWTKey;
const JWT_SECRET = 'your-secret-key-keep-it-safe';
const JWT_ALGORITHM = 'HS256';
const JWT_EXPIRE = 7200; // 2小时过期
function generateToken(int $userId, string $role): string
{
$payload = [
'user_id' => $userId,
'role' => $role,
'iat' => time(),
'exp' => time() + JWT_EXPIRE,
];
return JWT::encode($payload, JWT_SECRET, JWT_ALGORITHM);
}
function parseToken(string $token): ?object
{
try {
return JWT::decode($token, new Key(JWT_SECRET, JWT_ALGORITHM));
} catch (Exception $e) {
return null;
}
}
接下来编写Auth中间件。这个中间件需要挂在需要登录校验的路由组上,而不是全局挂载——因为登录接口本身和部分公开接口不需要校验,全局挂载会导致死循环。
<?php
// app/middleware/Auth.php
namespace appmiddleware;
use thinkmiddlewareMiddlewareInterface;
use thinkRequest;
use thinkResponse;
use Closure;
class Auth implements MiddlewareInterface
{
public function handle(Request $request, Closure $next): Response
{
// 从请求头里获取Authorization字段
$authorization = $request->header('Authorization', '');
// 标准格式是 "Bearer {token}"
if (empty($authorization) || !str_starts_with($authorization, 'Bearer ')) {
return $this->unauthorized('缺少认证令牌');
}
$token = substr($authorization, 7);
// 解析并验证Token
$payload = parseToken($token);
if ($payload === null) {
return $this->unauthorized('令牌无效或已过期');
}
// 把解析出来的用户信息注入到Request对象中
// 后续的控制器和中间件可以通过$request->userInfo来获取
$request->userInfo = [
'user_id' => $payload->user_id,
'role' => $payload->role,
];
return $next($request);
}
private function unauthorized(string $message): Response
{
return Response::create([
'code' => 401,
'message' => $message,
], 'json', 401);
}
}
这个中间件里有一个设计细节值得单独提一下:把用户信息挂在$request对象上。ThinkPHP的Request对象支持动态属性赋值,你可以在中间件里设置$request->userInfo,在后面的控制器里直接用$request->userInfo读取。这比用全局变量或者Session传递用户信息要干净得多,也更容易做单元测试。控制器里获取当前登录用户的写法变得非常简单:
public function getProfile(Request $request)
{
$userId = $request->userInfo['user_id'];
$user = UserModel::find($userId);
return json(['code' => 200, 'data' => $user]);
}
控制器不需要知道用户信息是怎么来的,它只管从Request里取,解耦得很彻底。
六、RBAC权限控制中间件:带参数的高级用法
登录校验做完之后,下一个需求是权限控制。不同角色的用户能访问的接口不一样:管理员可以增删改查所有数据,编辑只能修改内容但不能删除,普通用户只能查看。这个场景用中间件来实现最合适,而且需要用到”中间件传参”的功能——每个接口需要的权限标识不一样,不能写死在中间件类里。
先定义权限标识的命名规范。我习惯用模块.操作的格式,比如user.create表示创建用户的权限,article.delete表示删除文章的权限。这些标识存储在数据库的权限表里,和角色做多对多关联。具体的数据表设计和RBAC模型网上资料很多,这里不重复,重点看中间件怎么用这些数据。
<?php
// app/middleware/Rbac.php
namespace appmiddleware;
use thinkmiddlewareMiddlewareInterface;
use thinkRequest;
use thinkResponse;
use Closure;
use appmodelRoleModel;
use appmodelPermissionModel;
class Rbac implements MiddlewareInterface
{
public function handle(Request $request, Closure $next, array $params = []): Response
{
// 从Auth中间件注入的userInfo里获取当前用户角色
$userRole = $request->userInfo['role'] ?? 'guest';
// 从中间件参数里获取当前接口需要的权限标识
// $params是在路由定义时传入的,比如 ['user.create', 'user.edit']
$requiredPermissions = $params;
if (empty($requiredPermissions)) {
// 如果路由没有指定权限要求,默认放行
return $next($request);
}
// 查询该角色拥有的权限列表
// 实际项目中建议加缓存,避免每次请求都查数据库
$rolePermissions = $this->getRolePermissions($userRole);
// 检查当前角色是否拥有任意一个所需的权限
$hasPermission = false;
foreach ($requiredPermissions as $permission) {
if (in_array($permission, $rolePermissions)) {
$hasPermission = true;
break;
}
}
if (!$hasPermission) {
return Response::create([
'code' => 403,
'message' => '没有权限执行此操作',
], 'json', 403);
}
return $next($request);
}
private function getRolePermissions(string $role): array
{
// 这里简化处理,实际应该从数据库或缓存中查询
// 示例:管理员拥有所有权限,编辑拥有部分权限
$permissionsMap = [
'admin' => ['user.create', 'user.edit', 'user.delete', 'user.view', 'article.create', 'article.edit', 'article.delete'],
'editor' => ['user.view', 'article.create', 'article.edit'],
'viewer' => ['user.view'],
];
return $permissionsMap[$role] ?? [];
}
}
这个Rbac中间件比Auth多了一个$params参数,它就是路由定义中传过来的权限标识数组。接下来看路由怎么把中间件和参数挂上去。
七、路由配置:中间件的挂载与参数传递
ThinkPHP 8的路由定义在route/app.php文件里。我们把需要登录和权限校验的接口放在同一个路由组里,给这个组挂上Auth中间件,然后给每个具体的路由挂上Rbac中间件并传入对应的权限标识。
<?php
// route/app.php
use thinkfacadeRoute;
// 公开接口:不需要登录
Route::post('/login', 'AuthController/login');
Route::post('/register', 'AuthController/register');
// 需要登录的接口组
Route::group('', function () {
// 用户管理相关接口
Route::get('/users', 'UserController/index')
->middleware(appmiddlewareRbac::class, ['user.view']);
Route::post('/users', 'UserController/create')
->middleware(appmiddlewareRbac::class, ['user.create']);
Route::put('/users/:id', 'UserController/update')
->middleware(appmiddlewareRbac::class, ['user.edit']);
Route::delete('/users/:id', 'UserController/delete')
->middleware(appmiddlewareRbac::class, ['user.delete']);
// 文章管理相关接口
Route::get('/articles', 'ArticleController/index')
->middleware(appmiddlewareRbac::class, ['article.view']);
Route::post('/articles', 'ArticleController/create')
->middleware(appmiddlewareRbac::class, ['article.create']);
Route::put('/articles/:id', 'ArticleController/update')
->middleware(appmiddlewareRbac::class, ['article.edit']);
Route::delete('/articles/:id', 'ArticleController/delete')
->middleware(appmiddlewareRbac::class, ['article.delete']);
})->middleware(appmiddlewareAuth::class);
路由组末尾的->middleware(appmiddlewareAuth::class)让Auth中间件对这个组里的所有路由生效。每个具体路由上的->middleware(appmiddlewareRbac::class, ['权限标识'])则是在Auth执行完之后再执行Rbac校验。执行顺序是:先Auth(校验登录、注入用户信息),再Rbac(根据用户角色和接口权限标识判断是否有权限),最后才到控制器。
如果一个接口既需要登录又需要多个权限(比如同时需要user.view和article.view),Rbac中间件里写的是”拥有任意一个所需权限即可放行”的逻辑,如果需求改为”必须同时拥有所有权限”,只需把foreach循环里的break逻辑改成计数判断即可,改动范围完全局限在中间件内部。
八、令牌桶限流中间件:保护接口不被滥用
登录和权限做完之后,系统的基础安全框架就算搭起来了。但还有一个风险没有覆盖——某个合法用户因为误操作或者脚本bug,在短时间内向某个接口发送了大量请求,导致服务器负载飙升。接口限流就是用来应对这种情况的。
限流算法有很多种,令牌桶是其中比较经典的一种。它的核心思想是:每个用户有一个”令牌桶”,桶的容量固定,系统以固定速率向桶里放入令牌。用户每次请求需要消耗一个令牌,如果桶里没有令牌了,请求就被拒绝。桶满了之后多余的令牌会被丢弃,不会无限积累。
在ThinkPHP 8里实现令牌桶限流,中间件是非常合适的载体。限流数据需要持久化存储,我选用Redis——TTL机制天然适合处理”时间窗口过期”的问题,INCR和EXPIRE命令配合使用可以实现简单的计数器,更复杂的令牌桶逻辑可以用Lua脚本保证原子性。
<?php
// app/middleware/RateLimit.php
namespace appmiddleware;
use thinkmiddlewareMiddlewareInterface;
use thinkRequest;
use thinkResponse;
use Closure;
use thinkfacadeCache;
class RateLimit implements MiddlewareInterface
{
// 默认配置:每分钟60次请求,桶容量100
private int $defaultCapacity = 100;
private int $defaultRate = 60;
public function handle(Request $request, Closure $next, array $params = []): Response
{
// 从参数中读取限流配置,未设置则使用默认值
$capacity = $params['capacity'] ?? $this->defaultCapacity;
$rate = $params['rate'] ?? $this->defaultRate;
// 生成限流key:基于用户ID或IP地址
$userId = $request->userInfo['user_id'] ?? null;
if ($userId) {
$key = "rate_limit:user:{$userId}:" . $request->pathinfo();
} else {
$key = "rate_limit:ip:" . $request->ip() . ":" . $request->pathinfo();
}
// 令牌桶算法的Redis Lua脚本
// 保证"检查-消耗"这两个操作的原子性
$luaScript = << 0 then
tokens = math.min(capacity, tokens + refill)
redis.call('SET', key, tokens)
redis.call('SET', key .. ':last_refill', now)
end
-- 消耗一个令牌
if tokens > 0 then
redis.call('DECR', key)
return 1
else
return 0
end
LUA;
$redis = Cache::store('redis')->handler();
$result = $redis->eval($luaScript, [
$key,
$capacity,
$rate,
time(),
], 1);
if ($result === 0) {
return Response::create([
'code' => 429,
'message' => '请求过于频繁,请稍后再试',
], 'json', 429);
}
return $next($request);
}
}
这个中间件的实现有几个值得注意的地方。第一,限流key的生成规则包含了用户ID和请求路径,这样限流粒度是”每个用户对每个接口”,不会出现用户A的频繁请求把用户B的配额也消耗掉的情况。第二,Redis Lua脚本保证了令牌检查和消耗的原子性,避免了并发请求绕过限流的竞态问题。第三,$params参数允许在路由定义时为不同接口设置不同的限流策略——登录接口可能限制每分钟5次,列表查询接口限制每分钟120次,灵活度很高。
在路由中挂载限流中间件,并为不同接口传入不同的限流参数:
// 登录接口:每分钟最多5次请求,桶容量10
Route::post('/login', 'AuthController/login')
->middleware(appmiddlewareRateLimit::class, ['capacity' => 10, 'rate' => 5]);
// 用户列表接口:每分钟最多120次请求,桶容量200
Route::get('/users', 'UserController/index')
->middleware(appmiddlewareAuth::class)
->middleware(appmiddlewareRbac::class, ['user.view'])
->middleware(appmiddlewareRateLimit::class, ['capacity' => 200, 'rate' => 120]);
注意多个中间件的挂载顺序。RateLimit中间件放在Auth之后、Rbac之前,意味着只有登录成功的用户才会消耗限流配额,未登录的请求会被Auth直接拦截,不会进入RateLimit的逻辑。这个顺序的合理性在于:登录校验是身份验证,应该在资源消耗之前完成,避免恶意请求通过伪造大量不存在的用户来消耗Redis的限流计数资源。
九、请求日志中间件:排错利器
最后一个中间件是请求日志。它属于那种”平时感觉不到存在,出问题时第一个想到它”的基础设施。全局挂载在app/middleware.php里,记录每个请求的方法、路径、参数、执行耗时和响应状态码。
<?php
// app/middleware/RequestLog.php
namespace appmiddleware;
use thinkmiddlewareMiddlewareInterface;
use thinkRequest;
use thinkResponse;
use Closure;
use thinkfacadeLog;
class RequestLog implements MiddlewareInterface
{
public function handle(Request $request, Closure $next): Response
{
$startTime = microtime(true);
// 前置记录:请求进入
$requestInfo = [
'method' => $request->method(),
'path' => $request->pathinfo(),
'ip' => $request->ip(),
'params' => $request->param(),
];
// 执行后续中间件和控制器
$response = $next($request);
// 后置记录:响应返回
$endTime = microtime(true);
$duration = round(($endTime - $startTime) * 1000, 2); // 毫秒
$logData = array_merge($requestInfo, [
'status' => $response->getCode(),
'duration' => $duration . 'ms',
'time' => date('Y-m-d H:i:s'),
]);
Log::info(json_encode($logData, JSON_UNESCAPED_UNICODE));
return $response;
}
}
这个中间件没什么复杂的逻辑,但它用到了后置处理——在$next($request)执行完之后记录耗时和状态码。如果你在开发环境想看到更详细的日志,可以在$requestInfo里追加请求体和响应体,但生产环境要谨慎,避免日志文件膨胀过快。
十、中间件的测试与调试心得
中间件写完之后,测试是绕不开的环节。ThinkPHP 8没有内置中间件单元测试的专用工具,但可以通过构造Request和Response对象来模拟请求流程。我的做法是写一个简单的测试脚本,手动触发中间件的handle方法,传入mock的Request和$next闭包,然后断言返回的Response状态码和内容是否符合预期。
调试中间件时最容易遇到的坑是执行顺序不符合预期。如果你发现某个中间件没生效,先检查三个地方:一是类名和命名空间是否正确,二是app/middleware.php全局注册数组里的顺序,三是路由定义里->middleware()的链式调用顺序。ThinkPHP按照”全局中间件 → 路由中间件 → 控制器中间件”的顺序执行,同一层级的中间件按照注册或挂载的先后顺序执行。
另一个容易忽略的点是中间件里的异常处理。如果中间件抛出了未捕获的异常,ThinkPHP会走全局异常处理机制,返回一个默认的错误页面或JSON响应。对于API项目,建议在每个中间件里用try-catch包裹可能出错的代码(比如Redis连接失败),返回一个友好的错误响应,而不是让框架的异常处理器接管。这样前端能拿到一致的数据格式,排查问题也更方便。
十一、回顾与总结
经过这一轮重构,后台系统的代码结构发生了明显的变化。控制器里的方法变得非常”干净”——不再有重复的if (!$user) return error('未登录'),不再有散落各处的角色判断,不再有手动写的限流计数器。所有横切逻辑都被中间件接管了,控制器只做它该做的事情:接收参数、调用业务逻辑、返回结果。
中间件的组合能力在后续迭代中也体现出了价值。有一次产品经理要求给某个导出接口额外加上”操作频率限制”,因为导出操作比较耗资源。我只在路由定义里给这个接口追加了一行->middleware(appmiddlewareRateLimit::class, ['capacity' => 5, 'rate' => 2]),三分钟搞定了需求,没有改任何业务代码。
ThinkPHP 8的中间件机制相比6版本在API设计上更加简洁,性能也有优化——中间件的加载和调度采用了预编译的方式,减少了反射调用的开销。如果你正在用TP 6或者更早的版本,升级到8之后可以放心地把原来写在BaseController里的initialize方法里的逻辑迁移到中间件里,架构上会更合理。
最后留一个实际项目中的小建议:中间件不要写得太”重”。如果一个中间件的handle方法超过了五十行,就该考虑是不是把部分逻辑抽到独立的Service类里。中间件的职责是调度和拦截,具体的业务判断(比如复杂的权限规则计算)应该封装在领域层,中间件只负责调用并判断返回值。保持中间件的轻量,项目维护起来会省力很多。

