acts_as_state_machineのイベント・保存・コールバック等のタイミングに悩む

restful_authenticationを--statefulオプションで使っていて、パスワード忘れの処理を追加したくなった。そこで:resetという状態を追加して、最初は以下のようにしてみた。一見、うまく動きそうな気がするが、実際に動かしてみるとreset_passwordが思っている通りに反応してくれない...。
この問題を解決するには、acts_as_state_machineとActiveRecordが、どのようなタイミングでお互いの処理を連携させるのか、ちゃんと理解しておく必要があったのだ...。

...(中略)...
        acts_as_state_machine :initial => :pending
        state :passive
        state :pending, :enter => :make_activation_code
        state :active,  :enter => :do_activate
        state :suspended
        state :deleted, :enter => :do_delete
        state :reset,   :enter => :forgot_password, :exit => :reset_password
...(中略)...
        event :activate do
          transitions :from => :pending, :to => :active 
        end

        event :forgot_password do
          transitions :from => :active, :to => :reset
        end

        event :reset_password do
          transitions :from => :reset, :to => :active, :guard => Proc.new {|u| u.valid? }
        end
...(中略)...
        def do_activate
          # :pendingから:activeへの遷移の時だけ処理するため
          if pending?
            @activated = true
            self.activated_at = Time.now.utc
          end
        end

        def forgot_password
          @forgotten_password = true
          self.password_reset_code = self.class.make_token
        end

        def reset_password
          @reset_password = true
          self.password_reset_code = nil
        end
...(中略)...

acts_as_state_machineのイベント発生から処理が完了するまでの流れ

実験
  • 以下のようなステートマシンを定義して実験してみた。
  before_save :do_before_save
  after_save  :do_after_save

  acts_as_state_machine :initial => :pending
  state :active,    :enter => :do_activate, :exit  => :do_exit
  state :suspended, :enter => :do_enter,    :after => :do_after

  event :suspend do
    puts "*"*40 + "suspend!"
    transitions :from => [:passive, :pending, :active], :to => :suspended, :guard => Proc.new {puts "*"*40 + "guard"; true;}
  end

  def do_enter
    puts "*"*40 + "enter"
  end
      
  def do_after
    puts "*"*40 + "after"
  end
      
  def do_exit
    puts "*"*40 + "exit"
  end
      
  def do_before_save
    puts "*"*40 + "before_save"
  end
      
  def do_after_save
    puts "*"*40 + "after_save"
  end
  • script/consoleで確認してみる。
$ script/console
Loading development environment (Rails 2.1.0)
>> @user = User.new
=> #
>> @user.suspend!
 ****************************************guard
 ****************************************enter
 ****************************************before_save
 ****************************************after_save
 ****************************************after
=> true
>> @user
=> #

>> @user = User.find :first
=> #
>> @user.suspend!
 ****************************************guard
 ****************************************enter
 ****************************************before_save
 ****************************************after_save
 ****************************************after
 ****************************************exit
=> true
>> @user
=> #
理解したこと
  • new_record(新規レコード)の場合は、acts_as_state_machineのstate :exitオプションは処理されない。
  • 処理の流れ - 以下の順序で処理されている
    1. acts_as_state_machineのeventブロックの:guardオプションの処理
    2. acts_as_state_machineのstate :enterオプションの処理
    3. ActiveRecord::Base.before_save
    4. ActiveRecord::Base.save
    5. ActiveRecord::Base.after_save
    6. acts_as_state_machineのstate :afterオプションの処理
    7. acts_as_state_machineのstate :exitオプションの処理
  • state :after、state :exitの処理はDB保存後、一連のコールバック完了後に処理される。
    • state :enter(自動でDBに保存されてコールバックも処理される)と同じ気持ちで設定してしまうと、おかしな動作になってしまう。
    • 自動でDBに保存されないからといってsaveしてしまうと、before_save・save・after_saveが1イベントで2回実行されることになる。無駄な処理だと思う。
  • :guard => Proc.new {puts "*"*40 + "guard"; false;}の場合(上記実験には表示してないが)
    • acts_as_state_machineのeventブロックの:guardオプションの処理のみ実行された。
    • つまり、:guardオプションは常に実行されるということ。
    • 通常、イベントメソッドによるDB保存では検証(validation)は無視されるが、以下のように書いておけば、ちゃんと検証されるはず。
event XXXX do
  transitions :from => :oooooo, :to => :xxxxxx, :guard => Proc.new {|u| u.valid? }
end

処理の流れを理解して修正してみると...

  • :exitオプションの利用はやめて、:enter => :do_activateの中で、どこから遷移してきたか条件判定して処理することにした。
...(中略)...
        acts_as_state_machine :initial => :pending
        state :passive
        state :pending, :enter => :make_activation_code
        state :active,  :enter => :do_activate
        state :suspended
        state :deleted, :enter => :do_delete
        state :reset,   :enter => :forgot_password #, :exit => :reset_password
...(中略)...
        event :activate do
          transitions :from => :pending, :to => :active 
        end

        event :forgot_password do
          transitions :from => :active, :to => :reset
        end

        event :reset_password do
          transitions :from => :reset, :to => :active, :guard => Proc.new {|u| u.valid? }
        end
...(中略)...
        def do_activate
          case
          when pending?
            @activated = true
            self.activated_at = Time.now.utc
          when reset?
            @reset_password = true
            self.password_reset_code = nil
          end
        end

        def forgot_password
          @forgotten_password = true
          self.password_reset_code = self.class.make_token
        end

        # def reset_password
        #   @reset_password = true
        #   self.password_reset_code = nil
        # end
...(中略)...

:exitが:enterの直前に処理されれば良いのに...

  • 上記の書き方だと、せっかくのacts_as_state_machineの簡潔さを、無駄にしている気がする。
  • そもそも、状態遷移図の通りに直感的に:exit ---> :enterの順に処理してくれれば良いのだ。(実際のDBのstateフィールドの状態に合わせる必要はないはず。)
 +-------+                  +-------+
 |       |                  |       |
 |   A   |exit ------> enter|   B   |
 |       |                  |       |
 +-------+                  +-------+
  • そうすると、理想の流れは以下のようになる。
    1. acts_as_state_machineのeventブロックの:guardオプションの処理
    2. acts_as_state_machineのstate :exitオプションの処理
    3. acts_as_state_machineのstate :enterオプションの処理
    4. ActiveRecord::Base.before_save
    5. ActiveRecord::Base.save
    6. ActiveRecord::Base.after_save
    7. acts_as_state_machineのstate :afterオプションの処理
  • これなら、DB保存も自動で処理されるし、とても都合が良さそう。
  • acts_as_state_machineのソースを覗いてみると、52行目のperformの辺りが処理の順序を決定しているように見える。
  • どこにどんな影響が出るか予想がつかないが、以下のように修正してしまった...。
# ---------- vender/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb ----------
module ScottBarron                   #:nodoc:
  module Acts                        #:nodoc:
    module StateMachine              #:nodoc:
...(中略)...
      module SupportingClasses
...(中略)...
          def perform(record)
            return false unless guard(record)
            loopback = record.current_state == to
            states = record.class.read_inheritable_attribute(:states)
            next_state = states[to]
            old_state = states[record.current_state]
          
            old_state.exited(record) unless loopback #### :exitの順序を変更のため
            next_state.entering(record) unless loopback
          
            record.update_attribute(record.class.state_column, to.to_s)
          
            next_state.entered(record) unless loopback
            # old_state.exited(record) unless loopback #### :exitの順序を変更のため
            true
          end

これで最初に考えたコードのままで、正常に動くようになった!