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する。

--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という考え方を理解しておくと、これから多くの場面で役に立ちそう。

疑問

  • 果たして「forgot_password」という状態を定義してしまって良いものだろうか?(状態としては定義せずに運用すべきだろうか?)
  • 定義したとして「forgot_password」の最中にsuspend!されてしまったユーザーの扱いはどのようにすべきだろうか?