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