ThinkPHP 8 中间件实战:从零搭建 API 签名验证与请求日志

2026-08-09 0 354

做接口开发的时候,签名验证和请求日志几乎是两个绕不开的必备环节。我以前总是把这些逻辑写在控制器公共类里面,搞得到后面每一个接口方法里都充满了重复代码,时间一长想死的心都有。后来接触了 ThinkPHP 8 的中间件机制,才算是真正解放了出来。

中间件这东西听起来玄乎,其实本质就是在请求到达控制器之前,以及在响应返回客户端之前,让你能插入一段自己的逻辑。TP8 的中间件机制非常灵活,可以全局注册,也可以只给某个路由或某个分组绑定。今天我就用两个实际场景,带你把 TP8 中间件彻底玩明白。

先说一说中间件在 TP8 里的基本姿势

TP8 的项目里,中间件默认放在 app/middleware.php 文件中注册。一个最简单的中间件类,其实只需要一个 handle() 方法:

<?php
namespace appmiddleware;

use Closure;
use thinkRequest;

class Hello
{
    public function handle(Request $request, Closure $next)
    {
        // 请求进来,先执行这里
        echo '中间件前置逻辑';

        $response = $next($request);

        // 响应离开时,再执行这里
        echo '中间件后置逻辑';

        return $response;
    }
}

这里有两个核心点:$next($request) 是让请求继续往下走,别停住。如果你在调用它之前就返回了响应,那请求会被短路,根本到不了控制器。很多人利用这一点来做权限校验,不通过就直接返回错误 JSON。

创建中间件用命令行最方便:

php think make:middleware ApiSign

执行完它会在 app/middleware/ 下自动生成一个 ApiSign.php 文件,省得手写。

实战一:API 签名验证中间件

先解决签名验证。假设我们小程序前端和 TP8 后端约定了一个简单的签名规则:把 timestamp、nonce 和一个约定的 app_secret 拼接,然后做 md5 得到 sign。后端需要校验这个 sign,并且还要防止重放。

中间件代码如下:

<?php
declare(strict_types=1);

namespace appmiddleware;

use Closure;
use thinkRequest;
use thinkResponse;

class ApiSign
{
    protected $secret = '54a3f8e9b2c7d1f0'; // 实际项目从配置读取

    public function handle(Request $request, Closure $next)
    {
        // 1. 拿参数
        $params = $request->param();
        $timestamp = $params['timestamp'] ?? '';
        $nonce = $params['nonce'] ?? '';
        $sign = $params['sign'] ?? '';

        if (!$timestamp || !$nonce || !$sign) {
            return json(['code' => 4001, 'msg' => '缺少签名参数']);
        }

        // 2. 判断时间戳是否过期(允许5分钟误差)
        if (abs(time() - intval($timestamp)) > 300) {
            return json(['code' => 4002, 'msg' => '请求已过期']);
        }

        // 3. 判断 nonce 是否被使用过(简单起见用缓存)
        if (cache('nonce_' . $nonce)) {
            return json(['code' => 4003, 'msg' => '重复请求']);
        }
        cache('nonce_' . $nonce, true, 300);

        // 4. 计算服务端签名
        $serverSign = md5($this->secret . $timestamp . $nonce);
        if (!hash_equals($serverSign, $sign)) {
            return json(['code' => 4004, 'msg' => '签名错误']);
        }

        return $next($request);
    }
}

这里面有几个小细节值得说。第一,我使用了 hash_equals() 而不是 ==,是为了避免时序攻击。第二,nonce 校验我用 TP8 自带的 cache,默认是文件缓存,正好能用。第三,时间戳过期判断用了 abs(time() - $timestamp),这样就算客户端时间有偏差也能兼容。

现在如果控制器想要拿到签名后再放行的业务参数,可以直接在 $request->param() 继续获取,不会受影响。中间件里已经完成了校验,控制器里就不用再写一堆判断了。

测试一下签名生成规则:

