做接口开发的时候,签名验证和请求日志几乎是两个绕不开的必备环节。我以前总是把这些逻辑写在控制器公共类里面,搞得到后面每一个接口方法里都充满了重复代码,时间一长想死的心都有。后来接触了 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']]
];
}
这里还可以用 only 和 except 限定方法。适合局部场景,比如只有 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 限流、分布式锁、接口幂等等。纸上得来终觉浅,建议你打开自己的项目,从写一个日志中间件开始,慢慢体会中间件带来的编程乐趣。

