odeの開発メモ日記

プログラマーやってます。今までの道のり ポケコンbasic→C(DirectX5 ネットやろうぜ)→Perl(ちょろっと)→Java→C#→Ruby→Android(Java)

dbのマスタースレーブに対応するためにacts_as_readonlyableを使ってみる

日本最大規模のrailsを使ったサイトのクックパッドで改良して
使ってるとのことなので、そこそこ使えるのだろうとのことで
改良するの覚悟でチャレンジしてみました。

下記がオフィシャルページ?(ただのblogの1ページだけども)
http://revolutiononrails.blogspot.com/2007/04/plugin-release-actsasreadonlyable.html

使い方

インストール

script/plugin install svn://rubyforge.org/var/svn/acts-as-with-ro/trunk/vendor/plugins/acts_as_readonlyable


database.yml

development:
  adapter: mysql
  database: hoge_development
  username: root
  password: hoge
  host: 192.168.xxx.xxx #マスターDBのIP
  encoding: utf8

  #下記を追加
  read_only:
    adapter: mysql
    database: hoge_development
    username: root
    password: hoge
    host: 192.168.xxx.xxx #スレーブDBのIP
    encoding: utf8

model(適用したいdbごとに)

class Fruit < ActiveRecord::Base
  acts_as_readonlyable :read_only #引数はdatabase.ymlで作った名前
end

これで下記のような動作になる。

r = Fruit.find(:first) # スレーブが読まれる。基本はスレーブ。rails2.1からは更新したフィールドの値しかupdateされないから問題はないと思われる。
r.field = 'value'
r.save! # 書き込みのときはマスターに書かれる。

r = Fruit.find(:first, :readonly => false) # マスターから読まれる。明示的にマスターから読みたい場合はオプション渡しで可能

r.reload # ドキュメントにはスレーブが読まれるように書いてあるが、実際はマスターから読まれる。ソース見ると途中で変更したくさい。
r.reload(:readonly => false) # スレーブから読まれる。
r.reload(:readonly => true) # マスターから読まれる。

tips

・スレーブを複数台にする場合はdbのロードバランサーを使用する。
 もし、プログラム側で行いたい場合は
 modelに
 acts_as_readonlyable [:first_read_only, :second_read_only]
 と複数のスレーブを指定できる。(database.ymlにも項目を追加する)

・全モデルにacts_as_readonlyableを適用したい場合は
config/environment.rbに下記を追加する

class << ActiveRecord::Base
  def read_only_inherited(child)
    child.acts_as_readonlyable :read_only
    ar_inherited(child)
  end
  
  alias_method :ar_inherited, :inherited
  alias_method :inherited, :read_only_inherited
end

ちなみに最初、↑のソースが何をするかわからなかったので
別のやりかたをrubyの黒魔術を使って書きました。。
以下に一応黒魔術テクニックとしてのせておきます。

#一度modelのクラスを全部requireし、認識できる状態にする。
Dir["#{RAILS_ROOT}/app/models/*.rb"].sort.each do |path|
  filename = File.basename(path)
  require "#{filename}"
end
#ActiveRecord::Baseを継承してるクラスを抽出し回して
#class_evalを使ってacts_as_readonlyableを実行させる。
Object.subclasses_of(ActiveRecord::Base).each do |klass|
  klass.class_eval do
    acts_as_readonlyable :read_only
  end
end

トランザクションの中ではマスターを見に行く。

・なんのためかはわからないが
 内部的にjoinsを渡していて且つselectを渡していない場合はreadonlyをtrueにしている。
 つってもreadonlyを渡していない場合はスレーブ見にいくから意味ないと思う。。
 けどなんかある気もするがわからない。。

注意点

・データを更新した後にすぐに更新後の値を見せる場合には
 スレーブへのデータ反映が遅くて古い値を表示してしまう可能性がある。

改良した点

・注意点で書いた「データを更新した後にすぐに更新後の値を見せる場合」への対応として
 DBのデータ更新後の一定時間はマスターを見るようにした。

controllers/application.rb

  acts_as_readonlyable_update_after_fix :expires=>10.second

