キャッシュの多言語対応

(5385d) 更新


このページの内容は非常に古いです(Rails 1.x.x)。最新の Rails では洗練された国際化の機構が標準で入っているため、下記は読むだけ無駄な内容になっています。

ソフトウェア/rails/言語ネゴシエーション

ページキャッシュの多言語対応

まずは rails そのままで、ページキャッシュ機能をONにしてみる。

LANG:console
$ jed config/environments/development.rb
 config.action_controller.perform_caching = true
$ jed app/controllers/test_controller.rb
 class TestController < ApplicationController
   caches_page :index
 end
$ (restart script/server)
$ ls public/test*
 ls: cannot access public/test*: そのようなファイルやディレクトリはありません
$ wgetc http://localhost:3000/test
 English: Test   OK
 
 
$ ls public/test*
 public/test.html
$ cat public/test.html
 English: Test   OK
 
 
$ wgetc http://localhost:3000/test.en
 English: Test   OK
 
 
$ ls public/test*
 public/test.html  public/test.en

test.html というファイルができてしまうと、 次回からは言語設定にかかわらず常にこれが表示されてしまうため、うまくいかない。

それに、test.en というキャッシュファイルもいけてない。

期待するのは、test.html.en や test.html.ja というファイルができること。

ページキャッシュは ActionController::Caching::Pages が作成するので、 このときファイル名に言語名やフォーマット名を正しく付ければよい。

ファイル名に付ける言語指定を、path として指定された物にするか 実際に使ったテンプレートの言語にするか。 提供できない言語は何度訪れても提供できないので、 path として指定された物を付けるのが正しそう。

ちなみに cache_page が暗黙に呼び出される時、 ActionController::Caching::Pages.cache_page には引数が与えられないので、 キャッシュファイル名は request.path から作る事になる。

LANG:ruby(linenumber)
module ActionController::Caching::Pages
  def cache_page(content = nil, options = nil)
    return unless perform_caching && caching_allowed

    path = case options
      when Hash
        url_for(options.merge(
            :only_path => true, 
            :skip_relative_url_root => true, 
            :format => params[:format],
            :rails_language => request.accepts_languages.first))
      when String
        options
      else
        p= request.path.split('.')
        p.pop if ActionController::AbstractRequest.acceptable_language?(p.last)
        p[0]+= self.class.page_cache_extension if p.count==1
        p << request.accepts_languages.first
        p.join('.')
    end

    self.class.cache_page(content || response.body, path)
  end
end

テスト

LANG:console
$ (restart script/server)
$ rm -r public/test*
$ wgetc http://localhost:3000/test
 English: Test   OK

$ ls public/test*
 public/test.html.en
$ wgetc http://localhost:3000/test.en
 English: Test   OK

$ ls public/test*
 public/test.html.en
$ wgetc http://localhost:3000/test.ja
 日本語:テスト ばっちり

$ ls public/test*
 public/test.html.en  public/test.html.ja
$ wgetc http://localhost:3000/test
 日本語:テスト ばっちり

$ ls public/test*
 public/test.html.en  public/test.html.ja
$ wgetc http://localhost:3000/test/index
 日本語:テスト ばっちり

$ ls public/test*
 public/test.html.en  public/test.html.ja
 
 public/test:
 index.html.ja
$ wgetc http://localhost:3000/test/index/0
 日本語:テスト ばっちり

$ ls public/test* public/test*/* 
 public/test.html.en  public/test.html.ja  public/test/index.html.ja
 
 public/test:
 index  index.html.ja
 
 public/test/index:
 0.html.ja
$ wgetc http://localhost:3000/test/index/0.html
 日本語:テスト ばっちり

$ ls public/test* public/test*/* 
 public/test.html.en  public/test.html.ja  public/test/index.html.ja
 
 public/test:
 index  index.html.ja
 
 public/test/index:
 0.html.ja
$ wgetc http://localhost:3000/test/index/0.html.ja
 日本語:テスト ばっちり

