Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: レスポンスのライフサイクルとETagを理解する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。
本記事は以下の続編です。

Rails: リクエストのライフサイクルとRackを理解する(翻訳)

参考

以下のサイトで、Railsのリクエストの全ライフサイクルをビジュアル表示で追うことができます。本記事と合わせて参照することで理解が進むと思います。


rails-trace.chriszetter.comより

Rails: レスポンスのライフサイクルとETagを理解する(翻訳)

原注

本記事はRailsConf 2020での発表内容を元にしています。当日のスライドと動画は以下でご覧いただけます。

昨年、Skylightチームは「リクエストのライフサイクル」という題で発表いたしました。この発表では、ブラウザにURLを入力してからリクエストがRailsコントローラのアクションに到達する間に起こるすべてを扱いましたが、時間の都合で以下のセリフで終わってしまいました。

Q: "コントローラのアクションに到達したら、Railsはどのようにレスポンスをブラウザに送り返すでしょうか?"

リクエストとレスポンスの両方について解説することで、ブラウザのリクエスト/レスポンスサイクルの全体像が見えてきます。しかしこれらを理解するのに講演動画を見る必要はありませんのでご安心ください。まずは重要なコンセプトを少しおさらいしておきましょう。

それでは皆さまベルトをお締めください。当機Safariはこれよりレスポンスのライフサイクルに突入いたします。

サファリの風景に

最初におさらいを少々

それではサファリジープに乗って、skylight.io/safari1に向かうことにしましょう。このページにアクセスすると、"Hello World"が表示されるはずです。

ブラウザでskylight.io/safariにアクセスしたときの様子。レスポンスには'Roar Savanna'と表示される。

大変!サファリのWebサーバーがライオンに占領されてしまったようです。"Hello World"の代わりに"Roar Savanna"(ガオー、サバンナ)と表示されています。何が起きたのか調べてみましょう。最初に以下の疑問を解消する必要があります。

Q: "ブラウザがサーバーに接続したとき、サーバーはブラウザからの指示をどうやって知るのでしょうか?"

ブラウザとサーバー間のやりとりをテキストメッセージ化した様子。ブラウザは'もしもし、ちょっとお願いがあるのですが'と問い合わせ、サーバーは'いいけど...?'と応答する。

ブラウザとサーバーは、互いに「会話する」ときの言語について事前に合意し、相手が何を求めているかを理解できるようにしておく必要があります。この一連のルールを「HTTP」(Hypertext Transfer Protocol)と呼び、ブラウザとサーバーはどちらもこのHTTPという言語を理解します。ここで言うプロトコルとは会話の「ルールセット」を指します。

skylight.io/safariにアクセスするためのリクエストは、最もシンプルに行える形を取っています。ここでは/safariパスに対してGETリクエストを指定し、HTTPプロトコルバージョンは1.1を用い、ホストは"skylight.io"です。

GET /safari HTTP/1.1
Host: skylight.io

サーバーから送り返される、HTTPに準拠するレスポンスは以下のような感じになります。

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12
Date: Thu, 25 Apr 2019 18:52:54 GMT

Roar Savanna

このレスポンスではリクエストが成功したことが示されており、その他にもContent-TypeContent-LengthDateというヘッダー情報が添えられています。そして最後にやっと"Roar Savanna"という文字列が出現します。これはレスポンスのbody(本文)です。

Q: "リクエストを送信してからレスポンスを受信するまでの間に、どんなことが起きるのでしょうか?"

リクエストはブラウザから送信されると、インターネットを経由してWebサーバーに到達します(はいはい👋👋、詳しくは前回の記事をどうぞ)。

ここで別のプロトコルが登場します。"Rackプロトコル"は、HTTP準拠のリクエストをRack準拠のRubyアプリ(Railsなど)が理解できる形に変換するルールのセットです。

PumaやunicornなどのWebサーバーは、HTTPリクエストを解釈してenvハッシュに変換してから、このハッシュを引数としてRailsアプリを呼び出します。