・findするときにオプション渡しでスレーブをoffにする機能があるが
 デフォルトがスレーブはちょいと危険と思われるのでデフォルトでslaveをoffにし、通常はマスターを見るようにしてオプション渡しでslaveをonにさせた。
 負荷のかかる部分に明示的に適用させるスタイル。

・コントローラーごとにslave on/offをできるようにした。オプションでonly、exceptの指定も可能。

controllers/xxx.rb

  acts_as_readonlyable :only=>:index

・全モデルにacts_as_readonlyableを適用したい場合のメソッドを用意



改良版acts_as_readonlyableのソース。(acts_as_readonlyable.rbを直接更新してます。)

# Copyright (c) 2007 Revolution Health Group LLC. All rights reserved.

module ActiveRecord; module Acts; end; end

module ActiveRecord::Acts::ActsAsReadonlyable

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods

    def acts_as_readonlyable(*readonly_dbs)
      readonly_dbs = readonly_dbs.flatten.collect(&:to_s)
      readonly_models = readonly_classes(readonly_dbs)
      if readonly_models.empty?
        logger.warn("Read only mode is not activated for #{ self }")
      else
        define_readonly_model_method(readonly_models)
        self.extend(FinderClassOverrides)
      end
      self.send(:include, FinderInstanceOverrides)
    end

    private

    def readonly_classes(dbs)
      dbs.inject([]) do |classes, db|
        if configurations[RAILS_ENV][db]
          define_readonly_class(db) unless ActiveRecord.const_defined?(readonly_class_name(db))
          classes << ActiveRecord.const_get(readonly_class_name(db))
        else
          logger.warn("No db config entry defined for #{ db }")
        end
        classes
      end
    end

    def readonly_class_name(db)
      "Generated#{ db.camelize }"
    end

    def define_readonly_class(db)
      ActiveRecord.module_eval %Q!
        class #{ readonly_class_name(db) } < Base
          self.abstract_class = true
          establish_connection configurations[RAILS_ENV]['#{ db }']
        end
      !
    end

    def define_readonly_model_method(readonly_models)
     (class << self; self; end).class_eval do
        define_method(:readonly_model) { readonly_models[rand(readonly_models.size)] }
      end
    end

    module FinderClassOverrides

      def find_every(options)
        run_on_db(options) { super }
      end

      def find_by_sql(sql, options = nil)

        # Called through construct_finder_sql
        if sql.is_a?(Hash)
          options = sql
          sql = sql[:sql]
        end

        run_on_db(options) { super(sql) }

      end

      def count_by_sql(sql, options = nil)
        run_on_db(options) { super(sql) }
      end

      #KmAdd comment out
      #何故必要なのだろう?validates_uniquness_ofでバグる原因になってるのでコメントアウトする。
      #      def construct_finder_sql(options)
      #        options.merge(:sql => super)
      #      end

      def set_readonly_option!(options) #:nodoc:
        # Inherit :readonly from finder scope if set.  Otherwise,
        # if :joins is not blank then :readonly defaults to true.
        unless options.has_key?(:readonly)
          if scoped?(:find, :readonly)
            options[:readonly] = true if scope(:find, :readonly)
            #KmAdd comment out
            #          elsif !options[:joins].blank? && !options[:select]
            #            options[:readonly] = true
          end
        end
        p options
      end

      def calculate(operation, column_name, options = {})
        run_on_db(options) do
          options.delete(:readonly)
          super
        end
      end


      private

      def run_on_db(options)
        if ((Thread.current['open_transactions'] || 0) == 0) and with_readonly?(options)
          run_on_readonly_db { yield }
        else
          yield
        end
      end

      def with_readonly?(options)
        #KmAdd start
        ext_readonly = Thread.current['acts_as_readonlyable_readonly']
        if ext_readonly != nil
          return ext_readonly
        end
        #KmAdd end

        #KmAdd comment out
        #        (! options.is_a?(Hash)) || (! options.key?(:readonly)) || options[:readonly]

        #KmAdd start
        if options.is_a?(Hash) && options.key?(:readonly)
          return options[:readonly]
        elsif Thread.current['acts_as_readonlyable_controller_readonly']
          return Thread.current['acts_as_readonlyable_controller_readonly']
        else
          return false
        end
        #KmAdd end
      end

      def run_on_readonly_db
        klass_conn = connection
        begin
          self.connection = readonly_model.connection
          self.clear_active_connection_name
          yield
        ensure
          self.connection = klass_conn
          self.clear_active_connection_name
        end

      end

    end

    module FinderInstanceOverrides

      # backport from 1.2.3 for 1.1.6 + disable readonly by default for reload - replication lag
      def reload(options = {})
        options[:readonly] = false unless options.has_key?(:readonly)
        clear_aggregation_cache
        clear_association_cache
        @attributes.update(self.class.find(self.id, options).instance_variable_get('@attributes'))
        self
      end

    end

  end

