最近気になるライブラリ「Rack」でミドルウェアを作った話

塩井美咲氏(以下、塩井):すみません、なんの脈絡もなくRackの話をしようとしています。他意はありません。

(会場笑)

先にお伝えしておきたいことがあって、今回、発表資料の枚数がだいたい60枚ぐらいあります。私はもともとすごく早口なんですけど、今日はちょっと自分の限界に挑戦してみたいと思っています(笑)。よろしくお願いします。詳細な資料についてはのちほど共有します。

私はしおいといいます。普段はこの辺り(Asakusa.rb、Tama.rb、Fukuoka.rb)のコミュニティにいます。最近気になるライブラリは「Rack」。ということで、今日はそのお話をします。

さっそく内容に入りますが、要はRackミドルウェアを作りましたというお話をしたいと思っています。なぜ作ったのか、どんなものができたのか、そしてその実装と、あとはそれが実際に役に立ったのかについてお話しします。

レスポンスヘッダの変化をターミナルに表示するミドルウェアを作る

最初は目的と成果物について。まずイメージ図を見ていただきたいのですが。これはよくあるRackの機構を表したイメージ図で、一番下の赤い枠に囲まれているものがRackアプリケーション。それを取り囲むようにして、Rackミドルウェアが入れ子状に積み重なっている。それぞれのRackミドルウェアが、それぞれの機能を追加しているイメージだと思ってもらえるといいかと思います。この入れ子構造が、全体で1つのWebアプリケーションになっています。

Webアプリケーションにリクエストを送信したあと、レスポンスが返ってくると思うんですが、Rackミドルウェアによってはこのときにレスポンスヘッダに変更を加えている場合があります。このRackミドルウェアを通す前後で、レスポンスヘッダにどういった変更があったのかを調べたいと考えました。例えば、このBのミドルウェアを通すときに、どんな変更が加わったのか、などです。

そこで作ったのが、レスポンスヘッダの変化をターミナルに表示するためのRackミドルウェア「TraceHeader」です。使い方は、これはRailsの場合ですが、insert_beforeを使って調べてみたいミドルウェアの直前に追加します。そうすると、アプリケーションにアクセスが発生するたびに、レスポンスヘッダの差分がターミナルに表示されます。

RackアプリケーションとRackミドルウェアによるcallの呼び出し

では、実装について触れていきます。前提をまず共有したいんですけれども、RackアプリケーションとRackミドルウェアは、共通の規格に沿って実装されています。その規格とは、実行環境を引数にとるインスタンスメソッド「call」を実装していることです。

Rackアプリケーションの場合、このcallメソッドが呼ばれたときに、ステータスコード、ヘッダ、ボディの3つの要素を持つ配列を返します。

Rackミドルウェアの場合は、一般的に@appというインスタンス変数を持っていて、この中には、自分の内側にあるクラスのインスタンスが入っています。callメソッドが呼ばれると、このcallメソッドの中で、(@appに代入されている)内側のクラスのインスタンスに対して、さらにcallを呼ぶ、ということをやっています。

もう少し詳しく見ていくと、インスタンス化するときに、自分の内側にあるクラスのインスタンスを引数の中に入れて、それがそのまま@appの中に代入されています。callメソッドが呼ばれると、この内側のクラスのインスタンスに対して連鎖的にcallが呼ばれていき、最終的に一番内側のRackアプリケーションにたどり着いて、ステータスコード、ヘッダ、ボディの配列を返します。

ここからわかることは、Rackミドルウェアは共通の規格に沿って実装されているので、どのミドルウェアに対してどの時点でcallを呼んだとしても、最終的に必ず「ステータスコード 、ヘッダ、ボディ」という同じ形の配列が返ってくるということです。

そこで立てた仮説です。対象のミドルウェア、今回の例はBのミドルウェアですが、Bのミドルウェアとその直前に呼ばれているCのミドルウェアに対してそれぞれcallメソッドを呼べば、それぞれのcallメソッド呼び出しに対して「ステータスコード 、ヘッダ、ボディ」の形の配列が返ってくるはずなので、その配列同士を比較することによって差分が取れるのではないか、と考えました。

