« Rails3 to Rails4 変更点まとめ:4 / 4)

カテゴリー:
Rails
タグ:
 Rails Rails4 RussianDollCaching cache_digests

このエントリーをはてなブックマークに追加
更新日時:
2014年01月11日(土)
作成日時:
2013年11月12日(火)

前の記事 / 次の記事

Rails4では従来の page cache や action cache は廃止され、
デフォルトのキャッシュ機構としては、Rails3までの fragment cache のようなものだけが使える。

Rails4のこのキャッシュ機構は今のところ
RussianDollCaching、cache_digests、fragment cache 等と
呼ばれているみたいだけど指し示すものは全部同じもの。

というか最初別々の何かだと思ってて無駄に嵌まった。

Rails4で内部的に使われているgemが、
従来のRailsにおける fragment cache を進化させた cache_digests であり、
cache_digestsの仕組みに対する愛称が RussianDollCaching であるという話。

RussianDollCachingを楽しむためのまとめって題にしたけど、
そもそも fragment cache を使ったことがないので、
それは RussianDollCaching の話じゃなくて fragment cache の話だ
っていう部分も多々あると思われ。

目次

  1. 基本的な考え方
  2. RussianDollCachingする
  3. キャッシュキー生成の仕組み
  4. 気になる動作、注意点など
  5. 参考ページ

1.基本的な考え方

基本的な考え方はフラグメントキャッシュと同じ。

-# haml
- cache @object do
  %h1=@object.name
  %p=@object.description

というビューがあったら、

if 渡された値(@object)を元に生成されたキーに一致するキャッシュを読める?
  そのキャッシュを出力
else
  渡された値(@object)を元に生成されたキーを付与したキャッシュを書いて出力
  (キャッシュの内容はブロックのレンダリング結果)
end

特定の条件の時だけキャッシュすることもできる

- cache_if condition, @object do
  condition が true だったらキャッシュする。false だったら普通に出力。
- cache_unless cond, @object do
  condition が false だったらキャッシュする。true だったら普通に出力。

2.RussianDollCachingする

とは言っても自動的にRussianDollCachingされるので、
基本的にやることはモデルに touch: true を追加するだけ。

ただ、touch: true の追加も、こうしたら綺麗に動くっていうだけで
必ずそうしないといけないという訳ではない。
(とにかく結果として親キャッシュのキャッシュキーが新しくなればいい。)

2.1 touch: true で親のキャッシュを飛ばす

ブログの記事と、
その記事に対する複数のコメントを表示するビューをキャッシュする場合。

基本的な使い方としてはActiveRecordオブジェクトをキャッシュキーとして使う。
その場合キャッシュキーに updated_at 列が含まれるのでオブジェクトに更新が
あるとキャッシュも更新されるようになる。

具体的には次のようなキャッシュキーが生成される。
(キャッシュキーの生成のされ方については後述。)

views/articles/201-20131114055308919866000/7048d597b127b39269502e142ad370d7

記事

-# views/articles/show.html.haml
-# haml
- cache @article do
  %h1=@article.headline
  %p=@article.content

  %h2 コメント
  %ul
    =render @article.comments

コメント

-# views/comments/_comment.html.haml
-# haml
- cache comment do
  %li=comment.value

但しこのままではコメントが更新されても @article.updated_at は変わらないので、
記事のキャッシュが書き換わらず、コメントの更新は反映されない。

これでは役に立たないので、これを解決するために、
コメントモデルに touch: true の記述を追加する。

この記述を追加することで、
コメントが更新されたタイミングで記事のupdated_atも更新されるようになり、
コメントが更新されれば自動的に記事のキャッシュも飛ぶようになる。

class Comment < ActiveRecord::Base
  belongs_to :article, touch: true # 更新時に article の updated_at も更新
end

2.2 テンプレート自体に変更があったらキャッシュは自動的にぶっ飛ぶ

この仕様がRussianDoll。

ここまでで、コメントが更新されれば記事のキャッシュも飛ぶようになったけれど、

例えば、次のようにコメントオブジェクトではなく、
コメントのパーシャルに変更があった場合、

-# views/comments/_comment.html.haml
-# haml
-# これを
- cache comment do
  %li=comment.value
-# 次のように変更した場合
- cache comment do
  %li="#{comment.value} (#{comment.user.name} #{comment.created_at})"

この場合、記事の更新日時が変わらないので、
記事のキャッシュは書き換わらないように思える、が実際は書き換わる。

