言語ネゴシエーション のバックアップ(No.12)

更新


公開メモ

Rails で言語ネゴシエーション(Language Negotiation)

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

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

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

やりたい内容としては

  1. views/*/*.html.ja.erb と views/*/*.html.en.erb とを両方作っておき、 閲覧者の環境設定、手動選択に合わせて自動的に切り替えて送信する
  2. キャッシュされたページも apache のネゴシエーション機能を使って 自動で切り替わるようにする

の2点。

ブラウザ設定における表示言語の優先順位

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

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

キャッシュに対する apache2 の言語ネゴシエーション

2. について。

rails のページキャッシュ機能は、表示内容を .html ファイルとして保存する事で、 次回からは rails を通さず、apache で直接処理させるというものなので、 適切な名前でキャッシュファイルを作成する事と、apache2 にキャッシュファイルを 正しく認識させる事が必要になる。

その他の多言語化情報

rails アプリの多言語化については他にも、

  • ネゴシエーションっぽいもの

などがあるようだけれど、よく調べていない。

ActiveHeart と GetText については、今後調べてみる必要がありそうだ。

以下は今回の試行錯誤の経過とその成果物

negotiation という名前のアプリケーションの作成

テスト用に 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 ****
 ...
 
$ $ 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        (rails)/negotiation
 Environment             development
 Database adapter        sqlite3
 Database schema version 0
