被忽视的 PSR 标准,PSR-7/14/15/17/18 组合使用
大多数 PHP 开发者都熟悉 PSR-4。他们知道自动加载机制可以正常工作、Composer 会代为处理,之后便再也不用为此操心。相当一部分人了解 PSR-12,或者至少配置了某种 linter 来强制遵循它,而自己从未真正读过规范原文。每当有人要使用日志库时,也会顺带提起 PSR-3。
但 PHP-FIG 还产出了另一批标准。它们对 PHP 编写方式的改变是真正具有变革性的——却几乎被完全忽视,被所选框架叠加在它们之上的各种抽象所取代。PSR-7、PSR-14、PSR-15、PSR-17 与 PSR-18 共同描绘了一套完整且自洽的模型,用于编写具备 HTTP 感知能力的 PHP 代码,且不绑定于任何特定框架、任何特定 HTTP 客户端或任何特定事件分发器。将这些标准组合使用,就能写出随处可运行的代码。
本文将逐一剖析这些标准,说明它们各自究竟定义了哪些内容,并展示仅凭纯 PHP 与几个正确实现了相应接口的精心选定的包,能够构建出什么样的成果。
为什么这很重要
在深入细节之前,值得先弄清楚 PHP-FIG 究竟想解决什么问题。
从历史上看,问题在于每个框架都构建了各自的 HTTP 抽象、各自的事件系统、各自的 HTTP 客户端,而这些实现彼此之间互不衔接。如果开发者编写了一个用于发送 HTTP 请求的库,就必须在 Guzzle、Symfony 或 curl 之间做出选择,其用户也被迫接受这一选择。如果开发者编写了中间件,它只能在一个框架内运行,在其他任何地方都无法使用。
PSR 标准通过定义描述行为而非实现的接口来解决这一问题。一个接受 Psr\Http\Message\RequestInterface 的包,可以与任何符合 PSR-7 的请求对象协同工作,无论该对象由哪个库生成。这就是核心约定:面向接口编程,而非面向实现。
一旦真正理解这一点,其余一切都将顺理成章。
PSR-7:HTTP 消息接口
PSR-7 定义了一组用于表示 HTTP 消息(请求与响应)的接口,覆盖客户端侧(向 API 发送请求)与服务器侧(从浏览器或客户端接收请求)两方面的场景。
其核心接口包括:
- Psr\Http\Message\MessageInterface - 请求与响应共同的基础接口,涵盖头部与正文
- Psr\Http\Message\RequestInterface - 出站(客户端侧)HTTP 请求
- Psr\Http\Message\ServerRequestInterface - 入站(服务器侧)HTTP 请求,包含已解析的正文、Cookie、上传文件与服务器参数
- Psr\Http\Message\ResponseInterface - HTTP 响应
- Psr\Http\Message\StreamInterface - 以流的形式表示的消息正文
- Psr\Http\Message\UriInterface - 一个 URI
理解 PSR-7 最重要的一点在于:它是不可变的。所有会修改状态的方法,都会返回一个应用了该修改的新实例,而非直接改动原对象。这是有意为之的设计。HTTP 消息本质上是值——它们描述的是某一时间点上的状态——如果将其当作可变对象处理,当消息在需要检查原对象的中间件栈或中间件链中传递时,就会引发难以察觉的 bug。
以下使用 nyholm/psr7 演示如何处理 PSR-7 响应。nyholm/psr7 是一个轻量级、零依赖的 PSR-7 实现:
<?php
declare(strict_types=1);
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
$body = Stream::create((string) json_encode(['status' => 'ok', 'user' => 'steve'], JSON_THROW_ON_ERROR));
$response = new Response(
status: 200,
headers: ['Content-Type' => 'application/json'],
body: $body,
);
// Because PSR-7 is immutable, withHeader returns a new instance
$response = $response->withHeader('X-Request-Id', 'abc-123');
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('Content-Type'); // application/json
echo $response->getBody(); // {"status":"ok","user":"steve"}再看服务器端请求的处理方式:
<?php
declare(strict_types=1);
use Nyholm\Psr7\ServerRequest;
$request = new ServerRequest(
method: 'POST',
uri: 'https://api.example.com/v1/users',
headers: ['Content-Type' => 'application/json'],
body: '{"name":"Steve","email":"steve@example.com"}',
);
$parsed = json_decode((string) $request->getBody(), true);
echo $request->getMethod(); // POST
echo $request->getUri()->getPath(); // /v1/users
echo $parsed['name']; // Steve这里没有任何框架特有的内容。任何接受 ServerRequestInterface 的代码,都能与这个对象协同工作。
PSR-17:HTTP 工厂
PSR-17 是几乎无人谈论的一个标准,这颇为可惜,因为它解决了一个显而易见的问题:如何在不依赖特定实现的前提下创建 PSR-7 对象?
如果库需要创建响应对象,开发者不能直接调用 new Nyholm\Psr7\Response(),否则就会与 Nyholm 产生耦合。PSR-17 定义了工厂接口,让开发者可以把对象创建工作委托给用户已安装的任何实现:
- Psr\Http\Message\RequestFactoryInterface
- Psr\Http\Message\ResponseFactoryInterface
- Psr\Http\Message\ServerRequestFactoryInterface
- Psr\Http\Message\StreamFactoryInterface
- Psr\Http\Message\UploadedFileFactoryInterface
- Psr\Http\Message\UriFactoryInterface
下面说明这一标准在实践中的意义。假设要编写一个处理 API 响应的库。没有 PSR-17 时,要么硬编码对某个特定 PSR-7 实现的依赖,要么要求用户传入预先构建好的响应对象。有了 PSR-17,只需请求一个工厂,让用户自带实现即可:
<?php
declare(strict_types=1);
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
final readonly class JsonResponder
{
public function __construct(
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
) {}
public function respond(mixed $data, int $status = 200): ResponseInterface
{
$body = $this->streamFactory->createStream(
(string) json_encode($data, JSON_THROW_ON_ERROR),
);
return $this->responseFactory
->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($body);
}
}使用 Nyholm 进行装配:
<?php
declare(strict_types=1);
use Nyholm\Psr7\Factory\Psr17Factory;
$factory = new Psr17Factory();
$responder = new JsonResponder(
responseFactory: $factory,
streamFactory: $factory,
);
$response = $responder->respond(['id' => 1, 'name' => 'Steve']);
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('Content-Type'); // application/json请注意,Nyholm 的 Psr17Factory 实现了全部 PSR-17 工厂接口,因此一个实例即可同时满足两个构造函数参数。这是一种常见的用法模式。
PSR-7 与 PSR-17 相辅相成。使用了其中之一,就应当同时使用另一个。
PSR-15:HTTP 服务器请求处理器
PSR-15 定义了两个用于处理服务器端 HTTP 请求的接口。
第一个是 RequestHandlerInterface:
namespace Psr\Http\Server;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface;
}这就是位于调用链末端的东西:接收一个请求,返回一个响应。控制器、最终处理器、应用入口——本质上都是这个样子。
第二个是 MiddlewareInterface:
namespace Psr\Http\Server;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface;
}中间件接收一个请求以及链中的下一个处理器。它可以在向下传递之前检查或修改请求,可以检查或修改返回的响应,也可以不调用 $handler->handle() 而直接返回一个响应来短路整条链,还可以让请求原样通过。
下面是一个具体的认证中间件:
<?php
declare(strict_types=1);
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final readonly class AuthMiddleware implements MiddlewareInterface
{
public function __construct(
private ResponseFactoryInterface $responseFactory,
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$token = $request->getHeaderLine('Authorization');
if ($token === '' || ! str_starts_with($token, 'Bearer ')) {
return $this->responseFactory
->createResponse(401)
->withHeader('WWW-Authenticate', 'Bearer');
}
// Attach the verified token to the request for downstream use
$request = $request->withAttribute('token', substr($token, 7));
return $handler->handle($request);
}
}以及一个将中间件串联起来的简单调度器:
<?php
declare(strict_types=1);
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class MiddlewareDispatcher implements RequestHandlerInterface
{
/** @var list<MiddlewareInterface> */
private array $middleware = [];
public function __construct(
private readonly RequestHandlerInterface $fallback,
) {}
public function pipe(MiddlewareInterface $middleware): self
{
$clone = clone $this;
$clone->middleware[] = $middleware;
return $clone;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
if ($this->middleware === []) {
return $this->fallback->handle($request);
}
$middleware = $this->middleware[0];
$remaining = clone $this;
array_shift($remaining->middleware);
return $middleware->process($request, $remaining);
}
}把它们装配起来:
<?php
declare(strict_types=1);
// AuthMiddleware and MiddlewareDispatcher are defined in the samples above
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
$factory = new Psr17Factory();
// The final handler - your application logic
$handler = new class ($factory) implements RequestHandlerInterface {
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$token = $request->getAttribute('token');
return $this->responseFactory
->createResponse(200)
->withHeader('Content-Type', 'application/json');
}
};
$dispatcher = (new MiddlewareDispatcher($handler))
->pipe(new AuthMiddleware($factory));
$request = new ServerRequest('GET', '/api/users', [
'Authorization' => 'Bearer my-token-here',
]);
$response = $dispatcher->handle($request);
echo $response->getStatusCode(); // 200这是不足 100 行纯 PHP 即可运行起来的 HTTP 中间件栈。没有框架,没有魔法。同一个 AuthMiddleware 类可以在 Slim、Mezzio 或任何自有应用中工作——因为它实现的是接口,而非某个框架特有的契约。
PSR-14:事件分发器
PSR-14 定义了事件分发与监听的简洁模型,包含三个接口:
- EventDispatcherInterface - 负责分发事件:
namespace Psr\EventDispatcher;
interface EventDispatcherInterface
{
public function dispatch(object $event): object;
}- ListenerProviderInterface - 返回给定事件对应的监听器:
namespace Psr\EventDispatcher;
interface ListenerProviderInterface
{
/** @return iterable<callable> */
public function getListenersForEvent(object $event): iterable;
}- StoppableEventInterface - 可选接口,供能够停止传播的事件使用:
namespace Psr\EventDispatcher;
interface StoppableEventInterface
{
public function isPropagationStopped(): bool;
}分发器与监听器提供者之间的分离是刻意的,也是重要的。分发器负责迭代与传播;监听器提供者负责回答哪些监听器关心哪些事件这一问题。保持二者分离,意味着可以在不修改分发器的前提下替换监听器发现策略——无论是基于简单数组的提供者、感知容器的提供者,还是读取 attribute 注解的提供者。
下面是一个可运行的实现:
<?php
declare(strict_types=1);
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;
final class EventDispatcher implements EventDispatcherInterface
{
public function __construct(
private readonly ListenerProviderInterface $listenerProvider,
) {}
public function dispatch(object $event): object
{
$stoppable = $event instanceof StoppableEventInterface;
foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) {
if ($stoppable && $event->isPropagationStopped()) {
break;
}
$listener($event);
}
return $event;
}
}一个由数组支撑的简单监听器提供者:
<?php
declare(strict_types=1);
use Psr\EventDispatcher\ListenerProviderInterface;
final class ListenerProvider implements ListenerProviderInterface
{
/** @var array<class-string, list<callable>> */
private array $listeners = [];
public function on(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
/** @return iterable<int, callable> */
public function getListenersForEvent(object $event): iterable
{
return $this->listeners[$event::class] ?? [];
}
}事件类只是一个普通的 PHP 对象,无需任何基类:
<?php
declare(strict_types=1);
final class UserRegistered
{
public function __construct(
public readonly string $userId,
public readonly string $email,
public readonly \DateTimeImmutable $registeredAt,
) {}
}把它们装配起来:
<?php
declare(strict_types=1);
$provider = new ListenerProvider();
$provider->on(UserRegistered::class, function (UserRegistered $event): void {
echo "Sending welcome email to {$event->email}\n";
});
$provider->on(UserRegistered::class, function (UserRegistered $event): void {
echo "Provisioning account for user {$event->userId}\n";
});
$dispatcher = new EventDispatcher($provider);
$dispatcher->dispatch(new UserRegistered(
userId: 'usr_01jt3x9a2b3c',
email: 'steve@example.com',
registeredAt: new \DateTimeImmutable(),
));// Output:
// Sending welcome email to steve@example.com
// Provisioning account for user usr_01jt3x9a2b3cPSR-14 有一个容易让人困惑的地方:dispatch() 会将事件返回。这是有意设计的。它允许监听器对事件对象进行扩充、添加元数据,或将结果回传给调用方。例如,分发一个 ValidatePayment 事件后,如果某个监听器在其上设置了 $result 属性,调用方即可从返回的事件中读取该属性。
结合 StoppableEventInterface,这就为流水线的短路提供了一套干净的模型。分发一个实现 StoppableEventInterface 的事件,让第一个拒绝该事件的监听器调用停止传播的方法,其余监听器便不会再执行。既不会抛出异常,也不会改动任何全局状态。
PSR-18:HTTP 客户端
对于编写库或集成第三方 API 的开发者而言,PSR-18 可以说是这一组标准中实际影响最大的一个。
它只定义了一个接口:
namespace Psr\Http\Client;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
interface ClientInterface
{
public function sendRequest(RequestInterface $request): ResponseInterface;
}整个接口就是如此。发送一个 PSR-7 请求,得到一个 PSR-7 响应。任何与响应本身无关的异常(网络故障、DNS 故障)都必须实现 Psr\Http\Client\ClientExceptionInterface。
其中包含两个具体的异常接口:NetworkExceptionInterface 用于未收到响应的故障,RequestExceptionInterface 用于无法发送的格式错误请求。
以下说明它如何改变库代码的编写方式。与其硬编码 Guzzle 或 cURL,不如接受一个 ClientInterface,让用户自带 HTTP 客户端。Guzzle 实现了 PSR-18,Symfony 的 HTTP 客户端也实现了 PSR-18,php-http/curl-client 这类基于 cURL 的客户端同样如此。库本身并不关心究竟使用的是哪一个:
<?php
declare(strict_types=1);
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final readonly class GithubClient
{
private const BASE_URI = 'https://api.github.com';
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private string $token,
) {}
public function getUser(string $username): array
{
$request = $this->requestFactory
->createRequest('GET', self::BASE_URI . '/users/' . $username)
->withHeader('Authorization', 'Bearer ' . $this->token)
->withHeader('Accept', 'application/vnd.github.v3+json')
->withHeader('User-Agent', 'my-app/1.0');
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new \RuntimeException(
sprintf(
'GitHub API returned %d for user %s',
$response->getStatusCode(),
$username,
),
);
}
$data = json_decode(
json: (string) $response->getBody(),
associative: true,
flags: JSON_THROW_ON_ERROR,
);
if (! is_array($data)) {
throw new \RuntimeException('Unexpected response format from GitHub API');
}
return $data;
}
}该类的使用者可以传入任何符合 PSR-18 的客户端:Guzzle 7、Symfony HTTP Client,或是测试中的 mock 客户端,库本身并不在意。在生产环境中从 Guzzle 切换到 Symfony 的 HTTP 客户端,只需在组合根处修改一行代码——GithubClient 类完全无需改动。
在测试中,可以实现一个极其简单的 mock 客户端:
<?php
declare(strict_types=1);
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
final class MockHttpClient implements ClientInterface
{
/** @var list<ResponseInterface> */
private array $responses = [];
public function queue(ResponseInterface $response): void
{
$this->responses[] = $response;
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
if ($this->responses === []) {
// In production code, this would implement ClientExceptionInterface.
// For a test double, a plain RuntimeException is sufficient.
throw new \RuntimeException('No responses queued in MockHttpClient');
}
return array_shift($this->responses);
}
}无需访问网络即可测试 GithubClient:
<?php
declare(strict_types=1);
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
$factory = new Psr17Factory();
$mockClient = new MockHttpClient();
$body = $factory->createStream((string) json_encode([
'login' => 'juststeveking',
'name' => 'Steve',
'public_repos' => 42,
], JSON_THROW_ON_ERROR));
$mockClient->queue(
(new Response(200))
->withHeader('Content-Type', 'application/json')
->withBody($body),
);
$client = new GithubClient(
httpClient: $mockClient,
requestFactory: $factory,
token: 'test-token',
);
$user = $client->getUser('juststeveking');
assert($user['login'] === 'juststeveking');
assert($user['public_repos'] === 42);快速、结果可复现,且没有网络依赖。
组合使用
这五个标准组合使用时,为开发者提供的是一套完整的工具集,用于编写对运行时环境不做任何假设的 PHP 代码。
PSR-7 与 PSR-17 提供了一种描述 HTTP 消息并创建它们的共享语言,且不与具体实现耦合。PSR-15 提供了一种通过中间件处理服务器请求的可组合模型。PSR-14 提供了一套轻量、解耦的事件系统。PSR-18 提供了一种 HTTP 客户端抽象,使代码能够与任何符合规范的客户端协同工作。
入门所需的包少之又少。psr/http-message、psr/http-factory、psr/http-server-handler、psr/http-server-middleware、psr/event-dispatcher 和 psr/http-client 是来自 PHP-FIG 的接口包。nyholm/psr7 在单个包中提供了可投入生产环境的 PSR-7 与 PSR-17 实现,且没有任何传递依赖。对于 PSR-18,Guzzle 7 开箱即用,symfony/http-client 配合 symfony/http-client-psr18 bridge 也是如此。
框架并没有取代这些标准,优秀的框架恰恰是构建在它们之上的。Slim 4、Mezzio 等框架在其整个技术栈中原生使用 PSR-7 与 PSR-15。这在实践中的意义在于:按照 PSR-15 接口编写的中间件,可以在上述任何一个框架中无需修改即可运行;按照 PSR-18 编写的 HTTP 客户端,可以在任何提供 PSR-18 实现的环境中正常工作。
学习这些标准的理由,并不是说要放弃使用框架,而是说理解它们之后写出的代码是更好的代码——更易移植、更易测试、对依赖关系更加明确,并且真正独立于运行它的工具。
框架是交付机制,接口才是架构。
想更进一步?PHP-FIG 为每个 PSR 编写的元文档,详细解释了接口背后的设计决策,包括那些被刻意舍弃的内容。对照代码阅读这些文档,值得花上一些时间。