PHP GuzzleでのAsyncリクエストの挙動

先月ISUCON9の予選に参加して予選突破しました。なんとかギリギリの繰り上がりでしたが、その話はまた別途記事を書くとして。ここまで書いて本戦前日まで公開を忘れてた。 外部のサーバーへのHTTPリクエストを非同期化したいところがあったんですが、競技時間内にどうしてもうまく実装できなかったのでGuzzleのAsyncリクエスト周りの挙動とコードを追ってみたので雑にまとめます。

guzzle

PHPデファクトスタンダードなHTTPリクエストライブラリ。使ってみた記事とかいっぱいあるので詳細はgoogleで。

Asyncリクエス

Guzzle6から?もうちょっと前から?Asyncリクエストがサポートされるようになった。curl_multi_execのwrapperだと思っている。 PHPでどうやって非同期処理してるの?と思ってcurl_multi_execの実装を読むと、どうやら非同期に処理してるというよりはcurlリクエストのまとまりを裏側で順番に実行して結果をまとめて返してるように見えた。 なので非同期というよりは並行リクエストだな、と思っている。けど、C力が低いので誤解してるかもしれない。理解が間違ってたら教えてください。

GuzzleではHTTPのリクエスト周りを担うパッケージ(guzzle)と、非同期処理のパッケージ(promise)が分割されている。PromiseはJSにあるものと同じ感覚で使えるようになってると思う。 guzzleには getAsync, postAsync のような非同期リクエスト行うためのメソッドと、 get, post のような同期リクエストを行うメソッドがある。 Clientを見れば分かるけど、get, post などの同期リクエストメソッドでもPromiseが使われており、内部でwaitして同期的に処理している。

Async処理のFulfilled, Rejected

guzzleからpromiseを使うときは以下のようなコードになる。

$fulfilled = function (ResponseInterface $response) {};
$rejected = function (ClientException $exception) {};

$promise = $client->getAsync('url', $options);
$promise->then($fulfilled, $rejected);
$promise->wait();

実際にリクエストが発行されるのは

$promise->wait();

が実行されたときになる。JSのようにPromiseが作られた時点でリクエストして欲しいけどそれは無理で、なぜかというと

非同期に処理してるというよりはcurlリクエストのまとまりを裏側で順番に実行して結果をまとめて返してる

となっているから。

これはブロッキングな処理なので、実際にリクエストを送信する前にどのリクエストを同時に送信したいかを定義する必要がある。

複数のリクエストを並行で送信するには Promiseの中で定義されてる all 関数を呼ぶのが楽。

$p1 = ... // Promise
$p2 = ... // Promise
$p3 = ... // Promise
GuzzleHttp\Promise\all([$p1, $p2, $p3])->wait();

という感じでpromiseの配列を渡すとすべての処理が完了されるまで待ってくれる。 内部でEachPromiseに変換して処理される。

Promise::then は第1引数に完了時の処理(onFulfilled)、第2引数に失敗時の処理(onRejected)を渡すことができる。 promise->resolve(), promise->reject() を呼ぶと叩かれる。

だけど、guzzleの場合はHTTPリクエストが成功以外(status >= 400)を返したときにデフォルトでは onRejectedは呼ばれない。代わりに、waitの中から ClientException の例外が投げられる。 onRejected を叩かせるためには wait の引数にfalseを渡してwrapped promiseとして実行する必要がある。

この辺りの挙動はREADMEを読むとよい。

github.com

When synchronously waiting on a promise, you are joining the state of the promise into the current state of execution (i.e., return the value of the promise if it was fulfilled or throw an exception if it was rejected). This is called "unwrapping" the promise. Waiting on a promise will by default unwrap the promise state. You can force a promise to resolve and not unwrap the state of the promise by passing false to the first argument of the wait function: ...

JSだと >= 400 のステータスが返ってくるとcatchの方に入ってくれていてguzzleでもそのノリを期待してたんですが、この挙動を知らなかったのでちっともエラーハンドリングできなくて困りました。 複数のpromiseをまとめて処理したときに途中のリクエストがエラーを返すとそのタイミングで例外が投げられるのですべてのリクエストのレスポンスをハンドリングできません。 例えば、Httpリクエストを送信するpromiseを3つ実行してそれぞれが下のようなステータスのレスポンスだったとします。 promise1: 200 promise2: 400 promise3: 200

promise1は200なのでonFulfilledが実行されますが、promise2が400なので例外が投げられ、promise3のonFulfilledは実行されません。 なので、エラーがあった場合に例外を投げずに処理を継続したい場合は wait にfalseを渡して wrapped promiseとして実行する必要があります。

そうしないで実行すると、wait メソッド内から例外が投げられるので、そちらで例外をキャッチする必要がある、ということでした。

なお、fulfilledに渡した関数からそれぞれのレスポンスを抽出しようと思うとuseに参照渡しなどして取得する必要がありそう。

いまいち理解できていないところがちょいちょいあるけど、とりあえずそんな感じで。