言語ネゴシエーション の履歴(No.4)
更新- 履歴一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- ソフトウェア/rails/言語ネゴシエーション へ行く。
Rails で言語ネゴシエーション(Language Negotiation)†
を見ながら、ブラウザの Language Negotiation の機能を使って rails アプリケーションの多言語化する方法を調べてみた。
やりたい内容としては
- views/*/*.html.ja.erb と views/*/*.html.en.erb とを両方作っておき、 自動的に切り替えて読み込む
- キャッシュされたページも apache のネゴシエーション機能を使って 自動で切り替わるようにする
rails アプリの多言語化については他にも、
- ネゴシエーションっぽいもの
- ロケールによるテンプレート切り替え
- ネゴシエーションではなく Ruby-GetText を使った方法やネゴシエーションとの比較
- rails エラーメッセージの日本語化
- RubyOnRails を使ってみる 【第 5 回】 ActiveHeart
- /usr/share/rails/actionpack/lib/action_view/locale/xx.yml とか
などがあるみたい。
以下手順。
negotiation という名前のアプリケーションの作成†
LANG:console $ rails negotiation $ cd negotiation
開発用サーバーの起動と環境の確認†
LANG:console $ script/server & $ wget -qO- http://localhost:3000/ | html2text ... ****** Welcome aboard ****** ***** You’re riding Ruby on Rails! ***** **** About_your_application’s_environment **** ... $ 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
test コントローラとビューの作成†
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
$ jed app/controllers/test_controller.rb
class TestController < ApplicationController
def index
end
end
$ echo "Test" > app/views/test/index.html.erb
$ wget -qO- http://localhost:3000/test
Test
多言語化したビューの作成†
今作ったビューを削除して、日本語版と英語版を作成。
まだ rails に手を入れていないので、そのままではエラーになる。
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 500 Internal Server Error
これを正しく表示できるようにするのが目標。
config/routes.rb の設定†
多言語化後は、各ページに対して
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 などの言語指定があれば、その言語で表示する
- 一旦言語指定を行ったら、以降は言語指定がない場合にも直前に行った言語指定と 同じ言語で表示する
- 表示したい言語のテンプレートファイルが無ければ、ある物を使って表示する
受け入れ可能な言語のリストを ActionController::AbstractRequest に持たせることにする。
ActionController::AbstractRequest.acceptable_languages= :ja, :en
これ以外の言語が指定されても無視される。
route の設定は、
map.connect ':controller/:action/:id.:rails_language',
:requirements => { :rails_language => /ja|en/ }
のようにする。/^(ja|en)$/ ではなく /ja|en/ とするのが正しいらしい。
そのためのコードを以下のように書いた。
config/routes.rb
LANG:ruby(linenumber)
class ActionController::AbstractRequest
def self.acceptable_languages=(v)
@acceptable_languages = v.is_a?(Array) ? v : [v]
class << @acceptable_languages
def to_regexp
Regexp.new join("|")
end
end
end
def self.acceptable_languages
@acceptable_languages
end
def self.acceptable_language?(l)
@acceptable_languages.include?(l.to_sym)
end
end
ActionController::AbstractRequest.acceptable_languages= :ja, :en
ActionController::Routing::Routes.draw do |map|
lang_regexp= ActionController::AbstractRequest.acceptable_languages.to_regexp
map.connect ':controller/:action/:id.:rails_language',
:requirements => { :rails_language => lang_regexp }
map.connect ':controller/:action.:rails_language',
:requirements => { :rails_language => lang_regexp }
map.connect ':controller.:rails_language',
:requirements => { :rails_language => lang_regexp }
map.connect ':controller/:action/:id'
map.connect ':controller/:action/:id.:format.:rails_language',
:requirements => { :rails_language => lang_regexp }
map.connect ':controller/:action/:id.:format'
end
テスト†
LANG:console
$ jed app/controllers/test_controller.rb
class TestController < ApplicationController
def index
render :text => params[:rails_language].inspect
end
end
$ wget -qO- http://localhost:3000/test
nil
$ wget -qO- http://localhost:3000/test.ja
"ja"
$ wget -qO- 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 -qO- 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"
言語が指定されていない時を含めた言語選択の優先順位†
優先順位は以下のようにする。
- 指定された言語 (指定されていれば)
- 前回指定された言語 (もしあれば session に保存しておく)
- ブラウザから送られる HTTP_ACCEPT_LANGUAGE (もしあれば)
- ActionController::AbstractRequest.acceptable_languages に記述された言語(記述された順)
この順で提供可能な言語(作成されている言語テンプレート)を検索して、 見つかった物を使って表示する。
優先順位を決定するコードは以下の通り:
config/environment.rb の末尾に
LANG:ruby
class ActionController::AbstractRequest
# will be set by ActionView::Base._pick_template
attr :language, true
def language_order!
@language_order= ! @env['HTTP_ACCEPT_LANGUAGE'] ? [] :
@env['HTTP_ACCEPT_LANGUAGE'].split(",").collect {|l|
l.gsub(/;.*$/, '').gsub(/-.*$/, '').downcase.to_sym
}.delete_if { |l|
!ActionController::AbstractRequest.acceptable_language?(l)
}
@language_order+= ActionController::AbstractRequest.acceptable_languages
@language_order.unshift(session[:rails_language].to_sym) if session[:rails_language]
@language_order.uniq!
end
def language_order
@language_order ||= language_order!
end
end
class ActionController::Base
before_filter :set_language
def set_language
session[:rails_language]= params[:rails_language] if params[:rails_language] &&
ActionController::AbstractRequest.acceptable_language?(params[:rails_language])
request.language_order!
end
end
テスト†
LANG:console
$ jed app/controllers/test_controller.rb
class TestController < ApplicationController
def index
render :text => request.language_order.inspect + "\n"
end
end
$ wget -qO- http://localhost:3000/test
[:ja, :en]
$ wget -qO- --header="ACCEPT_LANGUAGE:en" http://localhost:3000/test
[:en, :ja]
$ wget -qO- --header="ACCEPT_LANGUAGE:ja" http://localhost:3000/test
[:ja, :en]
$ wget -qO- http://localhost:3000/test
[:ja, :en]
$ wget -qO- http://localhost:3000/test.en
[:ja, :en]
$ alias wgetc="wget -qO- --load-cookies=tmp/wget.cookie --save-cookies=tmp/wget.cookie --keep-session-cookies"
$ wgetc http://localhost:3000/test
[:ja, :en]
$ cat tmp/wget.cookie
# HTTP cookie file.
# Generated by Wget on 2009-06-06 13:18:08.
# Edit at your own risk.
localhost:3000 FALSE / FALSE 0 _test_session BAh7BiIKZmxhc2h...
$ wgetc http://localhost:3000/test.en
[:en, :ja]
$ cat tmp/wget.cookie
# HTTP cookie file.
# Generated by Wget on 2009-06-06 13:20:42.
# Edit at your own risk.
localhost:3000 FALSE / FALSE 0 _test_session BAh7BzoTcmFpbHN...
$ 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 に不正な言語指定が送られても はじくようにしてある。
言語の優先順位を元に利用するテンプレートを選択する†
テンプレートの選択は 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- 
なら "png" が、入っている。
指定されていない場合には "html" になる。
以下は,テンプレートファイルを探す手順:
- 与えられたファイル名から .erb などの拡張子を取り除く
- #{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.language_order の順で。
_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
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.language_order.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 について†
_pick_template は効率化のため memoize されている。=> 参照
ところが多言語化した後は controller.request.language_order の値によって _pick_template 関数の返す値が変化するため、_pick_template を memoize するわけに行かない。
効率を落とさないためには、代わりに _pick_template_sub を memoize すればいい。
ということで unmemoize/memoize を入れたのが上のコード。
ActiveSupport::Memoizable.unmemoize が無いので実装した。
LANG:ruby(linenumber)
module ActiveSupport
module Memoizable
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 を入れた。
テスト†
LANG:console $ jed app/controllers/test_controller.rb class TestController < ApplicationController def index end end $ echo "Test" > app/views/test/index.html.en.erb $ echo "テスト" > app/views/test/index.html.ja.erb $ 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
レイアウトに関するテスト†
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 -qO- http://localhost:3000/test.ja 日本語: テスト $ wgetc -qO- http://localhost:3000/test.en English: Test
部分テンプレートに関するテスト†
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 -qO- http://localhost:3000/test.ja 日本語:テスト ばっちり $ wgetc -qO- http://localhost:3000/test.en English: Test OK
テンプレートファイルの拡張子を正しく認識させる†
以上、とってもうまく行っているように見えるけど、実は問題がある。
LANG:console $ wgetc --save-header -qO- http://localhost:3000/test.en HTTP/1.1 200 OK Etag: "122cdccf079db5eebad7e19ccc3b311b" Connection: Keep-Alive Content-Type: html.en; charset=utf-8 Date: Sat, 06 Jun 2009 06:31:12 GMT 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 にキャッシュされるので、 その必要は無い。
テスト†
LANG:console $ 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 Date: Sat, 06 Jun 2009 06:43:20 GMT 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
今度はばっちり。
ページキャッシュの多言語対応†
まずは rails そのままで、ページキャッシュ機能をONにしてみる。
LANG:console $ jed config/environments/development.rb config.action_controller.perform_caching = true $ jed app/controller/test_controller.rb class TestController < ApplicationController caches_page :index def index end end $ wgetc -qO- http://localhost:3000/test English: Test OK $ ls public/test* public/test.html $ cat public/test.html English: Test OK $ wgetc -qO- http://localhost:3000/test.en English: Test OK $ ls public/test* public/test.html public/test.en
test.html というファイルができてしまうと、 次回からは言語設定にかかわらず常にこれが表示されてしまうため、うまくいかない。
ページキャッシュは ActionController::Caching::Pages が作成する。
このときファイル名に言語指定を正しく付ければよい。
ファイル名に付ける言語指定は、path として指定された物では無く、 実際に使ったテンプレートの言語にすべき。
ちなみに 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.language))
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.language
p.join('.')
end
self.class.cache_page(content || response.body, path)
end
end
テスト†
LANG:console $ rm -r public/test* $ wgetc -qO- http://localhost:3000/test English: Test OK $ ls public/test* public/test*/* public/test.html.en $ wgetc -qO- http://localhost:3000/test.en English: Test OK $ ls public/test* public/test*/* public/test.html.en $ wgetc -qO- http://localhost:3000/test.ja 日本語:テスト ばっちり $ ls public/test* public/test*/* public/test.html.en public/test.html.ja $ wgetc -qO- http://localhost:3000/test 日本語:テスト ばっちり $ ls public/test* public/test*/* public/test.html.en public/test.html.ja $ wgetc -qO- http://localhost:3000/test/index 日本語:テスト ばっちり $ ls public/test* public/test*/* public/test.html.en public/test.html.ja public/test: index.html.ja $ wgetc -qO- 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 -qO- 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 -qO- 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
アクションキャッシュ†
まずは rails そのままで、アクションキャッシュ機能をONにしてみる。
LANG:console
$ jed app/controller/test_controller.rb
class TestController < ApplicationController
# caches_page :index
caches_action :index
def index
end
end
$ wgetc -qO- http://localhost:3000/test.en
English: Test OK
$ tail log/development.log
Processing TestController#index (for 127.0.0.1 at 2009-06-07 13:14:18) [GET]
Cached fragment hit: views/localhost:3000/test (0.1ms)
Rendering template within layouts/application
Rendering test/index
Rendered test/_ok (0.4ms)
Cached fragment miss: views/localhost:3000/test (0.1ms)
Completed in 15ms (View: 8, DB: 0) | 200 OK [http://localhost/test]
$ wgetc -qO- http://localhost:3000/test
English: Test OK
$ wgetc -qO- http://localhost:3000/test.en
Processing TestController#index (for 127.0.0.1 at 2009-06-07 13:04:57) [GET]
Parameters: {"rails_language"=>"en"}
Cached fragment hit: views/localhost:3000/test?format=en (0.1ms)
Rendering template within layouts/application
Rendering test/index
Rendered test/_ok (0.4ms)
Cached fragment miss: views/localhost:3000/test?format=en (0.1ms)
Completed in 13ms (View: 8, DB: 0) | 200 OK [http://localhost/test.en]