上个月把公司一个老项目从 ThinkPHP6 升到了 ThinkPHP8,顺便把之前一直想做的“接口日志”和“统一鉴权”给收拾了。以前这两个功能,我在每个控制器里复制粘贴,改一个地方就要全局搜一遍,特别痛苦。后来发现 TP8 的中间件机制比 TP6 更灵活,只需要在中间件里写一遍,就能管住所有请求。
这篇文章就记录一下我怎么在 TP8 里用中间件同时做请求日志和登录校验,完整代码都在下面。这不是官方文档的复述,是我实际跑通后摘出来的,直接复制到项目里改改就用。
一个老项目的混乱现场
以前我的登录验证是这么干的:在每个控制器方法第一行写
$user = $this->checkLogin(); if (!$user) return json(['code'=>401, 'msg'=>'请先登录']);
这样写问题很多:忘了复制这段代码的接口就裸奔;前端调用接口返回格式也不统一;日志记录更是靠人肉。后来我决定用中间件统一处理,控制器里只留业务逻辑。
创建中间件
TP8 里可以用命令行生成中间件类:
php think make:middleware ApiCheck
这个命令会在 appmiddlewareApiCheck.php 生成一个空类。我给它写了两个核心逻辑:请求日志 + 鉴权。
先放上完整代码:
<?php
declare(strict_types=1);
namespace appmiddleware;
use Closure;
use thinkfacadeLog;
use thinkfacadeCache;
use thinkRequest;
class ApiCheck
{
public function handle(Request $request, Closure $next)
{
// 1. 记录请求日志
$this->writeLog($request);
// 2. 验证登录(简单示例用 token,从 header 里取)
$token = $request->header('token', '');
$user = Cache::get('user_' . $token);
if (!$user) {
return json([
'code' => 401,
'msg' => '登录已过期,请重新登录',
])->code(200);
}
// 3. 把用户信息绑定到请求对象上,方便后续控制器获取
$request->user = $user;
return $next($request);
}
/**
* 写日志的简单实现,实际项目可以改成异步或者用消息队列
*/
private function writeLog(Request $request)
{
$path = $request->pathinfo();
$method = $request->method();
$ip = $request->ip();
$params = $request->param();
// 密码字段脱敏,避免明文记录
if (isset($params['password'])) {
$params['password'] = '******';
}
$logData = [
'path' => $path,
'method' => $method,
'ip' => $ip,
'params' => json_encode($params, JSON_UNESCAPED_UNICODE),
'time' => date('Y-m-d H:i:s'),
];
Log::write('API请求: ' . json_encode($logData, JSON_UNESCAPED_UNICODE), 'api');
}
}
注意这里 $request->user 并不是自带的属性,PHP 动态属性在 TP8 里可以直接用,因为 Request 类支持动态属性。这样后续在控制器里就能用 $request->user 拿到用户信息,不用再查一遍缓存。
注册中间件,并排除不需要登录的接口
TP8 的中间件可以在路由/控制器/全局等不同层级注册。我这里用全局中间件最省事,但登录校验不能搞一刀切,像登录接口、注册接口、验证码接口不能拦。我把中间件注册到 app/middleware.php 里,并用一个循环动态排除。
// app/middleware.php
<?php
// 全局中间件定义
return [
// 不用登录的接口前缀
appmiddlewareApiCheck::class,
];
这样所有请求都会经过 ApiCheck。现在我需要在中间件里判断当前路径是否在免登录名单里。怎么搞?我选择在中间件里加一个白名单数组:
private $whitelist = [
'login/login',
'login/captcha',
'public/index',
];
然后在 handle 方法最前面加一段:
$path = $request->pathinfo();
if (in_array($path, $this->whitelist)) {
// 不需要登录,直接放行
return $next($request);
}
这样登录接口就不会被拦截了。注意 pathinfo() 返回的是类似 login/login,不包含入口文件,如果你有路由后缀需要自己调整。
控制器里怎么用
中间件放行后的请求,控制器只需要直接从 Request 对象里取用户信息就行,不用再调校验方法。比如用户中心的接口:
<?php
namespace appcontroller;
use thinkRequest;
class User
{
public function profile(Request $request)
{
// 中间件已经给 $request->user 赋了值,直接取
$user = $request->user;
return json([
'code' => 200,
'data' => [
'id' => $user['id'],
'name' => $user['name'],
]
]);
}
}
看,控制器不再关心是否登录,只关心自己的业务。如果你需要知道当前用户是谁,用 $request->user 就有。多个接口都是这样,代码统一了。
通过路由给中间件分组更精细
全局中间件的好处是漏不掉,坏处是想精细化控制时还要写白名单。后来我参考 TP8 官方文档,把中间件按路由分组来注册,这样更精确。比如在 route/app.php 里:
Route::group(function () {
Route::post('user/profile', 'User/profile');
Route::post('order/list', 'Order/list');
})->middleware(appmiddlewareApiCheck::class);
Route::post('login/login', 'Login/login'); // 不经过中间件
这种方式更清爽,但我个人更偏爱全局中间件+白名单,因为有时候后端同学会漏写 middleware,导致某些接口被裸奔。全局中间件更安全,只要在白名单外就必须登录,很难因为忘记注册而出错。
实际踩坑:写日志把接口拖慢了
最开始我是同步写日志到日志文件,每个请求都写一条,结果压测时发现吞吐量降了不少。后来加了 Log::write() 的时候,发现 TP8 的日志本身有异步写入的配置,但默认还是同步。如果你也遇到性能问题,可以改成 thinkfacadeLog::write(..., 'api') 后用队列异步处理,或者把日志写到 Redis 里再定时落盘。我这里只是示例,生产环境建议做异步。
另一个坑是 pathinfo() 在路由模式下可能和实际路由不对应。如果你用了 URL 重写和路由别名,最好用 $request->url() 来取路径,然后自己解析。我的项目是直接用控制器/方法路径,所以没遇到问题。
顺便给中间件加个跨域头
我在做接口的时候,还顺便在中间件里加了跨域响应头。因为 TP8 默认同源限制,前后端分离开发时如果不处理跨域,前端调接口会一堆报错。在中间件返回之前加几个头就行:
$response = $next($request);
$response->header([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS',
'Access-Control-Allow-Headers' => 'token,Content-Type,Accept',
]);
if ($request->isOptions()) {
return json('ok')->code(200);
}
return $response;
这段放在 handle 方法里,比 Nginx 配置更灵活,也能解决某些开发环境的跨域 preflight 请求。
最后想说的
中间件这种设计,让我的接口从以前“每个控制器自己重复校验”变成了“一次配置,全局生效”。改动最大的还是那些老接口,删掉了每行开头的 checkLogin,代码短了一截,后来接手的同事也说好维护了。
TP8 的中间件支持不少好用的特性,比如前置/后置行为,我暂时只用了前置和简单登录校验,但已经比以前优雅太多。如果你也为接口鉴权和日志烦恼,不妨试试这个写法。