env = {
  'REQUEST_METHOD' => 'GET',
  'PATH_INFO' => '/safari',
  'HTTP_HOST' => 'skylight.io',
  # ...
}

MyRailsApp.call(env)

Railsはenvハッシュを受け取ると、これをいくつもの「ミドルウェア」を経由してコントローラのアクションに渡します(はいはい👋👋、これも詳しくは前回の記事をどうぞ)。

私たちのコントローラでは、ライオンが以下のようなコードを書いていました。

class SafariController < ApplicationController
  def hello
    # サバンナは平野(plain)の一種で...(このシャレわかりますか?)
    render plain: "Roar Savanna"
  end
end

このSafariコントローラにはhelloアクションがあり、これは"Roar Savanna"という平文テキストをレンダリングするようRailsに指示しています。

Railsがこのコントローラのコードを実行すると、結果をすべてのミドルウェアを経由してから3つの値(ステータスコード、ヘッダーのハッシュ、レスポンスのbody)を持つ配列を返します。本記事では、これを"レスポンス配列"と呼ぶことにします。

env = {
  'REQUEST_METHOD' => 'GET',
  'PATH_INFO' => '/safari',
  'HTTP_HOST' => 'skylight.io',
  # ...
}

status, headers, body = MyRailsApp.call(env)

# レスポンス配列:
status  # => 200
headers # => { 'Content-Type' => 'text/plain', 'Content-Length' => '12', ... }
body    # => ['Roar Savanna']

Rack準拠のWebサーバーがこの配列を受け取ると、HTTP準拠の平文テキストに変換してからブラウザに送り返します。「ガオー、サバンナ!」

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12
Date: Thu, 25 Apr 2019 18:52:54 GMT

Roar Savanna

難しくありませんよね?

Q: "でも、Railsは配列に入れるべきものをどうやって知るのでしょうか?ブラウザはレスポンスを受け取ったときに何をすべきかをどうやって知るのでしょうか?"

その答えは以下の続きをどうぞ。

ステータスコード

レスポンス配列の第1項目はステータスコードです。ステータスコードは3桁の数値で、リクエストが成功した場合や失敗した場合、そして失敗した場合の理由を表します。

レスポンスのステータスコードは以下の5つのクラスに分類されています。

1xx
情報(利用頻度は低いのでここでは立ち入りません)
2xx
成功
3xx
リダイレクト
4xx
クライアントエラー(リクエストを送信したクライアント側に原因があるエラー)
5xx
サーバーエラー(サーバー側に原因があるエラー)

ステータスコードが標準化されているおかげで、英語がわからなくても(本文がどんな自然言語で書かれていても)レスポンスの意味を理解できます。ブラウザはこれを利用して、たとえばユーザーや開発ツールに適切なUI要素を表示したりできます。

ブラウザとサーバー間のやり取りをテキストチャット化したもの。ブラウザは'/safariパスにGETリクエストを送信する、Hostヘッダーはskylight.io'と話しかけ、サーバーは'200 OK; Roar Savanna?'と応答し、ブラウザは'了解、これでレンダリングできる'と返している。

Googleのクローラもステータスコードの指示に従います。ページのレスポンスが200 OKステータスならそのページをインデックスに登録し、ページのレスポンスが500エラーの場合は後でアクセスします。ページのレスポンスが300系のステータスコードの場合、クローラはリダイレクト指示に従います。

Googleクローラとサーバーのやり取りをテキストチャット化したもの。クローラは'/safariのページをインデックス化していいか?'と問い合わせ、サーバーが'500 Internal Server Error'を返すと、クローラは時間を空けて後ほどアクセスし、'今度はどう?'と問い合わせる。サーバーが'200 OK; Roar Savanna'と応答すると、クローラは'了解!インデックス化完了'と返す。

このような理由から、開発者はできるかぎり正確なステータスコードを選ぶことが望まれます。

