mnavarrocarter/php-fetch

一个简单、类型安全、无依赖的javascript fetch WebApi的PHP端口

0.3.0 2021-04-29 15:15 UTC

This package is auto-updated.

Last update: 2024-09-29 06:02:49 UTC


README

一个简单、类型安全、无依赖的javascript fetch WebApi的PHP端口。

注意:此库版本在< 1.0.0,根据语义化版本规范,在达到1.0.0之前,可能存在破坏性更改。请仔细指定您的约束。

安装

composer require mnavarrocarter/php-fetch

基本用法

一个简单的GET请求可以通过调用fetch并传递url来实现

<?php

use function MNC\Http\fetch;

$response = fetch('https://mnavarro.dev');

// Emit the response to stdout
while (($chunk = $response->body()->read()) !== null) {
    echo $chunk;
}

高级用法

类似于浏览器中的fetch实现,您可以传递一个选项映射作为第二个参数

<?php

use function MNC\Http\fetch;
use Castor\Io\Eof;

$response = fetch('https://some-domain.example/some-form', [
    'method' => 'POST',
    'headers' => [
        'Content-Type' => 'application/json',
        'User-Agent' => 'PHP Fetch'
    ],
    'body' => json_encode(['data' => 'value'])
]);

// Emit the response to stdout in chunks
while (true) {
    $chunk = '';
    try {
        $response->body()->read(4096, $chunk);
    } catch (Eof $e) {
        break;
    }
    echo $chunk;
}

目前,唯一支持的选项有

  • method (string):设置请求方法
  • body (resource|string):请求体。
  • headers (array<string|string>):一个关联数组,包含头名称和值。
  • follow_redirects (bool):是否跟随重定向。默认为true
  • protocol_version (string):要使用的http协议版本。默认为1.1
  • max_redirects (int):允许重定向的次数。默认为20

获取响应信息

您可以使用可用的API从响应中获取所需的所有信息。

<?php

use function MNC\Http\fetch;

$response = fetch('https://mnavarro.dev');

echo $response->status()->protocolVersion();  // 1.1
echo $response->status()->code();   // 200
echo $response->status()->reasonPhrase(); // OK
echo $response->headers()->has('content-type'); // true
echo $response->headers()->contains('content-type', 'html'); // true
echo $response->headers()->get('content-type'); // text/html;charset=utf-8
$bytes = '';
echo $response->body()->read(4096, $bytes); // Allocates reader data into $bytes
echo $bytes; // Outputs some bytes from the response body

异常处理

调用fetch可能会抛出两个异常,这些异常有适当的文档说明。

当无法与服务器建立TCP连接时,会抛出MNC\Http\SocketError。可能发生此情况的一些常见场景包括

  • 服务器关闭
  • 域名无法解析为IP地址(DNS)
  • 服务器响应时间过长(超时)
  • SSL握手失败(不受信任的证书)

当连接可以建立,并且服务器生成了响应,但根据HTTP协议规范,此响应为错误(400或500范围内的状态码)时,会发生MNC\Http\ProtocolError。此异常包含服务器生成的MNC\Http\Response对象。

这两种错误之间的区别非常重要,因为您可能会对每种错误做出不同的反应。

体缓冲

当您调用MNC\Http\Response::body()方法时,您将得到一个Castor\Io\Reader实例,这是一个非常简单的接口,灵感来源于golang的io.Reader。此接口允许您从数据源中读取字节数据,直到达到EOF

通常,您不想逐字节读取,但希望一次获取整个正文内容作为字符串。此库提供readAll函数作为便利

<?php

use function Castor\Io\readAll;
use function MNC\Http\fetch;

$response = fetch('https://mnavarro.dev');

echo readAll($response->body()); // Buffers all the contents in memory and emits them.

缓冲是一个非常好的便利,但需要谨慎使用,因为它可能会增加您的内存使用量,达到您正在获取的文件大小。请注意这一点,并在获取大文件时使用读取器。

处理常见编码

一些库以非常不可靠的方式使它们的响应实现了解析体的内容类型。

例如,Symfony的HTTP客户端响应对象包含一个toArray()方法,如果响应体的内容是JSON,则返回一个数组。

除了是一个不完美的抽象之外,它也不是一个好的抽象,因为它在类似 text/plain 的内容类型中可能会失败得很惨。然而,当我们提供这样的辅助工具时,用户体验会得到很大的提升。

这个库提供了一种更安全的方法。如果响应头包含 application/json 内容类型,则正文中的 Castor\Io\Reader 对象内部被一个 MNC\Http\Encoding\Json 对象装饰。该对象实现了 Reader 接口。检查前者是处理 JSON 负载数据最安全的方式。

<?php

use MNC\Http\Encoding\Json;
use function MNC\Http\fetch;

$response = fetch('https://api.github.com/users/mnavarrocarter', [
    'headers' => [
        'User-Agent' => 'PHP Fetch 1.0' // Github api requires user agent
    ]
]);

$body = $response->body();

