各位亲爱的 PHP 架构师、中级开发者,以及所有渴望理解 Web 请求背后“黑魔法”的同学们,大家好!
我是你们的老朋友,一个在代码泥潭里摸爬滚打多年、头发日益稀疏但依然对 PHP 热爱的技术老油条。
今天我们不聊 ORM 怎么把 SQL 注入防住,也不聊 Composer 怎么解决依赖冲突。今天我们要聊的是 Web 开发中那个看似简单、实则暗藏杀机的核心机制——中间件。
你可能听过无数次:“Laravel 的中间件太棒了”、“Symfony 的中间件很灵活”。但真的懂了吗?如果让你手写一个类似 Laravel 的中间件内核,你知道洋葱模型是如何层层包裹的吗?如果让你实现一个跨全栈(Web、App、小程序)的统一身份校验机制,你知道怎么用 PHP 这把“快刀”去切这块硬骨头吗?
今天,我们就把中间件剥开,看看里面到底藏着什么。
第一部分:中间件,其实就是门卫
在深入代码之前,我们先来做一个思想实验。
想象一下,你现在经营着一个超级大剧院。剧院里有一个舞台,观众坐在台下,演员在台上表演。现在的需求是:没有门票,谁也不许进场。
如果你用最原始的方法,你可能会在剧院门口放一个人,谁来了就问一句:“你有票吗?” 有就放行,没有就轰出去。这是最简单的逻辑。
但现实情况很复杂。你不仅仅有一个剧院,你还有一个 VIP 休息室、一个供观众存放物品的寄存处,还有一个用于记录观众信息的保安室。
当你走进剧院时,你首先遇到的是第一道门卫(入口中间件)。他检查你的票。
然后,你进入休息室,遇到第二道门卫(业务中间件),他检查你的会员等级。
接着,你去寄存处,遇到第三道门卫(数据中间件),他检查你的寄存单。
现在,假设你走进剧场,坐在观众席上,开始欣赏演出。演到一半,你肚子疼想去洗手间。这时候,第四道门卫(退出中间件)出现了。他把你送出剧院,检查你的行为是否得体,然后把你送回了大门口,此时你准备离开剧院。
在这个过程中,有一个非常经典的比喻,叫做洋葱模型。
请求就像一颗洋葱,中间件就像是剥洋葱的一层层皮。
- 洋葱外层(前置): 请求进来,先被最外层的门卫拦住。门卫检查完,撕下一层皮,把请求传给里面的门卫。
- 洋葱内层(核心): 核心业务逻辑(比如演出开始)在洋葱的最中心。此时,外面的门卫都已经进来了,但他们不能干涉演出。
- 洋葱内层(后置): 演出结束,请求要走了。这时候,最里面的门卫开始工作。他把刚才检查的信息记录下来,或者把响应的数据加密一下,然后撕下这一层皮,把响应传给外面的门卫。
最里面的门卫处理完,传给下一层……直到最外层的门卫处理完,响应发给用户。
如果你在里面设置了 return false,或者中途抛出了异常,这层皮就会变成一个硬壳,把洋葱包住,请求直接报错返回,根本碰不到核心业务。
这就是中间件的本质:拦截、处理、放行/阻断。
第二部分:手写一个中间件内核(闭包时代)
在 PHP 早期,或者是某些轻量级框架中,中间件往往只是一个个闭包。
$kernel = function($request) {
// 模拟请求处理
return "Hello World";
};
// 模拟中间件调用
function applyMiddleware($request, $handler, $middlewares) {
// 从最后一个中间件开始处理(因为它是洋葱的最外层)
$index = count($middlewares) - 1;
$next = function($req) use ($handler, $middlewares, &$index) {
// 如果还有中间件,且不是最后一个(或者需要继续递归)
if ($index >= 0) {
$middleware = $middlewares[$index];
$index--; // 移动指针
return $middleware($req, function($req) use ($next) {
return $next($req);
});
}
// 没有中间件了,执行真正的 handler
return $handler($request);
};
// 启动!
return $next($request);
}
// 使用示例
$result = applyMiddleware(
['url' => '/home'],
function($req) {
return "业务逻辑执行完毕,返回核心结果";
},
[
// 前置中间件:请求拦截
function($req, $next) {
echo "1. 请求到达,门卫 A 检查护照...";
$req['passed_A'] = true;
return $next($req);
},
// 前置中间件:请求拦截
function($req, $next) {
echo "2. 门卫 B 检查会员卡...";
$req['passed_B'] = true;
return $next($req);
}
]
);
echo $result;
看这段代码,$next 这个闭包就是关键的“传声筒”。它把请求传递给下一个层级的处理者。这种写法很优雅,适合快速原型开发。但是,一旦你需要在中间件里保存状态、使用依赖注入(比如数据库连接、Redis 客户端),闭包就会变得极其丑陋且难以维护。
你需要一个类。
第三部分:类的魅力——依赖注入与状态管理
为什么框架的中间件通常是 Class?因为 PHP 是面向对象的。中间件不仅仅是拦截,它通常还充当着控制器与后端逻辑之间的桥梁。
让我们看看一个标准的中间件类长什么样:
class AuthMiddleware
{
private $db;
// 依赖注入!这是框架专家的标志
public function __construct(PDO $db)
{
$this->db = $db;
}
public function handle(Request $request, Closure $next)
{
// 1. 拦截:获取 Header 里的 Token
$token = $request->header('Authorization');
if (!$token) {
// 拒绝!
return new Response('Unauthorized', 401);
}
// 2. 校验:去数据库查一下这个 Token
$stmt = $this->db->prepare("SELECT user_id FROM tokens WHERE token = ?");
$stmt->execute([$token]);
$user = $stmt->fetch();
if (!$user) {
return new Response('Invalid Token', 403);
}
// 3. 注入:把用户信息塞进 Request 对象里
// 这样后面的 Controller 就可以直接拿到了
$request->attributes->set('user', $user);
// 4. 放行
return $next($request);
}
}
看这里,我们不仅实现了拦截(没 Token 返回 401),还实现了身份校验(查数据库)。而且,通过构造函数注入,我们可以随意替换数据库连接(比如测试环境用 SQLite,生产环境用 MySQL),而不需要改中间件代码。
这就是面向对象给中间件带来的巨大优势。
第四部分:实战演练——构建跨全栈的身份校验系统
现在,我们面临一个真正的挑战:跨全栈应用的身份校验。
这意味着什么?意味着你的 API 可能会被三种完全不同的客户端调用:
- Web 网页:通过 Cookie 里的 Session ID 来验证。
- 移动端 App:通过 HTTP Header 里的
Bearer Token来验证。 - 第三方小程序:通过 OAuth 2.0 授权码换取的 Access Token 来验证。
如果每个中间件都写一遍逻辑,那是代码屎山。我们需要一个智能的、可配置的身份校验中间件。
我们来实现一个 UniversalAuthMiddleware。
1. 策略模式登场
我们需要定义几种校验策略:
SessionStrategy:针对 Web 浏览器。TokenStrategy:针对 App 和 API。ApiKeyStrategy:针对内部脚本。
interface AuthStrategyInterface
{
public function authenticate(Request $request): ?User;
}
class TokenAuthStrategy implements AuthStrategyInterface
{
private $jwtService;
public function __construct(JWTService $jwtService)
{
$this->jwtService = $jwtService;
}
public function authenticate(Request $request): ?User
{
$token = $request->header('X-Auth-Token');
if (!$token) {
return null;
}
// 解析 JWT
$payload = $this->jwtService->decode($token);
// 查询用户信息
return $this->db->findUserById($payload['sub']);
}
}
class SessionAuthStrategy implements AuthStrategyInterface
{
private $sessionHandler;
public function __construct(SessionHandlerInterface $sessionHandler)
{
$this->sessionHandler = $sessionHandler;
}
public function authenticate(Request $request): ?User
{
// Cookie 里的 Session ID
$sessionId = $request->cookie('PHPSESSID');
// 从 Redis 或 Memcache 获取 Session 数据
$sessionData = $this->sessionHandler->read($sessionId);
if ($sessionData && isset($sessionData['user_id'])) {
return $this->db->findUserById($sessionData['user_id']);
}
return null;
}
}
2. 策略路由中间件
现在,中间件本身只需要根据请求特征(比如请求的 URL 前缀)来决定用哪个策略。
class UniversalAuthMiddleware
{
private $strategies = [];
public function registerStrategy(string $prefix, AuthStrategyInterface $strategy)
{
$this->strategies[$prefix] = $strategy;
}
public function handle(Request $request, Closure $next)
{
// 1. 决策:根据 URL 判断用哪个策略
$strategy = $this->resolveStrategy($request);
if (!$strategy) {
// 没有策略,允许通过(或者是匿名接口)
return $next($request);
}
// 2. 执行:让策略去干活
$user = $strategy->authenticate($request);
if (!$user) {
// 认证失败,统一返回 JSON 格式的 401
return new JsonResponse(['error' => 'Authentication Failed'], 401);
}
// 3. 注入上下文
$request->attributes->set('user', $user);
$request->attributes->set('is_authenticated', true);
// 4. 放行
return $next($request);
}
private function resolveStrategy(Request $request): ?AuthStrategyInterface
{
foreach ($this->strategies as $prefix => $strategy) {
if (str_starts_with($request->getUri(), $prefix)) {
return $strategy;
}
}
return null;
}
}
3. 配置与注册(Kernel 层)
在框架的启动文件里,我们要把这个中间件“挂载”到系统上。
// app/Kernel.php
class Kernel
{
private $middlewares = [];
public function boot()
{
// 实例化中间件
$authMiddleware = new UniversalAuthMiddleware();
$db = new PDO(...);
// 注册 App 的 Token 校验策略
$appTokenStrategy = new TokenAuthStrategy(new JWTService($db));
$authMiddleware->registerStrategy('/api/v1/app', $appTokenStrategy);
// 注册小程序的 OAuth 校验策略
$miniappStrategy = new OAuthStrategy($db); // 假设有这个类
$authMiddleware->registerStrategy('/api/v1/miniapp', $miniappStrategy);
// 注册 Web 的 Session 校验策略
$webSessionStrategy = new SessionAuthStrategy($sessionHandler);
$authMiddleware->registerStrategy('/web/dashboard', $webSessionStrategy);
// 将中间件压入栈顶
$this->middlewares[] = $authMiddleware;
}
public function run(Request $request)
{
$handler = function($req) {
// 没有中间件了,执行真正的 Controller
return (new HomeController())->index($req);
};
// 遍历执行中间件
foreach (array_reverse($this->middlewares) as $middleware) {
// 这里需要实现具体的调用逻辑(见下文)
// ...
}
return $handler($request);
}
}
第五部分:递归与堆栈——核心内核的写法
上面只是把中间件列出来了,怎么调用它?这就是最核心的递归逻辑。
想象一下,中间件数组是 [A, B, C, Core]。
执行顺序应该是:A -> B -> C -> Core -> C -> B -> A。
在 PHP 里,这通常是用一个函数来递归处理的。
function handleRequest(Request $request, array $middlewares, $index = 0)
{
// 基准情况:如果到了最后一个中间件,或者是没有中间件了,执行业务逻辑
if ($index >= count($middlewares)) {
return new IndexController()->handle($request);
}
$middleware = $middlewares[$index];
return $middleware->handle($request, function($req) use ($middlewares, $index) {
// 递归调用下一个中间件
return handleRequest($req, $middlewares, $index + 1);
});
}
但是,这种简单的递归有一个致命问题:异步环境下的栈溢出风险。而且在现代框架(如 Laravel)中,中间件往往需要“响应前”和“响应后”的逻辑(日志记录、修改响应头等)。
Laravel 的做法是利用 PHP 的 call_user_func_array 或者 Pipeline 类。
// 模拟 Laravel 的 Pipeline 类
class Pipeline
{
private $stack = [];
public function pipe(Closure $middleware)
{
$this->stack[] = $middleware;
return $this;
}
public function then(Closure $destination)
{
return array_reduce(
array_reverse($this->stack),
function ($carry, $middleware) {
return $middleware($carry);
},
$destination
);
}
}
这里有一个非常巧妙的技巧。array_reduce 的初始值 $carry 就是我们的 Request 对象。
array_reverse把中间件顺序倒过来,确保从最外层开始处理。$middleware($carry)调用中间件,传入当前的 Request。- 中间件执行完毕后,它要么返回一个 Response,要么返回一个闭包(即 $next)。
- 如果返回的是闭包,这个闭包被传给下一个中间件。
- 如果返回的是 Response,reduce 停止,直接输出结果。
这比递归更安全,因为它不会在堆栈里创建几千个函数调用。
第六部分:高级中间件技巧
光有拦截和认证还不够。真正的专家会利用中间件做一些更“变态”的事情。
1. 日志中间件:记录每一个灵魂的轨迹
class LoggingMiddleware
{
public function handle(Request $request, Closure $next)
{
$start = microtime(true);
// 前置逻辑
$response = $next($request);
$duration = round((microtime(true) - $start) * 1000, 2);
// 后置逻辑
$logMessage = sprintf(
"[%s] %s %s - Status: %d - Time: %sms",
date('Y-m-d H:i:s'),
$request->getMethod(),
$request->getUri(),
$response->getStatusCode(),
$duration
);
error_log($logMessage); // 或者写入文件/发送到 ELK
return $response;
}
}
2. 响应压缩中间件:为用户省流量
class CompressionMiddleware
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// 如果用户浏览器支持 gzip,且内容长度大于 1KB,那就压缩吧
if ($response->headers->has('Content-Encoding')) {
return $response;
}
$content = $response->getContent();
if (strlen($content) > 1024 && strpos($request->header('Accept-Encoding'), 'gzip') !== false) {
$compressed = gzencode($content, 9);
if ($compressed !== false) {
$response->setContent($compressed);
$response->headers->set('Content-Encoding', 'gzip');
$response->headers->set('Content-Length', strlen($compressed));
}
}
return $response;
}
}
3. CORS 中间件:打破浏览器围墙
跨全栈应用最头疼的就是跨域(CORS)。与其在每个 Controller 里写一堆 header,不如加一个中间件。
class CORSMiddleware
{
private $allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com'];
public function handle(Request $request, Closure $next)
{
$origin = $request->header('Origin');
// 简单粗暴:白名单允许的域名
if (in_array($origin, $this->allowedOrigins)) {
return $next($request)
->header('Access-Control-Allow-Origin', $origin)
->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
->header('Access-Control-Allow-Credentials', 'true');
}
// 如果是预检请求 (OPTIONS)
if ($request->isMethod('OPTIONS')) {
return new Response('', 200);
}
return new Response('Forbidden', 403);
}
}
第七部分:深度解析——身份校验的“坑”
在实现身份校验时,你会发现很多细节决定成败。
1. Token 过期时间
JWT 是无状态的,服务器不存 Token。但是,中间件必须能判断 Token 是不是过期了。
通常在解码 JWT 时,会得到一个 exp (过期时间) 字段。如果 time() > exp,直接抛出 401。
class JWTService
{
public function decode($token)
{
$parts = explode('.', $token);
$payload = json_decode(base64_decode($parts[1]), true);
if ($payload['exp'] < time()) {
throw new Exception('Token Expired');
}
return $payload;
}
}
2. 用户状态变更
用户在 App 里改了密码。下一次请求进来时,中间件解出来的 Token 还是旧的,但数据库里的密码 Hash 已经变了。
怎么办?
- 方案 A(简单): 忽略。除非用户重新登录,否则旧的 Token 依然能用。这叫“Token 撤销机制失效”。
- 方案 B(高级): 在中间件里查数据库,比对一下 User 的
updated_at。如果中间件发现数据库里的用户更新时间比 Token 签发时间晚,就强制退出。 - 方案 C(Redis 黑名单): 用户点击“登出”时,将 Token 的 JTI (JWT ID) 存入 Redis,设置过期时间。中间件每次请求都先查 Redis,如果在黑名单里,直接 401。
这里我们演示一下 Redis 黑名单的实现,这是企业级应用的标准做法。
class TokenRevocationMiddleware
{
private $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function handle(Request $request, Closure $next)
{
$token = $request->header('Authorization');
$jti = $this->extractJti($token);
// 检查黑名单
if ($this->redis->exists("blacklist:{$jti}")) {
return new JsonResponse(['message' => 'Token has been revoked'], 401);
}
return $next($request);
}
}
第八部分:响应拦截与修改
身份校验通过了,接下来要处理业务逻辑。但在业务逻辑返回结果之前,我们能不能修改一下?
假设我们的系统有一个需求:所有的 JSON 接口,都必须在返回的 JSON 最外层包裹一个 meta 对象,比如 { "code": 0, "data": {...}, "msg": "success" }。
这很烦人,因为每个 Controller 最后都要写 $response->json(['code' => 0, 'data' => $result])。
我们可以在中间件里做这个事。
class JsonResponseWrapperMiddleware
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// 只有 Content-Type 是 application/json 才包装
if (strpos($response->headers->get('Content-Type'), 'application/json') === false) {
return $response;
}
$content = $response->getContent();
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return $response; // 已经是字符串了,别乱动了
}
$wrappedData = [
'code' => 0,
'message' => 'success',
'data' => $data
];
return $response->setContent(json_encode($wrappedData));
}
}
这样,所有的 Controller 只需要返回纯粹的业务数据(比如用户列表数组),中间件会自动把它变成标准格式。
第九部分:性能优化与思考
中间件用得好是神器,用不好是性能杀手。
1. 避免重复查询
在 AuthMiddleware 里,我们查了一次数据库。在 LoggingMiddleware 里,又查了一次吗?不需要。
中间件是按顺序执行的。我们可以利用 PHP 的 引用传递 或者 Request 对象属性,把用户信息共享给后续的所有中间件。
// 在 AuthMiddleware 里
$request->attributes->set('user_id', $user->id);
// 在 LoggingMiddleware 里
$userId = $request->attributes->get('user_id');
这样,Logging 中间件就不需要再去查数据库了,直接用 $userId 写日志。这是极致的性能优化。
2. 异步非阻塞中间件
传统的 PHP 是同步的。如果一个中间件里调用了 sleep(1),或者向一个慢速数据库发起请求,整个请求都会卡住 1 秒。
在 PHP 8+ 的 Swoole、RoadRunner 等高性能环境中,中间件必须支持异步。
但是,闭包中间件天然是同步的。如果你写了一个 AuthMiddleware 里面用了 sleep(),在异步环境下会阻塞整个进程。
这时候,你就需要把这个中间件改成 Promise 风格的回调,或者使用框架提供的 Async-Await 语法。
结语:把好每一道关
好了,同学们,今天我们聊了很多。
我们从最朴素的“门卫”概念出发,经历了闭包的灵活、类的严谨,最后到了策略模式、黑名单机制、响应包装这些企业级实战技巧。
中间件,本质上就是代码的控制反转。你把原本写在 Controller 里的逻辑(比如“这个请求是不是合法的”),抽离出来,变成了一层层的管道。
当你需要给系统加功能时(比如加登录校验、加日志),你不需要去动每一个 Controller。你只需要在管道里塞一个新的中间件。
这就是框架设计的魅力。这就是为什么成熟的框架能支撑数百万级 QPS 的原因。
现在,回到你的 IDE。打开你的 Kernel.php 或者 bootstrap.php。看看那里的中间件栈。如果它像洋葱一样层层包裹,如果每一层都尽职尽责地完成自己的使命(检查、记录、修改、放行),那么恭喜你,你构建的不仅仅是一个应用,而是一个坚不可摧的堡垒。
记住,中间件拦截的不是流量,而是错误和风险。把好这道关,你的系统才能稳如泰山。
好了,下课!去写代码吧!