ただ、これにはちょっと課題があります。Webアプリケーションは、リクエストが来たときに一番外側のミドルウェアから順番にcallメソッドが呼ばれていき、一番内側ののRackアプリケーションまで一気に到達してレスポンスを返すため、途中で処理を挟むことができません。つまり、BのミドルウェアとCミドルウェアの間で処理を止められないということです。

そこで、TracePointです。TracePointの詳細については割愛しますが、簡単に説明すると、プログラムを実行しているときに起こるイベント、例えばメソッドの呼び出しやメソッドのリターンなどといったタイミングに、いろいろな処理を挟み込めるRubyの標準ライブラリです。

TraceHeaderクラスとResultクラス、Describableモジュールの役割

では実際に、TraceHeaderの実装について紹介します。まずは内部の構造についてです。2つのクラスと、1つのモジュールを実装しています。

まずクラスのほうには、TraceHeaderクラスとResultクラスがあります。

TraceHeaderクラスでは、TracePointを使用して、自分よりも内側にあるすべてのミドルウェアの情報を収集します。TraceHeaderミドルウェア自身はアプリケーションに変更を加えないので、自分よりも後に実行されるミドルウェアに対して、自分の1つ前のミドルウェアの返り値をそのまま渡します。これは後続の処理のために必要な処理です。

Resultクラスは、集めた情報の中から、ターゲットのミドルウェアとその直前に実行されているミドルウェアのインスタンスだけを抽出して、それぞれcallを実行して返り値を比較し、その差分を返します。

Describableモジュールについてはターミナル出力時の文字列の体裁を整えているだけなので、詳細は割愛します。

処理の流れは、この図のようなイメージです。TraceHeaderミドルウェアのインスタンスに対してcallが呼ばれると、このcallメソッドの中で、ターゲットのミドルウェアに対するcallメソッドの呼び出しを行います。

この処理をフックにしてTracePointが実行されます。これによって、自分よりも前に実行されているすべてのミドルウェアの情報を収集し、その中から調べたいミドルウェアのインスタンスだけを抽出、callを呼んで返り値を比較して差分を取得する、と。取得した差分については、outputメソッドで、ターミナルに出力するということをやっています。

ターゲットのミドルウェアの返り値については、そのまま後続のミドルウェアに渡します。

TraceHeaderクラスの実装

ということで、コードはこんな感じです。まず、7行目でDescribable#outputメソッドを使用するためにモジュールをincludeしています。

9行目からのinitializeメソッドの中身は図のようになっていて、まずターゲットのミドルウェアが入っているインスタンス変数@appと、TracePointの実行結果を格納する空の配列が@datasとして定義されています。また、ターゲットのミドルウェアの返り値を格納するための変数@fixed_appが定義されていますが、まだこの時点ではnilです。

15行目からのcallメソッドの中は図のようになっています。

まず16行目を見てもらいたいのですが、ここでtracerというメソッドが登場しています。このtracerの中身は、TracePointのインスタンスです。このtracerに対して呼んでいるenableメソッドは、ブロックの中で@app.callを実行しています。ここでは、この処理をフックにしてTracePointを実行する、ということをやっています。

TracePointを実行した結果は、resultというメソッドを通じてターミナルに表示するためのoutputメソッドに渡します(17行目)。最終的にターゲットのミドルウェアの返り値は、そのまま後続のミドルウェアに渡ります(18行目)。

30行目からがtracerメソッドの詳細になるのですが、まず31行目でTracePointをインスタンス化しています。このとき、引数に:callと:returnというシンボルをそれぞれ入れています。これは「メソッドの呼び出しとリターンのとき、このブロックの中の処理が実行される」ということを意味しています。

