Rails: さようならRack::BodyProxy、こんにちはrack.response_finished(翻訳)
今やrack.response_finished
が私の心の友です。
🔗 Rackは想像以上に奥深い
Rackについて少しでもご存知であれば、おそらく以下のような書き方を見たことがあるでしょう。
# config.ru
class Application
def call(env)
[200, {}, ["Hello Rack"]]
end
end
run Application.new
このアプリケーションは、environment
引数を1個持つ#call
に応答し、status
、headers
、body
を要素として含む配列を返しています。どの概念も全然難しくありませんよね?status
は整数、environment
とレスポンスのheaders
はハッシュ、body
は文字列の配列です。
これはRackアプリケーションとして有効ですが、話はまだ終わりません。この全体像を把握するには、Rackの仕様を読む必要があります。
本記事では、Rackのbody
の仕様に注目します。要件は時とともに進化しますが、Rackのごく初期1からまったく変わっていない、「enumerableなbodyは#each
メソッドに応答して文字列をyield
しなければならない」という仕様が存在します。
つまり、アプリケーションでレスポンス全体をメモリにバッファしたくない場合は、bodyを以下のように実装できるということです。
# config.ru
class Body
def each
yield "Hello Rack"
end
end
class Application
def call(env)
[200, {}, Body.new]
end
end
run Application.new
ここから話が複雑になってきます。
上の「文字列の配列」の例では、bodyの生成後にミドルウェアが何かを実行するのは簡単です。
class LoggerMiddleware
def call(env)
response = @app.call(env)
logger.info "Request processed!"
response
end
end
しかしBody
クラスの例では、Webサーバーでbodyに対して#each
が呼び出されるまで、bodyの内容は生成されません。この呼び出しがミドルウェアの#call
呼び出しが返された後で行われるのであれば、ミドルウェアは処理後に何をしてやれるでしょうか?
🔗 BodyProxy
の登場
ありがたいことに、Rackの仕様にはこんなフックも含まれています。
bodyが
#close
に応答する場合、これはイテレーションの後で呼び出される。
ここでProxy
オブジェクトが登場します。Proxy
オブジェクトは、bodyに対する#close
呼び出しをインターセプトして、bodyのイテレーション後にミドルウェアが何らかの処理を行う機会を提供します。
RackでProxy
クラスが最初に導入された理由は、enumerableなbodyがイテレーションされる前にRack::Lock
がアンロックされてしまう問題を修正するためでした。それから間もなく、この機能がRack::BodyProxy
クラスに切り出され、現在では広く用いられています。
class LoggerMiddleware
def call(env)
status, headers, body = @app.call(env)
body = Rack::BodyProxy.new(body) do
logger.info "Request processed!"
end
[status, headers, body]
end
end
🔗 BodyProxy
の困った点
BodyProxy
を使えば、レスポンスのbodyが生成された後でミドルウェアでの処理を可能にしますが、このソリューションは完璧とは言えません。
最も明らかな欠点は、ミドルウェアごとに独自のBodyProxy
をアロケーションしてしまうことです。ミドルウェアが増えてくると、レスポンスのbodyは最終的に以下のような感じになります。
BodyProxy.new(BodyProxy.new(BodyProxy.new(BodyProxy.new(["actual body"]))))
Rubyオブジェクトのアロケーションは高速化されつつあるとはいえ、今もパフォーマンスのボトルネックになりがちです。アロケーションの数だけガベージコレクタの作業が増えるため、アプリケーションが遅くなります。アロケーションを完全に回避できる代替案が欲しいところです。
もうひとつの問題は、BodyProxy
がリクエストのライフサイクルでタスクを実行するタイミングが早すぎるために、特定のタスクを実行できなくなる可能性があることです。GitHubのブログ記事でも、メトリクスを発行するときにRack::Events
を利用できなかった(BodyProxy
が使われていて、メトリクスの発行が完了するまでページの読み込みが完了しないように見えるため)ことについて言及しています。
Shopifyでもこれと同様の問題が発生しました。メトリクスを発行している間、PitchforkというWebサーバーがリバースプロキシへの接続を開きっぱなしにするため、オープン中のプロキシ接続数が増えてパフォーマンスが悪化したのです。
この問題に対するGitHub(と私たちShopify)のソリューションは、メトリクスを発行するタイミングをBodyProxy
よりも後にすることで、コネクションが完全にクローズするようにすることです。その方法こそ、rack.after_reply
です。
🔗 友情が生まれた
rack.after_reply
が2011年にPumaに追加されたのは、BodyProxy
のすぐ後のことでした。rack.after_reply
は、リクエストのenvironment
に保存された呼び出し可能なオブジェクトのシンプルな配列で、レスポンスのbodyがclose
した後で呼び出されます。
この機能はその後Unicornにも追加され(673c15e)、さらにrack.response_finished
としてRack 3の仕様にオプションとして組み入れられました(856c4f9)。
Webサーバーは、このrack.response_finished
をサポートしていることを、リクエストのenvironment
に含めることで示せます。ミドルウェアは、コールバックを追加する形でコールバックを登録できます。
class LoggerMiddleware
def initialize
@callback = ->(env, status, headers, error) {
logger.info "Request processed!"
}
end
def call(env)
# ほら、アロケーションが発生しない!
if response_finished = env["rack.response_finished"]
response_finished << @callback
end
@app.call(env)
end
end
このコールバックは、「リクエストのenvironment
」「レスポンスのheaders
とstatus
」「error
」という4つの引数を受け取らなければなりません2。environment
は省略不可ですが、status
/headers
とerror
は相互排他なのでいずれか一方のみを受け取ります。
🔗 BodyProxy
がまだある理由
rack.response_finished
の採用は随分と遅れました。しかし、これは「卵が先かニワトリが先か」という問題そのものとも言えます。Webサーバーがこの機能を実装していないうちは、アプリケーションやフレームワークがこの機能を取り入れる理由はありません。そしてアプリケーションやフレームワークがこの機能をサポートしていないうちは、Webサーバーもこの機能を取り入れる理由がありません3。
Falconも、Rack 3のリリースを見越してrack.response_finished
を実装しました(10b8ade )が、Pitchforkが昨年rack.response_finished
を追加したとき(7ac65bb)まで、Rack 3仕様に基づいた実装はFalconしかありませんでした。
しかし、Railsで「構造化イベントレポーター」機能のプルリク(#55334)がオープンされると、 ついにrack.response_finished
が勢いづき始めました。
既に述べたように、Shopifyではメトリクスの発行をrack.after_reply
や rack.response_finished
のコールバック内で行うようにすることで、レスポンスの送信後に接続を不必要にオープンしたままにしないようにしています。同じ理由で、リクエストの概要も(イベントレポーターを用いて)ここでログに出力しています。
これがきっかけで、イベントレポーターを本家Railsにアップストリームする際に、ある興味深い課題が発覚しました。イベントレポーターのcontext
は、リクエスト間で漏洩しないよう、その都度クリアされなければなりませんが、既存のリクエスト分離メカニズム(ActionDispatch::Executor
ミドルウェア)でBodyProxy
が使われていたのです。BodyProxy
が使われているということは、context
がクリアされるタイミングが、rack.response_finished
でリクエストの概要をログ出力可能になるよりも「前」になるということです。
この部分を期待通りに動かすため、同僚のAdriannaと私がrack.response_finished
をActionDispatch::Executor
に追加しました(#55425)。
Executor
はこれによって、イベントレポーターのcontext
がrack.response_finished
によってリクエストの合間のタイミングでクリアされるようになり、リクエストサマリーのログ出力からイベントレポーターにアクセス可能になりました!
🔗 こうしてrack.response_finished
が心の友となった
ActionDispatch::Executor
でrack.response_finished
を実装して以来、アプリケーションで使われている他のRackミドルウェアで動いていたBodyProxy
を置き換える機会を伺っていました。
Rack 3.2でもBodyProxy
がさらに削減される予定で、Rack::ConditionalGet
とRack::Head
はどちらもBodyProxy
を使わなくなります。
また、Rack::TempfileReaper
(#2363、マージ済み)とActiveSupport::Cache::Strategy::LocalCache
(#55447、オープン中)をサポートするプルリクもオープンしました。
最後に、Pumaでも最近rack.response_finished
のサポートがマージされました(1b08ed7)。
この調子でrack.response_finished
の勢いが続けば、Rack::BodyProxy
とのお付き合いも終わりを迎えることでしょう。
関連記事
-
原注: 実は、Rackのこの要件がどのぐらい昔からあったのかに興味が湧いたので、git-spelunkで掘ってみたところ、
Rack::Lint
が最初に追加されたときに入ってきたことがわかりました。 ↩ -
原注: 「受け取らなければならない(MUST)」という文言は、実は最近になって仕様に追加されました(
-> {}
のような引数を受け取らないコールバックの定義を防ぐため)。 ↩ -
原注: 公平のために申し添えておくと、PumaもUnicornもPitchforkも既に
rack.after_reply
をサポートしていましたが、Rackの仕様の一部としてではありませんでした。 ↩
概要
CC BY-NC-SA 4.0 International Deedに基づいて翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: Rails と Rack - Railsガイド