時計を壊せ

駆け出してからそこそこ経ったWebプログラマーの雑記

Google Cloud Firestore datastore modeをいい感じに操作するやつを作った

Google Cloudにおいて、古くはApp Engine Datastore、その後にCloud Datastore、現在はCloud Firestore datastore modeという形で存在するものがある。 俗に単にDatastoreと呼ばれることが多いと思うので、このエントリでは単にDatastoreと呼ぶことにする。

知らない方向けに簡単に説明すると、このDatastoreはスキーマレスでキーバリューストアライクな構造を持つGoogle Cloudのマネージドなドキュメントデータベースになっている。いわゆるNoSQLデータベースの一種。

キーバリューストアとはいうが、インデックスを貼ることである程度柔軟な検索をすることが可能で、キーを分散させる*1ことによってよしなにスケールしてくれるデータベースとして楽に大きなトラフィックを捌くことができるデータベースになってくれる。*2

という感じで、そういった大量のトラフィックと戦うことが多い人にとっては便利なデータベース製品になっている。

しかし、DatastoreにはWebUIこそあるもののCLIツールが提供されていない。

これは地味に不便で、ちょっとしたことをするにもスクリプトを書いて書き換えたり、WebUIからぽちぽちしたりなどする必要がある。 データ量が多いとそもそもそんな簡単にはいかないのだが、開発中にデータ量を気にせずに簡単なデータマイグレーションをするようなケースでもそういうことを要求されるのでちょっと困る。

たとえば、いくつかの限られたデータを他の開発環境からコピーしてきたいようなときでも、そういうスクリプトをわざわざ書かなければならない。 地味に面倒くさい。

dsioというやつもある*3けど、これはマスターデータのインポートとちょっとしたCLIからのデータ参照に便利って感じのもので自分が求めていたものとは少し違った。詳細は本題から逸れ過ぎてしまうので割愛する。

そこで、そういう感じのことが簡単にできるツールを開発した。

github.com

もともと datastore-cli という名前で開発していたが、データストアとのio以外の機能もちょっと入れたくなってきたので dutil という名前に変更した。

dutil io というサブコマンドの下にDatastoreを操作するためのCLIインターフェースが実装されている。

たとえば、あるEntityをキーで指定して取得する場合には dutil io lookup を使う。

% dutil io lookup -p karupanerura 'key(Example, "key1")'
{"key":{"kind":"Example","name":"key1"},"properties":[{"type":"string","value":"string","name":"s","noIndex":true},{"type":"timestamp","value":"2024-02-23T02:23:00Z","name":"d","noIndex":true},{"type":"entity","value":[{"type":"string","value":"value","name":"key"}],"name":"e","noIndex":true},{"type":"float","value":0.5,"name":"f","noIndex":true},{"type":"geo","value":{"lat":0,"lng":0},"name":"g","noIndex":true},{"type":"key","value":{"kind":"Example","name":"key1"},"name":"k","noIndex":true},{"type":"array","value":[{"type":"string","value":"string"}],"name":"a"},{"type":"bool","value":true,"name":"b"},{"type":"string","value":"text\ntext\ntext","name":"t","noIndex":true},{"type":"int","value":123,"name":"i","noIndex":true},{"type":"null","value":null,"name":"n","noIndex":true}]}

このように、入出力はすべてJSON Linesに統一されているのでgrepやsedやjqなどと組み合わせることも簡単。 データ型も明示的なので、たとえばユーザーの自由入力が絡む場面で意図しない型で扱われてしまうといったこともない。

そして、これだけでも jq などで整形して見やすくすることができるが、それでもちょっと面倒なときは dutil io table でJSON形式をテキストテーブル形式に変換する機能も作った。 これは今日GoConで感化されてrange over funcのインターフェースに沿った感じで実装した。

% dutil io lookup -p karupanerura 'key(Example, "key1")' | dutil convert table
+---------+------+--------+------+-------------------------------+-------+-----+-----------------------------------+-----+---------------------+------+--------+------+
| Kind    | Name | a[0]   |    b |                             d | e.key |   f | g                                 | i   | k                   | n    | s      | t    |
+---------+------+--------+------+-------------------------------+-------+-----+-----------------------------------+-----+---------------------+------+--------+------+
| Example | key1 | string | true | 2024-02-23 02:23:00 +0000 UTC | value | 0.5 | geo(lat: 0.000000, lng: 0.000000) | 123 | KEY(Example,"key1") | NULL | string | text |
|         |      |        |      |                               |       |     |                                   |     |                     |      |        | text |
|         |      |        |      |                               |       |     |                                   |     |                     |      |        | text |
+---------+------+--------+------+-------------------------------+-------+-----+-----------------------------------+-----+---------------------+------+--------+------+

