state_machineは何をしてくれるのだろう?
restful_authenticationには--statefulオプションというのがある。ところが、--statefulを指定しても、利用者から見れば--include-activationの時と同じサービスが提供されるだけ、何も変わらない。READMEにはacts_as_state_machineをサポートすると書いてある。ではacts_as_state_machineは何をしてくれるのか?開発者にとって何が嬉しいのか?
--statefulによる変化
- usersテーブルに以下のフィールドが追加された。
ctiveRecord::Schema.define(:version => 20080819220429) do
create_table "users", :force => true do |t|
...(中略)...
t.string "state", :default => "passive"
t.datetime "deleted_at"
end
- Userモデルではinclude Authorization::StatefulRolesが実行される。
- そしてAuthorization::StatefulRolesは以下のように定義されている。(このmodule書式によって、Userモデルにオレンジ色のコードが記述されるのと同等の効果が生まれる。)
# ---------- vender/plugins/restful_authentication/lib/authorization/stateful_roles.rb ---------- module Authorization module StatefulRoles unless Object.constants.include? "STATEFUL_ROLES_CONSTANTS_DEFINED" STATEFUL_ROLES_CONSTANTS_DEFINED = 'yup' # sorry for the C idiom end def self.included( recipient ) recipient.extend( StatefulRolesClassMethods ) recipient.class_eval do include StatefulRolesInstanceMethods acts_as_state_machine :initial => :pending ......(1) state :passive ......(2) state :pending, :enter => :make_activation_code state :active, :enter => :do_activate state :suspended state :deleted, :enter => :do_delete event :register do ......(3) transitions :from=>:passive, :to=>:pending, :guard=>Proc.new {|u| !(u.crypted_password.blank? && u.password.blank?) } end event :activate do transitions :from => :pending, :to => :active end event :suspend do transitions :from => [:passive, :pending, :active], :to => :suspended end event :delete do transitions :from => [:passive, :pending, :active, :suspended], :to => :deleted end event :unsuspend do transitions :from => :suspended, :to => :active, :guard => Proc.new {|u| !u.activated_at.blank? } transitions :from => :suspended, :to => :pending, :guard => Proc.new {|u| !u.activation_code.blank? } transitions :from => :suspended, :to => :passive end end end module StatefulRolesClassMethods end # class methods module StatefulRolesInstanceMethods # Returns true if the user has just been activated. def recently_activated? @activated end def do_delete self.deleted_at = Time.now.utc end def do_activate ......(4) @activated = true self.activated_at = Time.now.utc self.deleted_at = self.activation_code = nil end end # instance methods end end
何が起こるのか?
(1)
- acts_as_state_machineの初期状態が :pending であることを宣言している。
acts_as_state_machine :initial => :pending
- 状態はデフォルトでstateフィールドに保存される扱いになる。もし、フィールド名を変更したければ...
acts_as_state_machine :initial => :pending, :column => :フィールド名
- :columnオプションを設定することで、:フィールド名で指定したフィールドに状態が保存される。
(2)(4)
- stateメソッドによって、変化する状態をすべて記述しておく。(この場合、: passive、:pending、:active、:suspended、:deletedの5つの状態が登録されることになる。)
- stateメソッドでは、状態が遷移する時に実行する処理を指定することができる。
state :passive, :exit => :do_exit state :pending, :enter => :make_activation_code, :after => [:do_after_1, :do_after_2]
-
- :enterオプションを指定しておけば、状態が:pendingに移行する(保存される)直前にmake_activation_codeメソッドが実行されることになる。
- :afterオプションなら、状態が:pendingに移行した(保存された)直後にdo_after_1、do_after_2の順で実行される。(:afterオプションのみ、配列で指定すればその順序で実行される。)
- :exitオプションなら、状態が:passiveでなくなった時にdo_exitが実行される。
-
- 状態遷移図を書いてしまうと:exit ---> :enter ---> :afterの順に実行されるのが自然な気がするが...
# +-------+ +-------+ # | | | | # |passive|------>|pending| # | | | | # +-------+ +-------+
-
- 状態が保存されているstateフィールドが:pendingから:activeに変化する時のことを考えると...
- :pendingに遷移するまでは:passiveの状態なので、実際には:enter ---> :after ---> :exitの順で実行される。
(3)
- eventメソッドによって、状態を変化させるイベントを定義することが出来る。
- transitionsメソッドで、状態をどのように変化させるかを記述する。
- 定義したイベントは、イベント名! メソッドとして利用できる。
- 状態変化は常にイベント名! メソッドで変化させる必要がある。
- 自分でstateを設定して保存しても、:enter、:after、:exitのトリガーは稼働しない。
-
- 以下の例では、状態を:pendingから:activeに変化させる、activate! メソッドが追加されることになる。
event :activate do transitions :from => :pending, :to => :active end
-
- activate! メソッドを呼び出して、状態が:activeに変化する過程で...
@user = User.find_by_activation_code(params[:activation_code]) @user.activate! if @user && @user.valid?
-
- stateの設定により、:activeになる直前にはdo_activateメソッドが実行されることになる。
state :active, :enter => :do_activate
def do_activate @activated = true self.activated_at = Time.now.utc self.deleted_at = self.activation_code = nil end
- event名! メソッドが実行されると、その時のモデルの状態がDBに保存されるが、validationについてはノーチェック。
- eventには、:guardオプションを指定することで、eventを実行する条件を設定することができる。
- :guard => Proc.new {|u| true }のように、ブロック内がtrueと評価される場合のみeventが実行される。
event :register do transitions :from => :passive, :to => :pending, :guard => Proc.new {|u| !(u.crypted_password.blank? && u.password.blank?) } end
追加されるメソッドまとめ
追加メソッド | 処理内容 |
---|---|
current_state | 現在の状態をシンボルで返す。 |
state名? | そのstate名の状態であるかどうか。(例: @user.active? ...... @userの状態が:activeならtrueを返す) |
event名! | そのevent名のtransitionsで指定した状態遷移を、条件が一致していれば実現する。(例: @user.activate!) |
find_in_state(:first, state, *args) | stateと一致するレコードのみfind :firstする。 |
find_in_state(:all, state, *args) | stateと一致するレコードのみfind :allする。 |
count_in_state(state, *args) | stateと一致するレコードのみcountする。 |
calculate_in_state(state, *args) | stateと一致するレコードのみcalculateする。 |
-
-
- 複数のstateを指定したい時の技が紹介されていました。
-
--include-activationと比較してみると...
- active?メソッドはacts_as_state_machineが提供してくれるので不要になった。
class User < ActiveRecord::Base # def active? # # the existence of an activation code means they have not activated yet # activation_code.nil? # end
- activateメソッドはdo_activateメソッドに置き換えられて、最後のsaveはacts_as_state_machineが処理してくれるので不要になる。
class User < ActiveRecord::Base # Activates the user in the database. # def activate # @activated = true # self.activated_at = Time.now.utc # self.activation_code = nil # save(false) # end def do_activate @activated = true self.activated_at = Time.now.utc self.activation_code = nil self.deleted_at = nil end
- RESTの概念だけでは管理し難い状態管理も、うまく管理できている気持ちになってくる。コントローラーにはRESTの概念から外れた以下のアクションが定義された。
# ---------- app/controllers/users_controller.rb/ ---------- class UsersController < ApplicationController ...(中略)... def activate logout_keeping_session! user = User.find_by_activation_code(params[:activation_code]) unless params[:activation_code].blank? case when (!params[:activation_code].blank?) && user && !user.active? user.activate! flash[:notice] = "Signup complete! Please sign in to continue." redirect_to '/login' when params[:activation_code].blank? flash[:error] = "The activation code was missing. Please follow the URL from your email." redirect_back_or_default('/') else flash[:error] = "We couldn't find a user with that activation code -- check your email? Or maybe you've already activated -- try signing in." redirect_back_or_default('/') end end def suspend @user.suspend! redirect_to users_path end def unsuspend @user.unsuspend! redirect_to users_path end def destroy @user.delete! redirect_to users_path end def purge @user.destroy redirect_to users_path end protected def find_user @user = User.find(params[:id]) end end
上記変化だけでは些細なことかもしれない。でも、全体を通して考え直してみると...
- 状態変化とそれによって発生する必要な処理を、acts_as_state_machineのルールに従って明確に定義できるので、状態の追加・変更があった場合にもメンテナンスし易い。
- 他人がコードを見た場合でも、オブジェクト(restful_authenticationの場合はユーザー)の状態によってどのような処理が発生するのか、非常に分かり易い。
- acts_as_state_machineを利用するコードはmoduleとして抜き出し易くなるので、コードの見通しも非常に良い。
ということで、acts_as_state_machineは、開発者にとって実に思いやりのある実装方法を提供してくれるのであった。
状態変化を見つめ直してみると、業務システムなんて状態変化の集合だ。販売管理では見積・発注・納品・請求・入金とか、経理システムでは発生・計上・決算確定とか、各種伝票の承認レベルの管理とか、ショッピングカートでさえ商品選択・注文依頼・注文受付・発送・請求という状態変化が存在する。
state_machineという考え方を理解しておくと、これから多くの場面で役に立ちそう。
参考ページ
以下のページがたいへん参考になりました。感謝です!
- ARで状態管理したい(acts_as_state_machine) - yamazのRails日記 - Rubyist
- ma2log › acts_as_state_machineを業務システムに適用する 1回目 - ともかく実装
- ma2log › acts_as_state_machineを業務システムに適用する 2回目 - 遷移時にアクション(メソッド)を実行する
- ZDNet Japan Blog - あとで読むRailsのススメ:acts_as_state_machine
- acts_as_state_machineのfind_in_stateを複数状態指定可能にする - kaeruspoon
疑問
- 果たして「forgot_password」という状態を定義してしまって良いものだろうか?(状態としては定義せずに運用すべきだろうか?)
- 定義したとして「forgot_password」の最中にsuspend!されてしまったユーザーの扱いはどのようにすべきだろうか?