(プロ向け: ステータスコードについてはhttps://www.webfx.com/web-development/glossary/http-status-codes/で多くのことを学べます)。

最もシンプルなレスポンスでは、bodyは必須ではありません。さらに、適切なステータスコードを選ぶことで、ブラウザにレスポンスのbodyが存在しない前提で処理するよう指示することも可能です。

たとえば、Safariコントローラにeat_hippoアクションがあるとしましょう。

class SafariController < ApplicationController
  def eat_hippo
    consume_hippo if @current_user.lion?
    head :no_content # 204
  end
end

このアクションは、カレントユーザーがライオンの場合にのみカバ(hippo)を食べることを許可し、204レスポンスを返します。204は「サーバーは無事にリクエストを処理でき、レスポンスのbodyで送り返す追加のコンテンツは存在しない」という意味です。サファリ語で言うと「ライオンはカバをすっかり平らげ、送り返す死体(body)は残っていない」みたいな意味になります。

Railsのheadメソッドは、「bodyのないステータスコードのみのレスポンスを返す」ショートハンドです。headメソッドはステータスコードのシンボルを受け取ります。ここで使った:no_contentステータスコード204に相当します。

リダイレクト

この他によく使われるステータスコードには、リダイレクト用の300系ステータスコードがあります。

たとえば、skylight.io/find-hippoGETリクエストを送信すると、find_hippoアクションがoasis_urlにリダイレクトするとしましょう(その時期が乾季で、カバが水を求めてオアシスに向かうという理由で)。

[Railsコントローラのアクションに`before_action { redirect_to oasis_url if dry_season? }`を書くと302 Foundレスポンスが返る。

Railsのredirect_toメソッドは、デフォルトで302 Foundを返し、デフォルトでブラウザのリダイレクト先URLを示すLocationヘッダーを含めます(その他のヘッダーについては後述)。この302ステータスは「カバは一時的にoasis_urlに住んでいる。カバは他の場所に移住する可能性もあるので、このLocationヘッダーを常にチェックすること」という意味です。

しかし、カバが(地球温暖化か何かが理由で)oasis_urlに一時的ではなく恒久的に移住した場合、以下のようにstatusredirect_toを指定できます。

Railsコントローラのアクションに`redirect_to oasis_url, status: :moved_permanently`を書くと301 Moved Permanentlyレスポンスが返る。

この:moved_permanentlyというシンボルはステータスコード301に相当し、「カバがオアシスに恒久的に移住したので、カバを探したければ常にオアシスを探せ」とブラウザに指示します。次回/find-hippoにアクセスすると、ブラウザは自動的に/oasisにアクセスします。このときブラウザは、/find-hippoに余分なリクエストを送信しません。

または、ルーティングファイルに以下を追加することも可能です。

Railsコントローラのアクションに`redirect_to oasis_url, status: :moved_permanently`を書くと301 Moved Permanentlyレスポンスが返る。

リダイレクトをルーティングファイルで扱えば、コントローラのアクションを削除しても動くようになります。ルーター内のredirectヘルパーも301レスポンスを返します。

注意!redirect_toメソッドにはひとつ重大な注意点があります。redirect_toメソッドを呼び出した後であっても、コントローラは引き続きコードを実行します。たとえば以下のfind_hippoアクションを見てください。

コントローラのfind_hippoアクションに`redirect_to oasis_url if dry_season?`を書き、続けて`render :hippo`を書いた場合に、RailsのDouble Renderエラーが発生した様子。

redirect_toを呼び出してもreturnに相当する処理は行われないので、実際にはリダイレクトとレンダリングが両方行われてしまいます。Railsは301と200のどちらのレスポンスを返せばよいのかを認識できず、Double Render Errorが発生します。

[Railsコントローラのアクションに`before_action { redirect_to oasis_url if dry_season? }`を書くと302 Foundレスポンスが返る。

これを修正しました。リダイレクト処理をbefore_actionフックに移動することで、コントローラのアクション全体をスキップし、render処理が呼ばれないようになりました。

ステータスコードは、ブラウザに重要な情報をきわめて簡潔に伝える方法ですが、その他にブラウザがレスポンスをどう扱うべきかを追加で指示する必要もしばしば生じます。この追加指示がヘッダーです。

ブラウザとサーバー間のやり取りをテキストチャット化したもの。ブラウザは'/safariパスにGETリクエストを送信する、Hostヘッダーはskylight.io'と話し、サーバーは'200 OK; Roar Savanna?'と応答し、ブラウザは'了解、これでレンダリングできる'と返しつつ、'ちょっと待った、もう少し情報が必要だ'と返す。サーバーは'失礼、レスポンスは平文テキストです'と伝えるとブラウザが'OK!OK!'と返す。

ヘッダー

ヘッダーは、RailsがWebサーバーに返すレスポンス配列内の第2項目に、ハッシュの形で含まれます。

ヘッダーはレスポンスに追加情報を添えます。追加される情報は、たとえばレスポンスをキャッシュすべきか、するならどのぐらいの期間キャッシュするかをブラウザに指示したり、JavaScriptクライアントアプリで使うメタデータを提供したりするのに使われます。

Locationヘッダーについては既に解説しましたね。このヘッダーは、リダイレクト先をブラウザに指示するのに使われます。

Railsアプリでよく見かけるヘッダーの一部を以下に示します。

Content-Type
このレスポンスヘッダーは、実際に返されるContent-Type(コンテンツの種類)をブラウザに指示します。Content-Typeには、画像、HTMLドキュメント、フォーマットのない平文テキストなどがあります。ブラウザはこのヘッダーをチェックすることで、レスポンスをUI上に表示する方法を認識します。
Content-Length
このレスポンスヘッダーは、レスポンスのサイズ(単位はバイト)をブラウザに指示します。たとえば、あるエンドポイントにHEADリクエストを送信すると、エンドポイントはhead :okContent-Lengthをレスポンスとして返すので、これを元にダウンロードの進捗をパーセント表示したりできます(この場合レスポンスのbodyがすべて返されるまで待たなくてもレスポンスのサイズがわかるので、ダウンロードのパーセント値の有用性は落ちますが)。
Set-Cookie
このヘッダーは、Webサーバーとクライアントの間で共有するcookieを表します。cookieは、セミコロンで区切られた文字列の形のキーバリューを含みます。たとえば、Railsではcookieを用いて複数のセッションにまたがるユーザーのリクエストをトラッキングします。RailsのcookieはCookieJar(クッキーを入れる瓶)という冗談のような名前のクラスで管理されます。
response.headers['HEADER NAME'] = 'header value'

HTTPキャッシュ

ヘッダーは、ブラウザにキャッシュ関連の指示を出すのにも使われます。"HTTPキャッシング"とは、ブラウザ(またはブラウザのプロキシ)がHTTPレスポンス全体をどんなタイミングで保存するかを意味します。次回そのエンドポイントにリクエストを送信すると、キャッシュに保存されたレスポンスは通常よりも素早く表示されるようになります。

ブラウザとサーバー間のやり取りをテキストチャット化したもの。ブラウザは'/safariパスにGETリクエストを送信する、Hostヘッダーはskylight.io'と話し、サーバーは'200 OK; Roar Savanna?'と応答し、ブラウザはお礼を述べつつ、'/safariパスにGETリクエストを送信する、Hostヘッダーはskylight.io'という同じリクエストを再度送信する。サーバーは'さっきのステータスコード200(のキャッシュ)を使っていいよ'と返すと、ブラウザは'ナイス!'と応答する。

キャッシュの振る舞いは、レスポンスで返されるステータスコードによって変わります。これもステータスコードが重要な「もうひとつの」理由です。

キャッシュの振る舞いを制御する主なヘッダーは、その名の通りCache-Controlヘッダーと呼ばれます。いくつか例を見てみましょう。

(プロ向け: development環境でrails dev:cacheコマンドを実行するとキャッシュをオンオフできます)

カバは大きくてレンダリングが大変なので、カバのレンダリングは1回だけにして、恒久的にキャッシュしておきたいとしましょう。これは、コントローラでhttp_cache_foreverメソッドを使えば可能です。

Railsコントローラのアクションで`http_cache_forever { render :hippo }`を書くと図のような`Cache-Control`ヘッダーが出力される。

http_cache_foreverメソッドは、Cache-Controlヘッダーのmax-ageディレクティブに3155695200秒(約31億秒)を設定します(これは1世紀に相当し、コンピュータ時間としては「基本的に」永遠です)。その他にprivateディレクティブも設定されます。これはブラウザやブラウザプロキシに「このカバはプライベートなデータなので、共有キャッシュではなく、このユーザーのブラウザだけにキャッシュすることが"望ましい"」と指示します。

このprivateディレクティブは、アカウントの所有者だけがレスポンスにアクセス可能にすることを意味しています。ブラウザはこのレスポンスをキャッシュしますが、CDN(Content Distribution Network)のようなサーバーとクライアントの間に置かれるキャッシュはこのレスポンスを保存すべきではありません。このような共有キャッシュにレスポンスをキャッシュしたい場合は、http_cache_foreverpublic: trueを渡します。これにより、ブラウザに送信されるカバのレスポンスをキャッシュしてもよいということをブラウザプロキシに指示できます。

Railsコントローラのアクションに`http_cache_forever(public: true) { render :hippo }`を書くとCache-Controlヘッダーがpublicになる。

期限なしのキャッシュの別の例として、テンプレートにカバ画像を含めてみましょう。このページにアクセスしてHTMLソースの画像を調べてみると、画像は単なる/assets/hippo.pngではなく/assets/hippo-なんちゃらかんちゃら.pngというファイル名で配信されていることがわかります。

ビューテンプレートに`image_tag 'hippo.png'`と書くと、HTMLソースの画像ファイルは`img src='/assets/hippo-f90d8a8....png'`のようになっている。

Q: "一体これは何ですか?"

サーバーが画像を配信するときは、Cache-Controlヘッダーにhttp_cache_foreverのときと同等の値を設定します。この場合、ブラウザ(またはCDNなどのブラウザプロキシ)はカバ画像を「永久に」キャッシュします。

しかしカバ画像を差し替えた場合はどうなるのでしょうか?ユーザーはネット上で最新のカバ画像にどうやってアクセスできるのでしょうか?

その答えが「フィンガープリント」です。先ほど画像ファイル名に追加された"なんちゃらかんちゃら"は実際には画像のフィンガープリントであり、Railsのアセットパイプラインが画像の内容に応じて画像をコンパイルするたびにフィンガープリントが新たに生成されます。画像が変更されると、HTML内でリンクされるフィンガープリントもがらりと変更され、ブラウザは以前キャッシュされていた古い画像ではなく新しい画像をサーバーから取得します。

レスポンスのキャッシュに話を戻します。

ところで、カバ画像を「永久に」キャッシュするのは方法としていかがなものでしょうか?カバの寿命は40年程度だそうですし、姿かたちは生きている間変わり続けるので、おそらくカバ画像のキャッシュ期間はせいぜい1時間程度にするのがよさそうです。

コントローラのアクションに`expires_in 1.hour`を指定してCache-Controlヘッダーにmax-age=3600を設定する。

コントローラでexpires_inメソッドを使うと、Cache-Controlヘッダーのmax-ageディレクティブにキャッシュ期間を指定できます。これで、1時間以内にブラウザでカバを再度読み込むとカバ画像がキャッシュから再読み込みされます。

Q: "でも、1時間以内にカバ画像が変更されていないことをどうやって知ればいいのでしょうか?"

これを保証するのは簡単ではありません。カバが更新されたかどうかをサーバーに問い合わせて、カバが変更されていないときだけキャッシュを使えればいいのですが。

さて、ここで皆さんに嬉しいお知らせです!これは、キャッシュに特化したコードを一切使わないデフォルトの振る舞いです。

Railsコントローラのアクションで単に`render :hippo`と書くと、Cache-Controlヘッダーは図のようになる。

Railsは、Cache-Controlmust-revalidateディレクティブを追加します。このディレクティブは、「ブラウザは、キャッシュされたレスポンスを表示する前にキャッシュが有効かどうかを再検証すること」という意味です。また、Railsはmax-ageにゼロ秒を設定します。これは、キャッシュされたレスポンスは即座に古くなる(stale)という意味です。ブラウザはこの2つのディレクティブによって、キャッシュされたレスポンスを表示する前に常にキャッシュを再検証するようになります。

Q: "この'再検証'ってどんな仕組みですか?"

/find-hippoエンドポイントに初めてアクセスすると、Railsはレスポンスbodyを生成するコードを実行し、カバの探索とレンダリングに関連する作業もすべて行います。Railsがbodyをサーバーに渡す前に、Rack::ETagというRackミドルウェアがレスポンスbodyを"ダイジェスト化"して一意の"エンティティタグ"を生成します。このエンティティタグは、上述したアセットのフィンガープリントと似ています。

# Rack::ETagをシンプルにしたもの

module Rack
  class ETag
    def initialize(app)
      @app = app
    end

    def call(env)
      status, headers, body = @app.call(env)

      if status == 200
        digest = digest_body(body)
        headers[Etag] = %(W/"#{digest}")
      end

      [status, headers, body]
    end

    private

    #...

  end
end

するとRack::Etagは、このエンティティタグを持つETagレスポンスヘッダを以下のように設定します。

Cache-Control: max-age=0, private, must-revalidate
ETag: W/"48a7e47309e0ec54e32df3a272094025"

ブラウザはこのレスポンスをヘッダーごとキャッシュします。ブラウザがこのページに再度アクセスすると、キャッシュされたレスポンスがstaleしている(max-age=0)ことに気づき、レスポンスの"再検証"を要求されていることを認識します。そこでブラウザは、If-None-Matchリクエストヘッダーを含むGETリクエストをサーバーに送信します。このIf-None-Matchリクエストヘッダーには、先ほどキャッシュされたレスポンスに関連付けられているエンティティタグを含めています。

GET /find_hippo HTTP/1.1
Host: skylight.io
If-None-Match: W/"48a7e47309e0ec54e32df3a272094025"

サーバーは、レスポンスbodyを生成するコードを再び実行し(カバの探索とレンダリングに関連する作業もすべて行います)、bodyを再びRack::ETagに渡します。Rack::ETagはレスポンスbodyを再度ダイジェスト化して一意のエンティティタグを生成し、それをEtagレスポンスヘッダーに設定します。

すると、次に控えているRack::ConditionalGetミドルウェアが、新しいETagヘッダーがIf-None-Matchリクエストヘッダーのエンティティタグと一致するかどうかをチェックします。

# Rack::ConditionalGetをシンプルにしたもの

module Rack
  class ConditionalGet
    def initialize(app)
      @app = app
    end

    def call(env)
      status, headers, body = @app.call(env)

      if status == 200 && etag_matches?
        status = 304
          body = []
      end

      [status, headers, body]
    end

    private

    def etag_matches?
      headers['ETag'] == env['HTTP_IF_NONE_MATCH']
    end
  end
end

新しいEtagが一致する場合は、Rack::ConditionalGetがステータスコードを304 Not Modifiedに変更してbodyを捨てます。これでブラウザは余分なbodyをダウンロードするまで待つ必要もなくなり、ステータス304でキャッシュ済みレスポンスを使うようブラウザに指示します。

新しいEtagが一致しない場合、サーバーは元のステータスコードのまま完全なレスポンスを送り返します。これでブラウザは新しいカバをレンダリングするようになります。

この挙動は、サーバーがETagを生成して比較するだけのためにカバ全体をレンダリングするという力仕事を引き続き行っているように思われます。レスポンスbodyが変更される理由が、カバそのものが変更されたという理由しかないとわかっていれば、もっといい方法があるはずです。

次はstale?メソッドです。

[Railsコントローラのアクションに`render :hippo if stale?(@hippo)`を書くと、図のようなCache-Controlヘッダーが生成される。

これで、このアクションはカバがstaleしている場合にのみカバをrenderします。これは引き続きデフォルトのアクションと同様に同じキャッシュヘッダーを取得しますが、レスポンスbodyが完全に同一であってもEtagは変化します。一体何が変わったのでしょうか?

このstale?メソッドは、ETagをビルドするためにわざわざレスポンスbody全体をレンダリングしないようRailsに指示します。代わりに、カバ自身が変更されたかどうかだけをチェックし、それに基づいてETagをビルドします。このときRailsの背後では、「モデル名」「id」「updated_at」を組み合わせた文字列(この場合"hippo/1-20071224150000")だけを生成してから、ETagのダイジェストアルゴリズムを通じて実行します。これによって、サーバーはETagを生成するためにbody全体をレンダリングするという手間を省けます。

最後に、「このカバはプライベートなデータなので決してキャッシュしたくない」場合はどうすればいいでしょうか?Railsにはそのための組み込みメソッドがまだないので、自分でCache-Controlヘッダーを直接設定する必要があります。

Railsコントローラのアクションに`response.headers[

このno-storeディレクティブは、「このレスポンスは、ブラウザやプロキシなど、いかなるキャッシュにも保存してはならない」という意味です。

なお、名前のひどさで有名なno-cacheディレクティブと混同しないようご注意ください。no-cacheディレクティブはその名に反して、レスポンスは任意のキャッシュに保存可能であるにもかかわらず、保存されたレスポンスを使う前には毎回必ず再検証しなければなりません。

これで、ステータスコードやヘッダーを用いてブラウザがレスポンスに対して何を行うかを指示できるようになったので、そろそろレスポンスで最も重要な部分であるbodyについて話す必要があるでしょう。

レスポンスbody

レスポンス配列の最後にあるのがbodyです。bodyは、ユーザーがリクエストした実際の情報を文字列形式で表現したものです。

Railsで/find-hippoパスにリクエストを送信すると、コントローラやビューに書かれたコードはどのようにしてカバのHTMLページに変換されるのでしょうか?調べてみましょう!

コンテンツのネゴシエーション

ブラウザで"skylight.io/find-hippo"にアクセスすると、RailsアプリはHTMLレスポンスを返します。これは、レスポンスのContent-Typeヘッダーを見ることで確かめられます。

`Content-Type: text/html`のレスポンス。

Q: "RailsはこれがHTMLであることをどうやって知るのでしょうか?"

Railsは最初に、リクエストの明示的なファイル拡張子をチェックします("skylight.io/find-hippo.html"など)。"skylight.io/find-hippo"リクエストにファイル拡張子が含まれていない場合は、リクエストのAcceptヘッダーをチェックします。

Safariブラウザはデフォルトで、リクエストのAcceptヘッダーをtext/htmlとして扱います。これは、レスポンスのContent-Type("MIMEタイプ"としてフォーマットされる)としてHTMLを受け取ることを期待していることを示します。HTMLバージョンがない場合、SafariはXMLバージョンを受け付けます。

`Accept: text/html,application/xhtml+xml,application/xml;`のレスポンス。

コントローラ内のrenderメソッドは、リクエストされたContent-Typeに一致する拡張子を持つテンプレート(ここではsafari/hippo.html.erb)を探索します。また、レンダリングされるbodyに一致するContent-Typeヘッダーも設定します。

`render :hippo`

JSON版のカバも欲しくなったので、/find-hippo.jsonにリクエストを送信してみましょう。

Railsの'Template is missing'エラーページ。

おっと、JSONカバ用のテンプレートがありませんでした。テンプレートを追加するか、respond_toブロックで別のフォーマットを扱うようにすれば表示できます。

Railsコントローラのアクションに`respond_to do |format| { format.html { render :hippo }; format.json { render json: @hippo } }`を書いた場合。

これで、/find-hippo.jsonにリクエストを送信すればJSONカバを取得できます。

`{ id: 1, name: Jason, ... }`のようなJSONレスポンス

興味深いことに、ブラウザはContent-Typeヘッダーの指示に絶対従わないといけないというものではなく、ファイルの中身を「嗅ぎ分けて」ファイル種別を勝手に判定しようとする可能性もあります。そのため、RailsではX-Content-Type-Options: nosniffを指定して、この挙動をブラウザで禁止しています。

`Content-Type: text/html`ヘッダーと`X-Content-Type-Options: nosniff`ヘッダーのあるレスポンス。

テンプレートのレンダリング

Railsコントローラでは3とおりの方法でレスポンスを生成できます。3つのうち、コントローラのredirect_toメソッドとheadメソッドを用いて、bodyのみ空でステータスコードとヘッダーを持つレスポンスを生成する方法については既に解説しました。bodyを含む完全なレスポンスを生成できるコントローラメソッドはrenderだけです。

先ほどの/find-hippoの例で、テンプレートが以下のようになっているとしましょう。

`Hey <%= current_user.name %>, meet <%= link_to @hippo.name, @hippo %>!`というテンプレート。

"skylight.io/find-hippo"にアクセスしてrender :hippoを呼び出すと、renderメソッドは適切なテンプレートを探索し、すべての空欄をインスタンス変数で埋め、レスポンスbodyを生成してブラウザに送り返します。

Railsではこれを行うために、コントローラごとに固有の「ビューコンテキスト」クラスを生成します。以下は、Safariコントローラのビューコンテキストクラスを簡略化した例です。

class SafariControllerViewContext < ActionView::Base
  include Rails::AllTheHelpers
  # link_toなど

  include MyApp::AllTheHelpers
  # current_userなど

  def initialize(assigns)
    assigns.each { |k, v| instance_variable_set("@#{k}", v) }
  end

  private

  # Hey <%= current_user.name %>, meet <%= link_to @hippo.name, @hippo %>!
  def __compiled_app_templates_hippo_erb
    output = ""
    output << "Hey "
    output << html_escape(current_user.name)
    output << ", meet"
    output << link_to(html_escape(@hippo.name), @hippo)
    output << "!"
    output
  end
end

原注

実際のコードを見たい場合は、以下をご覧ください。

ビューコンテキストが初期化されると、Railsはコントローラ内で設定したすべてのインスタンス変数(ここでは@hippo)をループで回してビューコンテキストオブジェクトにコピーし、テンプレート内でインスタンス変数を使えるようにします。これらのインスタンス変数はassignsとして知られています。

ビューコンテキストクラスは、Action Viewで利用できるすべてのヘルパー(link_toなど)と、アプリで定義されているすべてのヘルパー(current_userなど)をincludesします。

各テンプレートは、そのビューコンテキストクラス上のインスタンス変数にコンパイルされます。各テンプレートのメソッドは、基本的に以下のように文字列結合を強力にしたものです。

  • 最初に"Hey "という文字列を出力する
  • self.current_userの結果を取得する
    • このメソッドを利用できるのは、アプリのすべてのヘルパーがビューコンテキストクラスにインクルードされるため
  • current_user.nameをエスケープする
    • ユーザー入力が使われる可能性があるため
  • これらの結果を出力文字列に追加する
  • ", meet"を追加する
  • ビューコンテキスト初期化時に設定した@hippoインスタンス変数を取得する
  • self.link_toでカバのページへのリンクを生成する
    • このメソッドを利用できるのは、Action Viewのすべてのヘルパーがモジュールとしてインクルードされるため
  • "!"を出力文字列に追加する
  • 出力文字列を返す

以上をまとめた結果を以下に示します。

ブラウザに表示された'Hey RailsConf, meet Phyllis!'レスポンス

ついに珍種のカバPhyllisくんを見つけました!ステータスは200 OKです。

ここにたどり着くまでに、世にも珍しいアクション、想像を絶するスケール、信じられない秘境をいろいろ巡り、とうとうRailsの最深部に潜り込んで奥義を会得しました。偉大なるtext/plainを渡り歩き、壮大なAction Viewを探検し、ついにブラウザまで生還したのです。

「Railsレスポンスの素晴らしいライフサイクル」発掘ツアーにお付き合いいただき、ありがとうございました。

関連記事

Rails: リクエストのライフサイクルとRackを理解する(翻訳)


  1. このURLは現在は無効です。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。