言語ネゴシエーション のバックアップの現在との差分(No.9)

更新


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

~

>>>> [[Rails4 向けの内容はこちら>ソフトウェア/rails/国際化]]

~

----

[[公開メモ]]

#contents

* Rails で言語ネゴシエーション(Language Negotiation) [#h05f99dd]

http://blog.omdb-beta.org/

を見ながら、ブラウザの Language Negotiation の機能を使って
rails アプリケーションの多言語化する方法を調べてみた。

rails のバージョンが上がっているせいと、それ以外の問題とで、
結局かなりの部分を1から書いたのでメモしておく。
元々の情報が古いもので、結局かなりの部分を1から書いたので、
ここにメモしておく。

やりたい内容としては
+ views/*/*.html.ja.erb と views/*/*.html.en.erb とを両方作っておき、
閲覧者の環境設定、手動選択に合わせて自動的に切り替えて送信する
** やりたいこと [#w57d45b9]

+ views/*/*.html.ja.erb とか views/*/*.html.en.erb とか、言語ごとに
テンプレートファイルを作っておき、閲覧者の環境設定、手動選択に合わせて
自動的に言語別テンプレートを切り替えて使う
+ キャッシュされたページも apache のネゴシエーション機能を使って
自動で切り替わるようにする
自動で言語を切り替えて送信できるようにする
+ エラーメッセージなど、テンプレートファイルの切換で対応しきれない部分は
I18n.t を使って多言語化できるようにする

の2点。
の3点。

** ブラウザ設定における表示言語の優先順位 [#w3484f9d]
rails アプリケーションには、通常通り

1. は、ブラウザの言語設定で日本語と英語のどちらを優先しているかを判別して、
優先順位の高い方で表示するという話。
http://rails.server/controller~
http://rails.server/controller/action~
http://rails.server/controller/action/id~
http://rails.server/controller/action/id.html~

ブラウザはこの言語設定を元に、Accept-Language: というヘッダ情報をサーバーに
送るので、rails 側でこのヘッダ情報から表示言語を選べばよい。
などの他、

** キャッシュに対する apache2 の言語ネゴシエーション [#w6b05fcf]
http://rails.server/controller.ja~
http://rails.server/controller/action.ja~
http://rails.server/controller/action/id.ja~
http://rails.server/controller/action/id.html.ja~

2. について。
のように言語を指定した形でもアクセス可能とする。

rails のページキャッシュ機能は、表示内容を .html ファイルとして保存する事で、
次回からは rails を通さず、apache で直接処理させるというものなので、
適切な名前でキャッシュファイルを作成する事と、apache2 にキャッシュファイルを
正しく認識させる事が必要になる。
言語指定の無いアドレスでアクセスされたときには
ブラウザとのネゴシエーションによって使うテンプレートを決める。

** その他の多言語化情報 [#f2bd1cf6]
** 言語の優先順位 [#w3484f9d]

rails アプリの多言語化については他にも、
使用する言語の優先順位を決めるための情報源としては、

-- ネゴシエーションっぽいもの
- [[ロケールによるテンプレート切り替え>http://www.yotabanana.com/lab/?date=20060224#p01]]
- ネゴシエーションではなく Ruby-GetText を使った方法やネゴシエーションとの比較
-- [[Railsで日本語を使う時に必須のパッケージ Ruby-GetText>http://blog.masuidrive.jp/articles/2006/07/03/gettext]]
-- [[Ruby on RailsでRuby-GetText-Packageを使う>http://www.yotabanana.com/hiki/ja/ruby-gettext-howto-ror.html]]
-- [[ruby-gettext>http://tech.feedforce.jp/ruby-gettext.html]]
-- [[Rails で行こう! - Ruby on Rails を学ぶ>http://d.hatena.ne.jp/elm200/20070719/1184856941]]
-- [[Rails のためのものぐさな Web アプリケーションの国際化手法>http://d.hatena.ne.jp/secondlife/20070207/1170835130]]
- rails エラーメッセージの日本語化
-- [[RubyOnRails を使ってみる 【第 5 回】 ActiveHeart>http://jp.rubyist.net/magazine/?0012-RubyOnRails]]
-- /usr/share/rails/actionpack/lib/action_view/locale/xx.yml とか
+ ユーザーが手動で選んだ言語を使う prams[:rails_language]
+ ユーザーが以前に手動で選んだ言語を使う cookie[:rails_language]
+ ブラウザの言語設定にある優先順位を使う header['Accept-Language']
+ アプリケーションのデフォルトの優先順位を使う ENV['RAILS_ACCEPTABLE_LANGUAGES']

などがあるようだけれど、よく調べていない。
** キャッシュに対する apache2 の言語ネゴシエーション [#w6b05fcf]

ActiveHeart と GetText については、今後調べてみる必要がありそうだ。
rails のページキャッシュ機能は、表示内容を .html ファイルとして保存する事で、
次回からは rails を通さず、apache で直接処理させるというもの。

以下は今回の試行錯誤の経過とその成果物
rails を通らないので、行うこととしては、~
適切な名前でキャッシュファイルを作成する事と、~
apache2 にキャッシュファイルを正しく認識させる事と、~
の2つになる。

* negotiation という名前のアプリケーションの作成 [#vb4e8dd6]
** その他の多言語化方法 [#f2bd1cf6]

テスト用に negotiation という名前のアプリケーションを作成。
rails アプリの多言語化については、これまで Ruby-GetText, I18n, ActiveHeart など、
開発者ごとに異なる方法で行われてきた。

 LANG:console
 $ rails negotiation
 $ cd negotiation
そんな中、rails 2.2 から、rails 本体の推奨する多言語化手法が I18n に決まったようなので、
今後は I18n を用いるアプリケーションが増えると考えられる。

* 開発用サーバーの起動と環境の確認 [#s35cbbb4]
多言語化の方法には大きく分けて次の2つがある。
+ 言語ごとに別のテンプレートファイルを用いる方法
+ テンプレートファイルは共通にして、中で現れる文言を1つずつその都度翻訳する

 LANG:console
 $ script/server &
 $ wget -qO- http://localhost:3000/ | html2text
  ...
  
  ****** Welcome aboard ******
  ***** You’re riding Ruby on Rails! *****
  **** About_your_application’s_environment ****
  ...
  
 $ $ ls db/*
  ls: cannot access db/*: そのようなファイルやディレクトリはありません
 $ wget -qO- http://localhost:3000/rails/info/properties | html2text
  Ruby version            1.8.7 (i486-linux)
  RubyGems version        1.3.2
  Rails version           2.2.2
  Active Record version   2.2.2
  Action Pack version     2.2.2
  Active Resource version 2.2.2
  Action Mailer version   2.2.2
  Active Support version  2.2.2
  Edge Rails revision     unknown
  Application root        /home/samba/www/rails/negotiation
  Environment             development
  Database adapter        sqlite3
  Database schema version 0
 $ ls db/*
  db/development.sqlite3
Ruby-GetText はこの両方の機能を持っているが、~
I18n は 2. のみを行うもの。

このとき自動的に sqlite3 のデータベースが作成される。
1. は 2. に比べて「国際化対応というのは異なる文化慣習への対応でもあるから、
単なる文言の修正では済まないこともある。テンプレートごと差し替えてしまえば
そういうケースでも対応が楽。」という利点があると言われる。

* test コントローラとビューの作成 [#k0a5e25a]
逆に 1. は 2. に比べて、アプリケーションの手直しでテンプレートに変更が
必要になったとき、アプリケーション開発者が翻訳済みのテンプレートに手を
入れられずに、テンプレートがメンテナンス不可能になる事態が生じる。

 LANG:console
 $ script/generate controller Test
      exists  app/controllers/
      exists  app/helpers/
      create  app/views/test
      exists  test/functional/
      create  app/controllers/test_controller.rb
      create  test/functional/test_controller_test.rb
      create  app/helpers/test_helper.rb
 $ echo "Test" > app/views/test/index.html.erb
 $ (restart script/server)
 $ wget -qO- http://localhost:3000/test
  Test
というような欠点もあると言われている。[[参照>http://www.yotabanana.com/lab/?date=20060224#p01]]

* 多言語化したビューの作成 [#b2db2fc3]
開発者が翻訳者を兼ねている、~
ページキャッシュを行う、~
あたりを考慮すると 2. も良い物だと思うので、実装したのがこの記事。

今作ったビューを削除して、日本語版と英語版を作成。
* 試行錯誤の経過 [#e232eb64]

まだ rails に手を入れていないので、そのままではエラーになる。
+ [[開発&テスト用環境の構築>ソフトウェア/rails/言語ネゴシエーション/開発用&テスト用環境の構築]]
-- negotiation という名前のアプリケーションの作成
-- 開発用サーバーの起動と環境の確認
-- test コントローラとビューの作成
+ [[routesの設定と言語選択の優先順位>ソフトウェア/rails/言語ネゴシエーション/routesの設定と言語選択の優先順位]]
-- 多言語化したビューの作成
-- config/routes.rb の設定
-- 言語が指定されていない時を含めた言語選択の優先順位]]
+ [[言語別テンプレートの選択>ソフトウェア/rails/言語ネゴシエーション/言語別テンプレートの選択]]
-- 言語の優先順位を元に利用するテンプレートを選択する]]
--- memoize について
-- テンプレートファイルの拡張子を正しく認識させる
+ [[キャッシュの多言語対応>ソフトウェア/rails/言語ネゴシエーション/キャッシュの多言語対応]]
-- ページキャッシュの多言語対応
-- ページキャッシュの多言語化に対応させるための apache の設定
-- アクションキャッシュの多言語対応
-- フラグメントキャッシュの多言語対応
+ [[プラグイン化と公開準備>ソフトウェア/rails/言語ネゴシエーション/プラグイン化と公開準備]]
-- プラグイン化
-- テストケース
-- fcgi 化
+ [[github を使った公開>ソフトウェア/rails/言語ネゴシエーション/github を使った公開]]
-- git リポジトリ(ローカル)の作成
-- プロジェクトを github に登録
-- git を使った編集作業例

 LANG:console
 $ rm app/views/test/index.html.erb
 $ echo "Test" > app/views/test/index.html.en.erb
 $ echo "テスト" > app/views/test/index.html.ja.erb
 $ wget -O- http://localhost:3000/test
  エラー 404: Not Found
* プラグインのダウンロード [#l2dd967e]

これを正しく表示できるようにするのが目標なのだが、
その前に rails に言語ファイル選択の優先順位を教える必要がある。
github からどうぞ。

* config/routes.rb の設定 [#w47da434]
http://github.com/osamutake/RailsLanguageNegotiationPlugin/tree/master

多言語化後は、各ページに対して
* プラグインの使い方 [#sb57c7b2]

 http://localhost:3000/test
 http://localhost:3000/test.ja
 http://localhost:3000/test/index
 http://localhost:3000/test/index.ja
 http://localhost:3000/test/index/0
 http://localhost:3000/test/index/0.ja
 http://localhost:3000/test/index/0.html
 http://localhost:3000/test/index/0.html.ja

などの形でアクセスできるようにする。

+ .ja などの言語指定がない場合
-- ブラウザからも指定が無ければサーバー側の優先順位で表示言語を選ぶ
-- ブラウザが優先順位を指定していれば、その順で表示言語を選ぶ
+ .ja などの言語指定があれば、その言語で表示する
+ 一旦言語指定を行ったら、以降は言語指定がない場合にサーバーやブラウザの優先順位を無視して、
直前に行った言語指定と同じ言語で表示する
+ 表示したい言語のテンプレートファイルが無ければ、ある物を使って表示する
* TODO [#ye7b917c]

3. は、明示的に表示言語を選択できるようにするためのもの。
** ページキャッシュ上での言語切換(Cookie 書き換え) [#o28ad6fc]

http://rails.server/controller.ja~
などの、言語指定の付いたアドレスで参照してページキャッシュが返される場合、
Cookie の書き換えが起きないので、表示言語の切換ができない。

受け入れ可能な言語のリストは config/routes.rb の先頭で
ページに JavaScript を埋め込んで、location.href に言語指定が含まれている時に
Cookie を書き換える事で対応できるはず。

 ENV['RAILS_ACCEPTABLE_LANGUAGES'] ||= 'ja|en'
 <%= link_to "link text", { :rails_language=>:ja }, { :on_click="document.cookie='rails_language=ja'" } %>

として設定する事にする。
のような感じ。

サーバー側の優先順位はここでの指定順で決まる物とする。~
これ以外の言語が指定されても無視することにする。
もちろん、ブラウザ側で javascript や cookie を off にされていれば
動作しないが、それは仕方のないところ。

route の設定は、
<noscript> タグで javascript を on にするよう書いておくべき。

 map.connect ':controller/:action/:id.:rails_language', 
                     :requirements => { :rails_language => /ja|en/ }
** ActionMailer のネゴシエーション [#c283a97a]

のようになる。/^(ja|en)$/ ではなく /ja|en/ とするのが正しいらしい。
こちらもしないと片手落ちか。

以下がコード。
参照 > http://d.hatena.ne.jp/kusakari/20090226/1235616295

config/routes.rb
 LANG:ruby(linenumber)
 ENV['RAILS_ACCEPTABLE_LANGUAGES'] ||= 'ja|en'
 
 ActionController::Routing::Routes.draw do |map|
 
   lang_regexp= Regexp.new( ENV['RAILS_ACCEPTABLE_LANGUAGES'] )
 
   map.connect ':controller.:rails_language', :format => 'html', :action => 'index',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action.:rails_language', :format => 'html',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action/:id.:rails_language', :format => 'html',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action/:id', :format => 'html'
 
   map.connect ':controller/:action/:id.:format.:rails_language',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action/:id.:format',
                       :defaults => { :action => 'index', :format => 'html' }
 
 end
と思ったら、何もしなくても上記で対応しているみたい。

** テスト [#s18f0fb5]
テストケースを追加すべき。

 LANG:console
 $ (restart script/server)
 $ jed app/controllers/test_controller.rb
  class TestController < ApplicationController
    def index
      render :text => params[:rails_language].inspect + "\n"
    end
  end
 $ wget -qO- http://localhost:3000/test
  nil
 $ wget -qO- http://localhost:3000/test.ja
  "ja"
 $ wget -O- http://localhost:3000/test.html.ja
  404: Not Found
 $ wget -qO- http://localhost:3000/test/index
  nil
 $ wget -qO- http://localhost:3000/test/index.ja
  "ja"
 $ wget -O- http://localhost:3000/test/index.html.ja
  404: Not Found
 $ wget -qO- http://localhost:3000/test/index/0.html
  nil
 $ wget -qO- http://localhost:3000/test/index/0.html.ja
  "ja"

言語指定を正しく行えている事が分かる。

* 言語が指定されていない時を含めた言語選択の優先順位 [#k5f2df0a]

優先順位は以下のようにする。

+ 指定された言語 (指定されていれば)
+ 前回指定された言語 (もしあれば cookie に保存しておく)
+ ブラウザから送られる Accept-Language (もしあれば)
+ RAILS_ACCEPTABLE_LANGUAGES に記述された言語(記述された順)

この順で提供可能な言語(作成されている言語テンプレート)を検索し、
見つかった物を使って表示する。

優先順位を決定するコードは以下の通り:

config/environment.rb の末尾に
 LANG:ruby(linenumber)
 class ActionController::AbstractRequest
 
   def self.acceptable_languages
     @acceptable_languages ||=
       ENV['RAILS_ACCEPTABLE_LANGUAGES'].split('|').map {|l| l.to_sym}
   end
 
   def self.acceptable_language?(l)
     acceptable_languages.include?(l.to_sym)
   end
 
 end
 
 class ActionController::AbstractRequest
 
   # will be set by ActionView::Base._pick_template
   attr :language, true
 
   def accepts_languages!(language=nil)
     @accepts_languages= [
         language ? language.to_sym : nil,
         ( cookie = cookies['rails_language'] and l = cookie.first and 
             ActionController::AbstractRequest.acceptable_language?(l) ) ? l.to_sym : nil,
         ( @env['HTTP_ACCEPT_LANGUAGE'] || '' ).split(",").collect {|l|
             lsym= l.split(/;|\-/,2)[0].downcase.to_sym
             ActionController::AbstractRequest.acceptable_language?(lsym) ? lsym : nil
         },
         ActionController::AbstractRequest.acceptable_languages
     ].flatten.compact.uniq
   end
 
   def accepts_languages
     @accepts_languages || accepts_languages!
   end
 
 end
 
 class ActionController::Base
 
 #  before_filter :set_language
 #    fails for some unknown reason.
 #  following is a workaround.
 
   def perform_action_with_set_rails_language
     set_rails_language
     perform_action_without_set_rails_language
   end
   alias_method_chain :perform_action, :set_rails_language
 
 protected
   def set_rails_language
     rails_language= params[:rails_language]
     if rails_language && ActionController::AbstractRequest.acceptable_language?(rails_language)
       cookies['rails_language']= rails_language
       request.accepts_languages!(rails_language)
     end
   end
 end

ブラウザから送られる Accept ヘッダーを解釈して、
ブラウザの受け入れ可能な Mime::Type の配列にして返す関数が
ActionController::AbstractRequest::accepts
という名前であるのに習って、
Accept-Language を解釈して配列にして返す関数を accepts_languages とした。

一旦指定された言語選択は、ActionController::Base.set_rails_language にて
cookies['rails_language'] に保存される。

クッキーの書き換えを行った場合には、accepts_languages を更新する必要がある。
このための関数が accepts_languages! 。

set_rails_language を before_fileter に登録したかったのだけれど、
なぜかうまく行かなかったので、perform_action の前に無理矢理はさんだ。

実際にどの言語で送信されるかは、優先順位に従ってテンプレートファイルを検索し、
初めて見つかった物が何であるかによって決まる。

request.language はこの実際のテンプレートファイルの言語を表すプロパティで、
後から ActionView::Base._pick_template によって設定される事になる。

** テスト [#h4a02e5d]

 LANG:console
 $ jed app/controllers/test_controller.rb
  class TestController < ApplicationController
    def index
      render :text => request.accepts_languages.inspect + "\n"
    end
  end
 $ (restart script/server)
 $ wget -qO- http://localhost:3000/test
  [:ja, :en]
 $ wget -qO- --header="Accept-Language:ja" http://localhost:3000/test
  [:ja, :en]
 $ wget -qO- --header="Accept-Language:en" http://localhost:3000/test
  [:en, :ja]
 $ wget -qO- http://localhost:3000/test
  [:ja, :en]
 $ wget -qO- http://localhost:3000/test.en
  [:en, :ja]
 $ alias wgetc="wget -qO- --load-cookies=tmp/wget.cookie --save-cookies=tmp/wget.cookie --keep-session-cookies"
 $ cat tmp/wget.cookie 
  cat: tmp/wget.cookie: そのようなファイルやディレクトリはありません
 $ wgetc http://localhost:3000/test
  [:ja, :en]
 $ cat tmp/wget.cookie
  # HTTP cookie file.
  # Edit at your own risk.
  
 $ wgetc http://localhost:3000/test.en
  [:en, :ja]
 $ cat tmp/wget.cookie
  # HTTP cookie file.
  # Edit at your own risk.
  
  localhost:3000  FALSE   /       FALSE   0       _negotiation_session    BAh7BiIKZm...
  localhost:3000  FALSE   /       FALSE   0       rails_language  en
 $ wgetc http://localhost:3000/test
  [:en, :ja]
 $ wgetc http://localhost:3000/test.ja
  [:ja, :en]
 $ wgetc http://localhost:3000/test
  [:ja, :en]

正しく切り換えができている事が分かる。

ACCEPT_LANGUAGE や rails_language, cookie['rails_language'] 
に不正な言語指定が送られてもはじくようになっている。

* 言語の優先順位を元に利用するテンプレートを選択する [#tdc62b56]

テンプレートの選択は ActionView::Base._pick_template で行われる。

デフォルトではコントローラ名とアクション名を controller/action という形に
繋げたテンプレートファイル名を引数にして _pick_template_sub が呼ばれ、
以下の順で実際に適用するテンプレートファイルを見つけ出す。

テンプレートファイル名を明示的に指定して render が呼ばれた場合や、
レイアウトファイル、部分テンプレート(partial) に対する render でも、
実際のテンプレートファイルを探す際には必ずこの関数が呼び出される。

高速化のため、この関数が呼び出される時点で、
self.view_paths[file_name_without_extention] にテンプレートファイルの
一覧が作成されていて、.erb などの拡張子を除いたファイル名を与えれば、
(もしテンプレートファイルが存在すれば)ActionView::Template 
オブジェクトとして取り出す事ができるようになっている。

template_format は routes で出てきた params[:format] のことで、~
wget -qO- http://localhost:3000/test/index/0.html なら "html" が、~
wget -qO- http://localhost:3000/test/index/0.xml なら "xml" が、~
wget -qO- http://localhost:3000/test/index/0.png なら "png" が、入っている。~
指定されていない場合には "html" になる。

以下は,テンプレートファイルを探す手順:
+ 与えられたファイル名から .erb などの拡張子を取り除いたものを 
template_file_name_without_extention とする
+ #{template_file_name_without_extention}.#{template_format} があればそれを使う
+ template_file_name_without_extention があればそれを使う
+ partial からの呼び出しでは、オリジナルのテンプレートの format_and_extention を使って~
#{template_file_name}.#{first_render.format_and_extension} があればそれを使う
+ フォーマットが js つまり JavaScript だったら、~
#{template_file_name}.html があればそれを使う
+ 以上で見つからなければ、app/views フォルダ以外にあるかもしれないので、
とりあえずそのまま与えられた文字列を ActionView::Template.new に渡してみる

言語ごとのテンプレートファイルを使うには、これらのファイル名に .ja などを加えた
テンプレートも検索する事になる。このとき検索は request.accepts_languages の順で行う。

_pick_template_sub は template_file_name に lang (例えば ".ja") を加えた
テンプレートを検索するヘルパー関数として作成した。~
lang = "" とすれば、オリジナルの _pick_template と同じ動作になる。

#{template_file_name}.#{first_render.format_and_extension} の検索について:~
first_render.format_and_extension は "html.erb" のようなものになる可能性があるので、
.ja をこの間に挟んで "html.ja.erb" を作らなければならない。~
first_render.format_and_extension が "html.ja.erb" の形をしている場合には、
以下のコードでは "html.ja.ja.erb" を探す事になってしまうけれど、
検索は時間の掛かる物では無いため、実害はないはず。

memoize/unmemoize については次項を参照

 LANG:ruby(linenumber)
 class ActionView::Base
 
 private
 
   def _pick_template_sub(template_file_name, lang="")
     if template = self.view_paths["#{template_file_name}.#{template_format}#{lang}"]
       return template
     elsif template = self.view_paths["#{template_file_name}#{lang}"]
       return template
     elsif (first_render = @_render_stack.first) && first_render.respond_to?(:format_and_extension)
       m= first_render.format_and_extension.match(/(.*)(\.\w+)?$/)
       template = self.view_paths["#{template_file_name}.#{m[1]}#{lang}#{m[2]}"]
       return template
     end
     if template_format == :js && template = self.view_paths["#{template_file_name}.html#{lang}"]
       @template_format = :html
       return template
     end
     nil
   end
   memoize :_pick_template_sub
 
   unmemoize :_pick_template if memoized? :_pick_template
   def _pick_template(template_path)
     return template_path if template_path.respond_to?(:render)
 
     path = template_path.sub(/^\//, '')
     if ( m = path.match(/(.*)\.(\w+)$/) ) && 
           ActionView::Template.template_handler_extensions.include?(m[2])
       template_file_name, template_file_extension = m[1], m[2]
     else
       template_file_name = path
     end
 
     # search for localized version
     if controller && controller.respond_to?(:request)
       controller.request.accepts_languages.each do |lang|
         if template = _pick_template_sub(template_file_name, ".#{lang}")
           controller.request.language= lang
           return template
         end
       end
     end
 
     # search for not localized version
     if template = _pick_template_sub(template_file_name)
       template
     else
       # not found in view_paths
       template = ActionView::Template.new(template_path, view_paths)
 
       if self.class.warn_cache_misses && logger
         logger.debug "[PERFORMANCE] Rendering a template that was " +
           "not found in view path. Templates outside the view path are " +
           "not cached and result in expensive disk operations. Move this " +
           "file into #{view_paths.join(':')} or add the folder to your " +
           "view path list"
       end
 
       template
     end
   end
 
 end

** memoize について [#o48332bd]

_pick_template は効率化のため memoize されている。=> [[参照>http://wota.jp/ac/?date=20081025#p11]]

ところが多言語対応した後は controller.request.accepts_languages の値によって
_pick_template 関数の返す値が変化するため、_pick_template を memoize するわけに行かない。

効率を落とさないため、代わりに _pick_template_sub を memoize する。

ということで unmemoize/memoize を入れたのが上のコード。

ActiveSupport::Memoizable.unmemoize が無いので実装した。

 LANG:ruby(linenumber)
 module ActiveSupport
   module Memoizable
     def memoized?(symbol)
       original_method = :"_unmemoized_#{symbol}"
       class_eval <<-EOS, __FILE__, __LINE__
         method_defined?(:#{original_method})
       EOS
     end
     def unmemoize(*symbols)
       symbols.each do |symbol|
         original_method = :"_unmemoized_#{symbol}"
         memoized_ivar = MEMOIZED_IVAR.call(symbol)
         class_eval <<-EOS, __FILE__, __LINE__
           raise "Not memoized #{symbol}" if !method_defined?(:#{original_method})
           undef #{symbol}
           alias #{symbol} #{original_method}
           #{memoized_ivar}= nil
         EOS
       end
     end
   end
 end

実際には上のコードでは _pick_template を上書きしてしまっているので unmemoize の必要はない。

上書きしたコードをもう一度 memoize し直すような用途には unmemoize をしておかないと
いけないのと、memoize されてないことを明示するため、unmemoize を入れた。

** テスト [#nb0c36b7]

 LANG:console
 $ jed app/controllers/test_controller.rb
  class TestController < ApplicationController
  end
 $ echo "Test" > app/views/test/index.html.en.erb
 $ echo "テスト" > app/views/test/index.html.ja.erb
 $ (restart script/server)
 $ rm tmp/wget.cookie
 $ wgetc -qO- http://localhost:3000/test
  テスト
 $ wgetc --header="Accept-Language:en" http://localhost:3000/test
  Test
 $ wgetc -qO- http://localhost:3000/test
  テスト
 $ wgetc -qO- http://localhost:3000/test.en
  Test
 $ wgetc http://localhost:3000/test
  Test
 $ wgetc --header="Accept-Language:ja" http://localhost:3000/test
  Test

** レイアウトに関するテスト [#j36ad09b]

 LANG:console
 $ echo "日本語: <%= @content_for_layout %>" > app/views/layouts/application.html.ja.erb
 $ echo "English: <%= @content_for_layout %>" > app/views/layouts/application.html.en.erb
 $ wgetc http://localhost:3000/test.ja
  日本語: テスト
 
 $ wgetc http://localhost:3000/test.en
  English: Test
  
** 部分テンプレートに関するテスト [#vc77e9b2]

 LANG:console
 $ echo "Test   <%= render :partial=>'ok' %>" > app/views/test/index.html.en.erb
 $ echo "テスト <%= render :partial=>'ok' %>" > app/views/test/index.html.ja.erb
 $ echo "OK"       > app/views/test/_ok.html.en.erb
 $ echo "ばっちり" > app/views/test/_ok.html.ja.erb
 $ wgetc http://localhost:3000/test.ja
  日本語:テスト ばっちり
  
  
 $ wgetc http://localhost:3000/test.en
  English: Test   OK
  
  

* テンプレートファイルの拡張子を正しく認識させる [#tdc62b56]

以上、とってもうまく行っているように見えるけど、実は問題がある。

 LANG:console
 $ wgetc --save-header http://localhost:3000/test.en
  HTTP/1.1 200 OK 
  Etag: "122cdccf079db5eebad7e19ccc3b311b"
  Connection: Keep-Alive
  Content-Type: html.en; charset=utf-8
  Server: WEBrick/1.3.1 (Ruby/1.8.7/2008-08-11)
  X-Runtime: 11ms
  Content-Length: 21
  Cache-Control: private, max-age=0, must-revalidate
  Set-Cookie: _test_session=BAh7BzoTcm...
  
  English: Test   OK
  
  
 $

どこが問題かというと、
 Content-Type: html.en; charset=utf-8

の部分。

使っているテンプレートファイル名が 
 test/index.html.en.erb 

なので、これが
 {test/}{index}.{html.en}.{erb}

のように分割されて、html.en の部分を Mime-Type として返してしまっている。

この部分を受け持っているのは ActionView::Template クラスで、変更すべきは
 {test/}{index}.{html}.{en}.{erb}

のように正しく切る、というところのみなのだが、実際に直すのは多少大がかりになる。

 LANG:ruby(linenumber)
 module ActionView
   class Template
     attr_accessor :language
 
     def initialize(template_path, load_paths = [])
       template_path = template_path.dup
       @base_path, @name, @format, @language, @extension = split(template_path)
       @base_path.to_s.gsub!(/\/$/, '') # Push to split method
       @load_path, @filename = find_full_path(template_path, load_paths)
 
       # Extend with partial super powers
       extend RenderablePartial if @name =~ /^_/
     end
 
     unmemoize :format_and_extension
     def format_and_extension
       (extensions = [format, language, extension].compact.join(".")).blank? ? nil : extensions
     end
     memoize :format_and_extension
 
     unmemoize :path
     def path
       [base_path, [name, format, language, extension].compact.join('.')].compact.join('/')
     end
     memoize :path
 
     unmemoize :path_without_extension
     def path_without_extension
       [base_path, [name, format, language].compact.join('.')].compact.join('/')
     end
     memoize :path_without_extension
 
     unmemoize :path_without_format_and_extension
     def path_without_format_and_extension
       [base_path, name].compact.join('/')
     end
     memoize :path_without_format_and_extension
 
     private
 
       # Returns file split into an array
       #   [base_path, name, format, language, extension]
       def split(file)
         if m = file.match(/^(.*\/)?([^\.]+)(?:\.(\w+)(?:\.(\w+)(?:\.(\w+)(?:\.(\w+))?)?)?)?$/)
           if m[6] # multi part format
             [m[1], m[2], "#{m[3]}.#{m[4]}", m[5], m[6]]
           elsif m[5]
             if ActionController::AbstractRequest.acceptable_language?(m[4])
               [m[1], m[2], m[3], m[4], m[5]]
             else # multi part format
               [m[1], m[2], "#{m[3]}.#{m[4]}", m[5]]
             end
           elsif m[4] # no format
             if valid_extension?(m[4])
               if ActionController::AbstractRequest.acceptable_language?(m[3])
                 [m[1], m[2], nil, m[3], m[4]]
               else # Single format
                 [m[1], m[2], m[3], nil, m[4]]
               end
             else
                 [m[1], m[2], m[3], m[4], nil]
             end
           else
             if valid_extension?(m[3])
               [m[1], m[2], nil, nil, m[3]]
             elsif ActionController::AbstractRequest.acceptable_language?(m[3])
               [m[1], m[2], nil, m[3], nil]
             else
               [m[1], m[2], m[3], nil, nil]
             end
           end
         end
       end
   end
 end

split はかなり重たい関数なので memoize したくなるが、
実際には ActionView::Template のインスタンス自体が
ActionView::Base.view_paths にキャッシュされるので、
その必要は無い。

** テスト [#he34416a]

 LANG:console
 $ (restart script/server)
 $ wgetc --save-header -qO- http://localhost:3000/test.en
  HTTP/1.1 200 OK 
  Etag: "122cdccf079db5eebad7e19ccc3b311b"
  Connection: Keep-Alive
  Content-Type: text/html; charset=utf-8
  Server: WEBrick/1.3.1 (Ruby/1.8.7/2008-08-11)
  X-Runtime: 13ms
  Content-Length: 21
  Cache-Control: private, max-age=0, must-revalidate
  
  English: Test   OK
  
  
今度はばっちり。

* ページキャッシュの多言語対応 [#z6cc2a4b]

まずは 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

** テスト [#t5c0d140]

 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 の設定 [#t2a0cd95]

まずは 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 /home/samba/www/negotiation/] pass through /home/samba/www/negotiation/test.html.ja
 $ wgetc http://localhost/negotiation/test.ja
  日本語: テスト ばっちり
  
 $ tail /var/log/apache2/rewrite.log
  [perdir /home/samba/www/negotiation/] pass through /home/samba/www/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 /home/samba/www/negotiation/] pass through /home/samba/www/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 形式の呼び出しにキャッシュが効かない [#q96ec24a]

まず、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]

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

このルールは、

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

という意味になる。

** 406 エラーの回避 [#n6841768]

次に、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 の順に探して、
見つかった物を返すようにした。

** キャッシュミスを検出 [#he27a9f0]

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

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

 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行は省略した方が良い。

** 言語指定を記憶する [#c3196e2a]

もう一点、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] にマッチする時に限って
値を採用する。

** 言語指定のある時にキャッシュミスを検出 [#v76d4482]

 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 に書き直すため、

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

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

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

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

** .htaccess の最終形 [#h8d761ca]

最終的に、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 を使うのであれば [#y50f2fe4]

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

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

の部分を

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

にすればよい?

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

** テスト [#f34fa374]

 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: /home/samba/www/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: /home/samba/www/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: /home/samba/www/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: /home/samba/www/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

* アクションキャッシュ [#gd9d14bc]

まずは 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 で示される言語指定が
付くにもかかわらず、中身は提供可能な言語で表示された内容になる。
注意が必要ではあるが、恐らくこれは正しい動作。

** テスト [#n1f7d9d6]

 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 では省略形をより上の方に書くのが良いようだ。

* フラグメントキャッシュ [#p06455c8]

まずは例によって 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

** テスト [#le18b1e9]

 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)

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

* ここまでのコード [#o65761f9]

以下にここまでのコードをまとめておく。

public/.htaccess
 LANG:shell(linenumber)
 # 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

config/routes.rb
 LANG:ruby(linenumber)
 ENV['RAILS_ACCEPTABLE_LANGUAGES'] ||= 'ja|en'
 
 ActionController::Routing::Routes.draw do |map|
 
   lang_regexp= Regexp.new( ENV['RAILS_ACCEPTABLE_LANGUAGES'] )
 
   map.connect ':controller.:rails_language', :format => 'html', :action => 'index',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action.:rails_language', :format => 'html',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action/:id.:rails_language', :format => 'html',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action/:id', :format => 'html'
 
   map.connect ':controller/:action/:id.:format.:rails_language',
                       :requirements => { :rails_language => lang_regexp }
   map.connect ':controller/:action/:id.:format',
                       :defaults => { :action => 'index', :format => 'html' }
 
 end

config/environment.rb
 LANG:ruby(linenumber)
 #### acceptable languages
 
 class ActionController::AbstractRequest
 
   def self.acceptable_languages
     @acceptable_languages ||=
       ENV['RAILS_ACCEPTABLE_LANGUAGES'].split('|').map {|l| l.to_sym}
   end
 
   def self.acceptable_language?(l)
     acceptable_languages.include?(l.to_sym)
   end
 
 end
 
 #### language preference order
 
 class ActionController::AbstractRequest
 
   # will be set by ActionView::Base._pick_template
   attr :language, true
 
   def accepts_languages!(language=nil)
     @accepts_languages= [
         language ? language.to_sym : nil,
         ( cookie = cookies['rails_language'] and l = cookie.first and 
             ActionController::AbstractRequest.acceptable_language?(l) ) ? l.to_sym : nil,
         ( @env['HTTP_ACCEPT_LANGUAGE'] || '' ).split(",").collect {|l|
             lsym= l.split(/;|\-/,2)[0].downcase.to_sym
             ActionController::AbstractRequest.acceptable_language?(lsym) ? lsym : nil
         },
         ActionController::AbstractRequest.acceptable_languages
     ].flatten.compact.uniq
   end
 
   def accepts_languages
     @accepts_languages || accepts_languages!
   end
 
 end
 
 #### store specified language into cookie
 
 class ActionController::Base
 
 #  before_filter :set_language
 #    fails for some unknown reason.
 #  following is a workaround.
 
   def perform_action_with_set_rails_language
     set_rails_language
     perform_action_without_set_rails_language
   end
   alias_method_chain :perform_action, :set_rails_language
 
   def set_rails_language
     rails_language= params[:rails_language]
     if rails_language && ActionController::AbstractRequest.acceptable_language?(rails_language)
       cookies['rails_language']= { :value => rails_language }
       request.accepts_languages!(rails_language)
     end
   end
 end
 
 #### unmemoize
 
 module ActiveSupport::Memoizable
 
   def memoized?(symbol)
     original_method = :"_unmemoized_#{symbol}"
     class_eval <<-EOS, __FILE__, __LINE__
       method_defined?(:#{original_method})
     EOS
   end
 
   def unmemoize(*symbols)
     symbols.each do |symbol|
       original_method = :"_unmemoized_#{symbol}"
       memoized_ivar = MEMOIZED_IVAR.call(symbol)
       class_eval <<-EOS, __FILE__, __LINE__
         raise "Not memoized #{symbol}" if !method_defined?(:#{original_method})
         alias #{symbol} #{original_method}
         undef #{original_method}
         #{memoized_ivar}= nil
       EOS
     end
   end
 
 end
 
 #### to pick language-specific templates
 
 class ActionView::Base
 
 private
 
   def _pick_template_sub(template_file_name, lang="")
     if template = self.view_paths["#{template_file_name}.#{template_format}#{lang}"]
       return template
     elsif template = self.view_paths["#{template_file_name}#{lang}"]
       return template
     elsif (first_render = @_render_stack.first) && first_render.respond_to?(:format_and_extension)
       m= first_render.format_and_extension.match(/(.*)(\.\w+)?$/)
       template = self.view_paths["#{template_file_name}.#{m[1]}#{lang}#{m[2]}"]
       return template
     end
     if template_format == :js && template = self.view_paths["#{template_file_name}.html#{lang}"]
       @template_format = :html
       return template
     end
     nil
   end
   memoize :_pick_template_sub
 
   unmemoize :_pick_template if memoized? :_pick_template
   def _pick_template(template_path)
     return template_path if template_path.respond_to?(:render)
 
     path = template_path.sub(/^\//, '')
     if ( m = path.match(/(.*)\.(\w+)$/) ) && 
           ActionView::Template.template_handler_extensions.include?(m[2])
       template_file_name, template_file_extension = m[1], m[2]
     else
       template_file_name = path
     end
 
     # search for localized version
     if controller && controller.respond_to?(:request)
       controller.request.accepts_languages.each do |lang|
         if template = _pick_template_sub(template_file_name, ".#{lang}")
           controller.request.language= lang
           return template
         end
       end
     end
 
     # search for not localized version
     if template = _pick_template_sub(template_file_name)
       template
     else
       # not found in view_paths
       template = ActionView::Template.new(template_path, view_paths)
 
       if self.class.warn_cache_misses && logger
         logger.debug "[PERFORMANCE] Rendering a template that was " +
           "not found in view path. Templates outside the view path are " +
           "not cached and result in expensive disk operations. Move this " +
           "file into #{view_paths.join(':')} or add the folder to your " +
           "view path list"
       end
 
       template
     end
   end
 
 end
 
 #### for correct mime-type detection
 
 module ActionView
   class Template
     attr_accessor :language
 
     def initialize(template_path, load_paths = [])
       template_path = template_path.dup
       @base_path, @name, @format, @language, @extension = split(template_path)
       @base_path.to_s.gsub!(/\/$/, '') # Push to split method
       @load_path, @filename = find_full_path(template_path, load_paths)
 
       # Extend with partial super powers
       extend RenderablePartial if @name =~ /^_/
     end
 
     unmemoize :format_and_extension
     def format_and_extension
       (extensions = [format, language, extension].compact.join(".")).blank? ? nil : extensions
     end
     memoize :format_and_extension
 
     unmemoize :path
     def path
       [base_path, [name, format, language, extension].compact.join('.')].compact.join('/')
     end
     memoize :path
 
     unmemoize :path_without_extension
     def path_without_extension
       [base_path, [name, format, language].compact.join('.')].compact.join('/')
     end
     memoize :path_without_extension
 
     unmemoize :path_without_format_and_extension
     def path_without_format_and_extension
       [base_path, name].compact.join('/')
     end
     memoize :path_without_format_and_extension
 
     private
 
       # Returns file split into an array
       #   [base_path, name, format, language, extension]
       def split(file)
         if m = file.match(/^(.*\/)?([^\.]+)(?:\.(\w+)(?:\.(\w+)(?:\.(\w+)(?:\.(\w+))?)?)?)?$/)
           if m[6] # multi part format
             [m[1], m[2], "#{m[3]}.#{m[4]}", m[5], m[6]]
           elsif m[5]
             if ActionController::AbstractRequest.acceptable_language?(m[4])
               [m[1], m[2], m[3], m[4], m[5]]
             else # multi part format
               [m[1], m[2], "#{m[3]}.#{m[4]}", m[5]]
             end
           elsif m[4] # no format
             if valid_extension?(m[4])
               if ActionController::AbstractRequest.acceptable_language?(m[3])
                 [m[1], m[2], nil, m[3], m[4]]
               else # Single format
                 [m[1], m[2], m[3], nil, m[4]]
               end
             else
                 [m[1], m[2], m[3], m[4], nil]
             end
           else
             if valid_extension?(m[3])
               [m[1], m[2], nil, nil, m[3]]
             elsif ActionController::AbstractRequest.acceptable_language?(m[3])
               [m[1], m[2], nil, m[3], nil]
             else
               [m[1], m[2], m[3], nil, nil]
             end
           end
         end
       end
   end
 end
 
 #### for page cache
 
 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
 
 #### for action cache
 
 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
 
 #### for fragment cache
 
 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

* プラグイン化 [#j4d21556]

http://akimichi.homeunix.net/hiki/rails/?%A5%D7%A5%E9%A5%B0%A5%A4%A5%F3 ~
http://blog.s21g.com/articles/374 ~
あたりを参考に。

まずは script/generate でプラグインの雛型を作成し、
起動用スクリプト init.rb に require を追加。
 LANG:console
 $ script/generate plugin LanugageNegotiation
       create  vendor/plugins/lanugage_negotiation/lib
       create  vendor/plugins/lanugage_negotiation/tasks
       create  vendor/plugins/lanugage_negotiation/test
       create  vendor/plugins/lanugage_negotiation/README
       create  vendor/plugins/lanugage_negotiation/MIT-LICENSE
       create  vendor/plugins/lanugage_negotiation/Rakefile
       create  vendor/plugins/lanugage_negotiation/init.rb
       create  vendor/plugins/lanugage_negotiation/install.rb
       create  vendor/plugins/lanugage_negotiation/uninstall.rb
       create  vendor/plugins/lanugage_negotiation/lib/lanugage_negotiation.rb
       create  vendor/plugins/lanugage_negotiation/tasks/lanugage_negotiation_tasks.rake
       create  vendor/plugins/lanugage_negotiation/test/lanugage_negotiation_test.rb
       create  vendor/plugins/lanugage_negotiation/test/test_helper.rb
 $ echo 'require "lanugage_negotiation"' >> vendor/plugins/lanugage_negotiation/init.rb
  
そして、config/environment.rb の内容をごそっと 
vendor/plugins/lanugage_negotiation/lib/lanugage_negotiation.rb
へ移動。

動作チェック

 $ wgetc http://localhost/negotiation/test
  English: Test   OK
  
 $ wgetc http://localhost/negotiation/test.ja
  日本語: テスト ばっちり
  
 $ wgetc http://localhost/negotiation/test
  日本語: テスト ばっちり
 

うーん、簡単すぎる。

恐らく今回の場合、以下のファイルは変更の必要は無い?

 vendor/plugins/lanugage_negotiation/Rakefile
 vendor/plugins/lanugage_negotiation/install.rb
 vendor/plugins/lanugage_negotiation/uninstall.rb
 vendor/plugins/lanugage_negotiation/tasks/lanugage_negotiation_tasks.rake

残るはドキュメントとテストケースだ。

* テストケース [#z90f29b8]

プラグインのテストケースを書かなければ。

テストケースとして書けるのかどうか自信はないのだけれど、
存在しない言語テンプレートがあった場合や、
言語に依存しないテンプレートがある場合にどうなるか、
ちゃんと試してみる必要がありそう。

** テストの動かし方 [#u8d040b6]

まずいきなりテストしてみる。

 $ cd vendor/plugins/lanugage_negotiation/
 $ rake 
  (in (rails)/negotiation/vendor/plugins/lanugage_negotiation)
  /usr/bin/ruby1.8 -I"(rails)/negotiation/vendor/plugins/lanugage_negotiation/lib" \
                   -I"(rails)/negotiation/vendor/plugins/lanugage_negotiation/lib" \
                   -I"(rails)/negotiation/vendor/plugins/lanugage_negotiation/test" \
                   "/usr/lib/ruby/1.8/rake/rake_test_loader.rb" "test/lanugage_negotiation_test.rb" 
  /usr/lib/ruby/1.8/rubygems/custom_require.rb:31:in `gem_original_require': no such file to load -- active_support (LoadError)
          from /usr/lib/ruby/1.8/rubygems/custom_require.rb:31:in `require'
          from (rails)/negotiation/vendor/plugins/lanugage_negotiation/test/test_helper.rb:2
          from ./test/lanugage_negotiation_test.rb:1:in `require'
          from ./test/lanugage_negotiation_test.rb:1
          from /usr/lib/ruby/1.8/rake/rake_test_loader.rb:5:in `load'
          from /usr/lib/ruby/1.8/rake/rake_test_loader.rb:5
          from /usr/lib/ruby/1.8/rake/rake_test_loader.rb:5:in `each'
          from /usr/lib/ruby/1.8/rake/rake_test_loader.rb:5
  rake aborted!
  Command failed with status (1): [/usr/bin/ruby1.8 -I"(rails)/...]
  
  (See full trace by running task with --trace)
 $ cat test/test_helper.rb 
  require 'rubygems'
  require 'active_support'
  require 'active_support/test_case'

パスが通っていないらしい。

確かに通ってないんだけど、いくら何でも通ってなさすぎ。~
どうしてこんな事に?

この rake をいきなり起動しちゃだめなのね。

アプリケーションのホームから、

 $ cd ../../..
 $ pwd
  (rails)/negotiation
 $ rake test:plugins
  (in (rails)/negotiation)
  /usr/bin/ruby1.8 -I"(rails)/negotiation/lib" -I"(rails)/negotiation/test" \
      "/usr/lib/ruby/1.8/rake/rake_test_loader.rb" "vendor/plugins/lanugage_negotiation/test/lanugage_negotiation_test.rb" 
  Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
  Started
  .
  Finished in 0.035457 seconds.
  
  1 tests, 1 assertions, 0 failures, 0 errors

うまく行ってる。

** テストの作成 [#n11b0fa7]

あとはもりもり書くのみ。

テストの書き方は~
 test "what to test" do
   assert true
   assert_equal 1, 1
 end

という感じ。

詳しい書式: http://www.ruby-lang.org/ja/man/html/Test_Unit.html

 LANG:ruby(linenumber)
  test "ActionController_AbstractRequest_acceptable_language?" do
 
    ar = ActionController::AbstractRequest
    ar.class_eval <<-EOS
      def self.acceptable_languages=(v)
        @acceptable_languages = v
      end
    EOS
 
    ar.acceptable_languages= :ja, :en, :fr
 
    assert ar.acceptable_language? :ja
    assert ar.acceptable_language? :en
    assert ar.acceptable_language? :fr
 
    assert ! ar.acceptable_language?(:de)
    assert ! ar.acceptable_language?(:es)
 
    assert ar.acceptable_language? "fr"
    assert ! ar.acceptable_language?("de")
  end

問題なし。

 LANG:ruby(linenumber)
  test "ActionController_AbstractRequest_accepts_languages!" do
 
    ar= ActionController::AbstractRequest
    ar.class_eval <<-EOS
      def self.acceptable_languages=(v)
        @acceptable_languages = v
      end
      attr :cookies, true
      attr :env, true
    EOS
 
    rec = ar.new
    ar.acceptable_languages= :ja, :en, :fr
    rec.cookies = {}
    rec.env = {}
 
    # server default
    assert_equal [ :ja, :en, :fr ], rec.accepts_languages!
 
    # ignore invalid specifications
    rec.cookies = { 'rails_language' => ['xx'] }
    rec.env = { 'HTTP_ACCEPT_LANGUAGE' => 'es, de' }
    assert_equal [ :ja, :en, :fr ], rec.accepts_languages!
    
    # Accept-Language
    rec.env = { 'HTTP_ACCEPT_LANGUAGE' => 'en-us, fr, en, ja' }
    assert_equal [ :en, :fr, :ja ], rec.accepts_languages!
 
    rec.env = { 'HTTP_ACCEPT_LANGUAGE' => 'en-us;q=0.8, fr;q=0.1, en, ja' }
    assert_equal [ :en, :fr, :ja ], rec.accepts_languages!
 
    # cookie
    rec.cookies = { 'rails_language' => ['ja'] }
    assert_equal [ :ja, :en, :fr ], rec.accepts_languages!
 
    rec.cookies = { 'rails_language' => ['fr'] }
    assert_equal [ :fr, :en, :ja ], rec.accepts_languages!
 
    rec.env = {}
    assert_equal [ :fr, :ja, :en ], rec.accepts_languages!
 
    # arg
    assert_equal [ :ja, :fr, :en ], rec.accepts_languages!('ja')
 
    assert_equal [ :en, :fr, :ja ], rec.accepts_languages!('en')
 
    assert_equal [ :fr, :ja, :en ], rec.accepts_languages!('fr')
 
  end

テストに引っかかった。

 LANG:ruby
 class ActionController::AbstractRequest.accepts_languages!(language=nil)
  ...
  
  lsym= l.split(/;|\-/,2)[0].strip.downcase.to_sym

の部分、

 LANG:ruby
  lsym= l.split(/;|\-/,2)[0].downcase.to_sym

とする必要有り。

 LANG:ruby(linenumber)
  class MemoizedTestClass
    extend ActiveSupport::Memoizable
    def count
      @count
    end
    def calc(v)
      @count= ( @count || 0 ) + 1
      v
    end
    memoize :calc
  end
 
  test "memoize" do
    mt= MemoizedTestClass.new
    mt.calc(1)
    mt.calc(2)
    mt.calc(1)
    mt.calc(3)
    mt.calc(1)
    assert_equal 3, mt.count
    mt.calc(1)
    mt.calc(2)
    mt.calc(3)
    assert_equal 3, mt.count
    mt.calc(4)
    assert_equal 4, mt.count
    assert MemoizedTestClass.memoized?(:calc)
    MemoizedTestClass.unmemoize :calc
    assert !MemoizedTestClass.memoized?(:calc)
    assert_equal 4, mt.count
    mt.calc(1)
    mt.calc(2)
    mt.calc(3)
    mt.calc(4)
    assert_equal 8, mt.count
    MemoizedTestClass.memoize :calc
    mt.calc(1)
    mt.calc(2)
    mt.calc(3)
    assert_equal 8, mt.count
  end

unmemoize 後にもう一度 memoize した場合には、
以前の計算結果が残ってしまうことに注意。

 module ActiveSupport::Memoizable
   def unmemoize(*symbols)
       class_eval <<-EOS, __FILE__, __LINE__
         #{memoized_ivar}= nil

などと書いてたけれど、呼び出されるコンテキストが
異なるので変数のクリアはできない。

この行は削除した。


以下、コントローラ込みのファンクショナルテストについては
http://d.hatena.ne.jp/elm200/20070724/1185282738 が参考になる。

  LANG:ruby(linenumber)
  def setup
    @ar         = ActionController::AbstractRequest
    @controller = TestController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new
  end

としておくと、

  LANG:ruby(linenumber)
  test "server default1" do
    @ar.acceptable_languages = :ja, :en, :fr
    @request.cookies = {}
    @request.env = {}
    get :index, :format=>"html"
    assert_response :success
    assert_template 'index.html.ja'
    assert_equal "/test", @request.request_uri
  end

のようなコードを書く事ができる。

キャッシュをテストするには、~
config/environments/test.rb

config.action_controller.perform_caching             = true

さらに、
  LANG:ruby(linenumber)
  def assert_cached_page( page )
    ActionController::Base.class_eval <<-EOS
      @@page_cache_directory = defined?(Rails.public_path) ? Rails.public_path : ""
      @@page_cache_directory += "/cahce_in_test_environment"
    EOS
    @controller.expire_page page
    yield
    full_path= ActionController::Base.__send__(:page_cache_path, page)
    assert File.exist?(full_path), "Cached file '#{full_path}' not created."
  end

として、development や production 環境にキャッシュを作成してしまわないようにする。

  LANG:ruby(linenumber)
  test "page cache 1" do
    @ar.acceptable_languages = :fr, :ja, :en
    @request.cookies = {}
    @request.env = { }
    page = "/test/pagecached.html.fr"
    assert_cached_page( page ) do
      get :pagecached, :format=>"html"
      assert_response :success
      assert_template 'pagecached.html.ja'
      assert_equal "/test/pagecached", @request.request_uri
      assert_layout "layouts/application.html.ja"
    end
  end

のようにテストする事ができる。

ミスして入力したテストで気づいた。

 module ActionView
   class Template
       def split(file)
             elsif && ActionController::AbstractRequest.acceptable_language?(m[3])

の部分
             elsif m[3] && ActionController::AbstractRequest.acceptable_language?(m[3])

とすべき。

routes.rb では
   map.connect ':controller/:action/:id', :format => 'html'

を始めの方に書いておかないと、
  url_for(:controller=>:test, :action=>:acceptid, :index=>1, :format=>"html")


   map.connect ':controller/:action/:id.:rails_language', :format => 'html',
                       :requirements => { :rails_language => lang_regexp }

を使って変換されてしまい、  
  /test/acceptid/1.

となるので、順番を入れ替えた。



* 手動テスト [#wd595311]

 LANG:console
 $ jed app/controllers/test_controller.rb
  class TestController < ApplicationController
    caches_page :pagecached
    caches_action :actioncached
    def index
    end
    def pagecached
    end
    def actioncached
    end
    def fragmentcached
    end
    def onlyja
    end
    def rendereaction
      render :action=>:onlyja
    end
    def renderfile
      render :file=>"renderfile_"
    end
    def acceptid
    end
  end
 $ echo "Test   <%= render :partial=>'ok' %>" > app/views/test/index.html.en.erb
 $ echo "テスト <%= render :partial=>'ok' %>" > app/views/test/index.html.ja.erb
 $ echo "Page cached"      > app/views/test/pagecached.html.en.erb
 $ echo "ページキャッシュ" > app/views/test/pagecached.html.ja.erb
 $ echo "Action cached"        > app/views/test/actioncached.html.en.erb
 $ echo "アクションキャッシュ" > app/views/test/actioncached.html.ja.erb
 $ echo "Fragment cached"        > app/views/test/fragmentcached.html.en.erb
 $ echo "フラグメントキャッシュ" > app/views/test/fragmentcached.html.ja.erb
 $ echo "日本語のみ" > app/views/test/onlyja.html.ja.erb
 $ echo "File"     > app/views/test/renderfile_.html.en.erb
 $ echo "ファイル" > app/views/test/renderfile_.html.ja.erb
 $ echo "html <%= params[:id] %>" > app/views/test/acceptid.html.ja.erb
 $ echo "text <%= params[:id] %>" > app/views/test/acceptid.text.ja.erb
 $ echo "English <%= @content_for_layout %>" > app/views/layouts/application.text.en.erb
 $ rm public/test*
  rm: cannot remove `public/test*': そのようなファイルやディレクトリはありません
 $ alias wgetc="wget -qO- --load-cookies=tmp/wget.cookie --save-cookies=tmp/wget.cookie --keep-session-cookies --header='ACCEPT: text/html'"
 $ jed public/.htaccess
  RewriteRule ^(.*)$ dispatch.fcgi [QSA,L]
 $ wgetc --header="Accept-Language: en" http://localhost/negotiation/test
  エラー: ActionController::RoutingError (no route found to match to "/negotiation/test")

うまくいかない。~
.htaccess の SetEnv RAILS_RELATIVE_URL_ROOT /negotiation の効果が得られていないみたい。

 vendor/actionpack/CHANGELOG 
 *2.2 (November 21st, 2008)*
 * AbstractRequest.relative_url_root is no longer automatically configured 
   by a HTTP header. It can now be set in your configuration environment with 
   config.action_controller.relative_url_root [Josh Peek]

どうやらこれらしい。~
でもなんで fcgi の時だけだめなんだろう?

changelog に書かれているのだからバグという事でもないのだろうし、
コードを追うのはやめにして、素直に environments/production.rb に
設定を追加したところ、動き出した。

さすが、cgi に比べると段違いに速い。

 LANG:console
 $ echo 'config.action_controller.relative_url_root = "/negotiation"' >> environments/production.rb
 $ wgetc --header="Accept-Language: en" http://localhost/negotiation/test
  日本語: テスト ばっちり
 $ rm tmp/wget.cookie
 $ wgetc --header="Accept-Language: en" http://localhost/negotiation/test
  English: Test   OK
 $ wgetc --header="Accept-Language: en" http://localhost/negotiation/test.ja
  日本語: テスト ばっちり
 $ wgetc --header="Accept-Language: en" http://localhost/negotiation/test
  日本語: テスト ばっちり
 $ wgetc http://localhost/negotiation/test/pagecached
  日本語: ページキャッシュ
 $ ls public/test/*
  public/test/pagecached.html.ja
 $ wgetc http://localhost/negotiation/test/pagecached.en
  English: Page cached
 $ ls public/test/*
  public/test/pagecached.html.en  public/test/pagecached.html.ja

* コメント [#kfcbe2ab]

気づいた点など、ぜひとも突っ込みを入れて下さい。
気づいた点など、ぜひ突っ込みを入れて下さい。

#article_kcaptcha


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