コネヒト開発者ブログ

コネヒト開発者ブログ

PSR-18: HTTP Clientが採択されたので読んで・考え・まとめてみる

こんにちは、サーバーサイドのお仕事してます金城(@o0h_)です。
先日、社内Slackで「面白い漫画、オススメ教えてください!」と投稿したら弊社デザイナー氏から推薦された「舞妓さんちのまかないさん」がとても良かったです。ほっこりして、「なんか・・仕事頑張ろう・・」という気持ちに。

舞妓さんちのまかないさん 1 (1) (少年サンデーコミックススペシャル)

そんな弊社デザイナ、12月に開催されるDesignshipに公募スピーカーとしてお話をするよ!!との事でコネヒトメンバー一同で応援をしております。

design-ship.jp

前回・前々回と私がこちらに書いた記事がPHPを離れていた事もあり、「そろそろPHPネタを」というエントリーです。
つい先日、新しいPSRが採択されて && そのタイトルが「自分が触れるものに関わってきそう!!」と興味を持ちました。思考の整理がてら情報のシェアしてみたいと思います。

PSR-18: HTTP Client についてです。

PSRを知っておくと良いことがある(かも?)

コネヒトではサーバーサイドの主たる言語としてPHPを利用しています。常に「良いやり方」を模索する日々ですが、個人的にはその軸として「言語を知る」「プラクティスを知る」「トレンドや行く先を知る」があると整理しています。
今日のPHPコミュニティにおける影響力の巨人として「PSR」があります。ここで語られている内容というのは、「トレンドを知る」ための手段として良質なものであると言って差し支えないでしょう*1

そして、今回新しく採択されたPSRのタイトルが「HTTP Client」です。

HTTP Clientといえば、外部サービスを叩いたりMicro Services文脈で通信を行う時にも、極めて重要な領域と言えると思います。実際、私がこれまで見てきたり書いてきたアプリケーションコードでもHTTP Clientは色々なタイミングで出てきました。恐らく、これからもお世話になり続けるであろう確信があります。
そんな「HTTP Clientの実装についての指針」が出たのかい!?ということで、PSR-18に興味を持って調べてみた次第です。

なお、用語が揺らぎそうなので本記事では以下のように使い分けていきたいと思います

  • request/response: PSR-7のRequestInterface ResponseInterface を実装したクラス
  • 外部サービス: HTTPによる通信を行う相手
  • リクエスト/レスポンス: (外部サービスとの)HTTP通信

ざっくりいうと?〜PSR-18、3行まとめ〜

PSR-7で「HTTPメッセージオブジェクトはどういう風に作られるべきか」という内容が定義されましたが、その中では「リクエストの送り方」「レスポンスの返し方」というHTTPクライアントの実装については触れられていません。*2
PSR-18が担うのは、その領域です。*3

  • ClientはPSR-7準拠の request を引数にリクエストを行う sendRequestメソッドを実装し、それはPSR-7準拠のresponseを返すこと (MUST)
  • sendRequest() の内部で、引数もしくは通信結果として受け取ったrequest/responseを改変して通信や返答をしても良い(MAY)。その場合、必ず改変した内容にあわせて内部の整合性を保つこと(MUST)
  • 例外を投げていいのは「ネットワークエラー」「引数$requestの内容不正」に関するものに限定され、「レスポンスは受信できたがその内容が4xx/5xx」といったケースは例外を投げてはならない(MUST NOT)

背景; なぜ「HTTP Client」のStandardが必要か?

Standards は、「あると便利」もしくは「無いとどっかしらで困る」からこそ策定されていくものだと思います。では「HTTP ClientのStandard」を求めるモチベーションはどこにあるのでしょうか?

PSRのメタドキュメント及びphp-figのMedium記事を見ると、その背景をうかがうことができます。

www.php-fig.org medium.com

詳細はこれらのドキュメントを読んでいただきたいのですが、このMedium記事から参照されているHTTPlugのブログ記事が簡潔にポイントを突いていると思いましたので引用します。

sagikazarmark.hu

The main goal is to let developers build HTTP-based libraries without relying on an actual implementation, thus avoiding the vendor lock-in. It can also prevent conflicts between libraries, since the user only need to install one HTTP Client and not one for each library (eg. Guzzle 5 and 6).

ざっくり言えば、

  • 「HTTPリクエストを飛ばして情報を得る・作用をする」という要求って非常に多いよね
  • 結果として色々なライブラリが「通信処理」を持つことになるよ
    • 例えば「Twitter SDK」「Slack SDK」「AWS SDK」を使うアプリケーション、みたいな・・
  • それぞれのライブラリが「自分が使いたいHTTPクライアントを1つ選ぶ」という選択肢をとった場合、互いの依存ライブラリやバージョンのコンフリクトが生じて・・・

