Hatena::ブログ(Diary)

Web屋の人の日記 || WebJourney 開発ログ

id:yssk22 が記述するすべてのコンテンツは個人の意見であり、勤務先の組織とは一切無関係のものです。

2008-01-01

あけまして、Rails 2.0 対応

| 01:45 |  あけまして、Rails 2.0 対応を含むブックマーク

2008年になりました。カウントダウンTVを見ながらコーディングをしつつ年を越えました。

とりあえず昨年の積み残しで、実際のコードで1.2.3ベースのコードを2.0対応させています。とりあえずは、トップページの表示に問題がないレベルまでいってから rake test:unit かまそうと思っています。

session data integrity

| 01:45 |  session data integrityを含むブックマーク

environment.rb が変わっています。こんな感じで、config.action_controller.session = {:session_key => .. , :secret => .. } をつけないと怒られます。

  characters = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
  secret = Array.new(48){characters[rand(characters.size)]}.join

  config.action_controller.session = {
    :session_key => "_webjourney_session",
    :secret => secret
  }

なんで?ともって、Rails 2.0 の セッション周りのコードを読む。

Rails 2.0では、セッションのデフォルトストアがCookieになりました、という噂からactionpack/lib/action_controller/session/cookie_store.rb を参照します。

  # Restore session data from the cookie.
  def restore
    @original = read_cookie
    @data = unmarshal(@original) || {}
  end

  # Write the session data cookie if it was loaded and has changed.
  def close
    if defined?(@data) && !@data.blank?
      updated = marshal(@data)
      raise CookieOverflow if updated.size > MAX
      write_cookie('value' => updated) unless updated == @original
    end
  end

こんな感じで、Cookieに対してセッションデータを読み書きします。marshalして渡しておいて、unmarshalで元に戻す、と。

    # Marshal a session hash into safe cookie data. Include an integrity hash.
    def marshal(session)
      data = ActiveSupport::Base64.encode64(Marshal.dump(session)).chop
      CGI.escape "#{data}--#{generate_digest(data)}"
    end

    # Unmarshal cookie data to a hash and verify its integrity.
    def unmarshal(cookie)
      if cookie
        data, digest = CGI.unescape(cookie).split('--')
        unless digest == generate_digest(data)
          delete
          raise TamperedWithCookie
        end
        Marshal.load(ActiveSupport::Base64.decode64(data))
      end
    end

つまり、Cookie に書き出すときに、(本物のデータ)--(digestデータ)で書き出すルールにしておいて、Cookieを受け取ったときは、digestデータが一致することで信用するようです。さらに、generate_digestメソッド。

  # Generate the HMAC keyed message digest. Uses SHA1 by default.
  def generate_digest(data)
    key = @secret.respond_to?(:call) ? @secret.call(@session) : @secret
    OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new(@digest), key, data)
  end

ここで、秘密鍵 @secret がでてきているわけですが、これをenvorinment.rbで指定しろ、ということのようです。digestが異なると例外が発生するようなので、アプリケーションで固定にした方がよさそうですね。最初にあげた例では、起動時に乱数でとってきてたので、これではまずい(ユーザーがアクセス中に再起動すると例外がかならず発生する)。


というわけで、

# ruby -e "c=('a'..'z').to_a+('A'..'Z').to_a+('0'..'9').to_a;puts Array.new(48){c[rand(c.size)]}.join"

で48文字生成しておいて、

  config.action_controller.session = {
    :session_key => "_webjourney_session",
    :secret => "2XTUIOx7hbFeCYnxxKMyMpEKfnHgq78PlGhzYS9hsyD0ByCS"
  }

で書くと。

それにしても、これで、セッションはサーバー側で保持するから安全、という眉唾ものの話が減りそうで一安心。たまに、

session[:current_password] = "hogefuga"

とかいうコードを見かけるものだから。

ちなみにWebJourneyではsessionを使っている箇所は1カ所、認証済ユーザーIDを保持するため、でした。JavaScriptでもっていてもいいんだけれど、レベルの話です。

BufferedLogger

| 10:20 |  BufferedLoggerを含むブックマーク