// PHP 模拟客户端
$secret = '54a3f8e9b2c7d1f0';
$timestamp = time();
$nonce = md5(rand(1000, 9999));
$sign = md5($secret . $timestamp . $nonce);
$url = '/api/user?timestamp=' . $timestamp . '&nonce=' . $nonce . '&sign=' . $sign;

你自己写个测试脚本,访问这个 url,发现能正常到达控制器;去掉 sign 或者改错值,就会被拦截。

实战二:请求日志中间件

签名验证解决完,再看看请求日志。日志中间件要做的事情比较杂:记录请求路径、请求参数、响应内容、执行时间、用户IP,以及可能出现的异常。中间件里记录响应的一个麻烦点是,你要拿到控制器返回的 Response 对象内容。TP8 的 Response 对象有 getContent() 方法可以拿到输出内容。

下面这段代码是我在项目里实际用过的,稍作简化:

<?php
declare(strict_types=1);

namespace appmiddleware;

use Closure;
use thinkRequest;
use thinkResponse;

class ApiLog
{
    public function handle(Request $request, Closure $next)
    {
        // 前置时间
        $start = microtime(true);

        // 执行后续逻辑(控制器等)
        $response = $next($request);

        // 计算执行时间(毫秒)
        $time = round((microtime(true) - $start) * 1000, 2);

        // 记录日志
        $logData = [
            'url'    => $request->url(),
            'method' => $request->method(),
            'ip'     => $request->ip(),
            'params' => json_encode($request->param(), JSON_UNESCAPED_UNICODE),
            'result' => $response->getContent(),
            'time'   => $time,
            'header' => json_encode($request->header(), JSON_UNESCAPED_UNICODE),
        ];

        // 写入日志文件
        trace(json_encode($logData, JSON_UNESCAPED_UNICODE), 'api_log');

        return $response;
    }
}

注意我用的是 trace() 函数,TP8 默认会写入运行时日志里。如果你希望单独存到某一个文件,可以用 Log::write($content, 'log') 然后配置日志通道。我这边偷懒就直接丢到 api_log 标签里,在运行日志里可以 grep 到。

有一点要提醒:如果接口返回的是文件流或图片二进制,getContent() 可能会拿到一长串乱码,那日志文件就爆了。这种情况最好在中间件里判断一下 Response 的 getHeader() 中的 Content-Type,或者只记录前几百个字符。我这里只是展示基础思路,实际生产请自己加判断。

日志中间件还有一个隐藏的好处,就是当接口报了 500 错误,你在日志里能看到响应内容,不用去自己抓包了。但要注意别把用户敏感信息全打出来,比如密码、token,要过滤掉。

三种注册方式,按需选择

TP8 的中间件注册有三种常见方式,我简单说说区别。

第一种是全局中间件。直接在 app/middleware.php 里追加:

<?php
// 全局中间件定义
return [
    appmiddlewareApiSign::class,
    appmiddlewareApiLog::class,
];

这种对所有请求生效,适合全站接口都需要用的逻辑。不过我一般不建议把签名验证放在全局,因为有些公开接口(比如前端获取验证码的接口)是不需要签名的,除非你自己在中间件里做白名单。

第二种是路由中间件。在 route/app.php 里定义路由时指定:

Route::group('api', function () {
    Route::post('user', 'UserController@read');
    Route::post('update', 'UserController@update');
})->middleware(appmiddlewareApiSign::class);

这样只有这个分组下加中间件,其他分组不干扰。灵活度很高,推荐。

第三种是控制器中间件。在某个控制器里定义 $middleware 数组:

<?php
namespace appcontroller;

use appmiddlewareApiSign;
use appmiddlewareApiLog;

class User
{
    protected $middleware = [
        ApiSign::class,
        ApiLog::class => ['only' => ['read', 'update']]
    ];
}

这里还可以用 onlyexcept 限定方法。适合局部场景,比如只有 User 控制器需要日志,那就只在控制器里配。

三种方式可以混合,而且执行顺序有讲究:全局中间件是所有请求都要过的,路由中间件是在路由匹配后追加的,控制器中间件是在控制器初始化时绑定。如果同一个请求都有,执行顺序大致是:全局 -> 路由 -> 控制器。所以如果你的签名验证里依赖了某些路由参数,最好放在路由中间件或控制器中间件,全局的可能会拿不到。

