与客户端无关 API 使用 PSR 7、17 和 18 而非 Guzzle 的包装器

Client agnostic API Wrapper using PSR 7, 17 and 18 instead of Guzzle

PSR

PSR-7, PSR-17 and PSR-18 的引入是使

成为可能的计划的一部分

build applications that need to send HTTP requests to a server in an HTTP client agnostic way

PSR-18: The PHP standard for HTTP clients

我一直在处理许多历史上严重依赖 Guzzle 而不是抽象接口的应用程序。大多数这些应用程序使用包含 JSON 正文的 GET 或 POST 请求发出简单的 API 请求,响应也包含 JSON 正文或抛出 HTTP 4xx 或 5xx 错误的异常。

API 包装器

这个问题来自最近的一个项目,我试图开发一个 API 包,它没有明确依赖 Guzzle,而是仅依赖于 PSR 接口。

我们的想法是制作一个 class ApiWrapper 可以使用以下方式启动:

  1. 满足 PSR-18 ClientInterface
  2. HTTP 客户端
  3. A 请求工厂 实现 PSR-17 RequestFactoryInterface
  4. A Stream Factory 实现 PSR-17 StreamFactoryInterface

这个 class 有它需要的任何东西:

  1. 使用 Request FactoryStream Factory
  2. 发出请求 (PSR-7)
  3. 使用 HTTP 客户端发送请求
  4. 处理响应 - 因为我们知道这将满足 PSR-7 ResponseInterface

这样的 API 包装器不依赖于上述接口的任何具体实现,而只需要这些接口的任何实现。因此,开发人员将能够使用他或她最喜欢的 HTTP 客户端,而不是被迫使用像 Guzzle 这样的特定客户端。

问题

现在,首先,我真的很喜欢 Guzzle,这不是 post 质疑 Guzzle 的强大之处,这只是 post 询问如何让开发人员能够使用它根据他们的需要选择正确的 http 客户端。

但问题是显式依赖 Guzzle 提供了很多不错的功能,因为 Guzzle 的功能远不止上述这些。 Guzzle 还应用了一系列 handlers and middlewares,例如跟随重定向或为 HTTP 4xx 响应抛出异常。

问题

冗长的描述,但问题来了:如何处理常见的 HTTP 请求处理,例如遵循重定向或以受控方式为 HTTP 4xx 响应抛出异常(因此无论使用何种 HTTP 客户端,都会产生相同的响应)无需确切指定要使用的 HTTP 客户端?

例子

这是 ApiWrapper 实施的示例:

<?php

use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;

/*
 * API Wrapper using PSR-18 ClientInterface, PSR-17 RequestFactoryInterface and PSR-7 RequestInterface
 *
 * Inspired from: https://www.php-fig.org/blog/2018/11/psr-18-the-php-standard-for-http-clients/
 * Require the packages `psr/http-client` and `psr/http-factory`
 *
 * Details about PSR-7 taken from https://www.dotkernel.com/dotkernel3/what-is-psr-7-and-how-to-use-it/
 *
 * Class Name                               Description
 * Psr\Http\Message\MessageInterface        Representation of a HTTP message
 * Psr\Http\Message\RequestInterface        Representation of an outgoing, client-side request.
 * Psr\Http\Message\ServerRequestInterface  Representation of an incoming, server-side HTTP request.
 * Psr\Http\Message\ResponseInterface       Representation of an outgoing, server-side response.
 * Psr\Http\Message\StreamInterface         Describes a data stream
 * Psr\Http\Message\UriInterface            Value object representing a URI.
 * Psr\Http\Message\UploadedFileInterface   Value object representing a file uploaded through an HTTP request.
 */

class ApiWrapper
{
    /**
     * The PSR-18 compliant ClientInterface.
     *
     * @var ClientInterface
     */
    private $psr18HttpClient;

    /**
     * The PSR-17 compliant RequestFactoryInterface.
     *
     * @var RequestFactoryInterface
     */
    private $psr17HttpRequestFactory;

    /**
     * The PSR-17 compliant StreamFactoryInterface.
     *
     * @var StreamFactoryInterface
     */
    private $psr17HttpStreamFactory;

    public function __construct(
        ClientInterface $psr18HttpClient,
        RequestFactoryInterface $psr17HttpRequestFactory,
        StreamFactoryInterface $psr17HttpStreamFactory,
        array $options = []
    ) {
        $this->psr18HttpClient($psr18HttpClient);
        $this->setPsr17HttpRequestFactory($psr17HttpRequestFactory);
        $this->setPsr17HttpStreamFactory($psr17HttpStreamFactory);
    }

    public function psr18HttpClient(ClientInterface $psr18HttpClient): void
    {
        $this->psr18HttpClient = $psr18HttpClient;
    }

    public function setPsr17HttpRequestFactory(RequestFactoryInterface $psr17HttpRequestFactory): void
    {
        $this->psr17HttpRequestFactory = $psr17HttpRequestFactory;
    }

    public function setPsr17HttpStreamFactory(StreamFactoryInterface $psr17HttpStreamFactory): void
    {
        $this->psr17HttpStreamFactory = $psr17HttpStreamFactory;
    }

    public function makeRequest(string $method, $uri, ?array $headers = [], ?string $body = null): RequestInterface
    {
        $request = $this->psr17HttpRequestFactory->createRequest($method, $uri);

        if (! empty($headers)) {
            $request = $this->addHeadersToRequest($request, $headers);
        }

        if (! empty($body)) {
            $stream = $this->createStreamFromString($body);
            $request = $this->addStreamToRequest($request, $stream);
        }

        return $request;
    }