デフォルトのロギングがrubyのLoggerクラスではなく、ActiveSupport::BufferedLogger クラスに変わっています。


WebJourneyでは、Applicationのロギングとフレームワークのロギングを明確に区別するために、


class Logger
  %w(fatal error warn info debug).each do |level|
    module_eval %{
      def app_#{level}(msg, &block)
        if block
          self.#{level} wj_msg_build('#{level}', msg) do block.call end
        else
          self.#{level} wj_msg_build('#{level}', msg)
        end
      end
    }
  end

  private
  def wj_msg_build(level, msg)
    "[WebJourney][#{level.camelize}] - #{msg}"
  end
end

で、Loggerクラスに app_* メソッドを付け加えていたのですが、どうもこれはいけない。ということで、lib/app_logging.rb に AppLoggingという形でモジュールを定義して、environment.rb に

RAILS_DEFAULT_LOGGER.extend(AppLogging)

でオブジェクト拡張して解決。Loggerクラスの拡張がいらなくなったと思うので削除っと。

gettext/rails が動かない

| 13:14 |  gettext/rails が動かないを含むブックマーク

結論から言うと、Ruby の cgi の仕様と、Rails の cgi の仕様がミスマッチを起こしているようです。Ruby のバージョンアップ(1.8.6-p26以上)をすることで解決できます。Rubyのバージョンアップなんてやだよ!な人は原因に関しては続き参照してしかるべき対処を。

rescue_action_locally を呼び出しているところで、render_file を呼び出しているわけだが、再度Exceptionが投げられてしまう。