if ($body instanceof Json) {
    var_dump($body->decode()); // Dumps the json as an array
} else {
    // The response body is not json encoded
}

这使得代码更易于维护和扩展,因为我们可以在未来支持更多的编码,如 csvxml,而不会损害基础 API,也不会对我们的内容类型做出比应有的更多假设。

这种做事方式(鼓励组合的小接口)是从 Go 语言的特点中吸取的另一个原则。

处理标准头部

从结构上讲,HTTP 是一个非常通用的协议。一个 HTTP 响应实际上只是键值对(头部)形式的元数据,以及该响应的内容本身。

然而,有一组在多个 RFC 中标准化的头部,不容忽视。它们不是 HTTP 协议规范的一部分,但它们非常普遍,被广泛使用,因此一个好的协议实现应该承认它们。

这个库保持了协议的纯洁性,但通过使用 MNC\Http\StandardHeaders 类,提供了更好的标准头部 API。

MNC\Http\Response::headers() 方法返回一个 MNC\Http\Headers 实例。该对象只是一个字符串键和字符串值的集合。当获取头部时,名称应由您提供,根据协议规范,它们是不区分大小写的。

通过使用 MNC\Http\StandardHeaders 类,您可以为 MNC\Http\Headers 对象添加装饰,以提供对一些标准化和有用的头部的 API。

<?php

use MNC\Http\StandardHeaders;
use function MNC\Http\fetch;

$response = fetch('https://mnavarro.dev');

$stdHeaders = StandardHeaders::from($response);
$lastModified = $stdHeaders->getLastModified()->diff(new DateTimeImmutable(), true)->h;
echo sprintf('This html content was last modified %s hours ago...', $lastModified) . PHP_EOL;

您可以使用这些头部信息来处理缓存或在不必要的情况下避免读取整个流体。

由于这些标准头部可能不在某些响应中出现,因此它们都可以返回 null

函数组合

作为一个函数,如果不使用适当的模式,fetch 可能会非常冗长。这些模式之一就是组合。

例如,您可以以与组合对象相同的方式组合函数。将 fetch 包装在定义某些常见默认选项的匿名函数中,实际上是使用 fetch 的推荐方式,这不仅适用于这个库,也适用于浏览器中的库。

例如,以下代码定义了一个函数,它接受一个令牌作为参数,然后返回另一个函数,该函数调用 fetch 使用简化的 API,并使用令牌内部。

<?php

use MNC\Http\Encoding\Json;
use function MNC\Http\fetch;

$authenticate = static function (string $token) {
    return static function (string $method, string $path, array $contents = null) use ($token): ?array {
        $url = 'https://my-api-service.example' . $path;
        $response = fetch($url, [
            'method' => $method,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $token
            ],
            'body' => is_array($contents) ? json_encode($contents) : ''
        ]);

        $body = $response->body();
        if ($body instanceof Json) {
            return $body->decode();
        }
        return null;
    };
};

$client = $authenticate('your-api-token');

$ordersArray = $client('GET', '/orders');
$createdOrderArray = $client('POST', '/orders', ['id' => '1234556']);

注意 $client 函数没有暴露 fetch 的工作细节,并将与客户端类的交互减少到 PHP 原始类型。当然,这个例子缺少异常处理,但思路是相同的。

您可以在应用程序的任何地方传递那个 $client 变量,而不会将您的代码绑定到这个库,而是绑定到一个具有相同签名的可调用对象。

依赖注入

根据前面的例子,我们不推荐您在代码中直接调用 fetch。至少,如果您非常担心与您可能在未来替换的特定 HTTP 客户端库耦合,就不应该这样做。

我个人常用的一种常见模式是,我为需要使用的 API 客户端创建一个接口。

<?php

use MNC\Http\Encoding\Json;
use function MNC\Http\fetch;

// We start with an interface, a well defined contract.
interface ApiClient
{
    public function getOrder(string $id): array;

    public function createOrder(string $id): array;

    public function deleteOrder(string $id): void;
}

// Then, we can have an implementation that uses this library.
final class FetchApiClient implements ApiClient
{
    /**
     * @var callable
     */
    private $client;

    /**
     * @param string $token
     * @return FetchApiClient
     */
    public static function authenticate(string $token): FetchApiClient
    {
        $client = static function (string $method, string $path, array $contents = null) use ($token): ?array {
            $url = 'https://my-api-service.example' . $path;
            $response = fetch($url, [
                'method' => $method,
                'headers' => [
                    'Accept' => 'application/json',
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . $token
                ],
                'body' => is_array($contents) ? json_encode($contents) : ''
            ]);

            $body = $response->body();
            if ($body instanceof Json) {
                return $body->decode();
            }
            return null;
        };
        return new self($client);
    }

    /**
     * FetchApiClient constructor.
     * @param callable $client
     */
    public function __construct(callable $client)
    {
        $this->client = $client;
    }

    public function getOrder(string $id): array
    {
        return ($this->client)('GET', '/orders/'.$id);
    }