具体的にはどんな処理が実行されるのかというと、まずif文で条件を絞っています。最初の条件は、「実行されているイベントが、Rackミドルウェアのインスタンスに対するcallメソッドの呼び出しあるいはリターンかどうか」です。

さらに次の行で「実行されているイベントが、まだ処理されていないRackミドルウェアに対するcallメソッドの呼び出しであるかどうか」で条件を絞っています。 条件を満たした場合は、@datasという空の配列の中に、実行中の各ミドルウェアのクラス、インスタンス、実行環境を保存しています。

ここが重要な部分なのですが、まず①の部分、tp.selfというのは、callメソッドが呼ばれているオブジェクト、すなわちこのミドルウェアのインスタンスのことです。②の部分、tp.binding.local_variable_getは、このcallメソッドが呼ばれた時点の引数envを指しています。

留意点としては、このときの各ミドルウェアのインスタンスと引数のenvは、「今実行しているTraceHeaderミドルウェア」と「ターゲットのミドルウェア」、「ターゲットの直前のミドルウェア」、すべてのミドルウェアで同じものを指しています。

このインスタンスと引数envは、callメソッドによって値が変更されることがあります。同じオブジェクトに対して何度もcallメソッドを呼んでしまうと、本来の正しい返り値を渡せなくなり、アプリケーション自体が壊れてしまう可能性があります。

ですので回避策として、今回はActiveSupport#deep_dupを使ってディープコピーを行なっています。こうすることによって、TraceHeaderのミドルウェア内で使われるオブジェクトは、本来のアプリケーションの実行とは無関係のオブジェクトとして値を取得できます。

あとは、最後の条件になりますが、「実行中のイベントがcallメソッドのリターンである場合」、ターゲットのミドルウェアの返り値を@fixed_appに代入しています。

また、ここでresultというメソッドが実装されています。これは、配列の差分を取得するResultクラスをインスタンス化したものです。インスタンス化するとき、引数として「ターゲットのミドルウェアのインスタンス(@app)」と、先ほどの「TracePointの実行結果を格納した配列(@datas)」を渡しています。

Resultクラスのコードの実装

ということで、次はResultクラスを見てみます。まず、5行目からのinitializeメソッドの中で、ターゲットのミドルウェアのインスタンスと、TracePointの実行結果を確認した配列を引数に取っています。ここからターゲットのミドルウェアの情報と、ターゲットの直前に呼ばれたミドルウェアの情報を抽出できます。

これらをそれぞれ@target_hashと@inner_hashというインスタンス変数の中に入れています。このとき、それぞれのインスタンス変数の中身は図のようなハッシュになっています。ミドルウェアのクラス、インスタンス、実行時の環境を表す情報が入っています。

この情報を元にしてそれぞれに対してcallメソッドを呼べます。そうするとそれぞれ返り値の配列、つまり「ステータスコード、ヘッダ、ボディ」の配列を取得できます。 今回はヘッダの部分だけほしいので、配列の二番目の要素であるヘッダを取得するためインデックス[1]を渡すことによって、ターゲットのヘッダと、直前のヘッダを取得できます。

あとは、22行目でミドルウェアを通した前後で共通のヘッダ名を抽出、次に26行目で新しく追加されたヘッダ名を抽出、その後30行目で値が書き換わったヘッダを抽出し、最後に47行目でヘッダ名からヘッダの値を取得しています。

取得した値を元に、新しく追加されたヘッダを返すnew_headersメソッドと、値が書き換わったヘッダを返すchanged_headersメソッドとして実装しています。そしてそれぞれをoutputメソッドに渡してでターミナルを表示しています。

ということで、これまで処理の流れをおさらいします。

TraceHeaderミドルウェアのインスタンスに対してcallが呼ばれると、callメソッドの中でターゲットのミドルウェアに対してcallを実行し、TracePointを実行。その後ターゲットのミドルウェアのインスタンスに対してcallを呼んで差分を取得。最後にターミナルに表示をしつつ、ターゲットのミドルウェアの返り値は後続のミドルウェアに渡す、ということをやっています。