という不便さへの課題意識です。
(eg. Guzzle 5 and 6)と名指しでの例示がされていますが、これは「メジャーバージョンの変更時に破壊的な変更が行われた」ことにより「Guzzle5に依存しているライブラリと6に依存するライブラリが共存できなくなった」問題を想起しています。
例えば、探してみると以下の様なIssueを見つけることができます。

github.com

Mediumの記事で言及されている通り、PSR-18はHTTPlugのチームにより持ち込まれたものです。なので、HTTPlugと本PSRはその思想において合致しています。*4*5

そのため、HTTPlugのサイトにあるビジョンを援用することが「PSR-18の描く理想」をイメージするための手助けとなるのではないでしょうか。これを以て、「なぜStandardが必要なのか」の説明と代えさせていただきます。

When all packages used in an application only specify HTTPlug, the application developers can choose the client that best fits their project and use the same client with all packages.

(http://docs.php-http.org/en/latest/httplug/introduction.html#httplug-http-client-abstraction)

前提; 語の整理 && PSR-18の狙い

それでは、ここから具体的にPSR-18のドキュメント本体の内容について見ていきます。

PSR-18のページには、 「Definitions」というセクションが設けられています。用語の定義です。これはPSR-6及びその関連であるPSR-16には見られたやり方なのですが、珍しいですね。
以下のようになっています

  • Client : PSR-7互換のメッセージオブジェクト(HTTP Request messages)の送信を行うためのライブラリで、Calling LibraryにPSR-7互換のメッセージオブジェクト(HTTP Response message)を返却する
  • Calling Library : Clientを使役する何らかのコードやライブラリ。(このPSR上でインターフェイス等が定義されるものではない)

こうした前提をおいた上で、「Goal」のセクションに挙げれている内容を読んでみました。

PSR-18の狙いは、「HTTP Clientの実装と(それを使役する)ライブラリの実装を切り離そう」「それによってライブラリ側の依存を少なくできるし、利用するパッケージのバージョンのコンフリクトも減るはずだし、つまり再利用性が高まる!」というところにあると説明されています。 また、実装されたHTTP Clientが「リスコフの置換原則に則って相互に交換が可能になること」も A second goal として言及されています。

具体的には、「Calling Library側が委譲を行いClientを使役する」といった利用例などが思い浮かびます。
ものすごく簡単にですが、次のようなイメージです

<?php

use Bulldog\HttpFactory\FactoryBuilder;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ResponseInterface;

class ExampleApi
{
    private const SERVICE_BASE_URL = 'https://example.com/';

    /** @var ClientInterface **/
    private $http;

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

    /**
      * Get specific article resource by $articleId
      *  
      * @param int $articleId the id of target article
      * @return ResponseInterface Response object of the article
      */
    public function getArticle(int $articleId): ResponseInterface
    {
        $targetUrl = self::SERVICE_BASE_URL . "article/{$articleId}";
        $request = FactoryBuilder::get('zend')
                ->requestFactory()
                ->createRequest('GET', $targetUrl);

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

ここで、 $this->http に注入されるオブジェクトは「互換性があるものなら何でも、そこら辺にあるやつをご自由に!」となるわけです。

実装; Clientについて

Clientの実装は、 ClientInterface によって定義されています。
これは、

<?php

interface ClientInterface
{
    public function sendRequest(RequestInterface $request): ResponseInterface;
}

というシグネチャのAPIを持つ、非常にシンプルなInterfaceです。つまり、PSR7のHTTP Messageのインスタンスを受け取り→返す、というメッセージングをするものです。

その役割としては、

  • sendReuqst()メソッドの中で、引数 $request の内容を変更することは可能
  • 同じく、受け取った送信結果となる内容を変更することは可能

となっています。例えば、「form-urlencodedで受け取った内容をjson化して外部サービスに送信する」「リクエストヘッダーにJWTを付与する」「外部サービスから受信した圧縮済みコンテンツを、解凍してから返却する」などの操作は許容されます。
ただし、その際に 内部情報の整合性が崩れてはいけない(MUST NOT) とされています。すなわち、「デシリアライズしてデータ量が変わったならContent-Lengthを変えましょう」「コンテンツの種別が変更されたならContent-Typeも合わせましょう」といった内容です。

また、注意点として「PSR-7 HTTP Messageは不変オブジェクトなので、sendRequest()に渡したものと例外から取り出したもの(RequestExceptionInterface::getRequest()、後述)が同一である事を前提としてはいけない → ===で比較するのは不可能である」ということも補足されています。

もう1つの要件としては、

  • Calling Libraryに返却されるべきはステータスコードが200かそれ以上のレスポンスに限る
    • 100番台の内容は、リアセンブルによって解消されなければならない

とされています。これにより、Calling Libraryは「通信が完了しているものが返却される」という前提を得ることができます。

実装; エラーハンドリングについて

PSR-18のエラーハンドリングに関する指示は特徴的で、私が眺めているTwitterでは「Clientの定義よりも例外の扱い方の定義がなされた事にインパクトがありそう」といった声もチラホラと見受けられました。

  • ClientExceptionInterface という基幹クラスと、それを継承する RequestExceptionInterface NetworkExceptionInterface の合計3つのExceptionが定義される
    • RequestExceptionInterface: Clientに渡されたrequestオブジェクトが適切な状態にあらず、リクエストの実行自体が行えなかった場合に利用する
    • NetworkExceptionInterface: タイムアウト等、ネットワークマターの理由でリクエストの実行が完遂されなかった場合に利用する
  • RequestExceptionInterface NetworkExceptionInterface は、それぞれ public function getRequest(): RequestInterface; というAPIを実装する
    • 先に言及した The request object MAY be a different object from the one passed to ClientInterface::sendRequest() というコメントが、メソッド上にコメントされています
  • request/responseが形式的に正しいなら例外を発生させてはならない(MUST NOT)
    • 例えば「host情報の書き込まれていないrequest」は形式的なNGなので例外で表現される
    • 正しい形式のレスポンスが得られているのに、その内容を解釈して例外とするのはNG
      Calling Libraryには、正常通りにresponseオブジェクトを返さなければならない(MUST)(

とりわけ、最後に触れた「形式的にOKなら例外だめ」は注目すべき点だと思います。すなわち、「相手サービス的にエラー(4xx/5xx)だった時も、Clientはちゃんとresponseを返すんだよ」という事を意味します。

大事なことなので原文も引っ張っておきます。

A Client MUST NOT treat a well-formed HTTP request or HTTP response as an error condition. For example, response status codes in the 400 and 500 range MUST NOT cause an exception and MUST be returned to the Calling Library as normal.

これが明言されたことは、実装時の悩みを軽減してくれるのではないでしょうか。通信して得られた内容についての「良いか悪いか」は完全にCalling Libraryの責務となるため、「使うライブラリによってはどこで処理が中断されるかわからない!」という恐怖もなくなります。

感想

ここまで丁寧にPSRを読んで見る〜〜というのは今回が初めてだったのですが、「何故この領域でStandardが必要になるのか」を歴史をもとに考えていくのは刺激的だなぁと思いました。
また、これに合わせたPHP-FIGのサイトの改修により*6、「HTTP-related」なPSRの関連を見出しやすくなりました。PSR-7を源泉として、また新たなPSRが登場してくるのでしょうか。
新しい動向がでてきたら、また雑学や教養程度にでもウォッチしていきたいなーと思います。

参考リンク

本文中に登場したものも含め、PSR-18の狙いや背景にある思想・歴史を知るために参考にしたブログ記事・ページのURLを列挙します。

*1:大前提として、PSRは「必ずしも妄信的に付き従わなければならない対象ではない」とは考えています。実際、団体の成り立ちからして、「PHP Standards Group」という旧称を刷新して今の「PHP-FIG」へと鞍替えされたものです。あくまで「フレームワーク作るときにいい感じになると良いよね」グループです。個人的に、コチラの記事で語られている内容やスタンスが非常に受け入れやすかったので、ぜひ御覧ください https://qiita.com/tadsan/items/942a381e952e12a8fa5a

*2:PSRには、「HTTPオブジェクトの作り方」を規定するPSR-17や、「Server機能としてどのようにHTTPオブジェクトを扱うべきか」を規定するPSR-15といった、PSR-7を前提とした取り決めが存在します。

*3:Thanks to PSR-7 we know how HTTP requests and responses ideally look, but nothing defines how a request should be sent and a response received. https://www.php-fig.org/psr/psr-18/meta/

*4:The small team of young software developers were now a large team of not-so-young software developers. They decided to bring HTTPlug to the PHP-FIG as a proof of concept and saying:

Look, the community think this is great and the interfaces really work. Should we make a PSR of this?

*5:One major goal of the PSR was that it should be compatible with HTTPlug. Libraries that uses the HTTPlug interface should have a real smooth upgrade path. It should be possible to execute this upgrade path in a minor version.

*6:https://twitter.com/phpfig/status/1057302372354064386