PSR-15规范下如何通过中间件处理并修改响应?
我刚看到你的问题,其实你困惑的核心是没搞明白PSR-15的中间件本身就内置了处理响应的能力,完全不需要把入站和出站逻辑拆成两个独立的栈。咱们一步步理清楚:
先纠正一个误解:PSR-15中间件不是只能处理请求
你提到的MiddlewareInterface::process()方法确实接收RequestInterface并返回ResponseInterface,但关键是它还接收第二个参数RequestHandlerInterface——这个就是洋葱的"下一层":要么是下一个中间件,要么是最终的请求处理器(比如你的控制器)。
当你的中间件调用$handler->handle($request)时,它会等待下一层执行完成并返回响应,这时候你就可以在中间件里修改这个响应,再返回给上一层。这就完美实现了洋葱模型的双向传递:请求穿过中间件到核心处理器,响应再从核心处理器穿回中间件。
举个实际例子:一个处理响应压缩的PSR-15中间件
比如我们写一个Gzip压缩中间件,它既可以在入站时检查请求头,又可以在出站时压缩响应:
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use GuzzleHttp\Psr7\Stream; class GzipMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // 1. 入站逻辑:检查客户端是否支持gzip压缩 $acceptEncoding = $request->getHeaderLine('Accept-Encoding'); $supportsGzip = str_contains($acceptEncoding, 'gzip'); // 2. 传递请求到下一层(可能是另一个中间件,也可能是控制器) $response = $handler->handle($request); // 3. 出站逻辑:如果支持gzip,就压缩响应内容 if ($supportsGzip && !$response->hasHeader('Content-Encoding')) { $compressedBody = gzencode((string) $response->getBody(), 9); $response = $response ->withHeader('Content-Encoding', 'gzip') ->withHeader('Content-Length', strlen($compressedBody)) ->withBody(new Stream(fopen('php://temp', 'rwb+'))); $response->getBody()->write($compressedBody); $response->getBody()->rewind(); } return $response; } }
看,这个中间件同时处理了入站(检查请求头)和出站(压缩响应)逻辑,完全符合PSR-15规范,也完美适配洋葱模型。
你的代码该怎么修改?合并中间件栈,按洋葱顺序排列
你现在拆分了入站和出站栈,虽然能跑,但不符合PSR-15的设计,也浪费了洋葱模型的灵活性。正确的做法是把所有中间件(不管是处理请求还是响应的)放到同一个栈里,按照洋葱顺序排列:
- 入站时需要先执行的中间件,放在栈的前面(比如CSRF检查、请求解析)
- 出站时需要后执行的中间件,也放在栈的前面(比如响应压缩,因为它要在所有响应处理的最后一步执行)
举个例子,你的中间件栈应该是这样的:
$all_middlewares = [ new Alpha, // 入站:CSRF检查;出站:添加安全响应头 new Bravo, // 入站:验证请求参数;出站:格式化错误响应 new Charlie, // 入站:解析JSON请求体;出站:统一响应格式 new Delta, // 入站:无;出站:压缩HTML内容 new Foxtrot, // 入站:无;出站:添加缓存控制头 new Golf, // 入站:无;出站:记录响应日志 ];
然后你需要把这些中间件和最终的请求处理器(路由匹配后的控制器)组装成一个完整的中间件栈。这里可以自己实现一个简单的栈逻辑,或者用现成的库:
// 创建最终的请求处理器:路由匹配后调用控制器 $finalHandler = new class($router) implements RequestHandlerInterface { private $router; public function __construct($router) { $this->router = $router; } public function handle(ServerRequestInterface $request): ResponseInterface { [$controller, $method] = $this->router->match($request); return (new $controller)->$method($request); } }; // 构建中间件栈:从最内层开始包裹,所以要倒序遍历中间件 $middlewareStack = $finalHandler; foreach (array_reverse($all_middlewares) as $middleware) { $middlewareStack = new class($middleware, $middlewareStack) implements RequestHandlerInterface { private $middleware; private $nextHandler; public function __construct(MiddlewareInterface $middleware, RequestHandlerInterface $nextHandler) { $this->middleware = $middleware; $this->nextHandler = $nextHandler; } public function handle(ServerRequestInterface $request): ResponseInterface { return $this->middleware->process($request, $this->nextHandler); } }; } // 处理请求,得到最终响应 $serverResponse = $middlewareStack->handle($request);
这样组装后,整个流程就是标准的洋葱模型:
- 请求依次经过Alpha → Bravo → Charlie → Delta → Foxtrot → Golf的入站逻辑
- 到达最终处理器(控制器),生成响应
- 响应依次经过Golf → Foxtrot → Delta → Charlie → Bravo → Alpha的出站逻辑
- 最后返回给客户端
为什么Laravel看起来不一样?
Laravel的中间件系统虽然不是严格遵循PSR-15,但本质逻辑是一样的。它的中间件handle方法接收一个Closure $next,这个闭包就相当于PSR-15的RequestHandlerInterface:
public function handle($request, Closure $next) { // 入站逻辑:比如检查权限 if (!auth()->check()) { return redirect('/login'); } // 传递请求到下一层,拿到响应 $response = $next($request); // 出站逻辑:比如添加响应头 $response->header('X-Frame-Options', 'SAMEORIGIN'); return $response; }
你看,和PSR-15的中间件逻辑完全一致,只是语法不同而已。Laravel底层也是把这些中间件组装成一个洋葱栈,只是封装得比较深,你看不到底层的RequestHandlerInterface而已。
总结
你的当前实现虽然能运行,但不是PSR-15推荐的方式。正确的做法是让每个中间件同时处理入站请求和出站响应,把所有中间件放到同一个栈里,按照洋葱顺序排列。这样既符合规范,又能充分利用洋葱模型的灵活性——比如你可以在同一个中间件里同时处理请求的验证和响应的格式化,逻辑更集中。
内容的提问来源于stack exchange,提问作者Alex