    public function createOrder(string $id): array
    {
        return ($this->client)('POST', '/orders', [
            'id' => $id
        ]);
    }

    public function deleteOrder(string $id): void
    {
        ($this->client)('DELETE', '/orders/'.$id);
    }
}

您可以在所有依赖于连接到实现该接口的 API 服务的服务中使用该接口。

为什么还需要另一个 HTTP 客户端?

也许您会想,“PHP 需要另一个 HTTP 客户端吗?”我认为这个是需要的。

在构建它之前,我对目前可用的选项进行了诚实的评估,并列举了在我看来它们所缺乏的东西。我还列出了我希望能从其他语言和实现中得到的期望功能。

最后,我列出了4个构建此客户端的原则/原因,综合考虑,只有在这个客户端中才能满足这些。

你不需要在你的应用中使用PSR-18 HTTP客户端

我对PHP-FIG以及他们为PHP生产的所有标准只有赞美之词。我个人是所有PSR-7事物的忠实粉丝,并且总是希望社区看到它的好处并开始转向使用它。

但是,当我在开发自己的应用程序并且只需要进行简单的HTTP请求时,我会尽力避免PSR-18及其实现中的冗长和臃肿。这就是我制作php fetch的原因:为90%的简单用例。如果你需要一个用于网络爬虫的HTTP客户端,不要使用这个(你需要重定向跟踪、多路复用、cookie支持、绕过csrf的插件、内置的javascript引擎等)。但如果你需要一个简单的HTTP客户端来对API进行请求,你会发现使用这个库绰绰有余。

"但关于互操作性和供应商锁定怎么办?" 事实上,如果你是一个负责任的程序员,你应该构建代码,通过适当的抽象(如接口)向HTTP端点发送请求。想想这样的东西:具有以下可能实现的ApiServiceGuzzleApiServiceCurlApiServiceFetchApiService。如果你这样做,你就不会有供应商锁定的问题。相反,如果你不将你的依赖隐藏在你自己的合同和需求所服务的接口之后,你不仅在进行HTTP请求时,而且在几乎所有其他事情上都会遇到麻烦。

PSR-18主要是为了库而制作的,以避免依赖冲突。这并不意味着它不能用于应用程序;许多人这样做,并且它有效!这意味着它的存在是为了服务于库,比如HTTP SDK或其他的。如果你熟悉几年前整个Guzzle丑闻,你会知道HTTPPlug(PSR-18的灵感来源)是为了防止某些库中的依赖冲突而制作的,这些冲突主要是由Guzzle非常积极的发布策略和Amazon非常宽松的发布策略引起的。

所以,这个库的简单性对于我的大多数应用程序来说已经足够了。

大多数HTTP客户端都过于臃肿

这并不是HTTP客户端本身的缺陷。一个具有许多功能的客户端将有许多代码和依赖项。问题是你是否需要这些功能来满足你的用例。在我的经验中,大多数时候我并不需要它们,我总是使用PHP流进行简单的HTTP请求。我构建这个库是为了我不再需要在简单用例中这样做。

再次,如果你的用例更复杂,你可能需要考虑使用更丰富的HTTP客户端。例如,对于网络爬虫,我推荐使用Symfony Panther

没有HTTP客户端只是一个函数

我喜欢与javascript一起工作的一个原因是它更友好地采用了函数式方法。尽管它与纯函数式语言还有很长的路要走,但大多数API的声明性特性使其非常易于使用(如果只有适当的类型和封装就好了!)。

fetch API是我最喜欢的一个,我一直希望PHP中能有这样的事情。我寻找过它,但没有找到,因此创建了这个库。有时,我们的单方法类完全可以是函数。

PHP中的一些人开始理解这一点并使用更多的函数,特别是自从函数可以命名空间化以来(请永远不要在全局命名空间中添加函数!)。

不可变性

嗯,PSR-18更倾向于使用引用克隆来实现不可变性。这个库通过只读状态来实现。在已经构建好的响应中,你无法进行任何修改。一切都是只读的。

你当然可以使用一些恶心的PHP技巧,比如闭包作用域绑定来修改它;但请别这么做,好吗?

没有理由需要修改来自服务器的响应。你可以对响应做的唯一一件事是将它组合成其他类型:仅此而已。

其他细节问题

当实现协议的库在协议错误发生时不抛出异常时,这真的让我非常烦恼。比如说,哪个SMTP客户端库在没有指定目标地址时给出包含错误的Response而不是抛出异常?你该如何知道发生了错误呢?

在一种语言中实现协议的目的是模仿协议在该语言构造中的特性。如果定义了错误状态码,应该使用该语言构造来表示错误:在这种情况下,应该抛出异常。

这是我对PSR-18最大的问题之一。我认为这是一个糟糕的设计决策,损害了用户体验。

总结

这个客户端简单、小巧、功能强大、不可变、类型安全、设计良好,在协议严格性和便利性之间取得了良好的平衡。我认为在PHP生态系统中现在还没有类似的东西,因此可能会有一定的用户基础。

希望您使用它时能像我喜欢构建它一样愉快。