こんな感じで表示できる。

他にもGQLをそのまま投げられたりなど様々な機能がある。

そんなわけで、個人的には仕事の中で割と便利に使っています。

Datastoreを使っている皆様方におかれましてはぜひ使ってみて感想を聞かせていただけると嬉しいです。

*1:裏側でレンジシャーディング的なことが行われるので、シーケンシャルなアクセスが発生しにくいように分散させる。

*2:むかしはその代償として制約がかなり厳しかったが、最近はかなり緩和されて使いやすくなっている。

*3:正直、辿り着けなくて後から存在を知ったけど

YAPC::Hiroshima 2024が終わった

yapcjapan.org

終わった……。というのが率直な気持ち。 怪我や事故などの大きなトラブルなく終わることができて本当にホッとしている。

2023年度は仕事も忙しく、かつプライベートも色々あり、とにかく様々が立て続けにあってずっと頭がパンクしていたように思う。 ほかにも様々な状況などが重なった結果、今回はあまりまともにYAPCを手伝うことができなかった。

できたのが杜甫々(とほほ)さんとそーだいさんへの講演オファーをするくらいで、あとは多少の相談に乗ったりくらいで運営としての事前準備は今回は殆ど関われていない。 それでもこれだけ多くの人がYAPCを楽しんでもらえたのは、これを準備してきたコアスタッフの面々と他のJPA理事の面々のおかげだとおもう。本当にありがたい。

「段取り八分、仕事二分」とは言うが、その「二分」の部分だけでも貢献しようと当日はできる限りのことをした。

その結果、当日は様々に気を配り目を配りとしていたところあまり余裕がなく、そーだいさんのトークを聞きにいくことは出来なかったが、 杜甫々(とほほ)さんのキーノートだけはなんとか途中から直接聞くことができた。

これが、本当に面白かった。

他の方も書いていたけど、仕事の合間にやったとさらっと言っているその内容が本当に凄かった。 特に印象に残ったWin32 APIのインターフェースをLinux上に構築する話はWineの一部を自作しているようなもので、 仮にやろうと思ってもそう簡単にできるものではないと思うしこういうことをさらっと「やった」と言えてしまうのはかっこいいと思った。

他にも様々なエピソードを聞くことができた。

そして、最後の質疑応答の答えにあった「好きだから」という言葉は、それまでの流れもあって「『好きだから』ここまでできるんだよ」「『好きだから』続けると色々なものを積み上げられるかもしれないよ」というメッセージとしても自分には響いた。

YAPCという場にお招きできて、自らの言葉でこういったことを語ってもらえたのは自分にとってはもちろん、YAPC参加者もとい日本のPerlコミュニティ*1にとって大きな価値になったと思う。 もちろん、このブログとは別途、杜甫々さん本人にも直接お礼を伝えた。

まだ後始末は終わっていないし次のYAPCの実現に向けて動き出している部分もあり、 目まぐるしいなという感じではあるが無理なくできる範囲で今後もやっていこうと思う。

JPA*2ではYAPCを手伝いたい。自分がYAPCを作りたい。自分こそがYAPCだ。 そんな人々のちからを借りてYAPC::Japanの開催を実現しています。

少しでも興味が湧いた方がいらっしゃったら、自分のTwitter DMやJPAのメール窓口 info@perlassociation.org までぜひお気軽にご連絡ください。

*1:Perlを知ってる人も知らない人もYAPCに参加して楽しめるひとは仲間じゃんPerlコミュニティじゃんっていう感じ

*2:Japan Perl Association YAPCの主催組織

Mustache Templateの実装を書いた

この記事はPerl Advent Calendar 15日目の記事です。

qiita.com

さて

ということで、ひさしぶりにCPANizeしました。なんか早速bug fixが見つかって早々に0.02です。

metacpan.org

今のPCでは初めてのCPANizeだったようで ~/.pause がなくてちょっと焦った。

なんで?

