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オプションは処理されない。
- 処理の流れ - 以下の順序で処理されている
- acts_as_state_machineのeventブロックの:guardオプションの処理
- acts_as_state_machineのstate :enterオプションの処理
- ActiveRecord::Base.before_save
- ActiveRecord::Base.save
- ActiveRecord::Base.after_save
- acts_as_state_machineのstate :afterオプションの処理
- 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 | | | | | +-------+ +-------+
- そうすると、理想の流れは以下のようになる。
- acts_as_state_machineのeventブロックの:guardオプションの処理
- acts_as_state_machineのstate :exitオプションの処理
- acts_as_state_machineのstate :enterオプションの処理
- ActiveRecord::Base.before_save
- ActiveRecord::Base.save
- ActiveRecord::Base.after_save
- 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
これで最初に考えたコードのままで、正常に動くようになった!