    /**
     * Add headers provided as nested array.
     *
     * Format of headers:
     * [
     *   'accept' => [
     *     'text/html',
     *     'application/xhtml+xml',
     *   ],
     * ]
     * results in the header: accept:text/html, application/xhtml+xml
     * See more details here: https://www.php-fig.org/psr/psr-7/#headers-with-multiple-values
     *
     * @param  \Psr\Http\Message\RequestInterface  $request
     * @param  array  $headers
     * @return \Psr\Http\Message\RequestInterface
     */
    public function addHeadersToRequest(RequestInterface $request, array $headers): RequestInterface
    {
        foreach ($headers as $headerKey => $headerValue) {
            if (is_array($headerValue)) {
                foreach ($headerValue as $key => $value) {
                    if ($key == 0) {
                        $request->withHeader($headerKey, $value);
                    } else {
                        $request->withAddedHeader($headerKey, $value);
                    }
                }
            } else {
                $request->withHeader($headerKey, $headerValue);
            }
        }

        return $request;
    }

    /**
     * Use the PSR-7 complient StreamFactory to create a stream from a simple string.
     *
     * @param  string  $body
     * @return \Psr\Http\Message\StreamInterface
     */
    public function createStreamFromString(string $body): StreamInterface
    {
        return $this->psr17HttpStreamFactory->createStream($body);
    }

    /**
     * Add a PSR 7 Stream to a PSR 7 Request.
     *
     * @param  \Psr\Http\Message\RequestInterface  $request
     * @param  \Psr\Http\Message\StreamInterface  $body
     * @return \Psr\Http\Message\RequestInterface
     */
    public function addStreamToRequest(RequestInterface $request, StreamInterface $body): RequestInterface
    {
        return $request->withBody($body);
    }

    /**
     * Make the actual HTTP request.
     *
     * @param  \Psr\Http\Message\RequestInterface  $request
     * @return \Psr\Http\Message\ResponseInterface
     * @throws \Psr\Http\Client\ClientExceptionInterface
     */
    public function request(RequestInterface $request): ResponseInterface
    {
        // According to PSR-18:
        // A Client MUST throw an instance of Psr\Http\Client\ClientExceptionInterface
        // if and only if it is unable to send the HTTP request at all or if the
        // HTTP response could not be parsed into a PSR-7 response object.

        return $this->psr18HttpClient->sendRequest($request);
    }
}

这是我的看法,主要基于尝试一些方法。

任何PSR-18 client都会有一个它必须符合的接口。 该接口本质上只是一种方法 - sendRequest()。 该方法将发送 PSR-7 request 和 return 一个 PSR-7 response.

请求中的大部分内容将用于构造 PSR-7 请求。那会在它到达 sendRequest() 之前放在一起 客户的。 PSR-18 规范没有定义的是 行为 客户端,例如是否遵循重定向。 它确实指定不应在事件中抛出异常 非 2XX 响应。

这似乎限制性很强,但这个客户端是行尾, 它只关心请求的物理发送和 捕获响应。 关于客户行为的其他一切都可以内置 中间件 以扩展该客户端。

那么 PSR-18 中间件能做什么?

  • 它可以访问原始 PSR-7 请求,因此可以 阅读并更改。
  • 它可以访问 PSR-7 响应,因此可以修改响应, 并根据该响应采取行动。
  • 它进行了 sendRequest() 调用,所以可以应用逻辑 已处理,例如重试、遵循重定向等。

PSR-18规范没有提到中间件,那么哪里会 而已?实现它的一种方法可能是装饰器。 装饰器环绕基本的 PSR-18 客户端,添加功能, 但会以 PSR-18 客户端的形式出现。 这意味着可以在基本客户端上分层多个装饰器以 添加您喜欢的任意数量的功能。

这是一个 PSR-18 装饰器的例子。 这个装饰器本质上什么都不做,只是提供了一个框架来放置 逻辑成。

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class Psr18Decorator implements ClientInterface
{
    // ClientInterface

    protected $client;

    // Instantiate with the current PSR-18 client.
    // Options could be added here for configuring the decorator.

    public function __construct(ClientInterface $client)
    {
        $this->client = $client;
    }

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        // The request can be processed here.

        // Send the request, just once in this example.

        $response = $this->client->sendRequest($request);

        // The response can be processed or acted on here.

        return $response;
    }

    // This is added so that if a decorator adds new methods,
    // they can be accessed from the top, multiple layers deep.

    public function __call($method, $parameters)
    {
        $result = $this->client->$method(...$parameters);

        return $result === $this->client ? $this : $result;
    }
}

所以给定基本的 PSR-18 客户端,它可以这样装饰:

$decoratedPsr18Client = new Psr18Decorator($basePsr18Client);

可以编写每个装饰器来处理单个问题。 例如,您可能想在响应出现时抛出异常 不 return 2XX 代码。可以编写一个装饰器来做到这一点。

另一个装饰器可以处理 OAuth 令牌,或监控对 API 的访问 所以它可以受到速率限制。另一个装饰器可以跟随重定向。

所以,你需要自己编写所有这些装饰器吗? 现在,是的,因为不幸的是周围缺少它们。 但是,由于它们是作为包开发和发布的, 它们本质上是可重用的代码,可以应用于 任何 PSR-18 客户端。

Guzzle 很棒,有很多功能,而且是整体的 那种尊重。我相信 PSR-18 方法应该允许我们 将所有这些功能分解为更小的独立块 因此可以根据需要应用它们。 装饰器管理包可能有助于添加这些装饰器 (也许确保他们正确订购并相互兼容) 并且可能以不同方式处理装饰器自定义方法以避免 需要 __call() 回退。

我敢肯定还有其他方法,但这个方法对我来说效果很好。