理由は親テンプレートのキャッシュキーの生成元に、
子テンプレートのMD5ハッシュ値(ダイジェスト)が含まれるから。

従来は子テンプレートの内容が変わったら何らかの方法で親テンプレートの
キャッシュキーを変更してあげる必要があって、なかなかだるかったけど、
今は自動的に変わってくれるから、もうだるくないんだって!やったね!

3.キャッシュキー生成の仕組み

キャッシュキーにはテンプレートのMD5ハッシュ値(以降ダイジェストと呼ぶ)が含まれるので
キーの重複は発生しにくいと思うけど、発生しないわけじゃないので、
重複しないキャッシュキーをつくるために引数に何を渡したらどんなキーが生成されるのかまとめてみる。

3.1 必ず自身のテンプレートを元にしたダイジェストが付与される

まず、キャッシュキーには明示的に指定した場合以外は必ず
自身のテンプレート内容を元にしたダイジェストが付与される。

また、この値はキャッシュする範囲を元に生成されるのではなく、
自身のテンプレート内容全てを元に生成される。

明示的にダイジェストを付与しないことを指定する場合

- cache obj, skip_digest: true do
  %p このキャッシュにはダイジェストが付与されないん。

3-2 何も値を指定しない時(キャッシュ内容がテンプレートにのみ依存する場合)

- cache do
   にゃんぱすー
パターン: views/自身のURI/ダイジェスト
例えば:   views/o.inchiki.jp/obbr/201/7048d597b127b39269502e142ad370d7

3.3 ActiveRecordオブジェクトを指定した時(主要な指定方法)

- cache @obj do
  にゃんぱすー
パターン: views/@obj.cache_keyで得られる値/ダイジェスト
例えば:   views/articles/201-20131114055308919866000/7048d597b127b39269502e142ad370d7
* ActiveRecordオブジェクトの配列を渡した場合は、
  オブジェクトの件数分 @obj.cache_keyで得られる値 が繰り返される。
* 20131114055308919866000の 値は 
  @obj.updated_at.utc.to_s(cache_timestamp_format) で得られる。
  cache_timestamp_format にはデフォルトで :nsec が入っている。

ActiveRecordオブジェクトから生成されるキャッシュキーについて(obj.cache_keyで得られる値について)。

オブジェクトがDBに保存されていない場合。

@article = Article.new
@article.cache_key
#=> "article/new"

オブジェクトがDBに保存済みの場合。

@article = Article.find(201)
@article.cache_key
#=> "article/201-20131114055308919866000"

オブジェクトがDBに保存済みで、オブジェクトに update_at 列がない場合。

@article = Article.find(201)
@article.cache_key
#=> "article/201"

3.4 文字列を指定した時

- cache "nonnon" do
  にゃんぱすー
パターン: views/文字列/ダイジェスト
例えば:   views/nonnon/7048d597b127b39269502e142ad370d7

3.5 配列を渡した時

- cache [1, 2, 3] do
  にゃんぱすー
パターン: views/1/2/3/ダイジェスト
例えば:   views/1/2/3/7048d597b127b39269502e142ad370d7

3.6 ハッシュを渡した時

- hash_value = { a: 1, b: 2}
- cache do hash_value do
  にゃんぱすー
パターン: views/自身のURI/key=value/ダイジェスト
例えば:   views/o.inchiki.jp/?a=1&b=2/7048d597b127b39269502e142ad370d7
※ 直接ハッシュを渡すとエラーになる

3.7 配列の中のハッシュ

- hash_value = { a: 1, b: 2}
- cache do [hash_value] do
  にゃんぱすー
パターン: views/自身のURI/key/value/ダイジェスト
例えば:   views/o.inchiki.jp/a/1/b/2/7048d597b127b39269502e142ad370d7

4.気になる動作、注意点など

使っていていくつか、気になる点を見つけたのでメモ。

4.1 キャッシュの掃除どうすんの?

キャッシュキーが変わると自動的に新しいキャッシュに切り替わるけど
古いキャッシュどうすんの?
テキストデータなんていくら増えてももはや問題にならないから気にすんなよって発想なのん?

ActionView::Helpers::CacheHelper
とか見ると、key value store を使えば古いキャッシュは自動的に追い出されるぜ
って書いてあるけどつまりそういうことなのん?

4.2 ヘルパーとロケールの変更は検知できない

Rails高速化のテクニックとしてパーシャルよりも高速なヘルパーを
ビューの代わりに使うっていうのがあるけど、

ヘルパーをパーシャルの代替として使っている場合、
テンプレートがヘルパーをパーシャルとして認識できないので、
ヘルパーに変更があっても親キャッシュが自動的に飛ばない。