人生色々。様々があります。 たとえば、やることに追われたり、悩ましい考え事に苛まれたり、やることに追われたり、やることに追われたり、様々があります。

一方でISUCONも迫っていました。PerlでISUCONに勝ちたいので日頃の仕事でなかなか書く機会が減ってしまったPerlを素振りしたくなってきます。 気分転換がてら、Perlでちまちまなんかを作ってみるのは良いかもしれないと思いました。

そんななか、趣味でプログラミングするのにちょうど良さそうな題材として、Mustache Templateが出てきました。

Template::Mustacheが1.0系になってめっちゃ遅くなったことに困っていそうな人をみかけたのです。 確かにめちゃくちゃ遅くなったのは知っていて、たしかに調べてみても代替モジュールによさげなのがなかったのです。

まあ簡単そうだしちょうどよいだろう。ミニマルなものを作っていい感じにしよう。と実装しながらMustache Templateの仕様を追っていきます。

すると、なんとも色々な仕様がでてくるではありませんか。

lambdaとか継承テンプレートとかmustacheで1回も使ったことないぞ!などとと思いましたが、それは自分はText::XslateやText::MicroTemplateを使っていたのでまあそれはそう。

まあ必要なひともいるんだろう、そうなるとこれもたぶんほしいんだろう、これを実装するならこれもまあ実装されていて然るべきだろう、などとうっかり色々実装していくと、あれよあれよと芋づる式に全部の仕様を実装してしまいました。

なんで????

実装

Lexerでトークンに分割して、ParserでAST*1を作り、Compilerでコンパイルするという王道な実装となっております。

Lexerといってもそんなに丁寧なものではなくて、素朴なLexerとしてはたとえば "foo{{bar}}" があったときに、これを ["foo", "{{", "bar", "}}"] と分割するのが一般的かと思います。 もうちょっとParser寄りな感じで、["foo", "{{bar}}"] という感じでタグの構文そのものだけ解釈してトークン分割をする実装にしました。

これにはちょっと理由があって、Mustacheって{{}}というデリミタでタグを構成するのがデフォルトなのですが、このデリミタを変更するタグが存在するので、Lexerはそれを解釈しないと正しくトークン分割ができないのです。すると、まあそこまでしてるのに {{bar}} を分ける必要もなかろうということで自ずとそれらを一緒のトークンにまとめて扱うことにしました。