踩过的坑:中间件里 use 错了类

新手最容易翻车的是把中间件里的 Request 用成了 thinkRequest,这个没问题,但有时候跟着老教程用 thinkfacadeRequest,那在 handle 方法的参数类型就会把 Request 当成一个普通变量,结果报错。记住中间件注入的 Request 是门面底层的容器对象,不是门面类本身。

还有一个坑就是中间件里返回了 json(),但格式不被框架正确识别。实际上 TP8 的 json() 返回的是一个 Response 对象,你直接 return 就没问题。如果你只是 echo 一段字符串再 return null,那客户端收到的可能是空响应,而且后续中间件链条也断了。老老实实返回 Response 对象才是正确姿势。

实战进阶:中间件里修改请求参数

你以为中间件只能做拦截和记录吗?其实它还可以修改请求参数。比如前面签名验证通过后,你想把解析出来的 user_id 放入请求对象里,方便控制器直接读。那就在中间件调用 $next 之前塞进去:

$request->userInfo = ['id' => 88, 'name' => '小明'];
return $next($request);

然后在控制器里通过 $request->userInfo 读取,注意这里因为是动态属性,所以 IDE 可能不认识,但实际能取到。更规范的做法是绑定 request 对象属性,或者 Request::macro,不过我不建议过度设计,直接在中间件里 setttribute 就行。

这个技巧在用户认证中间件里尤其好用。你写一个 Auth 中间件,登录校验通过后把用户信息放进 request,控制器就不用再查一次数据库了。

如果我的中间件需要注入配置参数呢

有时候签名验证的 secret 不想硬编码在中间件类里,可以写到 config 目录下。TP8 的中间件是在容器中实例化的,你可以通过构造函数注入 Config 类:

<?php
namespace appmiddleware;

use thinkConfig;

class ApiSign
{
    protected $secret;

    public function __construct(Config $config)
    {
        $this->secret = $config->get('app.api_secret', '');
    }

    // ... handle ...
}

这样系统更干净,不同环境用不同配置,不需要改中间件代码。不过要注意,如果你是在路由中间件里通过闭包创建的,容器也会自动解析依赖,所以没问题。

最后的完整测试

我把代码放在一个测试环境里,配置好签名中间件和日志中间件,启动内置服务器,用 curl 打了一个接口:

curl "http://127.0.0.1:8000/api/user?timestamp=1710000000&nonce=abc123&sign=xxx"

因为签名错误,中间件返回了 JSON:

{"code":4004,"msg":"签名错误"}

看一下 runtime/log/ 下的日志文件,发现 ApiLog 中间件根本没有执行到。因为签名验证中间件在调用 $next 之前就已经返回了。这符合预期,毕竟请求被短路了,日志中间件自然没机会记录。但假如你想把非法请求也记录到日志里,那就得把 ApiLog 放在 ApiSign 的前面,或者在 ApiSign 里自己写 log。

把签名改正确后再请求,发现控制器正常返回,日志文件里也出现了对应的请求记录,包括执行时间和参数。整个链路非常清晰。

总结

ThinkPHP 8 的中间件设计得相当顺手,它把请求的横切逻辑从控制器里抽了出来,让代码职责更单一。在实际项目中,我建议把签名验证、登录检查、权限校验、请求日志、跨域处理这些通用逻辑全部放进中间件。控制器只需要关心业务,看起来清爽,维护起来也轻松。

中间件不是万能的,但它能帮你省掉至少一半的重复冗余。今天分享的两个案例都是基础但实用的用法,你完全可以在此基础上扩展出属于自己的中间件,比如 API 限流、分布式锁、接口幂等等。纸上得来终觉浅,建议你打开自己的项目,从写一个日志中间件开始,慢慢体会中间件带来的编程乐趣。

ThinkPHP 8 中间件实战:从零搭建 API 签名验证与请求日志
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 中间件实战:从零搭建 API 签名验证与请求日志 https://www.taomawang.com/server/thinkphp/2511.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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