You have a nil object when you didn't expect it!
You might have expected an instance of ActiveRecord::Base.
The error occurred while evaluating nil.[]
    /usr/local/lib/ruby/1.8/cgi.rb:1166:in `[]'
    /usr/local/lib/ruby/gems/1.8/gems/gettext-1.10.0/lib/gettext/locale_cgi.rb:26:in `system'
    /usr/local/lib/ruby/gems/1.8/gems/gettext-1.10.0/lib/gettext/locale.rb:88:in `system'
    /usr/local/lib/ruby/gems/1.8/gems/gettext-1.10.0/lib/gettext/locale.rb:96:in `default'
    /usr/local/lib/ruby/gems/1.8/gems/gettext-1.10.0/lib/gettext/rails.rb:276:in `render_file'

該当は、gettext/rails で再定義しているrender_fileのここ。

      [locale.to_general, locale.to_s, locale.language, Locale.default.language].each do |v|

ここでqueryやheaderに何もLocale情報を渡さなかった場合に local.default が呼び出されているわけだが、locale.rb には

  def default
    @@default ? @@default : system
  end

とある。しかし、ここは問題がなさそうで、local_cgi.rb の

      if ret = cgi_["lang"] and ret.size > 0


が例外発生元。cgi_["lang"] が悪いらしい。cgi.rb を見ると、

    def [](key)
      params = @params[key]
      value = params[0]
      ...

となっており、value = params[0] が The error occurred while evaluating nil.[] を発生させている。ん?と思って、cgi.rb をよく読む。こんな記述がある。

# For instance, suppose the request contains the parameter 
# "favourite_colours" with the multiple values "blue" and "green".  The
# following behaviour would occur:
#
#   cgi.params["favourite_colours"]  # => ["blue", "green"]
#   cgi["favourite_colours"]         # => "blue"
#
# If a parameter does not exist, the former method will return an empty
# array, the latter an empty string.  The simplest way to test for existence
# of a parameter is by the #has_key? method.

つまり、cgi["lang"]で例外が発生する方がおかしい。まず、Rails 1.2.3 と 2.0.2 で

puts cgi.has_key?("lang")
puts cgi["lang"]

をして、langキーがないときに、Rails 2.0.X でのみnil例外が発生することを確認。なんでやねん!、というわけで、Railsデバッグ開始。どうも、Rails 2.0 でQueryExtensionによるinitizlie_query()が上書きされているのがあやしい。@params を初期化する重要なメソッド。

Rails 2.0 による initialize_query()

    def initialize_query
      # Fix some strange request environments.
      env_table['REQUEST_METHOD'] ||= 'GET'

      # POST assumes missing Content-Type is application/x-www-form-urlencoded.
      if env_table['CONTENT_TYPE'].blank? && env_table['REQUEST_METHOD'] == 'POST'
        env_table['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
      end

      @cookies = CGI::Cookie::parse(env_table['HTTP_COOKIE'] || env_table['COOKIE'])
      @params = {}
    end

で、cgi.rb の initialize_query()

    def initialize_query()
      if ("POST" == env_table['REQUEST_METHOD']) and
         %r|\Amultipart/form-data.*boundary=\"?([^\";,]+)\"?|n.match(env_table['CONTENT_TYPE'])
        boundary = $1.dup
        @multipart = true
        @params = read_multipart(boundary, Integer(env_table['CONTENT_LENGTH']))
      else
        @multipart = false
        @params = CGI::parse(
                    case env_table['REQUEST_METHOD']
                    when "GET", "HEAD"
                      if defined?(MOD_RUBY)
                        Apache::request.args or ""
                      else
                        env_table['QUERY_STRING'] or ""
                      end
                    when "POST"
                      stdinput.binmode if defined? stdinput.binmode
                      stdinput.read(Integer(env_table['CONTENT_LENGTH'])) or ''
                    else
                      read_from_cmdline
                    end
                  )
      end

      @cookies = CGI::Cookie::parse((env_table['HTTP_COOKIE'] or env_table['COOKIE']))
    end

ん、GETの挙動が違う。。cgi.rb の CGI::parse では、Hash.new([].freeze)で初期化しているハッシュなのでキーがない場合は空の配列を返す。Rails 2.0のほうは {} で初期化しているだけなので、cgi["key"] すると必ず例外が起こるようだ。Railsが悪いように見える。

で、Rails と GetText/Rails のどっち直そうかなぁと思ったところでググる。

http://ko.meadowy.net/~nay/?Rails2.0.1%A5%E1%A5%E2

う。おおばさん。

GetText で 500 Internal Server Error

が出た。むとう様に情報をいただいて解決。Ruby 1.8.6 p26 より前で出る現象だった。Ruby のバージョンアップで解決。

* http://d.hatena.ne.jp/craccho/20071210

* http://zargony.com/2007/07/29/using-ruby-gettext-with-edge-rails/

あれ、1.8.6-p26 で解決するのかい。ここまできたら、直したやつみないと気が済まないので、http://svn.ruby-lang.org/cgi-bin/viewvc.cgi/tags/v1_8_6_26/lib/cgi.rb?revision=12340&view=markup を参照すると、

    def [](key)
      params = @params[key]
      return '' unless params

ruby-dev(http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-dev/30740)にはエラーになるとあるんだが。@params をちゃんと Hash.new([].freeze)でキーが存在しない場合のデフォルトを与えたHashにしてあげればエラーにならない気がする。cgi.params["key"] = nil の場合は想定してないのかな?

まぁ、うだうだいってても先に進まないので、とりあえず、Ruby 1.8.6-p26以降にする、という形にした。

acts_as_tree

| 23:07 |  acts_as_treeを含むブックマーク

これは有名な話。ActiveRecordに入っていたacts_as_*はすべてプラグインとして外に出されているので

ruby script/plugin install acts_as_tree

のようにして、vendor/plugins に追加する。

fixtures の変更

| 00:58 |  fixtures の変更を含むブックマーク

Fixtureも変わっています。大きくは、関連オブジェクトをidではなくhuman readableな名前で記述できるようになった点ですが、このエントリは関係ありません。隠れた変更点?として、fixtureのキャッシュ機能が追加されています。


Rails 2.0 のFixtureクラスでは、Fixtureをファイルから読み込んでインスタンス化するcreate_fixtureメソッドに、

    table_names_to_fetch = table_names.reject { |table_name| fixture_is_cached?(connection, table_name) }

    unless table_names_to_fetch.empty?
    ...
    end

のような形で、キャッシュされている(すでにインスタンス化されている)Fixtureは、ファイルの再読込をしないようなロジックが追加されています。つまり、Rails 1.2.Xでは、test/unit のテストケースがN個ある場合に、test/fixtures はN回読み込まれるわけですが、Rails 2.0.X では、これが1回で済むようになりました、ということです。

このキャッシュ単位は、fixturesのファイル単位、つまりモデルごと、もう少しいえばテーブルごと、ということになります。

で、WebJourneyでは、(1)「初期データとして投入するfixture」と(2)「テストデータとして投入するfixture」を2つのFixtureファイルにしておいて、テスト実行時には、Fixtureクラスのlaod_fixtureメソッドを上書きして、2つのファイルを読み込めるように書き換えていたのですが、このキャッシュ機能の性で、2つめのファイルが読み込めなくなってしまいました。

(1)も(2)も、同じモデルのFixtureなので、(1)のファイルを読み込み終わったときに、「キャッシュしたぜ」フラグが立って、(2)のFixtureを作ろうとしたときに、「キャッシュにあるぜ」といわれて、読み込まれない、というオチ。

実際には、Rails 1.2.X ではこんな感じの記述を test/test_helper.rb に書いていました。db/init には (1)の初期データ用fixtureをいれておいて、test/fixturesには(2)のテスト用fixtureをいれておくようにしています。

  class_inheritable_accessor :initialize_fixtures_path
  self.initialize_fixtures_path = File.join(RAILS_ROOT, "db/init")

  alias :load_fixtures_from_test_directory :load_fixtures
  def load_fixtures
    @initialize_fixtures = {}
    @exist_fixtures = []
    fixture_table_names.each do |table_name|
      @exist_fixtures << table_name if File.exists?(File.join(initialize_fixtures_path, table_name + ".yml"))
    end

    fixtures = Fixtures.create_fixtures(initialize_fixtures_path, @exist_fixtures, fixture_class_names)
    unless fixtures.nil?
      if fixtures.instance_of?(Fixtures)
        @initialize_fixtures[fixtures.table_name] = fixtures
      else
        fixtures.each do |f|
          @initialize_fixtures[f.table_name] = f
        end
      end
    end
    load_fixtures_from_test_directory
    # load fixtures defined in db/init directory
    @initialize_fixtures.each { |t, f|
      f.insert_fixtures
    }
  end

Rails オリジナルのfixture(test/fixtures)をロードした時点(load_fixtures_from_test_directory)で、DBには、test/fixturesで定義されているデータのみが存在することになるので(それまでにあったデータはトランザクションコンテキストの中で一度消える、テストが終わるとRollbackされるのでもどる:use_transactional...=trueの場合)、ここで、db/initにあるデータをinsertしにいきます。

ところが、Rails 2.0 では、この load_fixtures_from_test_directory を実行しても、それ以前にFixture.create_fixtureをしているので、「キャッシュにあるぜ」フラグのせいで、正しくtest/fixtures の内容がDBに登録されません。

というわけで、「キャッシュにあるぜ」フラグを明示的に解消すべく、

    Fixtures.reset_cache
    fixtures = Fixtures.create_fixtures(initialize_fixtures_path, @exist_fixtures, fixture_class_names)

    ...(snip)...

    Fixtures.reset_cache
    load_fixtures_from_test_directory

として回避します。要するに、Fixtureのキャッシュなんかいらないよ、テストなんて時間がかかったっていいじゃないか、というモチベーション。「果報は寝て待て」ということです。

Fixtureは1テーブルにつき1ファイル、というConventionを無視した報い、ですね。。

acts_as_taggable :dependent => true が変

| 04:19 |  acts_as_taggable :dependent => true が変を含むブックマーク

これに関しては、:dependent => :destroy に直せばいいんだけれども、script/plugin で入る acts_as_taggable は別物に変わっているような?雰囲気でよくわからない状況に。結局、http://agilewebdevelopment.com/plugins/acts_as_taggable_on_steroids のほうを使うことにした。

で、これで、rake test:units がほとんど通る状況になったんだが、どういうことか、test/unit の直下にあるファイルだけをテストする分にはOKで test/unit/rss/rss_feed.rb をおいた状態でtest:unitsを流すと SystemStackError : stack level too deep になる。まだfixtureの読み込み関連でバグっているらしい。もっともこれは、以前からあったバグなんだけれど(ぉ。

Connection: close