そして、Parserはその一列のトークンを解釈してMustache Templateとしての文法の構造をデータ構造上の構造に構造化します。いわゆるASTというやつですね。 たとえば、Mustacheには{{#condition}}yes{{/condition}}みたいな複数のタグを使って1つの構造を作るみたいな文法をもっているので、これはAST上もこの分岐の子要素みたいな感じで整理してあげます。

ASTを使うとめっちゃ簡単にレンダリング処理が実装できます。 素朴にレンダリングする事もできると思いますが、今回はせっかくなのでキャッシュしたテンプレートの実行効率を高めるべくCompilerとして実装しようと思います。

Compilerってなんやねんとなると思いますが、これはText::MicroTemplateよろしくテンプレートに合わせたパターンの文字列を生成するPerlのコードを文字列で作り上げてevalすることで、事前に分岐などが最適化されたコードを作る操作を指しています。

Perlコードの生成時はASTを解釈して細かい分岐をたくさん辿りますが、生成されたPerlコードはある程度の分岐が事前に解決されたものになるので直にASTを毎度解釈するより高速に実行することができるという寸法です。

Apache::LogFormat::Compilerなど様々なCPANモジュールでこのアプローチは取られています。 なんなら、こういうコードを簡単に作るためのSub::Quoteってやつまであり、これはMooの実装で使われていたりもします。

ほか、render というテンプレートをパースして即レンダリングするというインターフェースもあるんですが、このケースではコンパイルしたものが1度実行したら廃棄される前提がありかつコンテキスト変数*2がコンパイル前にすでに決定しているという特徴があるので、コンテキスト変数をヒントにASTを最適化することが可能です。 ということで、そういう処理を挟んであり、うまくいくケースではこの最適化をやらない場合と比べて3倍くらい速くなるようになることがわかっています。

ほかにも、正規表現を最適化していたりなど、地味な高速化を測っている箇所がちょいちょいあったりします。

出来栄え

github.com

このテストケースをすべてPASSしています。やったね。 ついでにテストカバレッジもステートメントレベルでは100%*3で、ほかも90%台後半まで網羅してあります。

パフォーマンスも簡単なテンプレートをレンダリングするベンチマークで比較してみました。 Template::Mustacheに比べてパースの速度が10倍、レンダリングする速度がパース済のテンプレートをキャッシュしているケースで3倍、キャッシュしていないケースで40倍となることがわかっています。 やったね。

なお、Mustache::Simpleというモジュールがあるのですが、これはテストケースをpassしなかったので比較していません。(存在しない変数を無視せずにエラーにしてくる仕様非準拠な挙動だけ確認してあとは確認してない)

ということで、まあそれなりに実用に耐える品質ではあろうと思ったのでCPANnizeしておきました。

これを作る時間でISUCONの素振りすればよかったのでは?

ISUCONの素振りってまとまった時間が必要じゃないですか……。 これ細切れの微妙な時間でちまちま進めたのです。素振りもしたかった……。

そんなわけで

まあ、完成したのでもしご入用の方はお試し下さい。

*1:Abstract Syntax Tree

*2:テンプレートに与える変数

*3:普通は絶対にこのパスは通らないところとかは除いているのでちょっとズルかも

ISUCON13にPerlで挑んだ

この記事はPerl Advent Calendar 14日目の記事です。

qiita.com

チーム「銀河鉄道の昼」としてISUCON13にPerlで挑みました。 最終的に2台構成で最終スコア7083、最高スコアは1台構成での9381でした。

上位30チームが50000点弱以上なので、残念ながら惨敗と言って差し支えない結果です。 くやしい……。上位チームの皆さんおめでとうございます。

何をやったのか

なんやかんや、足回りを整えたりトラブルシューティングしたりで2時間くらい食ってたきがします。

StatsHandlerのN+1解消

順位を計算する処理があり、その順位とはtipの総額とリアクションの総数によって順位付けられていました。 この際のもとの実装は以下のような感じです。

    # ランク算出
    my $ranking = [];
    for my $livestream ($livestreams->@*) {
        my $reactions = $app->dbh->select_one(
            q[
                SELECT COUNT(*) FROM livestreams l
                INNER JOIN reactions r ON r.livestream_id = l.id
                WHERE l.id = ?
            ],
            $livestream->id
        );

        my $total_tips = $app->dbh->select_one(
            q[
                SELECT IFNULL(SUM(l2.tip), 0) FROM livestreams l
                INNER JOIN livecomments l2 ON l2.livestream_id = l.id
                WHERE l.id = ?
            ],
            $livestream->id
        );

        my $score = $reactions + $total_tips;
        push $ranking->@* => Isupipe::Entity::LivestreamRankingEntry->new(
            livestream_id => $livestream->id,
            score => $score,
        );
    }

    my @sorted_ranking = sort {
        if ($a->score == $b->score) {
            $a->livestream_id <=> $b->livestream_id;
        }
        else {
            $a->score <=> $b->score;
        }
    } $ranking->@*;

    $ranking = \@sorted_ranking;

    my $rank = 1;
    for (my $i = scalar $ranking->@* - 1; $i >= 0; $i--) {
        my $entry = $ranking->[$i];
        if ($entry->livestream_id == $livestream_id) {
            last;
        }
        $rank++;
    }

うーん、では中間テーブルを作りましょう。

RedisのSortedSetを使うことも考えましたが、scoreが同順であった場合はlivestream_idの昇順となるように実装されているためそのへんをうまくやれるだけのRedis力が僕にはなかったです。 (出来ないと思っていた。実はできるという話を聞いたけどどうやってやるのか知らないので知っているひとはコメントで教えてください。)

中間テーブルをこんな感じで作って

-- スコア
CREATE TABLE `user_scores` (
  `user_id` BIGINT NOT NULL PRIMARY KEY,
  `score` BIGINT NOT NULL,
  `user_name` VARCHAR(255) NOT NULL,
  INDEX ranking_idx (`score` DESC, `user_name` DESC)
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

CREATE TABLE `livestream_scores` (
  `livestream_id` BIGINT NOT NULL PRIMARY KEY,
  `score` BIGINT NOT NULL,
  INDEX ranking_idx (`score` DESC, `livestream_id` DESC)
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

更新時によしなに更新していくと、以下のような感じで順位がSQLだけで取得できるようになりました。

    $app->dbh->query('SET @r = 0');
    my $rank = $app->dbh->select_one(q!SELECT `rank` FROM (SELECT livestream_id, score, @r := @r+1 AS `rank` FROM livestream_scores ORDER BY score DESC, livestream_id DESC) a WHERE a.livestream_id = ?!, $livestream_id);

Window関数を使って DENSE_RANK() でなんとかする手もあるかもでしたが、うまくインデックスが効かないのでこういう感じにしました。

たしかまあまあスコアは上がった気がします。 しかし、なんやかんやハマって2~3時間くらいこれで溶かしてしまった気がします。

reservation slotsのチューニング

元々の実装はこんな感じでした。

    # 予約枠をみて、予約が可能か調べる
    # NOTE: 並列な予約のoverbooking防止にFOR UPDATEが必要
    my $slots = $app->dbh->select_all_as(
        'Isupipe::Entity::ReservationSlot',
        'SELECT * FROM reservation_slots WHERE start_at >= ? AND end_at <= ? FOR UPDATE',
        $params->{start_at},
        $params->{end_at},
    );

    for my $slot ($slots->@*) {
        my $count = $app->dbh->select_one(
            'SELECT slot FROM reservation_slots WHERE start_at = ? AND end_at = ?',
            $slot->start_at,
            $slot->end_at,
        );
        infof('%d ~ %d予約枠の残数 = %d', $slot->start_at, $slot->end_at, $slot->slot);
        if ($count < 1) {
            $c->halt(HTTP_BAD_REQUEST, sprintf("予約期間 %d ~ %dに対して、予約区間 %d ~ %dが予約できません", TERM_START_AT, TERM_END_AT, $params->{start_at}, $params->{end_at}));
        }
    }

この手のはロックする範囲をいかに絞れるかがキモっぽいという直感と、幸いにもslotは減ることはあっても増えることがない仕様であることがわかったため、 すでに空になっている予約枠を予め除外してロックを取ることでロックの競合をなるべく抑えるようにしてみました。

    # 予約枠をみて、予約が可能か調べる
    my %slots = @{
        $app->dbh->selectcol_arrayref(
            'SELECT id, slot FROM reservation_slots WHERE start_at >= ? AND end_at <= ?',
            { Columns => [ 1, 2 ] }, $params->{start_at}, $params->{end_at},
        )
    };
    if (any { $_ == 0 } values %slots) {
        $c->halt(HTTP_BAD_REQUEST, sprintf("予約期間 %d ~ %dに対して、予約区間 %d ~ %dが予約できません", TERM_START_AT, TERM_END_AT, $params->{start_at}, $params->{end_at}));
    }

    my $txn = $app->dbh->txn_scope;

    # NOTE: 並列な予約のoverbooking防止にFOR UPDATEが必要
    my $overed_slots = $app->dbh->select_one('SELECT COUNT(*) FROM reservation_slots WHERE id IN (?) AND slot = 0 FOR UPDATE', [keys %slots]);
    if ($overed_slots) {
        $c->halt(HTTP_BAD_REQUEST, sprintf("予約期間 %d ~ %dに対して、予約区間 %d ~ %dが予約できません", TERM_START_AT, TERM_END_AT, $params->{start_at}, $params->{end_at}));
    }

これも、たしかまあまあスコアは上がった気がします。

どうして複数台構成にした途端にスコアが落ちたのか

MySQLを2台目に追い出したのですが、どうやらその途端にDNS Serverのスループットが改善してDNS水責め攻撃の餌食となったようです。 2台構成が動いたのが終了10分前とかだったのでそこから元に戻すのは諦めました……。

次回に向けて

今回は素振りがほとんど出来なかったので素振りする時間をなんとか確保して、トラブルシューティングに費やす時間を減らし、チューニングの手札を増やしておきたいです。 今回はnginxでキャッシュさせるといったこともほとんど出来ず、またnginx luaを使えば楽にいきそうな部分もあったのでそういったこともできるようにしていきたい。

次回もPerlで挑む覚悟で、実装移植がなかったら自前でいちからコードを書いてでもPerlで戦おうと思います。

感想

移植はモダンなPerlの機能がふんだんに使われており、面白い実装でした。(Entityクラスは正直いらなかった気がしますが……。) 問題もボリューム満点で面白く、素朴なチューニングでスコアがちゃんと上がるような問題に仕上がっていてスコアが上がる楽しさも感じました。 運営の皆さんには感謝の気持ちでいっぱいです。ありがとうございました。

Linux以外ではDockerでIPv6が扱えないので簡易なTCP Reverse Proxy Serverを書いた

まずは公式ドキュメントをご覧ください。

docs.docker.com

IPv6 is only supported on Docker daemons running on Linux hosts.

残念!

Docker Desktop for Macなどでローカル開発をしているときに、ローカルで立ち上げたプロセスからDocker内にあるコンテナに通信したいことは割りとよくあるユースケースだと思う。

こういうときは、基本的には宛先をIPv4のLoopback Addressである127.0.0.1に向けてあげて、 IPv6を使わないようにしてあげるとよい。 localhostを使ってしまうと、名前解決でIPv6のLoopback Addressに名前解決されるケースがあり、そうなればIPv6に対して接続しようとしてしかしIPv4でしかlisten(2)されていないのでコケる。

しかし、世の中には親切(?)にも127.0.0.1localhostに正規化するようなコードが存在する。*1 困る。

ローカルの問題なので/etc/hostsを手で書き換えてlocalhostからIPv6に解決されないようにすればOK!という感じではあるのだが、たとえばチームで開発する基盤としてのローカル開発環境だとしてそれをチームメンバーに強いるにはトラブルの元になりそうでちょっともにょる。 なにより、プロジェクトローカルな事情で環境の設定を変えてしまうのは目的に対してtoo muchな策であり、副作用が大きくあんまり良い解決策とはいえないだろう。

では、nginxのTCP Reverse Proxy Server機能をつかってIPv6で待ち受けたやつをIPv4にProxyしてあげればよかろう。 と思いきや、nginxはDockerで建てるには楽だがローカルで建てるにはだるい!

ということで、Perlさえあればどこでも動く、簡易なTCP Reverse Proxy Serverを書きました。 PerlはだいたいのOSに最初から入ってるのでお手軽に使えるし、コピーしてプロジェクトのリポジトリに同梱しちゃえば各環境でのインストールも不要。お手軽!

github.com

簡易、というのは、素直なTCP Reverse Proxyとは異なりTCPパケットレベルでProxyしているわけではなくて、いったんTCPのペイロードを受け取ってからそれをまたTCPで流すということをやっているため。 たとえば、ACKが返ってきたからといって、それがReverse Proxyされた先でもACKされているとは限らないので、厳密にはL4 Proxyと言ってはいけない代物ではある。が、おそらく実用上はそんなに問題はないでしょう。というか、SOCK_RAWで頑張るには目的に対して大変すぎる……。

ニッチなケースで役に立つと思うのでお試しください。

裏話

もともと、これをIO::Socket::IPで書いて、IO::Socket::INETと違ってIO::Socket::IPではIPv4もIPv6も使えて便利!というLTをShonan.pm #1でやったのですが、実際にはIO::Socket::IPでpeeraddrやsockaddrなどをうまく取得できず(undefになる)、あきらめて標準関数とHash::Util::FieldHashを使って実装しなおしたという微妙な経緯があります。(そんなわけで。Shonan.pm #1のLT資料は動くでしょうって前提の間違いが微妙に散りばめられており、公開しておりません。あしからず。)

FAQ(追記)

ncでいいのでは?

2つの課題があって、ひとつはncではこれと同等のことを実現することは実はあんまり楽ではないこと。 たとえば、proxy modeを使うにしても送信側のproxy対応が必要になって(アプリケーションが小さければ楽かもしれないが少なくとも自分の関わっていたプロダクトでは対応箇所がどうしても多くなってしまって)面倒で、具体的にはhttpではなくgrpcなやつとかもいたりするから単にHTTP_PROXY環境変数を入れておしまいとはならないからsocks proxyを使わねばならず、まあ面倒。 nc -l とかを繋げたワンライナーっぽいので素朴にやることも考えられるが、複数のリクエストをそれも並行して扱えないとあんまり意味がない場面(まあまああると思う)ではまともにつかえないのでこれもやはり無し。

もうひとつは移植性の問題があるのでスクリプトみたいな形にまとめて使いづらいこと。

*1:厳密にはそうではないかもしれないが、外形的な挙動としてはそのような挙動に見えるケースに当たったことがある。