$ ls db/*
 db/development.sqlite3

このとき自動的に sqlite3 のデータベースが作成される。

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
$ echo "Test" > app/views/test/index.html.erb
$ (restart script/server)
$ 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
 エラー 404: Not Found

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

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

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

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

3. は、明示的に表示言語を選択できるようにするためのもの。

受け入れ可能な言語のリストは config/routes.rb の先頭で

ENV['RAILS_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)
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

テスト

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"

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

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

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

  1. 指定された言語 (指定されていれば)
  2. 前回指定された言語 (もしあれば cookie に保存しておく)
  3. ブラウザから送られる Accept-Language (もしあれば)
  4. 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 によって設定される事になる。

テスト

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'] に不正な言語指定が送られてもはじくようになっている。

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

テンプレートの選択は 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" になる。

以下は,テンプレートファイルを探す手順:

  1. 与えられたファイル名から .erb などの拡張子を取り除いたものを template_file_name_without_extention とする
  2. #{template_file_name_without_extention}.#{template_format} があればそれを使う
  3. template_file_name_without_extention があればそれを使う
  4. partial からの呼び出しでは、オリジナルのテンプレートの format_and_extention を使って

    #{template_file_name}.#{first_render.format_and_extension} があればそれを使う

  5. フォーマットが js つまり JavaScript だったら、

    #{template_file_name}.html があればそれを使う

  6. 以上で見つからなければ、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 について

_pick_template は効率化のため memoize されている。=> 参照

ところが多言語対応した後は 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 を入れた。

テスト

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

レイアウトに関するテスト

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
 

部分テンプレートに関するテスト

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
 
 

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

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

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 にキャッシュされるので、 その必要は無い。

テスト

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
 
 

今度はばっちり。

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

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

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

プラグイン化

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 へ移動。

動作チェック

LANG:console
$ 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

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

テストケース

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

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

テストの動かし方

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

LANG:console
$ 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 をいきなり起動しちゃだめなのね。

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

LANG:console
$ 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

うまく行ってる。

プラグインのテストの作成

あとは vendor/plugins/lanugage_negotiation/test/lanugage_negotiation_test.rb に テストケースをもりもり書いていけばいい。

テストの書き方は

LANG:ruby(linenumber)
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].downcase.to_sym

の部分、

LANG:ruby
 lsym= l.split(/;|\-/,2)[0].strip.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 した場合には、 以前の計算結果が残ってしまうことに注意。

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

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

この行は削除した。

LANG:console
$ rake test:plugins
 /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.038165 seconds.
 
 3 tests, 25 assertions, 0 failures, 0 errors

テスト成功。

他にももう少し plugin test でちまちま書いてく事もできるんだけど、 controller の test と合わせていっきにやってしまった方が楽そう?

negotiation アプリケーションのテストケース

コントローラ込みのファンクショナルテストについては、 プラグインのテストケースに入れるわけに行かないので、 negotiation アプリケーションの test コントローラのテストケースとして書く事になる。

まずテストに必要なアクションとビューを追加。

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 render_action
     render :action => :acceptid
   end
   def render_file
     render :file => "test/acceptid.html"
   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 "<% cache :param1=>:test do %>Fragment cached   <%= render :partial=>'ok' %><% end %>" > app/views/test/fragmentcached.html.en.erb
$ echo "<% cache do %>フラグメントキャッシュ   <%= render :partial=>'ok' %><% end %>" > app/views/test/fragmentcached.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

テストは test/functional/test_controller_test.rb に書き込む。

書き方は 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

LANG:ruby(linenumber)

 config.action_controller.perform_caching = true

さらに、

LANG:ruby(linenumber)
 def assert_cached_page_exists( page )
   full_path= ActionController::Base.__send__(:page_cache_path, page)
   assert File.exist?(full_path), "Cached file '#{full_path}' not created."
 end

 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
   assert_cached_page_exists page 
 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

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

その他作ったヘルパー関数達

LANG:ruby(linenumber)
 def assert_content_type(type)
   flunk "Content-Type '#{type}' was expected but "+
         "'#{@response.content_type}' found." unless
             @response.content_type == type
 end

 def assert_cached_fragment_exists(key, message="Fragment cache ['#{key}'] not exists.")
   flunk message unless @controller.fragment_exist? key
 end
 
 def assert_cached_fragment(key)
   @controller.expire_fragment key
   yield
   assert_cached_fragment_exists key
 end
 
 def assert_layout(v)
   assert_equal v, @response.template.send(
       :_pick_template, @response.layout).path_without_extension
 end

 def log(s)
   if s.is_a? String
     Rails.logger.warn s
   else
     Rails.logger.warn s.inspect
   end
 end

テストはかなりたくさん書いたので、ここに全部は載せない。
一部を取り出すと、

LANG:ruby(linenumber)
 test "page cache 3" do
   @ar.acceptable_languages = :fr, :ja, :en
   @request.cookies = {}
   @request.env = { 'HTTP_ACCEPT_LANGUAGE' => 'xx' }
   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"
     assert_content_type "text/html"
   end
 end
 
 test "page cache 4" do
   @ar.acceptable_languages = :fr, :ja, :en
   @request.cookies = {}
   @request.env = { 'HTTP_ACCEPT_LANGUAGE' => 'en' }
   page = "/test/pagecached.html.en"
   assert_cached_page( page ) do
     get :pagecached, :format=>"html", :extra=>:param
     assert_response :success
     assert_template 'pagecached.html.en'
     assert_equal "/test/pagecached?extra=param", @request.request_uri
     assert_layout "layouts/application.html.en"
     assert_content_type "text/html"
   end
 end

 test "action cache 1" do
   @ar.acceptable_languages = :fr, :ja, :en
   @request.cookies = {}
   key = "test.host/test/actioncached?rails_language=fr"
   assert_cached_fragment( key ) do
     get :actioncached, :format=>"html"
     assert_response :success
     assert_template 'actioncached.html.ja'
     assert_equal "/test/actioncached", @request.request_uri
     assert_layout "layouts/application.html.ja"
     assert_content_type "text/html"
   end
 end
 
 test "action cache 2" do
   @ar.acceptable_languages = :fr, :ja, :en
   @request.cookies = {}
   key = "test.host/test/actioncached.ja"
   assert_cached_fragment( key ) do
     get :actioncached, :format=>"html", :rails_language=>"ja"
     assert_response :success
     assert_template 'actioncached.html.ja'
     assert_equal "/test/actioncached.ja", @request.request_uri
     assert_layout "layouts/application.html.ja"
     assert_content_type "text/html"
   end
 end

こんな感じ。

実際にこれを走らせる。

LANG:console 
$ rake test:functionals
 rake aborted!
 no such file to load -- (rails)/negotiation/db/schema.rb
 
 (See full trace by running task with --trace)
$ rake db:schema:dump
$ rake test:functionals
 /usr/bin/ruby1.8 -I"(rails)/negotiation/lib" -I"(rails)/negotiation/test" "/usr/lib/ruby/1.8/rake/rake_test_loader.rb" "test/functional/test_controller_test.rb" 
 Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
 Started
 ..........................
 Finished in 0.358801 seconds.
 
 26 tests, 96 assertions, 0 failures, 0 errors

実行は rake test:functionals で良い。
schema.rb が無いと言われたので、rake db:schema:dump で作成した。

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

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

の部分などで、m[3] が nil になった場合、

LANG:ruby(linenumber)
class ActionController::AbstractRequest
  def self.acceptable_language?(l)
    acceptable_languages.include?(l.to_sym)
  end
end

の l.to_sym でこける。

LANG:ruby(linenumber)
  def self.acceptable_language?(l)
    l.respond_to?(:to_sym) && acceptable_languages.include?(l.to_sym)
  end

とすべき。

また、routes.rb では

LANG:ruby(linenumber)
  map.connect ':controller/:action/:id', :format => 'html'

を始めの方に書いておかないと、

LANG:ruby(linenumber)
 url_for(:controller=>:test, :action=>:acceptid, :index=>1, :format=>"html")

LANG:ruby(linenumber)
  map.connect ':controller/:action/:id.:rails_language', :format => 'html',
                      :requirements => { :rails_language => lang_regexp }

を使って変換されてしまい、

 /test/acceptid/1.

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

fcgi 化

ちょっとオフトピックだけれど、はまったので。

fcgi 化するには基本的には RewriteRule で dispatch.cgi となっていたところを dispatch.fcgi にするだけでよい。

LANG:console
$ 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

git リポジトリ(ローカル)の作成

時代は subversion ではなく git なのだそうで。

http://www.kaeruspoon.net/articles/477 とか、
http://www.kaeruspoon.net/articles/479 とか、
http://www.netfort.gr.jp/~dancer/diary/200812.html.ja とか、
http://blog.champierre.com/archives/670
http://www.tempus.org/n-miyo/git-course-trans-ja/svn.ja.html
あたりを参考に。

まずは git のインストール。

LANG:console
$ sudo aptitude install git-core
 以下の新規パッケージがインストールされます:
   git-core liberror-perl{a} rsync{a} 
 先に進みますか? [Y/n/?] Y
 liberror-perl (0.17-1) を設定しています ...
 git-core (1:1.6.3.1-1) を設定しています ...
 rsync (3.0.5-1) を設定しています ...
$ git --version
 git version 1.6.3.1
$ git config --global user.name "Osamu TAKEUCHI"
$ git config --global user.email "osamu@big.jp"
$ git config --global color.diff auto
$ git config --global color.status auto
$ git config --global color.branch auto
$ git config --global color.interactive auto
$ git config --global color.ui auto
$ git config --global core.pager "lv -c"
$ cat ~/.gitconfig 
 [user]
         name = Osamu TAKEUCHI
         email = osamu@big.jp
 [color]
         diff = auto
         status = auto
         branch = auto
         interactive = auto
         ui = auto
 [core]
         pager = lv -c

vendor/plugins/language_negotiation 以下にあるプラグインと、
テスト用の Rails アプリケーションと、
2つのリポジトリを別々に作成する。

まずはプラグインの方から

LANG:console
$ cd vendor/plugins/lanugage_negotiation/
$ git init
 Initialized empty Git repository in (rails)/negotiation/vendor/plugins/lanugage_negotiation/.git/
$ git add .
$ git commit
   First commit of LanguageNegotiation plugin for Ruby on Rails
   
   Almost working with:
     Ruby version            1.8.7 (i486-linux)
     RubyGems version        1.3.2
     Rails version           2.2.2
     apache2                 2.2.11-3
    
   Documentation should be completed.
 
 [master (root-commit) 3df21b6] First commit of LanguageNegotiation plugin for Ruby on Rails
  10 files changed, 636 insertions(+), 0 deletions(-)
  create mode 100644 MIT-LICENSE
  create mode 100644 README
  create mode 100644 Rakefile
  create mode 100644 init.rb
  create mode 100644 install.rb
  create mode 100644 lib/lanugage_negotiation.rb
  create mode 100644 tasks/lanugage_negotiation_tasks.rake
  create mode 100644 test/lanugage_negotiation_test.rb
  create mode 100644 test/test_helper.rb
  create mode 100644 uninstall.rb
$ git status
 # On branch master
 nothing to commit (working directory clean)

次にテストアプリケーション

LANG:console
$ cd ../../..
$ pwd
 (rails)/negotiation
$ git init
 Initialized empty Git repository in (rails)/negotiation/.git/
$ mkdir db/migration
$ find . -type d -empty|xargs -L1 -I{} touch {}/.gitignore # 空のディレクトリに .gitignore を作成
$ touch log/.gitignore tmp/.gitignore vendor/plugin/.gitignore
$ cp config/database.yml config/database.sample.yml
$ cat > .gitignore
 .DS_Store
 *~
 /log/*.log
 /db/*.sqlite3
 /config/database.yml
 /tmp/*
 !/tmp/*.gitignore
 !/tmp/cache
 !/tmp/pids
 !/tmp/sessions
 !/tmp/sockets
 /public/cahce_in_test_environment                                                          
 /vendor/plugins/language_negotiation
$ git add .
$ git status
 # On branch master
 #
 # Initial commit
 #
 # Changes to be committed:
 #   (use "git rm --cached <file>..." to unstage)
 #
 #       new file:   .gitignore
 #       new file:   README
 #       new file:   Rakefile
 #       new file:   app/controllers/application.rb
 #       new file:   app/controllers/test_controller.rb
 #       new file:   app/helpers/application_helper.rb
 #       new file:   app/helpers/test_helper.rb
 #       new file:   app/models/.gitignore
 #       new file:   app/views/layouts/application.html.en.erb
 #       new file:   app/views/layouts/application.html.ja.erb
 #       new file:   app/views/layouts/application.text.en.erb
 #       new file:   app/views/test/_ok.html.en.erb
 #       new file:   app/views/test/_ok.html.ja.erb
 #       new file:   app/views/test/acceptid.html.ja.erb
 #       new file:   app/views/test/acceptid.text.ja.erb
 #       new file:   app/views/test/actioncached.html.en.erb
 #       new file:   app/views/test/actioncached.html.ja.erb
 #       new file:   app/views/test/fragmentcached.html.en.erb
 #       new file:   app/views/test/fragmentcached.html.ja.erb
 #       new file:   app/views/test/index.html.en.erb
 #       new file:   app/views/test/index.html.ja.erb
 #       new file:   app/views/test/onlyja.html.ja.erb
 #       new file:   app/views/test/pagecached.html.en.erb
 #       new file:   app/views/test/pagecached.html.ja.erb
 #       new file:   app/views/test/renderfile_.html.en.erb
 #       new file:   app/views/test/renderfile_.html.ja.erb
 #       new file:   config/boot.rb
 #       new file:   config/database.sample.yml
 #       new file:   config/environment.rb
 #       new file:   config/environments/development.rb
 #       new file:   config/environments/production.rb
 #       new file:   config/environments/test.rb
 #       new file:   config/initializers/inflections.rb
 #       new file:   config/initializers/mime_types.rb
 #       new file:   config/initializers/new_rails_defaults.rb
 #       new file:   config/locales/en.yml
 #       new file:   config/routes.rb
 #       new file:   db/migration/.gitignore
 #       new file:   db/schema.rb
 #       new file:   doc/README_FOR_APP
 #       new file:   doc/api
 #       new file:   lib/tasks/.gitignore
 #       new file:   log/.gitignore  ...
 #       new file:   public/.htaccess
 #       new file:   public/404.html
 #       new file:   public/422.html
 #       new file:   public/500.html
 #       new file:   public/dispatch.cgi
 #       new file:   public/dispatch.fcgi
 #       new file:   public/dispatch.rb
 #       new file:   public/favicon.ico
 #       new file:   public/images/rails.png
 #       new file:   public/index.html
 #       new file:   public/javascripts/application.js
 #       new file:   public/javascripts/controls.js
 #       new file:   public/javascripts/dragdrop.js
 #       new file:   public/javascripts/effects.js
 #       new file:   public/javascripts/prototype.js
 #       new file:   public/robots.txt
 #       new file:   public/stylesheets/.gitignore
 #       new file:   script/about
 #       new file:   script/console
 #       new file:   script/dbconsole
 #       new file:   script/destroy
 #       new file:   script/generate
 #       new file:   script/performance/benchmarker
 #       new file:   script/performance/profiler
 #       new file:   script/performance/request
 #       new file:   script/plugin
 #       new file:   script/process/inspector
 #       new file:   script/process/reaper
 #       new file:   script/process/spawner
 #       new file:   script/runner
 #       new file:   script/server
 #       new file:   test/fixtures/.gitignore
 #       new file:   test/functional/test_controller_test.rb
 #       new file:   test/performance/browsing_test.rb
 #       new file:   test/test_helper.rb
 #       new file:   test/unit/.gitignore
 #       new file:   tmp/.gitignore
 #       new file:   tmp/cache/.gitignore
 #       new file:   tmp/pids/.gitignore
 #       new file:   tmp/sessions/.gitignore
 #       new file:   tmp/sockets/.gitignore
 #       new file:   vendor/actionmailer
 #       new file:   vendor/actionpack
 #       new file:   vendor/activemodel
 #       new file:   vendor/activerecord
 #       new file:   vendor/activeresource
 #       new file:   vendor/activesupport
 #       new file:   vendor/plugins/.gitignore
 #       new file:   vendor/rails
 #       new file:   vendor/railties
 #
$ git commit
   Testing framework of LanguageNegotiation plugin for Ruby on Rails
   
   Works with:
     Ruby version            1.8.7 (i486-linux)
     RubyGems version        1.3.2
     Rails version           2.2.2
     apache2                 2.2.11-3
 ...
$

git は空のディレクトリを無視するので、空のディレクトリ全てに .gitignore というファイルを作っておく。

個々のディレクトリの .gitignore にルールを書くことを勧める記述が 多いのだけれど、見通しが悪くなるのでルートディレクトリの .gitignore に 一元化するのがよいと思う。

ルールを / から始めるとリポジトリのルートディレクトリからの絶対パスとして、
ルールを / から始めなければ個々のディレクトリからの相対パスとして、解釈されるらしい。

tmp ディレクトリの指定が結構面倒で、

/tmp/*

とすると、.gitignore も含めて無視されるため、空のディレクトリとなった /tmp 自身も無視されてしまう。

!.giignore

とすることで、.gitignore は無視しないように指定できる。

同様に、/tmp/cache/.gitignore なども無視しないように書いたのが以下のルール。

 !/tmp/*.gitignore
 !/tmp/cache
 !/tmp/pids
 !/tmp/sessions
 !/tmp/sockets

これでローカルに2つのリポジトリが作成できた。

プロジェクトを github に登録

https://github.com/

にアカウントを作成し、プロジェクト RailsLanguageNegotiationPlugin と RailsLanguageNegotiationPluginTestApp を作成。

表示されたガイダンスに沿う形で、

LANG:console
$ git config --global github.user osamutake
$ git config --global github.token c2b35c1bd09d64bf37a713702d7ae659
$ git remote add origin git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
$ git push origin master
 Permission denied (publickey).
 fatal: The remote end hung up unexpectedly
$ cd ~/.ssh
$ ls id_dsa
 ls: cannot access id_dsa: そのようなファイルやディレクトリはありません
$ ssh-keygen # もし id_dsa がすでにあればスキップ
 Generating public/private rsa key pair.
 Enter file in which to save the key (/home/takeuchi/.ssh/id_rsa): (何もせずENTER)
 Enter passphrase (empty for no passphrase): (何もせずENTER)
 Enter same passphrase again: (何もせずENTER)
 Your identification has been saved in github.
 Your public key has been saved in github.pub.
 The key fingerprint is:
 ab:5e:07:df:a1:02:84:cf:a3:21:d1:7e:aa:38:b3:2f takeuchi@dora
 The key's randomart image is:
 +--[ RSA 2048]----+
 | ................|
 |                 |
 +-----------------+
$ cat id_isa.pub 
 ssh-rsa AAAB3Nza...
$ # 表示された内容をコピーして https://github.com/account#keys の SSH Public Keys に追加
$ cd (rails)/negotiation/vendor/plugins/language_negotiation
$ git push origin master
 Counting objects: 15, done.
 Compressing objects: 100% (13/13), done.
 Writing objects: 100% (15/15), 6.86 KiB, done.
 Total 15 (delta 0), reused 0 (delta 0)
 To git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
  * [new branch]      master -> master

https://github.com/ を見るとちゃんと登録されている。とても簡単。

取り出せる事をテスト

じゃ、取り出せるか試してみるか、と軽い気持ちで操作したら、えらい事になった。

LANGUAGE:console
$ cd ..
$ mkdir language_negotiation2
$ cd language_negotiation2
$ git pull git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
 From git@github.com:osamutake/RailsLanguageNegotiationPlugin
  * branch            HEAD       -> FETCH_HEAD
 Auto-merging README
 CONFLICT (add/add): Merge conflict in README
 Auto-merging Rakefile
 CONFLICT (add/add): Merge conflict in Rakefile
 Auto-merging test/test_helper.rb
 CONFLICT (add/add): Merge conflict in test/test_helper.rb
 Automatic merge failed; fix conflicts and then commit the result.

空のディレクトリでどうして conflict?
と思ったら、テストアプリケーションのリポジトリにマージされてました。

LANGUAGE:console
$ cd ../../..
$ git status
 README: needs merge
 Rakefile: needs merge
 test/test_helper.rb: needs merge
 # On branch master
 # Changes to be committed:
 #   (use "git reset HEAD <file>..." to unstage)
 #
 #       new file:   MIT-LICENSE
 #       new file:   init.rb
 #       new file:   install.rb
 #       new file:   lib/lanugage_negotiation.rb
 #       new file:   tasks/lanugage_negotiation_tasks.rake
 #       new file:   test/lanugage_negotiation_test.rb
 #       new file:   uninstall.rb
 #
 # Changed but not updated:
 #   (use "git add <file>..." to update what will be committed)
 #   (use "git checkout -- <file>..." to discard changes in working directory)
 #
 #       unmerged:   README
 #       unmerged:   Rakefile
 #       unmerged:   test/test_helper.rb
 #

pull したのを取り消し、マージされたファイルを書き戻す。

LANGUAGE:console
$ git reset HEAD
 README: locally modified
 Rakefile: locally modified
 test/test_helper.rb: locally modified
$ git status
 # On branch master
 # Changed but not updated:
 #   (use "git add <file>..." to update what will be committed)
 #   (use "git checkout -- <file>..." to discard changes in working directory)
 #
 #       modified:   README
 #       modified:   Rakefile
 #       modified:   test/test_helper.rb
 #
 # Untracked files:
 #   (use "git add <file>..." to include in what will be committed)
 #
 #       MIT-LICENSE
 #       init.rb
 #       install.rb
 #       lib/lanugage_negotiation.rb
 #       tasks/
 #       test/lanugage_negotiation_test.rb
 #       uninstall.rb
 no changes added to commit (use "git add" and/or "git commit -a")
$ rm MIT-LICENSE init.rb install.rb lib/lanugage_negotiation.rb 
$ rm test/lanugage_negotiation_test.rb uninstall.rb
$ rm -r tasks
$ git status
 # On branch master
 # Changed but not updated:
 #   (use "git add <file>..." to update what will be committed)
 #   (use "git checkout -- <file>..." to discard changes in working directory)
 #
 #       modified:   README
 #       modified:   Rakefile
 #       modified:   test/test_helper.rb
 #
 no changes added to commit (use "git add" and/or "git commit -a")
$ git checkout README Rakefile test/test_helper.rb
$ git status
 # On branch master
 nothing to commit (working directory clean)

ふう。
git は親ディレクトリをさかのぼってリポジトリデータを検索する仕様なのね。

negotiation アプリケーションの外に出て、

LANGUAGE:console
$ cd
$ mkdir language_negotiation
$ cd language_negotiation
$ git pull git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
 fatal: Not a git repository (or any of the parent directories): .git

あら、そもそも pull と書いていたのが間違い、と。

LANG:console
$ git clone git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
 Initialized empty Git repository in /home/takeuchi/language_negotiation/RailsLanguageNegotiationPlugin/.git/
 remote: Counting objects: 15, done.
 remote: Compressing objects: 100% (13/13), done.
 remote: Total 15 (delta 0), reused 0 (delta 0)
 Receiving objects: 100% (15/15), 6.86 KiB, done.
$ ls
 RailsLanguageNegotiationPlugin
$ cd RailsLanguageNegotiationPlugin/
$ ls
 MIT-LICENSE  README  Rakefile  init.rb  install.rb  lib  tasks  test  uninstall.rb
$ git status
 # On branch master
 nothing to commit (working directory clean)
$ cd ../..
$ rm -rf language_negotiation/

正しく落とせている。

もしかして・・・

LANG:console
$ cd (rails)/negotiation
$ cd vendor/plugins/
$ git clone git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
 Initialized empty Git repository in (rails)/negotiation/vendor/plugins/RailsLanguageNegotiationPlugin/.git/
 remote: Counting objects: 15, done.
 remote: Compressing objects: 100% (13/13), done.
 remote: Total 15 (delta 0), reused 0 (delta 0)
 Receiving objects: 100% (15/15), 6.86 KiB, done.
$ ls
 RailsLanguageNegotiationPlugin  lanugage_negotiation
$ ls RailsLanguageNegotiationPlugin
 MIT-LICENSE  README  Rakefile  init.rb  install.rb  lib  tasks  test  uninstall.rb
$ git status
 # On branch master
 # Untracked files:
 #   (use "git add <file>..." to include in what will be committed)
 #
 #       RailsLanguageNegotiationPlugin/
 nothing added to commit but untracked files present (use "git add" to track)
$ rm -rf RailsLanguageNegotiationPlugin

これでもうまく行くわけね。

やはりもう少し git に慣れないと危険だ。

で、いきなり公開されてしまうのだから、もう少しドキュメントをしっかりしないとだめだと今更反省。
テストアプリケーションの方は特に、なので、しばらくローカルで更新して、整ったら push するのが良さそう。

ローカルでも履歴が取れるのが subversion よりも良いところみたい。

git を使った編集作業

http://github.com/osamutake/RailsLanguageNegotiationPlugin/issues#issue/1

にも書いたけれど、言語の優先順位について考える必要がありそう。

これを例にして、git を使った編集作業について書く。

LANG:console
$ jed vendor/plugins/language_negotiation/lib/language_negotiation.rb
 (編集)
$ jed vendor/plugins/language_negotiation/test/language_negotiation_test.rb
 (編集)
$ cd ../../..
$ rake test:plugins
 Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
 Started
 ...
 Finished in 0.037807 seconds.
 
 3 tests, 30 assertions, 0 failures, 0 errors
$ rake test:functionals
 Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
 Started
 ..........................
 Finished in 0.31159 seconds.
 
 30 tests, 112 assertions, 0 failures, 0 errors

テストが無事通ったらコミット作業。

LANG:console
$ cd vendor/plugins/language_negotiation
$ git status
 # On branch master
 # Changed but not updated:
 #   (use "git add <file>..." to update what will be committed)
 #   (use "git checkout -- <file>..." to discard changes in working directory)
 #
 #       modified:   lib/lanugage_negotiation.rb
 #       modified:   test/lanugage_negotiation_test.rb
 #
 no changes added to commit (use "git add" and/or "git commit -a")
$ git diff
 (差分がカラー表示される)
$ git add lib/lanugage_negotiation.rb test/lanugage_negotiation_test.rb 
$ git status
 # On branch master
 # Changes to be committed:
 #   (use "git reset HEAD <file>..." to unstage)
 #
 #       modified:   lib/lanugage_negotiation.rb
 #       modified:   test/lanugage_negotiation_test.rb
 #
$ git commit
 Adjust priority of template file without language specification
 
 When template files 'some.html.en' and 'some.html' exist,
 request for 'some.ja' should be processed with 'some.html'
 instead of 'some.html.en'.
 
 See 
 http://github.com/osamutake/RailsLanguageNegotiationPlugin/issues#issue/1
 
 Other improvements:
 
  - removed not being used
      ActionController::AbstractRequest::language
 
  - calls for
      ActionController::AbstractRequest.accepts_languages.first
    were replaced by those for
      ActionController::AbstractRequest.language_priority.first
 
  - optimized template_handler_extension extraction from template_path
    in ActionView::Base::_pick_template
  
  - removed not being used
      ActionController::Caching::Actions::ActionCachePath::language

 [master e3a80c3] Adjust priority of template file without language specification
  2 files changed, 45 insertions(+), 44 deletions(-)
$ git push
 Counting objects: 11, done.
 Compressing objects: 100% (6/6), done.
 Writing objects: 100% (6/6), 1.37 KiB, done.
 Total 6 (delta 3), reused 0 (delta 0)
 To git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
    3df21b6..e3a80c3  master -> master

以上でリモートリポジトリも更新される。

テストアプリケーションのコミットではちょっとミスした。

LANG:console
$ cd ../../..
$ git status
 # On branch master
 # Changed but not updated:
 #   (use "git add <file>..." to update what will be committed)
 #   (use "git checkout -- <file>..." to discard changes in working directory)
 #
 #       modified:   app/controllers/test_controller.rb
 #       modified:   test/functional/test_controller_test.rb
 #
 # Untracked files:
 #   (use "git add <file>..." to include in what will be committed)
 #
 #       app/views/test/nolang.html.erb
 #       app/views/test/nolang.html.ja.erb
$ git commit -a
 Adjust priority of template file without language specification
 
 When template files 'some.html.en' and 'some.html' exist,
 request for 'some.ja' should be processed with 'some.html'
 instead of 'some.html.en'.
 
 See 
 http://github.com/osamutake/RailsLanguageNegotiationPlugin/issues#issue/1
 
 [master 26c50dc] Adjust priority of template file without language specification
  2 files changed, 49 insertions(+), 0 deletions(-)
$ git status
 # On branch master
 # Untracked files:
 #   (use "git add <file>..." to include in what will be committed)
 #
 #       app/views/test/nolang.html.erb
 #       app/views/test/nolang.html.ja.erb
 nothing added to commit but untracked files present (use "git add" to track)
$ git add app/views/test/nolang.html.*
$ git status
 # On branch master
 # Changes to be committed:
 #   (use "git reset HEAD <file>..." to unstage)
 #
 #       new file:   app/views/test/nolang.html.erb
 #       new file:   app/views/test/nolang.html.ja.erb
 #
$ git commit --amend
 [master 678da96] Adjust priority of template file without language specification
  4 files changed, 51 insertions(+), 0 deletions(-)
  create mode 100644 app/views/test/nolang.html.erb
  create mode 100644 app/views/test/nolang.html.ja.erb
$ git log
 commit 678da9633211e515a7ec56034bb7b46e6724bd3e
 Author: Osamu TAKEUCHI <osamu@big.jp>
 Date:   Wed Jun 10 17:45:01 2009 +0900
 
     Adjust priority of template file without language specification
     
     When template files 'some.html.en' and 'some.html' exist,
     request for 'some.ja' should be processed with 'some.html'
     instead of 'some.html.en'.
     
     See
     http://github.com/osamutake/RailsLanguageNegotiationPlugin/issues#issue/1
 
 commit ad51715241e8f480da6de8d1d87035fea6c0d2ba
 Author: Osamu TAKEUCHI <osamu@big.jp>
 Date:   Wed Jun 10 10:34:05 2009 +0900
 
     Testing framework of LanguageNegotiation plugin for Ruby on Rails
     
     Works with:
         Ruby version            1.8.7 (i486-linux)
         RubyGems version        1.3.2
         Rails version           2.2.2
         apache2                 2.2.11-3

git commit -a で変更点はすべてコミットされるかと思いきや、 新規作成ファイルについては対象外だったようだ。

手動で git add した後、git commit --amend したところ、 コミットのやり直しができて、ミスの形跡も残らなかった。

最後に github の該当する issue にコミット番号をコメントして、 Closed に移して一件落着。

CHANGELOG について

ファイル中に変更履歴を残したい。

こんな手順で良いんだろうか?

LANG:console
$ git log > CHANGELOG
$ git add CHANGELOG
$ git commit --amend
$ cd vendor/plugins/language_negotiation
$ git log > CHANGELOG
$ git add CHANGELOG
$ git commit --amend
$ git push origin master
 To git@github.com:osamutake/RailsLanguageNegotiationPlugin.git
  ! [rejected]        master -> master (non-fast forward)
 error: failed to push some refs to 'git@github.com:osamutake/RailsLanguageNegotiationPlugin.git'
$ git push origin +master:master

最後の部分、--amend でコミットしたせいでリモートの情報とローカルの情報が 食い違ってしまっていて、そのままだと push させてくれない。

ローカルを優先して強制的に push するための呪文が git push origin +master:master らしい。

同様の現象は、ローカルでの作業中にリモートリポジトリが書き換わった 場合にも起きる。

その場合には素直に git pull origin master して、ローカル側を更新する事になる。

tar ball の作り方

git ではなく tar ball で落としたい人向けに、 ファイルパスに "*.git*" を含まないファイルのみを 日付付きのファイル名に tar.gz してアップしておくと いいのかな、などと、

LANG:console
$ find . -not -path '*/.git*' -a -type f | \
  tar fcz `date +"language_negotiation-%y%m%d%H%M%S"` -T-

