stateful_enum gemのソースコードを読んでみた
はじめに
こんにちは、kmkntです。
弊社では、開発・運用しているRailsアプリケーションでstateful_enumというgemを活用しています。一言でいうと、ActiveRecordのenumを拡張する形でステートマシンを実装できるgemです。
私自身、前職までは使ったことがなかったのですが、使ってみてとても便利で好きになったgemなので、自身の勉強も兼ねてソースコードを読んでみようと思いました。
stateful_enumとは
stateful_enumがどういったものかは、READMEに記載されたサンプルコードを見るのが一番かと思います。以下に抜粋します。
class Bug < ApplicationRecord
enum status: {unassigned: 0, assigned: 1, resolved: 2, closed: 3} do
event :assign do
transition :unassigned => :assigned
end
event :resolve do
before do
self.resolved_at = Time.zone.now
end
transition [:unassigned, :assigned] => :resolved
end
event :close do
after do
Notifier.notify "Bug##{id} has been closed."
end
transition all - [:closed] => :closed
end
end
end
ブロックで状態遷移のトリガーとなるeventの定義を渡すことで、transitionで状態の遷移に制約をつけられたり、before/afterでcallbackを仕込むこともできます。また、eventごとに状態を遷移させる・状態が遷移できるかどうかを確認するメソッド等も追加されます。
今回は、上記で渡されたブロックがどのように処理されているかを見ていきたいと思います。
なお、今回ソースコードを読むstateful_enumのバージョンは0.7.0になります。
ソースコードを読む
lib/stateful_enum/railtie.rb
まずはここから見ていきます。ActiveSupport.on_load :active_record
でいわゆる遅延実行をしています。ActiveRecordがロードされたタイミングでブロックの中も実行されます。
StatefulEnum::StateInspection
はpossible_events
やpossible_event_names
などのメソッドが定義されているので、今回は省略します。StatefulEnum::ActiveRecordEnumExtension
を見ていきます。
::ActiveRecord::Base.extend
で読み込んでいるので、ActiveRecord::Base
のクラスメソッドとして定義されます。
lib/stateful_enum/active_record_extension.rb
enumメソッドの定義のみがあります。デフォルトのenumメソッドをここでオーバーライドしています。enumメソッドの定義はRailsのバージョンが7以上かどうかで分岐しているのですが、これはenumの構文が7で追加されたのが理由です。
バージョンが7以上の場合を見ていきます。
ブロックがない場合はデフォルトのenumなので、そのままsuperを返しています。
ブロックがある場合はさらにnameがnilかどうかで分岐しているのですが、これは先ほど言ったとおり、7以上は構文が追加されているので、新旧両方の書き方に対応できるようにするためです。また、enumの値はHashでもArrayでもどちらでも定義できるので、そちらの対応もしています。optionsの中から処理に必要なprefixとsuffixのみが渡されていますね。
@_defined_stateful_enums
に代入する処理はありますが、最後にsuperの戻り値を返すのは変わりありません。
enumに渡されたブロックはそのままStatefulEnum::Machine
のインスタンスに渡されているので、その中を見ていきます。
lib/stateful_enum/machine.rb
Machine
class
Machine
クラスの中身を見ていきます。
initialize
から行きます。少しだけ細かく見ていきます。
ここはprefixをつける場合に、trueが指定されるパターンと文字列そのものが指定されるパターンがあるため、その両方に対応しています。suffixも同様です。
ここではundef_method
でデフォルトのメソッドの定義を削除しています。これはgemのポリシーとしてMethod Names Should be Verbs
、メソッド名は動詞であるべきというものがあるためですね。
instance_eval
にブロックを渡しています。本題に辿り着いたようです。
冒頭で抜粋したブロックのうち、以下のeventが渡された場合を想定してコードを追いかけてみます。
event :assign do
transition :unassigned => :assigned
end
まずevent
が実行されます。重複があればArgumentError
を発生させますが、そうでなければEvent.new
した結果が@events
に追加されます。ブロックの中で定義された各eventはEvent
クラスのインスタンスになります。
Event
class
Event
クラスの中身を見ていきます。今回はinitialize
とtransition
を見ればよさそうです。
initialize
で色々やっているのでポイントを絞って見ていこうと思います。
まずinstance_eval
にブロックを渡しています。ここではeventに渡されたブロックが渡されていますね。今回の例で言うとtransition :unassigned => :assigned
が評価されることになります。
ですので、先にtransition
を見ていきます。
まずtransition
の引数で渡されたHashであるtransitions
に、ifやunlessでラムダが設定されていればそれらをoptions[:if]
のvalueとして設定しています。transitions
はこのあとeach_pair
で処理をするので、delete
メソッドでtransitions
から削除した上で代入しています。
options[:if] = -> { !instance_exec(&unless_condition) }
がちょっとややこしかったので少し補足すると、instance_exec(&unless_condition)
とすることで、定義時ではなく呼び出したインスタンスのコンテキストで実行させることができます。その実行結果を反転させています。実行結果を反転させたいがためにこう書いているとも言えます。
toは必ず1つになりますが、fromは複数の場合があるので、fromをkeyとして[to, options[:if]]
をHashである@transitions
に持たせていますね。
initialize
に戻ります。
次はclass_eval
の中で色々やっているのですが、eventに対応するメソッド群を動的に定義しています。今回の例で言うと、assign
というeventに対して、assign
、assign!
、can_assign?
、assign_transition
といったメソッドが定義されています。また、afterやbeforeがあれば、それらをcallbackとして設定することもしています。
ここだけピックアップして少し詳しく見てみます。
まずdetect_enum_conflict!
で名前が重複していないかを確認して、define_method
でメソッドを動的に定義しています。今回の例で言うとcan_assign?
になります。
state = send(column).to_sym
は、今回で言えばstate = send("status").to_sym
を実行しています。つまりインスタンスのstatusを呼び出して現在の状態を文字列で取得して、それをシンボル化しています。
現在の状態がtransitions
のkeyになければそもそもfalseになります。あとはcondition.nil? || instance_exec(&condition)
ですが、if/unlessが定義されていなければそのままtrueを返す、定義されていればラムダの結果を返すことになります。
おわりに
stateful_enumのソースコードすべてではないですが、目的を決めて追いかけてみました。
ソースコード全体のサイズも大きくないので、ActiveRecordを拡張するgemの書き方を勉強する対象としても良いと思いました。参考になれば幸いです。
Discussion