$ ls public/test* public/test*/* 
 public/test.html.en  public/test.html.ja  public/test/index.html.ja
 
 public/test:
 index  index.html.ja
 
 public/test/index:
 0.html.ja

ページキャッシュの多言語化に対応させるための apache の設定

まずは rails を apache から cgi 経由で production 環境で動くように設定する。

LANG:console
$ cp db/development.sqlite3 db/production.sqlite3
$ ln -s (negotiation app path)/public/ /var/www/negotiation
$ wgetc http://localhost/negotiation | html2text
 ...
 
 ****** Welcome aboard ******
 ***** You&rsquo;re riding Ruby on Rails! *****
 **** About_your_application&rsquo;s_environment ****
 ...
 
$ cat > public/.htaccess
 SetEnv RAILS_RELATIVE_URL_ROOT /negotiation
 SetEnv RAILS_ENV production
 SetEnv RAILS_ACCEPTABLE_LANGUAGES ja|en
 
 RewriteEngine On
 RewriteRule ^$ index.html [QSA]
 RewriteRule ^([^.]+)$ $1.html [QSA]
 RewriteCond %{REQUEST_FILENAME} !-f
 RewriteRule ^(.*)$ dispatch.cgi [QSA,L]
 ^D
$ rm -r public/test*
$ ls db
 development.sqlite3
$ wgetc http://localhost/negotiation/test
 日本語: テスト ばっちり
 
$ ls db
 development.sqlite3  production.sqlite3
$ ls public/test*
 public/test.html.ja
$ wgetc http://localhost/negotiation/test.en
 English: Test   OK
 
$ ls public/test*
 public/test.html.en  public/test.html.ja

DRY の原則になるべく従いたいので、

 SetEnv RAILS_ACCEPTABLE_LANGUAGES ja|en

をここに書いて、routes.rb での設定を無効化している。

とはいえ、後に見るように ja en を書かなければならないところは どうしても他に出てくる。RoR の外側なので仕方のないところ。

以下ページキャッシュのテスト。

apache2 は mod_rewrite, mod_negotiation, mod_cgi, mod_env, mod_setenvif が有効になっている。

また、デバッグ用に apache の設定で RewriteLogLevel を 2 とかに設定しておく。
後で必ず 0 に戻す事。

LANG:console
$ rm -r public/test*
$ wgetc http://localhost/negotiation/test.ja
 日本語: テスト ばっちり
 
$ ls public/test*
 public/test.html.ja
$ wgetc http://localhost/negotiation/test
 日本語: テスト ばっちり
 
$ tail /var/log/apache2/rewrite.log
 [perdir (DOCROOT)/negotiation/] pass through (DOCROOT)/negotiation/test.html.ja
$ wgetc http://localhost/negotiation/test.ja
 日本語: テスト ばっちり
 
$ tail /var/log/apache2/rewrite.log
 [perdir (DOCROOT)/negotiation/] pass through (DOCROOT)/negotiation/dispatch.cgi
$ wget -O- --header="Accept-Language: en" http://localhost/negotiation/test
 エラー 406: Not Acceptable
$ wgetc http://localhost/negotiation/test.en
 English: Test   OK
 
$ ls public/test*
 public/test.html.en  public/test.html.ja
$ wgetc http://localhost/negotiation/test
 日本語: テスト ばっちり
 
$ tail /var/log/apache2/rewrite.log
 [perdir (DOCROOT)/negotiation/] pass through (DOCROOT)/negotiation/test.html.ja
$ wget -qO- --header="Accept-Language: en" http://localhost/negotiation/test
 English: Test   OK
 
$ wget -qO- --header="Accept-Language: ja" http://localhost/negotiation/test
 日本語: テスト ばっちり
 

何点か問題が指摘できる。

test.ja 形式の呼び出しにキャッシュが効かない

まず、public/test.html.ja が作成された後も http://localhost/negotiation/test.ja の読み込みがキャッシュファイルではなく cgi 呼び出しに rewrite されている。

これは、

LANG:Shell(linenumber)
RewriteCond %{HTTP_ACCEPT} html
RewriteCond %{SCRIPT_FILENAME} !\.html\.
RewriteCond %{SCRIPT_FILENAME} (.*)\.([a-z][a-z])$
RewriteCond %1.html.%2 -f
RewriteRule (.*)\.([a-z][a-z])$ $1.html.$2 [L]

というルールを追加してやる事でキャッシュを読ませる事ができる。

このルールは、

  1. ACCEPT ヘッダに text/html が含まれていて
  2. ファイル名が .html. を含まず
  3. ファイル名が .ja や .en のように、2文字の拡張子で終わっていて
  4. .ja などの前に .html を追加すると、同名のファイルが見つかる
  5. のであれば、.html を追加して、test.en のような名前を test.html.en に書き換える

という意味になる。

406 エラーの回避

次に、public/test.html.ja があって public/test.html.en が無い 状況で wget -O- --header="Accept-Language: en" http://localhost/negotiation/test すると 406 エラーになる。

これは、RewriteCond %{REQUEST_FILENAME} !-f が public/test.html.ja に対して成立しないにもかかわらず、実際には言語指定が折り合わないため、 変装するデータが見つからないという状況のようだ。

まずエラーになるのを避けるため、

LanguagePriority ja en
ForceLanguagePriority Prefer Fallback

としてやることで、望みの言語が見つからない場合には ja, en の順に探して、 見つかった物を返すようにした。

キャッシュミスを検出

上記だけだと英語を要求したのに日本語の答えが返って来る。

正しい完全な動作とは言えないので、

LANG:shell(linenumber)
RewriteCond %{HTTP:Accept-Language} !$2
RewriteCond %{HTTP:Accept-Language} ([a-z][a-z])
RewriteRule (.*\.html\.)([a-z][a-z]) $1%1

を追加した。

この意味は、test.html.ja を {test.html.}{ja} の形に分けたとき、 後ろの {ja} が Accept-Language に含まれていなければ、 Accept-Language から望みの言語 ([a-z][a-z]) を取り出して、 test.html. の後ろに付ける。

ただこれだと、es や fr を求めてくる閲覧者に対しては 常にキャッシュが効かないという状況が生じる。

また、Accept-Language を En とか JA とか、 大文字を混ぜて指定されるとやはりキャッシュが効かない。

ここの記述が無くても、テストの最後の部分で確かめたように、 public/test.html.en と public/test.html.ja とが両方あれば Accept-Language が正常に働くので、そのページを表示した最初の一人だけが 被害を被り、test.en の形で言語を指定すればちゃんと読める。

また、以下のように、一度 cookie で言語指定してしまえば、 次からはキャッシュミスをしっかり検出可能。

という事で、記述が無くてもそれほどの害はない。

パフォーマンスが問題になるようであれば この3行は省略した方が良い。

言語指定を記憶する

もう一点、wgetc http://localhost/negotiation/test.en の後の wgetc http://localhost/negotiation/test 呼び出しで 日本語が表示されてしまっている部分に不具合がある。

これは、直前に選択した言語を apache が覚えていないせい(あたりまえ)。

SetEnvIf Cookie "rails_language=([a-z][a-z])" prefer-language=$1

とすることで、cookie の値に応じて apache に言語を選ばせることができる。

cookie の値は信用できないので、[a-z][a-z] にマッチする時に限って 値を採用する。

言語指定のある時にキャッシュミスを検出

LANG:shell(linenumber)
RewriteCond %{ENV:prefer-language} ^[a-z][a-z]$
RewriteCond %{ENV:prefer-language} !=$2
RewriteRule (.*\.html\.)([a-z][a-z]) $1%{ENV:prefer-language} [L]

とすることで、cookie に値があるときは他の言語のキャッシュがあっても 望みのファイルが無ければ dispatch.cgi を呼ぶようにした。

詳しく説明すると:
test.html.ja が存在して test.html.en が存在しない時、
prefer-language の設定にかかわらず、 test への呼び出しは apache のネゴシエーションで test.html.ja になってしまう。 これを検出して test.html.en に書き直すため、

  1. ファイル名が (.*\.html\.)([a-z][a-z]) の形で、
  2. prefer-language に正しそうな値が入っているにも関わらず
  3. ファイル名に付いている言語指定が prefer-language に含まれていなければ
  4. 言語指定を prefer-language の物に置き換える

というルールを追加している。

当然 test.html.en は存在しないので、結果的に dispatch.cgi に rewrite されて test.html.en が作られる事になる。

cookie が存在するとき、Accept-Language の指定は無視したいので、 RewriteRule に [L] を付けておく。

.htaccess の最終形

最終的に、public/.htaccess の内容は

# rails setting
SetEnv RAILS_RELATIVE_URL_ROOT /negotiation
SetEnv RAILS_ENV production
SetEnv RAILS_ACCEPTABLE_LANGUAGES ja|en

# save preferential language in cookie
SetEnvIf Cookie "rails_language=([a-z][a-z])" prefer-language=$1

# avoid error due to cache miss
LanguagePriority ja en
ForceLanguagePriority Prefer Fallback

# rewrite start
RewriteEngine On

# default format of ".html"
RewriteCond %{HTTP_ACCEPT} html
RewriteCond %{SCRIPT_FILENAME} !\.html\.
RewriteCond %{SCRIPT_FILENAME} (.*)\.([a-z][a-z])$
RewriteCond %1.html.%2 -f
RewriteRule (.*)\.([a-z][a-z])$ $1.html.$2 [L]

# rails' default rules
RewriteRule ^$ index.html [QSA]
RewriteRule ^([^.]+)$ $1.html [QSA]
RewriteCond %{REQUEST_FILENAME} !-f
# change this to adapt to your server
RewriteRule ^(.*)$ dispatch.cgi [QSA,L]

# detect cache miss for prefer-language
RewriteCond %{ENV:prefer-language} ^[a-z][a-z]$
RewriteCond %{ENV:prefer-language} !=$2
RewriteRule (.*\.html\.)([a-z][a-z]) $1%{ENV:prefer-language} [L]

# detect cache miss for accept-language
# omit this section if performance matters
RewriteCond %{HTTP:Accept-Language} !$2 [NC]
RewriteCond %{HTTP:Accept-Language} ([a-z][a-z])
RewriteRule (.*\.html\.)([a-z][a-z]) $1%1

となった。

まさに黒魔術。

Mongrel を使うのであれば

dispatch.cgi ではなく Mongrel に渡すのであれば

RewriteRule ^(.*)$ dispatch.cgi [QSA,L]

の部分を

RewriteRule ^/(.*)$ balancer://cluster%{REQUEST_URI} [P,QSA,L]

にすればよい?

これは冒頭の pdf の記述のままなのだけれど、、、未確認。
何か動かなさそう。

テスト

LANG:console
$ rm public/test*
$ wget -qO- --header="Accept: text/html" --header="Accept-Language: en" http://localhost/negotiation/test
 English: Test   OK

$ ls public/test*
 public/test.html.en
$ wget -qO- --header="Accept: text/html" --header="Accept-Language: en" http://localhost/negotiation/test
 English: Test   OK

$ tail /var/log/apache2/rewrite.log
 initial URL equal rewritten URL: (DOCROOT)/negotiation/test.html.en
$ wget -qO- --header="Accept: text/html" --header="Accept-Language: ja" http://localhost/negotiation/test
 日本語: テスト ばっちり
 
$ ls public/test*
 public/test.html.en  public/test.html.ja
$ wget -qO- --header="Accept: text/html" --header="Accept-Language: ja" http://localhost/negotiation/test
 日本語: テスト ばっちり
 
$ tail /var/log/apache2/rewrite.log
 initial URL equal rewritten URL: (DOCROOT)/negotiation/test.html.ja
$ wget -qO- --header="Accept: text/html" --header="Accept-Language: en" http://localhost/negotiation/test
$ tail /var/log/apache2/rewrite.log
 initial URL equal rewritten URL: (DOCROOT)/negotiation/test.html.en
$ rm public/test*
$ wget -qO- --header="Accept: text/html" http://localhost/negotiation/test.ja
 日本語: テスト ばっちり
 
$ ls public/test*
 public/test.html.ja
$ wget -qO- --header="Accept: text/html" http://localhost/negotiation/test.ja
 日本語: テスト ばっちり
 
$ tail /var/log/apache2/rewrite.log
 initial URL equal rewritten URL: (DOCROOT)/negotiation/test.html.ja
$ wget -qO- --header="Accept: text/html" http://localhost/negotiation/test.en
 English: Test   OK

$ ls public/test*
 public/test.html.en  public/test.html.ja
$ wgetc --header="Accept: text/html" http://localhost/negotiation/test.en
 English: Test   OK

$ cat tmp/wget.cookie 
 # HTTP cookie file.
 # Edit at your own risk.
 
 dora.bk.tsukuba.ac.jp   FALSE   /       FALSE   0       _negotiation_session    BAh7BiIKZm...
 dora.bk.tsukuba.ac.jp   FALSE   /       FALSE   0       rails_language  en
$ rm public/test.html.en
$ wgetc --header="Accept: text/html" http://localhost/negotiation/test
 English: Test   OK
 
$ ls public/test*
 public/test.html.en  public/test.html.ja

アクションキャッシュ

まずは rails そのままで、アクションキャッシュ機能をONにしてみる。

LANG:console
$ jed app/controllers/test_controller.rb
 class TestController < ApplicationController
 #  caches_page :index
   caches_action :index
   def index
   end
 end
$ rm public/test.*
$ (restart script/server)
$ wgetc http://localhost:3000/test.en
 404: Not Found
$ tail log/production.log
 Processing TestController#index to html (for 127.0.1.1 at 2009-06-08 21:56:33) [GET]
   Parameters: {"rails_language"=>"en"}
 Cookie set: rails_language=en; path=/
 
 
 ActionController::RoutingError (No route matches {:action=>"index", :format=>"en"}):
     /vendor/rails/actionpack/lib/action_controller/routing/route_set.rb:370:in `generate'
     /vendor/rails/actionpack/lib/action_controller/url_rewriter.rb:208:in `rewrite_path'
     /vendor/rails/actionpack/lib/action_controller/url_rewriter.rb:187:in `rewrite_url'
     /vendor/rails/actionpack/lib/action_controller/url_rewriter.rb:165:in `rewrite'
     /vendor/rails/actionpack/lib/action_controller/base.rb:626:in `url_for'
     /vendor/rails/actionpack/lib/action_controller/caching/actions.rb:144:in `initialize'

キャッシュ内容を保存する際に使うキーが生成できずにエラーになっている。

このあたりの処理は、ActionController::Caching::Actions::ActionCachePath で行われている。

LANG:ruby(linenumber)
module ActionController::Caching::Actions
  class ActionCachePath
    attr_reader :language

    def initialize(controller, options = {}, infer_extension=true)
      if infer_extension and options.is_a? Hash
        request_extension = extract_extension(controller.request)
        options = controller.params.merge(
                  options.reverse_merge(
                      :format => request_extension, 
                      :rails_language => controller.request.accepts_languages.first))
      end
      path = controller.url_for(options).split('://').last
      if infer_extension
        @extension = request_extension
        add_extension!(path, @extension)
      end
      @path = URI.unescape(path)
    end

  private

    def add_extension!(path, extension)
      if extension
        p= path.split('.')
        p.insert(-2, extension) unless path =~ /\b#{Regexp.escape(extension)}\b/
        p.join('.')
      end
    end
    
    def extract_extension(request)
      p= request.path.split('.')
      # drop file name
      p.shift
      # drop language
      p.pop if !p.empty? && ActionController::AbstractRequest.acceptable_language?(p.last)
      extension = p.join('.')

      # If there's no extension in the path, check request.format
      extension = request.cache_format if extension==""

      extension
    end
  end
end

8〜11行目の部分、元のコードでは controller.params とマージせずに うまく行っているのだけど、このコードでは controller.params とマージしないと action や id の指定が無視されるようだったので、対症療法として入れてある。

後で要チェック

request.accepts_languages.first の言語テンプレートファイルが存在しない時、 キャッシュキーには request.accepts_languages.first で示される言語指定が 付くにもかかわらず、中身は提供可能な言語で表示された内容になる。 注意が必要ではあるが、恐らくこれは正しい動作。

テスト

LANG:console
$ (restart script/server)
$ wgetc http://localhost:3000/test
 日本語:テスト ばっちり
 
$ less log/development.log
 Cached fragment miss: views/localhost:3000/test.ja (0.1ms)
$ wgetc http://localhost:3000/test
 日本語:テスト ばっちり
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.ja (0.1ms)
$ wgetc http://localhost:3000/test.ja
 日本語:テスト ばっちり
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.ja (0.1ms)
$ wgetc http://localhost:3000/test.en
 English: Test   OK
 
$ less log/development.log
 Cached fragment miss: views/localhost:3000/test.en (0.1ms)
$ wgetc http://localhost:3000/test.en
 English: Test   OK
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.en (0.1ms)
$ wgetc http://localhost:3000/test
 English: Test   OK
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.en (0.1ms)
$ wgetc http://localhost:3000/test/index
 English: Test   OK
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.en (0.1ms)
$ wgetc http://localhost:3000/test/index/0
 English: Test   OK
 
$ less log/development.log
 Cached fragment miss: views/localhost:3000/test/index/0.en (0.1ms)
$ wgetc http://localhost:3000/test/index/0.html
 English: Test   OK
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test/index/0.en (0.1ms)

http://localhost:3000/test/index.en へのアクセスに対して
views/localhost:3000/test.en が、
http://localhost:3000/test/index/0.html.en へのアクセスに対して
views/localhost:3000/test/index/0.en が、
それぞれ引かれていることに注意。

url_for で変換し直している効果が現れている。

routes の記述順によっては逆に冗長な記述への変換(/test を /test/index へ、など) が起こってしまう場合があるので、routes では省略形をより上の方に書くのが良いようだ。

フラグメントキャッシュ

まずは例によって rails そのままで、フラグメントキャッシュ機能を使ってみる。

LANG:console
$ echo "<% cache do %>Test   <%= render :partial=>'ok' %><% end %>" > app/views/test/index.html.en.erb
$ echo "<% cache do %>テスト <%= render :partial=>'ok' %><% end %>" > app/views/test/index.html.ja.erb
$ jed app/controllers/test_controller.rb
 class TestController < ApplicationController
 #  caches_page :index
 #  caches_action :index
   def index
   end
 end
$ (restart script/server)
$ wgetc -qO- http://localhost:3000/test
 English: Test   OK

$ less log/development.log
 Cached fragment miss: views/localhost:3000/test (0.1ms)

やっぱりうまく行っていない。

ここは、views/localhost:3000/test.en となってほしいところ。

これは ActiveSupport::Cache のテリトリーらしい。

LANG:ruby(linenumber)
module ActionController
  module Caching
    module Fragments
      def fragment_cache_key(key)
        ActiveSupport::Cache.expand_cache_key(
              key.is_a?(Hash) ? 
                  url_for(key.reverse_merge(
                           :rails_language=>request.accepts_languages.first)
                      ).split("://").last :
                  key, 
              :views)
      end
    end
 end
end

テスト

LANG:console
$ (restart script/server)
$ wgetc http://localhost:3000/test
 English: Test   OK
 
$ less log/development.log
 Cached fragment miss: views/localhost:3000/test.en (0.1ms)
$ wgetc http://localhost:3000/test
 English: Test   OK
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.en (0.1ms)
$ wgetc http://localhost:3000/test.en
 English: Test   OK
 
$ less log/development.log
 Cached fragment hit: views/localhost:3000/test.en (0.1ms)
$ echo "<% cache :param1=>:test do %>Test   <%= render :partial=>'ok' %><% end %>" > app/views/test/index.html.en.erb
$ wgetc http://localhost:3000/test
 English: Test   OK
 
$ less log/development.log
 Cached fragment miss: views/localhost:3000/test.en?param1=test (0.1ms)

うまく行っているようだ。


Counter: 7363 (from 2010/06/03), today: 1, yesterday: 1