end

ActiveRecord::Base.send(:include, ActiveRecord::Acts::ActsAsReadonlyable)














#KmAdd start

module KmActsAsReadonlyable
  def self.apply_all_model
    #acts_as_readonlyableを全モデルに適用するオフィシャル方法
    class << ActiveRecord::Base

      def read_only_inherited(child)
        child.acts_as_readonlyable :read_only
        ar_inherited(child)
      end

      alias_method :ar_inherited, :inherited
      alias_method :inherited, :read_only_inherited
    end
  end

  module Controller
    def self.included(base)
      base.extend(ClassMethods)
    end

    module ClassMethods
      # コントローラーレベルでのスレーブ使用をONにする。
      def acts_as_readonlyable(options={})
        before_filter :only => options[:only], :except => options[:except] do |c|
          c.__send__ :acts_as_readonlyable_action, options
        end
      end

      # dbのデータを更新後、一定秒以内ならマスターを見に行くようにする。
      def acts_as_readonlyable_update_after_fix(options={})
        before_filter :only => options[:only], :except => options[:except] do |c|
          c.__send__ :bf_acts_as_readonlyable_update_after_fix, options
        end
        after_filter :only => options[:only], :except => options[:except] do |c|
          c.__send__ :af_acts_as_readonlyable_update_after_fix, options
        end
      end
    end

    private

    def acts_as_readonlyable_action(options)
      Thread.current['acts_as_readonlyable_controller_readonly'] = true
    end

    def bf_acts_as_readonlyable_update_after_fix(options)
      # dbのデータを更新後、一定秒以内ならマスターを見に行くようにする。
      #session[:last_db_update_at] = Time.now
      last_db_update_at = session[:last_db_update_at]
      expires = options[:expires] || 10.second
      p "-"*100
      p expires
      if last_db_update_at && (last_db_update_at + expires) > Time.now
        Thread.current['acts_as_readonlyable_readonly'] = false
      end
    end

    def af_acts_as_readonlyable_update_after_fix(options)
      if last_db_update_at = Thread.current['acts_as_readonlyable_last_db_update_at']
        session[:last_db_update_at] = last_db_update_at
      end
    end
  end
end

ActionController::Base.class_eval do
  include KmActsAsReadonlyable::Controller
end

# acts_as_readonlyable_update_after_fixのためのdb更新を検知する部分
Object.subclasses_of(ActiveRecord::ConnectionAdapters::AbstractAdapter).each do |klass|
  klass.class_eval do
    def execute_with_acts_as_readonlyable(sql, name = nil)
      val = execute_without_acts_as_readonlyable(sql, name)

      sql = sql.strip.upcase
      if sql.starts_with?("SET") || sql.starts_with?("SHOW") || sql.starts_with?("SELECT") || sql.starts_with?("EXPLAIN") || sql.starts_with?("BEGIN") || sql.starts_with?("COMMIT") || sql.starts_with?("ROLLBACK")
        return val
      end

      Thread.current['acts_as_readonlyable_readonly'] = false
      Thread.current['acts_as_readonlyable_last_db_update_at'] = Time.now
      #      p "set acts_as_readonlyable_last_db_update_at. #{sql}"

      return val
    end
    alias_method_chain :execute, :acts_as_readonlyable
  end
end



#KmAdd end

改良verを使った適用の仕方

・全モデルにacts_as_readonlyableを適用
・applicationコントローラーにacts_as_readonlyable_update_after_fixを適用。
・トップページ等、負荷のかかるスレーブを使いたいページにコントローラーレベルのacts_as_readonlyableを適用する。

実行環境

rails(2.1.2)