PSR-15 的由来(写在 PSR-15 诞生之时)

file

昨天(写于 2018-01-23 ),经过核心委员会的一致表决,PHP-FIG 正式通过了 PSR-15, HTTP Server Handlers 标准的提案。

这个新标准为 请求处理器( request handlers ) 和 中间件( middleware ) 定义了接口。它们对 PHP 的生态系统有着巨大的潜在影响,因为它们为编写面向 HTTP 的服务端应用程序提供了标准的机制。实际上,它们为开发者在任何使用了 PSR-15 中间件或者请求处理器的程序中创建可复用的 web 组件铺了路。

备注

本人作为 PSR-15 的赞助商,同时也担任了审查期间的最终仲裁者。

背景

PSR-15 由 Woody Gilk 发起,在此期间他担任总编辑。最初的意图是制定一个中间件标准,并认为应该采用一个已经被广泛使用的模式:

function (
    ServerRequestInterface $request,
    ResponseInterface $response,
    callable $next
) : ResponseInterface

参数 $next  应按如下方式实现:

function (
    ServerRequestInterface $request,
    ResponseInterface $response
) : ResponseInterface

"双通道"

上述模式被称作“双通道”中间件,因为它将两个实列传递给协作者并传递到下一层。

然而,对这一做法批评的声音很快就出现了, 其中有来自 Anthony Ferrara 的 ,主要的问题如下:

  • 层对层之间传递响应可能会带来一些问题,当一个外部层对传递到内部层的响应进行更改,你期望外部层的更改能通过内部层后传播出去,但是内部层却返回了一个完全不同的响应。本质上,这种实现模式是有问题的,如果中间件需要操作响应,那么它应该基于另一层返回的响应进行操作。

  • 参数  $next 类型为回调意味着没有办法确保它能接收到参数,换句话说,它不是类型安全的。

在工作组内部讨论之后,下一个迭代版本提出了以下方案(一些细节可能不同,但基本交互是相同的):

interface DelegateInterface
{
    public function process(ServerRequestInterface $request) : ResponseInterface;
}

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        DelegateInterface $delegate
    ) : ResponseInterface;
}

这在很大程度上解决了上面提到的问题。然而,不同的团队会出现一些不同的实现细节。

首先,许多人指出定义相同的方法名可以防止多态性。通常的做法是定义一个可以调用的「请求处理程序」并会反过来加工它本身。所以,我们更新了接口如下:

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

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface;
}

其次,这个变更确定后,一部分人意识到请求处理器本身是有用的。例如,当创建一个简单的站点时,你可以处理一个服务请求,将其传递给一个处理器,然后发出返回的响应;在这种情况下中间件可能不是必须的。另一个使用场景是针对最终的内部的中间件应用程序:你可以将它们实现为请求处理器,而不是作为中间件,因为它们不操作处理器的结果。

因此,我们将这两个接口调整为 独立的包,包含 MiddlewareInterface 的包依赖于定义了 RequestHandlerInterface 的包。

最后,在本规范开发的近两年时间里,随着 7.1 版本和 7.2 版本的发布, PHP 7 变得成熟了。我们决定将规范推向 PHP7 或者更高版本,并且正式采用返回类型提示。

尽管本规范的工作尚在进程当中,但接口的每一次迭代都发布在 http-interop 这个 github 组织里了,每次发布,这些包都与任何当前规范详细匹配(先是 http 中间件,然后是 http-server 中间件,甚至是添加 http-server 处理器)。这些包也使用 Interop\Http 作为顶级命名空间。工作组成员及其他感兴趣的团体,将把他们的贡献推向特定的迭代中去。

最终包归 PHP-FIG 团队所有,并且使用 Psr 作为顶级命名空间。

接口

于是我们有了最终的标准:

psr/http-server-handler 提供了以下接口:

namespace Psr\Http\Server;

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

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

psr/http-server-middleware 提供了以下接口:

namespace Psr\Http\Server;

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

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ) : ResponseInterface;
}

两个包都依赖 PSR-7,因为需要用到该包中定义的 HTTP 消息接口作为类型提示。http 服务中间件(http-server-middleware) 包依赖于 http 服务处理器(http-server-handler) 包。

如何写出可复用的中间件

目前可用的绝大多数中间件转发器(事实证明,确实有很多!)都允许你以一种方式来组合中间件,在这种方式下,转发器无需了解它们是由什么东西如何组装起来的。这是件好事情™。 这样你写的中间件就能和使用它的上下文去耦合了。

但是要怎么做呢?

在规范的说明文档中,我们 有如下建议

  • 要测试请求所必须的前提条件(如果有的话)。如果不满足其中任何一项,就使用组合的 响应原型(response prototype) 或者 响应工厂(response factory) 来生成并返回响应。

  • 如果前提条件满足了,就对提供的处理程序委托创建的响应(PSR-7 的请求是固定的,因此这意味着调用一个 with*() 方法就行了,该方法会返回一个新的实例)。

  • 要么从处理程序逐字传回响应,要么操纵它的返回值返回一个新的响应( 再次通过 with*() 方法)。

第一点可能是最重要的一点:不要在你的中间件里直接实例化一个响应,而是使用实例化期间提供的一个 原型(prototype) 或者 一个 工厂(factory) 来代替。这能让你的中间件与程序中使用的 PSR-7 的实现去耦合。

实践中代码如下:

class CheckOriginMiddleware implements MiddlewareInterface
{
    private $acceptedOrigins;
    private $responsePrototype;

    public function __construct(array $acceptedOrigins, ResponseInterface $responsePrototype)
    {
        $this->acceptedOrigins = $acceptedOrigins;
        $this->responsePrototype = $responsePrototype;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
    {
        $origin = $request->getHeaderLine('origin');
        if (! in_array($origin, $this->acceptedOrigins, true)) {
            return $this->responsePrototype
                ->withStatus(401)
                ->withHeader('X-Invalid-Origin', $origin);
        }

        $response = $handler->handle($request);

        return $response->withHeader('X-Origin', $origin);
    }
}

关于这个中间件,有几点需要注意:

  • 它只通过构造器来接收依赖的类,这有助于提高此中间件的可测试性,并且所需依赖一目了然。

  • 返回的数据原型已经与 PSR-7 解耦。我可以传输一个 Diactoros 返回,Guzzle 返回,Slim 返回, 或者其他的实现。如此一来,此中间件的使用者就不用安装另一个的 PSR-7 实现了。

  • 此中间件对其使用的场景,以及其调用堆栈毫无所知。它只知道处理请求,当  process() 被调用时即返回处理器。

我该如何消费这样的中间件呢?

在 Expressive 中,我可能会做以下任何一项:

// 把它当做一种服务来从 DI 容器中拉出来:
$app->pipe(CheckOriginMiddleware::class);

// 在特定的路由管道中使用它:
$app->post('/api/foo', [
    CheckOriginMiddleware::class,
    FooMiddleware::class,
]);

在 northwoods/broker 中,(由 Woody Gilk 维护,他是 PSR-15 的编辑),看起来像这样:

$broker->always([CheckOriginMiddleware::class]);

在 middlewares/utils Dispatcher 中,你需要这样做:

$dispatcher = new Dispatcher([
    /* ... */
    new CheckOriginMiddleware($acceptedOrigins, $responsePrototype),
    /* ... */
]);

采用上述任何解决办法,只要你的中间件被执行了,那么它的表现都是一样的;它是 如何 组合的并不影响,因为它的运作方式只依赖于在 process() 期间传递给中间件的请求和处理器。

请求处理器又怎么样呢?

目前我见过的大多数库都是使用以下两种方式之一来定义处理器的:

  • 作为一个中间件转发器。在这种特定情况下,每个中间件都会被处理,直到其中一个返回了响应为止。如果最后一个被处理的中间件又调用了处理器,那么可能会返回一个预定的响应,或者抛出一个异常,又或者下一个场景会发挥作用:

  • 作为一个传递给中间件转发器的『终极』处理器。 换句话说,假设最后一个被处理的中间件 调用了它的处理器,这个『回退』 或者说 『终极』处理器就是那个被调用的。这个根据实现不同,一般会返回一个 404 响应或者一个 500 响应。

另一种可能是由多个 路由中间件 所使用。在这种情况下,一旦路由中间件匹配到了请求,它就会调用该请求映射到的那个请求处理器。
实现须知

PSR-15 已经定案;来,我们把所有的东西 PSR-15 化吧!

稍安勿躁!因为许多项目中仍旧在使用 http-interop 包进行持续迭代,这使得实现 PSR-15 规范化可能是一个长期目标。

举个例子,我们已经在 Stratigility 和 Expressive 跟踪了 http-interop 各种版本的迭代,但是升级到 PSR-15 规范需要做向后兼容, 所以需要出一个新的 3.0 版本——这要花上好几周的时间。 Slim 的 另一个 PSR-15 支持补丁,在即将到来的 4.0 版本发布之前,这个事也不会落地。

因此,我们应该给库和框架维护者一些时间和耐心,并帮助他们测试发布版。

另外,考虑下跟踪和测试  PSR-17 规范提案。 该提案将规范化 PSR-7 的 工厂,这些工厂将为生成中间件,尤其是返回响应,提供一种标准的方式。使用组合工厂来代替组合响应原型。那么为啥这样子会更轻松呢? 好吧,万一你想在某个地方放置 Psr\Http\Message\StreamInterface  的实例作为 响应体, 这种方式也是允许你创建那些实例的。由于流不是固定的(因为语言的限制),所以每次你向流写入东西的时候,你可以追加现有的内容,也就是说,中间件写入到响应原型体的时候通常也需要组合一个  原型。 这时候,如果你能组合一个单独的工厂来代替的话会怎样呢?

结论

最初当我开始为 PSR-7 标准工作的时候,是因为我希望能有一个标准的 PHP 中间件接口。我曾经使用过 Node ,更具体的说,是 Sencha Connect 和 ExpressJS。Node 一直以来都有着强大的中间件生态系统。形成这种情况的原因有两点:

  • 广泛接受的,标准的中间件签名。 虽然 JS 官方并没有提供相关接口,而且也没有用户层的标准组织,就这样出现了一个统一的签名,然后每个人都使用它。我想这大概是因为:

  • Node 核心库中内置的 HTTP 消息抽象 。

如果我想在 PHP 中实现标准的中间件,首先我们需要标准的 HTTP 消息, PSR-7 已经实现了这一点。也许可以到此为止,因为许多类库已经开始使用相同的中间件签名;然而,我们很快有了至少两种,也有可能是六种不同的实现。令人感谢的是,Woody 挺身而出面对这个任务并且提出了 PSR-15 标准的草案;而且,他和工作小组的其他成员一起,靠着他们的耐心和毅力最终目睹了标准的通过 (虽然据我所知有好几次他和及另外几个人差点就放弃了!)。

随着 PSR-15 标准的通过, 我们又朝着我曾经的设想近了一步:可能有一天,PHP 开发者们不再需要使用重量级的 MVC 框架, 而是用丰富的,可复用的中间件来构建应用程序。

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://mwop.net/blog/2018-01-23-psr-15....

译文地址:https://learnku.com/laravel/t/10123/the-...

本帖已被设为精华帖!
本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 3
Summer

@BaiLian 是的,有在准备翻译整个文档

5年前 评论

@Summer 《PHP PSR 标准规范》 中的信息已经过时,在 7 以后, 11, 13, 15, 16 都已经加入了标准。建议把这些也加入翻译计划,psr-7 也未完成,最好一并加入进来。

感谢各位热心翻译的人!

5年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!