ActionView::Helpers::CacheHelper
によるとコメントを付与せよって書いてある。

コメントの書き方自体に意味がある訳じゃなくてコメントに
更新日時を含めることで結果としてテンプレートの内容が変わるからキーも変わる。

また、同じ理由でロケールの値に変更があった場合も
キャッシュキーは変わらないので何らかの対応が必要。

とにかくrenderで呼び出したテンプレートの変更しか検知できないので、
それ以外の可変部分の中身が変更されたら何らかの対応が必要。

4.3 動的にパーシャルを呼び出してはいけない

テンプレート自体に変更があった場合は自動的にキャッシュがぶっ飛ぶと書いたけど、
テンプレートの呼び出しを動的に行うと cache_digests がテンプレート名を
捕捉できなくなって自動的にキャッシュが飛ばなくなる。

例えば次のようなケース。

=render "resource_types/#{obj.resource_type}"

こういう書き方をすると、cache_digestes から見えるテンプレート名は

"resource_types/"

となるようで、そうなると cache_digests はテンプレートに変更があったかどうかも
どのキャッシュを飛ばすべきなのかも判断できなくなって、
結果としてキャッシュが自動的にクリアされない状態になる。

冗長でも例えば次のように書く必要がある。

- case obj.resource_type
- when "Book"
  =render "resource_types/book"
- when "Music"
  =render "resource_types/music"
- when "Movie"
  =render "resource_types/movie"

4.4 キャッシュを沢山書くと遅くなるけど本番環境ではちゃんと速い

本番環境ではダイジェストはテンプレートの初回読み込み時にしか計算されないから
問題になることはほとんどないと思うけど、

開発環境ではキャッシュの呼び出し毎に計算されるので、
1つのテンプレートにキャッシュを書けば書くほど遅くなる。

特に1つのテンプレートから多数のパーシャルを呼んでいるような場合、
キャッシュの呼び出し毎に自身のテンプレートのダイジェストと子となる全ての
テンプレートのダイジェストが計算されるので、それなりに遅くなる。

で、キャッシュしてんのにおせーな。とか思ってしまったら(思った)
それは罠で、本番環境で動かせばダイジェストは初回しか計算しないのでちゃんと速い。

ただ、1つのテンプレートにキャッシュが沢山書かれている場合は、
敢えて書かれているのではなければそもそもキャッシュの書き方に問題がある気がする。

基本的にはテンプレートとキャッシュが1対1になっているのが良いと思う。
管理しやすいし。

例えば次のようなキャッシュを書いた場合、キャッシュの内容と依存関係がなくても、
"first", "third", "fourth" のパーシャルに変更があるかどうかチェックされるので、
開発環境ではキャッシュの数が増えれば増えるほど遅くなる。

-# haml
%p
  =render "first"
%p
  - cache :second do
    にばんめ
%p
  =render "third"
%p
  =render "fourth"
%p
  - cache :fifth do
    ごばんめ

4.5 何でもかんでもキャッシュすれば速くなる訳ではない

Rails3時代のページキャッシュやアクションキャッシュのノリで
何でもかんでもキャッシュしようとしてたんだけど、そういう訳ではなかった。

これは cache_digest だけじゃなくて、fragment_cache でも同じなのかな。

キャッシュを呼び出すのに一定のコストがかかるので、
そのコスト以下でレンダリングできる場合はキャッシュしない方が速い。

どの程度ならキャッシュしない方がいいのかっていうのは、
動かす環境によってばらばらのはずなので分からん。

自分の開発環境だと例えば link_to 一行くらいならキャッシュしない方が速い。

4.5 レイアウトはキャッシュできるがテンプレートは必ずレンダリングされる

レイアウトを丸ごとキャッシュすることはできるが意味が無いという話。
例えば次のようなキャッシュを書いた場合。

-# views/layouts/articles.html.haml
-# haml
- cache [@site_name, @article] do
  %div#site-container
    %h1=@site_name
    %div#article-container
      =yield

-# views/articles/show
-# haml
- cache @article do
  %h2=@article.name
  %p=@article.description

この場合、Railsがレンダリングする順番として、
まず、yieldで呼び出されてテンプレートがレンダリングされ、
その次に、レイアウトがレンダリングされるので、

レイアウトのキャッシュが飛んでいるか否かに関わらず、
テンプレートは必ずレンダリングされる。