これを Downloads ページにアップロードすれば・・・

む、よく見たら Dowloads ページに行かなくてもページの真ん中に download ボタンがあって、これをクリックすればソースツリーを zip か tar で落とせる仕組みになっているんだね。

自分で tar ball を作る必要は無かったようでした。

スペルミスの修正

!!!

今まで気づかなかったというのが信じられないほどに lanugage_negotiation 。

LANG:console
$ cd ..
$ mv lanugage_negotiation language_negotiation
$ cd language_negotiation
$ grep -i lanugage * */*
 README:LanugageNegotiation
 Rakefile:desc 'Test the lanugage_negotiation plugin.'
 Rakefile:desc 'Generate documentation for the lanugage_negotiation plugin.'
 Rakefile:  rdoc.title    = 'LanugageNegotiation'
 init.rb:require "lanugage_negotiation"
 lib/lanugage_negotiation.rb:# LanugageNegotiation
 tasks/lanugage_negotiation_tasks.rake:# task :lanugage_negotiation do
 test/lanugage_negotiation_test.rb:class LanugageNegotiationTest < ActiveSupport::TestCase
$ jed
 (編集)
$ grep -i lanugage * */*
$ find . -iname lanu*
 ./test/lanugage_negotiation_test.rb
 ./tasks/lanugage_negotiation_tasks.rake
 ./lib/lanugage_negotiation.rb