おつかれさまでした。

(会場拍手)

TraceHeaderミドルウェアが実際に動作するか確認する

実はまだ続きがあって(笑)、有効性の検証をしたいと思っています。

今回はActionDispatch::Cookiesミドルウェアを通して、新しく追加されたヘッダをターミナルに表示できるか検証してみたいと思います。検証環境としては、Scaffoldしただけの簡単なアプリケーションを、サーバを立ち上げて、適当なページにアクセスする、という方法を取りました。

その結果……無事にターミナルに表示されました。

参加者:お〜。

塩井:ちょっと見にくいと思うのですが、ちゃんとターミナルのスクリーンショットをを撮ってきました。

(会場拍手)

ありがとうございます(笑)。もう少し拡大してみると、これがミドルウェア名、そして新しいヘッダの名前と値、値が書き換わったヘッダの名前と値が表示されているのがわかります。

結果から得られたのは、ーーいろいろな発見があったんですがーー追加されたヘッダがSet-Cookieという名前であること、値は図のようなかたちになっているということ、デフォルトでpath属性がルートに設定されているということ、デフォルトでHttpOnlyになっていることなどなど。

もうちょっと調べたいと思ったのは、ETagはどのミドルウェアを通しても書き換わることがわかり、この辺りについては自分の理解が及んでいないので要勉強だなと思っています。

ところで、今回発表資料を作るに当たって、Fukuoka.rbという福岡のRubyコミュニティにレビューをお願いしました。そうしたところ、Fukuoka.rbのうづらさん(@udzura)からこのような感想をいただいくことができました。TraceHeaderの有効性、あったと言ってもいいじゃないでしょうか? みなさまからのプルリクエスト、いつでもお待ちしております。

ということで、今回ご協力いただいたRubyistのみなさんに感謝しつつ、ご清聴ありがとうございました。

(会場拍手)

実装期間とミドルウェアの名前が変わった理由

司会者:どうもありがとうございました。あと何分ありますか?

スタッフ:3分あります。

司会者:あと3分? 余裕じゃないですか。

塩井:やった!(笑)。

司会者:まだもう1回、回せますよ。

(会場笑)

なにかしゃべり足りてない?

塩井:しゃべり足りていないこと? 早口ですみませんでした、本当に……。

司会者:じゃあ質問にタイムいきましょうか。なにか今の中でここが難しかったようなものがあれば。

質問者1:興味本位ですが、これを実装するのにどれぐらい時間かかりました?

塩井:だいたい毎週火曜日にAsakusa.rbで取り組む、という感じだったので、そのペースで言うと、2ヶ月から3ヶ月弱ぐらいかかっています。ただ、最後のほうはかなり根を詰めてやりました。

質問者1:毎週2時間で×8みたいな。8じゃなくて10とかっていう感じですか?

塩井:そうですね。そちらにいらっしゃるようさん(@youchan)をはじめAsakusa.rbの人々がたくさん相談にのって下さったおかげで何とかなったという感じですね。この(Special Thanksの)方々のおかげです(笑)。

司会者:コミュニティの力が。

塩井:本当に。

司会者:あっ、質問、もう1人。

質問者2:今日はありがとうございます。最初作ったときとミドルウェアの名前が違ったと思うんですけれども、名前がかっこいい名前に変わった経緯とかお聞きしたいなと思いまして。

塩井:(笑)。これはちょっとコンテキストが難しい話なんですけど、これはもともと、Asakusa.rbで実装していたとき、その場にいた方が「(名前は)header_miruo_kunでいいんじゃない?」と仰っていたんですね。header_miruo_kun……私も途中までそう呼んでいて、別にそのままでもよかったんですが、なんか今回の発表に際して、「header_miruo_kunクラス」って連呼したくなかった……以上です。

(会場笑)

司会者:ありがとうございました。ほかになにか気になることや質問はありますか? 大丈夫ですか。じゃあ以上にしたいと思います。塩井さん、どうもありがとうございました。

(会場拍手)