🧐

stateful_enum gemのソースコードを読んでみた

2024/04/17に公開

はじめに

こんにちは、kmkntです。

弊社では、開発・運用しているRailsアプリケーションでstateful_enumというgemを活用しています。一言でいうと、ActiveRecordのenumを拡張する形でステートマシンを実装できるgemです。

https://github.com/amatsuda/stateful_enum

私自身、前職までは使ったことがなかったのですが、使ってみてとても便利で好きになった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

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/railtie.rb#L9-L12

まずはここから見ていきます。ActiveSupport.on_load :active_recordでいわゆる遅延実行をしています。ActiveRecordがロードされたタイミングでブロックの中も実行されます。

StatefulEnum::StateInspectionpossible_eventspossible_event_namesなどのメソッドが定義されているので、今回は省略します。StatefulEnum::ActiveRecordEnumExtensionを見ていきます。

::ActiveRecord::Base.extendで読み込んでいるので、ActiveRecord::Baseのクラスメソッドとして定義されます。

lib/stateful_enum/active_record_extension.rb

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/active_record_extension.rb#L12-L28

enumメソッドの定義のみがあります。デフォルトのenumメソッドをここでオーバーライドしています。enumメソッドの定義はRailsのバージョンが7以上かどうかで分岐しているのですが、これはenumの構文が7で追加されたのが理由です。

https://techracho.bpsinc.jp/hachi8833/2021_06_17/105107

バージョンが7以上の場合を見ていきます。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/active_record_extension.rb#L14

ブロックがない場合はデフォルトのenumなので、そのままsuperを返しています。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/active_record_extension.rb#L18-L24

ブロックがある場合はさらにnameがnilかどうかで分岐しているのですが、これは先ほど言ったとおり、7以上は構文が追加されているので、新旧両方の書き方に対応できるようにするためです。また、enumの値はHashでもArrayでもどちらでも定義できるので、そちらの対応もしています。optionsの中から処理に必要なprefixとsuffixのみが渡されていますね。

@_defined_stateful_enumsに代入する処理はありますが、最後にsuperの戻り値を返すのは変わりありません。

enumに渡されたブロックはそのままStatefulEnum::Machineのインスタンスに渡されているので、その中を見ていきます。

lib/stateful_enum/machine.rb

Machine class

Machineクラスの中身を見ていきます。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L7-L22

initializeから行きます。少しだけ細かく見ていきます。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L9-L11

ここはprefixをつける場合に、trueが指定されるパターンと文字列そのものが指定されるパターンがあるため、その両方に対応しています。suffixも同様です。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L16-L19

ここではundef_methodでデフォルトのメソッドの定義を削除しています。これはgemのポリシーとしてMethod Names Should be Verbs、メソッド名は動詞であるべきというものがあるためですね。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L21

instance_evalにブロックを渡しています。本題に辿り着いたようです。

冒頭で抜粋したブロックのうち、以下のeventが渡された場合を想定してコードを追いかけてみます。

event :assign do
  transition :unassigned => :assigned
end

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L24-L27

まずeventが実行されます。重複があればArgumentErrorを発生させますが、そうでなければEvent.newした結果が@eventsに追加されます。ブロックの中で定義された各eventはEventクラスのインスタンスになります。

Event class

Eventクラスの中身を見ていきます。今回はinitializetransitionを見ればよさそうです。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L32-L89

initializeで色々やっているのでポイントを絞って見ていこうと思います。

まずinstance_evalにブロックを渡しています。ここではeventに渡されたブロックが渡されていますね。今回の例で言うとtransition :unassigned => :assignedが評価されることになります。

ですので、先にtransitionを見ていきます。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L91-L107

まず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に対して、assignassign!can_assign?assign_transitionといったメソッドが定義されています。また、afterやbeforeがあれば、それらをcallbackとして設定することもしています。

https://github.com/amatsuda/stateful_enum/blob/0097d3cb42eda22dab05509dfbde907ee4905c23/lib/stateful_enum/machine.rb#L74-L81

ここだけピックアップして少し詳しく見てみます。

まず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の書き方を勉強する対象としても良いと思いました。参考になれば幸いです。

Social PLUS Tech Blog

Discussion