ThinkPHP 8 中间件实战:从登录校验到接口限流的完整落地指南

2026-07-12 0 412

上个月接手了一个后台管理系统的重构任务,技术栈定的是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.viewarticle.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类里。中间件的职责是调度和拦截,具体的业务判断(比如复杂的权限规则计算)应该封装在领域层,中间件只负责调用并判断返回值。保持中间件的轻量,项目维护起来会省力很多。

ThinkPHP 8 中间件实战:从登录校验到接口限流的完整落地指南
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 中间件实战:从登录校验到接口限流的完整落地指南 https://www.taomawang.com/server/thinkphp/2354.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务