$ cd test
$ git mv lanugage_negotiation_test.rb language_negotiation_test.rb
$ ls
 language_negotiation_test.rb  test_helper.rb
$ cd ../tasks/
$ git mv lanugage_negotiation_tasks.rake language_negotiation_tasks.rake 
$ cd ../lib
$ git mv lanugage_negotiation.rb language_negotiation.rb 
$ cd ..
$ find . -iname lanu*
$ git status
 #       renamed:    lib/lanugage_negotiation.rb -> lib/language_negotiation.rb
 #       renamed:    tasks/lanugage_negotiation_tasks.rake -> tasks/language_negotiation_tasks.rake
 #       renamed:    test/lanugage_negotiation_test.rb -> test/language_negotiation_test.rb
 #       modified:   README
 #       modified:   Rakefile
 #       modified:   init.rb
 #       modified:   lib/language_negotiation.rb
 #       modified:   tasks/language_negotiation_tasks.rake
 #       modified:   test/language_negotiation_test.rb
$ cd ../../..
$ rake test:plugins
 3 tests, 30 assertions, 0 failures, 0 errors
$ rake test:functionals
 30 tests, 112 assertions, 0 failures, 0 errors
$ cd vendor/plugins/language_negotiation/
$ git commit -a -m "Typo fixing lanugage -> language"
 [master aef2a17] Typo fixing lanugage -> language
  6 files changed, 9 insertions(+), 9 deletions(-)
  rename lib/{lanugage_negotiation.rb => language_negotiation.rb} (99%)
  rename tasks/{lanugage_negotiation_tasks.rake => language_negotiation_tasks.rake} (67%)
  rename test/{lanugage_negotiation_test.rb => language_negotiation_test.rb} (98%)
