在构建 API 的时候,你一定反复写过那些“每次请求都要检查一遍”的逻辑:判断用户有没有登录、有没有权限访问当前接口、请求频率有没有超标……这些代码散落在控制器方法里,既重复又难维护。ThinkPHP 8 的中间件机制就是为了把这类横切关注点从业务逻辑里剥离出来。今天我们不谈抽象概念,用两个真实可运行的中间件 —— 一个负责权限校验、一个负责接口限流 —— 把这件事彻底讲清楚。
一、动手之前的准备
下面所有操作基于 ThinkPHP 8.0.* 版本。如果你还没有安装,执行这一条命令即可创建一个新项目:
composer create-project topthink/think tp8-middleware-demo
进入项目目录后,确认app目录下有默认的controller和middleware文件夹(没有就手动创建)。我们还会用到缓存来记录调用次数,所以确保config/cache.php中的默认缓存驱动可用(默认的file就可以)。
为了后续演示,先创建一个简单的控制器,用来返回用户信息:
php think make:controller User
这条命令会在app/controller/User.php生成一个空控制器。我们给它加一个profile方法,假装返回当前登录用户的数据:
// app/controller/User.php
namespace appcontroller;
class User
{
public function profile()
{
// 这里假设我们已经通过了权限中间件,能拿到用户id
return json([
'code' => 0,
'data' => [
'id' => request()->uid,
'name' => '小明',
'role' => 'editor'
]
]);
}
}
注意,我们用request()->uid来获取当前请求的用户 ID,这个值我们会在权限中间件里塞进去。现在路由还没配,请求也跑不通,一步一步来。
二、第一个中间件:用 Token 验证用户身份
我们先把最常见的身份验证抽成一个中间件。在 ThinkPHP 8 中,中间件是一个普通的类,只需要实现handle方法,接收请求对象和一个闭包$next。当验证通过时,调用$next($request)把请求继续往下传;不通过时直接返回错误响应即可。
创建中间件文件
用命令行快速生成一个中间件骨架:
php think make:middleware Auth
命令会在app/middleware/Auth.php生成如下结构,我们往里填业务逻辑。
// app/middleware/Auth.php
namespace appmiddleware;
use thinkRequest;
class Auth
{
public function handle(Request $request, Closure $next)
{
// 从请求头中获取 token,约定 Header 名为 Authorization
$token = $request->header('Authorization');
if (!$token) {
return json([
'code' => 401,
'msg' => '缺少认证令牌'
])->header(['Content-Type' => 'application/json']);
}
// 模拟通过 token 查询用户信息(实际项目中可能是解密JWT或查Redis)
$user = $this->verifyToken($token);
if (!$user) {
return json([
'code' => 401,
'msg' => '令牌无效或已过期'
])->header(['Content-Type' => 'application/json']);
}
// 把用户信息挂到 request 对象上,方便后续控制器使用
$request->uid = $user['id'];
$request->role = $user['role'];
// 验证通过,继续执行后续中间件和控制器
return $next($request);
}
// 私有方法:模拟 token 验证逻辑
private function verifyToken($token)
{
// 这里简单地认为所有以 "token_" 开头的都是有效令牌
if (strpos($token, 'token_') === 0) {
// 模拟返回用户数据
return [
'id' => 1001,
'role' => 'editor'
];
}
return null;
}
}
这个中间件做的事情很直接:检查 Header 里有没有Authorization,没有就返回 401;有就验证,验证失败也返回 401;验证成功就把用户 ID 和角色写到 request 上,然后放行。实际项目里,verifyToken会调用 JWT 解析或去缓存/数据库取用户信息,逻辑一样。
注册并使用中间件
中间件写好了,得让它生效。ThinkPHP 8 支持多种注册粒度:全局、路由分组、控制器、甚至方法级别。我们先用最简单的路由注册方式,让/user/profile接口走这个 Auth 中间件。
打开route/app.php,添加一条路由并指派中间件:
// route/app.php
use thinkfacadeRoute;
Route::get('user/profile', 'User/profile')->middleware(appmiddlewareAuth::class);
现在启动内置服务器(php think run),用 Postman 或 curl 测试一下:
curl -X GET http://localhost:8000/user/profile
会得到:{"code":401,"msg":"缺少认证令牌"}
带一个有效令牌:
curl -X GET http://localhost:8000/user/profile -H "Authorization: token_demo123"
返回:{"code":0,"data":{"id":1001,"name":"小明","role":"editor"}}
一个可用的权限中间件就这么上线了。后面任何需要身份验证的路由,只需在定义时链式调用->middleware(Auth::class)即可,控制器里不用写一句重复的判断。
三、第二个中间件:接口调用频率限制(Throttle)
权限问题解决了,接下来是另一个常见需求:限制某个用户或 IP 在固定时间内的请求次数。比如“同一个用户每分钟最多调用 10 次/user/profile”。我们来实现一个可配置的频率限制中间件。
创建 Throttle 中间件
php think make:middleware Throttle
打开app/middleware/Throttle.php,编写逻辑:
// app/middleware/Throttle.php
namespace appmiddleware;
use thinkfacadeCache;
use thinkRequest;
class Throttle
{
// 默认限制:每分钟最多请求 10 次
protected $maxAttempts = 10;
protected $decayMinutes = 1;
public function handle(Request $request, Closure $next, int $maxAttempts = null, int $decayMinutes = null)
{
// 允许通过路由参数覆盖默认值
$maxAttempts = $maxAttempts ?? $this->maxAttempts;
$decayMinutes = $decayMinutes ?? $this->decayMinutes;
// 生成唯一的缓存键,这里按用户 ID 区分,未登录用户按 IP 区分
$userId = $request->uid ?? 0;
if ($userId) {
$key = 'throttle:user_' . $userId . '_' . $request->baseUrl();
} else {
$key = 'throttle:ip_' . $request->ip() . '_' . $request->baseUrl();
}
// 从缓存中获取当前请求次数
$currentAttempts = Cache::get($key, 0);
if ($currentAttempts >= $maxAttempts) {
return json([
'code' => 429,
'msg' => '请求过于频繁,请稍后再试'
])->header([
'Content-Type' => 'application/json',
'Retry-After' => $decayMinutes * 60
]);
}
// 次数加 1,并设置过期时间
Cache::set($key, $currentAttempts + 1, $decayMinutes * 60);
// 放行请求
return $next($request);
}
}
这里用到了 ThinkPHP 的Cache门面。缓存键由用户标识、请求 URL 组合而成,这样不同的接口可以有不同的限制。中间件的第三个和第四个参数$maxAttempts、$decayMinutes允许在路由中传递,实现每个接口独立限制,非常灵活。
给路由加上限流
回到route/app.php,在我们之前的路由上叠加中间件,同时传递参数。修改成这样:
Route::get('user/profile', 'User/profile')
->middleware(appmiddlewareAuth::class)
->middleware(appmiddlewareThrottle::class, ['maxAttempts' => 5, 'decayMinutes' => 1]);
现在这个接口会先经过 Auth 验证,验证通过了再进入 Throttle 检查频率。一分钟内同一个用户请求超过 5 次就会收到 429 错误。
测试也很简单:快速连续执行 6 次带相同 token 的请求,第 6 次会返回{"code":429,"msg":"请求过于频繁,请稍后再试"}。
注意中间件参数的传递方式:在middleware方法的第二个参数中传入一个关联数组,数组的键会自动匹配到handle方法的参数名。如果只传一个参数,也可以直接写,比如 ->middleware(Throttle::class, [10, 2])对应($maxAttempts, $decayMinutes)。
四、中间件的执行顺序与分组复用
现在我们的路由上挂了两个中间件,一个 Auth,一个 Throttle。默认情况下,中间件的执行顺序就是它们注册的顺序:先 Auth,后 Throttle。这个顺序不能乱——如果 Throttle 跑到 Auth 前面,限流对象就只能是 IP,无法区分登录用户;而且 Auth 的request->uid还没赋值,Throttle 也只能走 IP 分支。
实际项目中,你可能会有一堆接口需要相同的中间件组合。比如所有/api/v1/*下的用户接口都需要 Auth + Throttle,而管理后台接口可能还需要额外一个“管理员角色验证”中间件。这时候路由分组就派上用场了。
Route::group('api/v1', function () {
Route::get('user/profile', 'User/profile');
Route::get('user/orders', 'User/orders');
})->middleware([
appmiddlewareAuth::class,
appmiddlewareThrottle::class . ':10,1', // 也支持这样的快捷传参
]);
如果某些接口需要覆盖默认限流参数,可以单独再链式调用一次middleware,后面的同类型中间件会覆盖前面分组里的配置吗?实际上 ThinkPHP 8 是追加执行的,如果同一个中间件类被添加两次,那么它也会执行两次。因此更推荐利用参数传递,或者在中间件内部通过$request属性来动态调整,而不是重复注册。
一个更干净的实践是:在中间件里写死默认值,路由分组中只指定需要特殊限制的接口,用参数覆盖。这样大部分接口走默认限流,少数敏感接口(如短信发送、支付)可以收紧限制。
五、再加一个“管理员专属”中间件
为了进一步展示中间件的组合能力,我们写一个简单的AdminRole中间件,判断当前用户角色是否为admin,不是就拒绝访问。然后把它挂到管理员专属的路由上。
php think make:middleware AdminRole
// app/middleware/AdminRole.php
namespace appmiddleware;
use thinkRequest;
class AdminRole
{
public function handle(Request $request, Closure $next)
{
if (!isset($request->role) || $request->role !== 'admin') {
return json([
'code' => 403,
'msg' => '没有管理员权限'
]);
}
return $next($request);
}
}
在路由分组中,我们可以为管理员路由单独加上这个中间件:
Route::group('admin', function () {
Route::get('dashboard', 'Admin/dashboard');
})->middleware([
appmiddlewareAuth::class,
appmiddlewareAdminRole::class,
appmiddlewareThrottle::class . ':20,1' // 管理员接口宽松一点
]);
这样一来,普通带 token 的用户访问/admin/dashboard会收到 403,只有令牌对应的角色是admin的用户才能通过。中间件的职责划分得非常清晰,每个文件只做一件事,控制器里完全不需要写权限判断代码。
六、中间件中处理异常与日志记录
到目前为止,我们的中间件返回的都是 JSON 响应,这是在 API 场景下的标准做法。如果你想在中间件里记录访问日志,可以直接在handle方法中注入日志门面。比如在 Throttle 中间件里记录被限流的请求:
use thinkfacadeLog;
// 在429返回之前
Log::warning('请求频率限制触发', [
'ip' => $request->ip(),
'url' => $request->url(),
'uid' => $userId
]);
如果你希望整个应用中的某些中间件在抛出异常时有统一的处理,可以写一个自定义的异常处理中间件,放在全局中间件的最外层,用try-catch包裹$next($request),但是 ThinkPHP 8 本身已有完善的异常处理机制,简单的需求用不到这么复杂的结构。
另外要注意,中间件里如果调用了halt()或直接exit,后续中间件和控制器都不会执行。所以返回响应时最好使用框架提供的json()辅助函数或者门面,保证数据格式一致。
七、总结与更多可能性
从最初的“控制器里到处是判断”到现在的“路由上声明式地挂载中间件”,权限验证和接口限流这两件事彻底从业务代码里分离了出来。你可以在新项目里沿用这个模式,也可以在老项目里逐步把重复逻辑提取为中间件,一点一点提高代码的清爽程度。
沿着今天这套思路,你还能扩展出很多实用的中间件:
- 跨域处理中间件:统一添加 CORS 头,不必在每个控制器响应前手动写。
- 请求签名校验:适用于开放平台 API,校验参数签名、时间戳抗重放。
- 操作日志记录:自动记录每个修改类接口的请求参数、操作用户和响应状态。
- 网站维护模式:全局中间件检查某个开关,维护期间所有接口直接返回维护提示。
ThinkPHP 8 的中间件机制还支持闭包定义、前置后置操作区分(在$next前后写逻辑),可以应对更复杂的场景。但当你遇到大部分需求时,像今天这样通过类文件配合参数传递就完全够用了。把框架提供的能力用在实际业务上,解决实际的代码复用问题,这才是技术实践的价值所在。

