CatchAdmin PHP 后台管理框架 Logo CatchAdmin

被忽视的 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
<?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
<?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
<?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
<?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:

php
namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface;
}

这就是位于调用链末端的东西:接收一个请求,返回一个响应。控制器、最终处理器、应用入口——本质上都是这个样子。

第二个是 MiddlewareInterface:

php
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
<?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
<?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
<?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 - 负责分发事件:
php
namespace Psr\EventDispatcher;

interface EventDispatcherInterface
{
    public function dispatch(object $event): object;
}
  • ListenerProviderInterface - 返回给定事件对应的监听器:
php
namespace Psr\EventDispatcher;

interface ListenerProviderInterface
{
    /** @return iterable<callable> */
    public function getListenersForEvent(object $event): iterable;
}
  • StoppableEventInterface - 可选接口,供能够停止传播的事件使用:
php
namespace Psr\EventDispatcher;

interface StoppableEventInterface
{
    public function isPropagationStopped(): bool;
}

分发器与监听器提供者之间的分离是刻意的,也是重要的。分发器负责迭代与传播;监听器提供者负责回答哪些监听器关心哪些事件这一问题。保持二者分离,意味着可以在不修改分发器的前提下替换监听器发现策略——无论是基于简单数组的提供者、感知容器的提供者,还是读取 attribute 注解的提供者。

下面是一个可运行的实现:

php
<?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
<?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
<?php

declare(strict_types=1);

final class UserRegistered
{
    public function __construct(
        public readonly string $userId,
        public readonly string $email,
        public readonly \DateTimeImmutable $registeredAt,
    ) {}
}

把它们装配起来:

php
<?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(),
));
text
// Output:
// Sending welcome email to steve@example.com
// Provisioning account for user usr_01jt3x9a2b3c

PSR-14 有一个容易让人困惑的地方:dispatch() 会将事件返回。这是有意设计的。它允许监听器对事件对象进行扩充、添加元数据,或将结果回传给调用方。例如,分发一个 ValidatePayment 事件后,如果某个监听器在其上设置了 $result 属性,调用方即可从返回的事件中读取该属性。

结合 StoppableEventInterface,这就为流水线的短路提供了一套干净的模型。分发一个实现 StoppableEventInterface 的事件,让第一个拒绝该事件的监听器调用停止传播的方法,其余监听器便不会再执行。既不会抛出异常,也不会改动任何全局状态。

PSR-18:HTTP 客户端

对于编写库或集成第三方 API 的开发者而言,PSR-18 可以说是这一组标准中实际影响最大的一个。

它只定义了一个接口:

php
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
<?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
<?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
<?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 编写的元文档,详细解释了接口背后的设计决策,包括那些被刻意舍弃的内容。对照代码阅读这些文档,值得花上一些时间。

本作品采用《CC 协议》,转载必须注明作者和本文链接