$ git status
 nothing to commit (working directory clean)
$ git log > CHANGELOG
$ git commit -a --amend
 [master 79ce1e9] Typo fixing lanugage -> language
  7 files changed, 16 insertions(+), 10 deletions(-)
  rename lib/{lanugage_negotiation.rb => language_negotiation.rb} (99%)
  rename tasks/{lanugage_negotiation_tasks.rake => language_negotiation_tasks.rake} (67%)
  rename test/{lanugage_negotiation_test.rb => language_negotiation_test.rb} (98%)
$ git push origin master

アプリケーション側のコードに全く触れずに済むあたり、Rails の設計の良さが光る。

プラグイン内にしても、ドキュメント部分を除けば

 init.rb:require "lanugage_negotiation"
 test/lanugage_negotiation_test.rb:class LanugageNegotiationTest < ActiveSupport::TestCase

この2つだけ。

しかも後者は必ずしも直す必要のない部分。

すばらしい。

PS 今まで気づかなかったのが恐ろしい。

CHANGELOG 更新コマンド

メッセージの入力を求められるのがおっくうなとき、git commit の

  • C オプションが使える。
LANG:console
$ test -d .git && \
  git log > CHANGELOG && \
  git commit -a --amend -C `head -1 CHANGELOG | sed "s/.* //"`
  1. .git ディレクトリが存在すれば
    (リポジトリのトップディレクトリである事を確認してから)
  2. git log を CHANGELOG に入れ
  3. 前回と同じメッセージを付けて commit する

コメント

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





Counter: 9532 (from 2010/06/03), today: 2, yesterday: 0