2011年9月24日土曜日

GAEでIf-Modified-Sinceに応答

だいぶ前になってしまいましたが、GAEの課金体系の変更が話題になりました。

一番大きな変更を大雑把にまとめると
  • CPU時間ごとの課金($0.10/CPU hour、毎日6.5時間まで無料)が無くなる
  • インスタンス時間ごとの課金($0.08 / hour、毎日28時間まで無料)が導入される
という感じ。

CPU時間というのは、CPUが計算を行っている時間。そのまま。
対してインスタンス時間というのは何か。

GAEでは、アプリケーションがGoogleのサーバで動いてて、ユーザからのリクエストに応答します。これがインスタンス。
リクエストが増えて応答に時間がかかるようになると、GAEのスケジューラは新しいインスタンスを起動してリクエストの処理に当たらせます。なのでインスタンス時間とは、全インスタンスが起動している「のべ」の時間。

で、それぞれ具体的にどんな数字が入るかというと、例えばdrawlrのケースでは

CPU時間: 0.1~0.6時間/日 ぐらい
インスタンス時間: 100時間/日 ぐらい

なので、改定前だと毎日無料、改定後だと毎日$5.76かかる計算に。やってられん!

ということで、GAEユーザの間で、どうするんだみたいな状況になっているようです。経緯説明終わり。

で、どうするんだ

まあ僕もGAEで公開しているサイトがいくつかあるのですが、既にチューニングしきってて大量のアクセスをギリギリ捌いているならともかく全然そんなレベルではないので、改定後の無料割り当てに収まるように地道に工夫してこうかと。

まずはキャッシュをちゃんと使ってもらう

こっから本題です。言うまでもなくHTTPにはCachingという仕組みがあります。

GAEでは、staticなリソースに対しては最初からキャッシュが効くよう動いてくれますが、ハンドラを使った動的な応答では、自前でキャッシュが効くようにレスポンスヘッダを設定する必要があります。

例えばdrawlrのトップページには20枚のサムネイルが表示されていますが、これらはデータストアに保存されているものをハンドラが動的に返しています。特に何もしていない実装だと、ビジターがページを開くたびに20枚のサムネイルがリクエストされ、画像がデータストアから取得され、ネットワークをデータが流れ、その全てのステップで僕の口座からお金が減ります。こいつはいけない!

何もしていない状態のハンドラ
class ImageHandler(webapp.RequestHandler):
  """ 画像出力 """
  def get(self, drawing_id):
    drawing = Drawing.get_by_key_name(drawing_id)
    self.response.headers['Content-Type'] = 'image/png'
    self.response.out.write(drawing.image)

実際はもう少し複雑ですが、大体こういうことをしています。

では、ブラウザにキャッシュしてもらうためには何をすれば良いか。
  • Expiresレスポンスヘッダを設定する
  • Cache-Controlレスポンスヘッダを設定する
  • Last-Modifiedレスポンスヘッダを設定し、次からのIf-Modified-Sinceリクエストヘッダを正しく処理する
これら全てを行います。
(本当はLast-Modifiedより利点のあるETagというのもあるのですが、また今度。)

Expiresは、レスポンスの有効期限を指定するHTTP/1.0のヘッダです。
ここで指定した期日までレスポンスは有効、言い換えればサーバへの確認なしにキャッシュを信じて良い期間を指定しています。例えば以下の例では、キャッシュされたレスポンスが10日間はサーバへの問い合わせを行わずに再利用されることが期待できます。
expires = datetime.datetime.now() + datetime.timedelta(days=10)
self.response.headers['Expires'] = expires.strftime('%a, %d %b %Y %H:%M:%S GMT')

Cache-Controlは、キャッシュをどのように行わせるかを詳細に指示するHTTP/1.1のヘッダです。
以下のように指定することで、やはり10日間の有効期限を指定しています。
publicの指示は、複数ユーザが使う共有キャッシュに入れても良いことを示します。このレスポンスが今回のクライアントの為だけに用意されたものであれば、代わりにprivateを指示します。
self.response.headers['Cache-Control'] = 'public, max-age=864000'

Last-Modifiedは、今回のエンティティ(この場合はdrawing)が最後に更新された時間を指定するヘッダです。drawlrの場合は、drawing.updated_at というプロパティがあるのでそれを使っています。
self.response.headers['Last-Modified'] = drawing.updated_at.strftime('%a, %d %b %Y %H:%M:%S GMT')
上記で指定した有効期限が切れた場合などで"キャッシュの正当性"を検証する必要がある場合、ブラウザは前回のLast-Modifiedの値をIf-Modified-Sinceリクエストヘッダに入れてリクエストを行います。
この際、サーバがステータスコード304(Not Modified)を返せば、キャッシュは引き続き正当となり、画像自体がネットワークを流れる分の帯域と時間の節約になります。

これらを実装して、こんな感じになりました。
class ImageHandler(webapp.RequestHandler):
  """ 画像出力 """
  def get(self, drawing_id):
    expires = datetime.datetime.now() + datetime.timedelta(days=10)
    self.response.headers['Last-Modified'] = drawing.updated_at.strftime('%a, %d %b %Y %H:%M:%S GMT')
    self.response.headers['Expires'] = expires.strftime('%a, %d %b %Y %H:%M:%S GMT')
    self.response.headers['Cache-Control'] = 'public, max-age=864000'
    if 'If-Modified-Since' in self.request.headers:
      cached = datetime.datetime.strptime(self.request.headers['If-Modified-Since'], '%a, %d %b %Y %H:%M:%S GMT')
      current = drawing.updated_at - datetime.timedelta(seconds=1)
      if cached > current:
        self.response.set_status(304)
        return
    drawing = Drawing.get_by_key_name(drawing_id)
    self.response.headers['Content-Type'] = 'image/png'
    self.response.out.write(drawing.image)
※drawlrの場合は描き足しがあるので、10日ではなくかなり短い時間にしていますが、描き足しがない場合は365日とかで良いはず。


動いているか確認

キャッシュされてない状態で訪問。画像の応答に約1.5秒、ステータスは200。

キャッシュされた状態で再訪問。画像のリクエストは行われない。(Sizeの欄にfrom cacheと出る)

キャッシュされた状態でリロード。画像の応答に約0.2秒、ステータスは304。

実際にページを再訪問(リロードせずにTopのリンクを開くなど)すると効果が体験できます。
応答が早い時間で終わるほど、一つのインスタンスが多くのリクエストを捌けるので、こういった工夫を積み重ねればまだまだGAEを使えるんじゃないかな!

次回はこれを応用して、JavaScriptコードをごにょごにょする記事を書きます。