コンソールを見ているとレイアウトのキャッシュが飛んでいなくても、
テンプレートのキャッシュは必ず読み書きされているので、
レイアウトとテンプレートのキャッシュはそれぞれ独立して動いているように見えるが、
テンプレートをレンダリングしてしまった後にレイアウトのキャッシュを使うかどうか
を判定するという無意味な動作をしているだけで、

実際にはレイアウトのキャッシュが飛んでいなければ
テンプレートの内容に変更があっても表示は変わらない。

4.6 レイアウトとしてのパーシャルに渡されるキャッシュの変更は検知されない

何が言いたいのかよく分からないと思うし、説明しても相当分かりにくいと思うけど、
レイアウトとしてのパーシャルにキャッシュされた内容を渡す時は注意が必要ということ。

既にある場合は仕方ないけど、
これからつくる場合で cache_digests を使うことが分かっている場合は、
レイアウトとしてのパーシャルは使わない方がいい気がしている。

まず、パーシャルはrenderにlayoutを指定することで、レイアウトとして利用することができる。

-# haml
-# veiws/shared/table
%table
  =yield

-# views/items/index
=render layout: "table" do
  - @items.each do |item|
    %tr
      %tr=item.name
      %tr=item.price

これをキャッシュしようとする時に次のようなキャッシュを書くと
このテンプレート自体に変更があった時に検知されない。
(テンプレートのダイジェストが変更されても検知されない。)

-# haml
-# views/items/index
=render layout: "table" do
  - cache do
    - @items.each do |item|
      %tr
        %tr=item.name
        %tr=item.price

例えば上記のテンプレートを次のように書き換えたとしてもキャッシュは書き換わらない。
(キャッシュキーが変更されれば書き換わる。)

-# haml
-# views/items/index
=render layout: "table" do
  - cache do
    - @items.each do |item|
      %tr
        %tr=item.name
        %tr=item.price
        %tr=item.count

理由は、このキャッシュに付与されるダイジェストは、
キャッシュが書かれているテンプレートのダイジェストではなく、
渡す先のレイアウトのダイジェストが付与されるから。

また、渡す先のレイアウトのダイジェストが付与されるということは、
キャッシュの内容に変更があってもキャッシュが書き換わらないだけではなく、
どのテンプレートから呼ばれても同じダイジェストが付与されるということなので、
気を付けないとキャッシュキーが容易に重複する。

例えば別々のテンプレートに次のようなキャッシュが書かれていた場合、
先にレンダリングされた方が先勝ちで両方とも同じ内容が表示されてしまう。
(通常はキャッシュが書かれているテンプレートのダイジェストがキャッシュキーに
付与されるので、同じキーを渡していてもテンプレートが異なれば衝突は起きない。)

-# haml
-# views/people/show
%h1 #{@person.name}のアイテム一覧
=render layout: "table" do
  - cache @person do
    - @person.items.each do |item|
      %tr
        %tr=item.name
        %tr=item.count
        %tr=item.price
        %tr=item.description

-# views/people/index
%h1 人物一覧
- @people.each do |person|
  %dl
    %dt 名前
    %dd #{person.name}

    %dt 所有アイテム  
    %dd
      =render layout: "table" do
        - cache person do
          - person.items.each do |item|
            %tr
              %tr=item.name
              %tr=item.count

レイアウトとしてのパーシャルを丸ごとキャッシュする場合は問題ない。
また、デフォルトで"views/layout"に格納されるレイアウトとしてのテンプレート
に関しては同じような問題は発生しない。

4.8 とにかくデリケートである

とにかく色んな契機で機嫌が悪くなる。

ヘルパーやロケールの変更は検知できなかったり、
レイアウトとしてのパーシャルの中のダイジェストの付き方がいまいちだったり、
テンプレートを動的に呼び出すとテンプレートの変更を検知できなかったり、

色んなやり方で機嫌を損ねることができる。

なので、キャッシュを書いたらちゃんと読み書きされているか
面倒でもいちいち確認しながら進めた方がいいと思う。

慣れて来ると「これは嵌まるパターン」っていうのが分かるようになってくるので、
そうなったら嵌まりそうな予感がした時だけ確認して「あーやっぱり」って思えばいいと思う。

4.9 でも楽

そんな微妙なもん使わない方がいいんじゃないかと思うかも知れないけど、
実際使ってみての感想としては、この書き方でキャッシュのクリア漏れは
発生しないかとか、ほとんど考える必要がなくなるのでかなり楽。

微妙さを考えたらキャッシュのクリア漏れがないか考える方が遙かに微妙。

5 参考ページ