Rails: aasm gemは今すぐRailsの新しいenumに置き換えよう(翻訳)
Railsアプリには、いわゆるステートマシン実装を提供するgemが含まれていることがよくあります。それがaasmというgem(旧名acts_as_state_machine
)である可能性はさらに高いでしょう(ところでacts_as_hasselhoffというジョークgemがあったのを覚えている人っていますか?)。
それはともかく、このaasm gemをActiveRecord
モデルにinclude
すると、かなりいろいろなことができるようになります。問題は、それが本当に必要なのかどうかです。
🔗 aasmでこんな問題を踏みました
私はこれまで、無茶な使い方をされていたaasm gemで何度も痛い目に遭ったので、それ以来、新しいプロジェクトに参加するときは真っ先にcat Gemfile | grep aasm
を実行するようになりました。以下のミームは、1年半ほど前に私が作ったものです。
このgemで私が主に気がかりな点は、このgemが提供する機能は、おそらくまったく不要であることです。機能が増えると、つい使いたくなってしまいます。アプリのコードベース全体で使われるようになると、外部ライブラリへの癒着を増やしてしまい、将来のRailsバージョンと互換性が失われたときにアップグレードの邪魔になったり、相当なコストを強いられたりする可能性があります。しかも、重要なビジネスアプリケーションで問題を引き起こす可能性のある変更や、振る舞いの微妙な変更を調べるために目を通さなければならないChangelogが1つ増えてしまいます。
気がかりな点その2は、このaasm gemはコールバックのようなパターンを促進することです。個人的には、これは複雑なRailsアプリケーションで大きな問題になると考えています。 私は明快なコードが好きなのですが、コールバックは明快ではありません。「XXXXコールバックはフレームワークの一部である」といった話は重々承知していますが、この議論は別の機会に譲ることにします。
また、aasmのDSLによって定義されたメソッド定義を見つけ出すのは、どんなIDEや言語サーバープロバイダ(LSP: Language Server Providers)を使おうと困難です。そのため、grep
を駆使してコードを注意深く読み進め、そこで見つかったactive!
メソッドはどこから生えてきたのか、pending
のスコープはどこで定義されているのかといったことをいちいち判断しなければなりません。
aasmは個別のステートを表す定数も自動生成することを最近知ったので、そういうことは自分でやる必要はありません。最近Transaction::STATUS_FAILED
という定数をどこかで見かけたのですが、Transaction
クラス内で明示的に定義されていないこの定数が一体どこから生えてきたのかを突き止めるのに相当時間をかけました。
よく言われるように、「フリーランチ」は美味しいかもしれませんが、やがていつかは料金を支払うはめに追い込まれるのです。
最後に言っておきたいのは、state
やstatus
という属性を使うのは「コードベースの設計がまずい」兆候であることはよくあるのですが、それはまったく別の話だということです。
🔗 出発点
大成功したビジネスを9年間支え続けているレガシーRailsアプリがあるとしましょう。これを少し詳しく見てみることにします。
-> ruby -v
ruby 3.3.0 (2023-12-25 revision 5124f9ac75)
-> bin/rails r "p Rails.version"
"7.1.3.2"
-> bundle list | wc -l
319
-> rg include\ AASM -l | wc -l
33
model.aasm.states.map(&:name)
が使われている場所はほとんどありません。
<select>
タグにオプションを少々提供する目的で、SwitchRequest.aasm.states_for_select
が2箇所で使われています。
🔗 白馬の騎士Rails参上
Rails開発者なら、とっくにenumを使いこなしていることでしょう。enumはRails 6.0で導入され1、値のリストをデータベース内の整数値にマッピングした属性を宣言できるようになります。enumはRailsの次のバージョンで少し進化しましたが、Rails 7.1でついに、私が取り組んでいるコードから1つ残らずaasmを置き換えるのに必要な機能がすべて導入されたのです。
ここからは、以下のシンプルなクラスをコード例として利用してみましょう。
class Transaction < ApplicationRecord
include AASM
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :successful
CANCELED = :canceled
aasm column: :status do
state PROCESSING, initial: true
state FAILED
state SUCCESSFUL
state CANCELED
end
end
🔗 Railsのenumでaasmと完全に同じ機能を実現するのに必要なもの
- ステータス
successful
を持つあらゆるトランザクションをクエリするためのTransaction.successful
などのスコープ
これはActiveRecord::Enum
から無料で手に入ります。 -
以下を行うためのインスタンスメソッド
- オブジェクトのステータスが
successful?
かどうかをチェックするメソッド
これもenumがしっかりカバーしてくれます。 -
ステータスを
successful
に変更していつものようにsave
するメソッド
これもenumがサポートしてくれます。transaction.successful!
を呼び出せばこの作業をまとめて行えます。
- オブジェクトのステータスが
-
新規オブジェクトに設定したい初期ステートを指定するメソッド
これはenum
メソッドにdefault
キーワード引数を渡せばできます。例:default: PROCESSING
-
アプリケーションには、状態遷移やガード文が1つもないのが理想です(aasmを採用しない別の理由でもあります)。しかし
ActiveRecord::Dirty
を利用すれば、そういう実装がかなり楽になることは容易に想像がつきます。
あるいは、この振る舞いをもっと高レベルの抽象化として実装し、モデルはこの問題についてダミー扱いしておくとさらに良いでしょう。 -
コールバックは1つもありません、やったね!
-
aasmで定義されている
STATUS_PROCESSING
やSTATUS_SUCCESSFUL
などの定数(あるいは頼まれもしないのにaasmが定義するもろもろの定数)など、ステートを表すのに使われる可能性がある定数は、すべてenumに揃っています。
🔗 渡された値をバリデーションする
enumのデフォルトの振る舞いは、aasmと少し異なります。渡した値が指定の値と一致しない場合、値を代入しようとするとArgumentError
がraiseされます。
上述したように、Rails 7.1ではvalidate: true
を指定できます。この場合、enumの振る舞いはaasmと完全に一致し、指定の値の有効性をsave前にチェックして、ActiveRecord::RecordInvalid
を発生するようになります。
🔗 コード例
class Transaction < ApplicationRecord
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :successful
CANCELED = :canceled
enum :status,
{ processing: PROCESSING,
failed: FAILED,
successful: SUCCESSFUL,
canceled: CANCELED
}.transform_values(&:to_s),
default: PROCESSING.to_s,
validate: true
end
たったこれだけでできます。スッキリしてわかりやすいですよね?
🔗 enumの値にある「癖」
上のコード例には、transform_values(&:to_s)
という妙なものがあることにお気づきでしょうか。私は当初、シンボルを値として渡したときの振る舞いでかなり混乱しました。変更前のコード例がどうだったかを見てみましょう。
class Transaction < ApplicationRecord
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :successful
CANCELED = :canceled
enum :status,
{ processing: PROCESSING,
failed: FAILED,
successful: SUCCESSFUL,
canceled: CANCELED
},
default: PROCESSING,
validate: true
end
enumのドキュメントには、値は文字列でなければならないとは明記されていません。このコード例ではString
値を使っていましたが、特に明確な意図があったわけではありません。しかし、このオブジェクトを保存すると、ある時点でstatus
がnil
になってしまったのです。
この特殊なケースに対応するために、作業中のメインアプリケーションとは別にダミーアプリとテストを作成しました。この振る舞いに他の何かが影響を与えていないことを100%確信するために、以下の再現コードを書きました。
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'activerecord'
gem 'sqlite3'
gem 'minitest'
end
require 'active_record'
require 'sqlite3'
require 'minitest/autorun'
begin
db_name = 'enum_test.sqlite3'.freeze
SQLite3::Database.new(db_name)
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db_name)
ActiveRecord::Schema.define do
create_table :transactions, force: true do |t|
t.string :status
end
end
class Transaction < ActiveRecord::Base
PROCESSING = :processing
FAILED = :failed
SUCCESSFUL = :successful
CANCELED = :canceled
enum :status,
{ processing: PROCESSING,
failed: FAILED,
successful: SUCCESSFUL,
canceled: CANCELED
},
default: PROCESSING,
validate: true
end
class TestTransaction < Minitest::Test
def test_enum_behavior
transaction = Transaction.new(status: :successful)
assert_equal 'successful', transaction.status
transaction.save!
assert_equal 'successful', transaction.reload.status
end
end
ensure
Dir.glob(db_name + '*').each { |file| File.delete(file) }
end
実行結果は以下のとおりです。
ruby enum.rb
-- create_table(:transactions, {:force=>true})
-> 0.0076s
Run options: --seed 3038
# Running:
F
Finished in 0.013456s, 74.3163 runs/s, 148.6326 assertions/s.
1) Failure:
TestTransaction#test_enum [enum.rb:44]:
Expected: "successful"
Actual: nil
1 runs, 2 assertions, 1 failures, 0 errors, 0 skips
なるほど、値は確かに正しく設定されていますが、save!
を呼び出すと値が消えてしまっています。デバッガーで取り急ぎ調べたところ、"successful"
が 意図せずnil
に変わってしまった場所はEnum::EnumType#deserialize
の中であることが判明しました。
ActiveRecord::Enum::EnumType
の親クラスでもある汎用のActiveModel::Type::Value
クラスには、deserialize
メソッドの目的が以下のように説明されていました。
データベース入力の値を適切なRuby型に変換する。このメソッドの戻り値は、
ActiveRecord::AttributeMethods::Read#read_attribute
から返される。デフォルトの実装では単にValue#cast
を呼び出す。
それでは私たちのシナリオではどうなるかを見てみましょう。
# value = "successful"
# mapping = ActiveSupport::HashWithIndifferentAccess.new({
# "processing" => :processing,
# "failed" => :failed,
# "successful" => :successful,
# "canceled" => :canceled,
# })
# subtype = ActiveModel::Type::String instance
def deserialize(value)
mapping.key(subtype.deserialize(value))
end
実際には以下が起きていました。
subtype.deserialize(value)
は、"successful"
(文字列)を返すmapping
は、"successful"
値に対応するキーを返すようリクエストされるが、ハッシュにはそんなものはなく、あるのはSymbol
の:succesful
値だった -- これだ!deserialize("successful")
は、"successful"
ではなくnil
を返す
おそらく、ドキュメントに「enumを定義するときは値にシンボルを使わないこと」とひとこと書いてあれば、私が費やした時間を(そして頭を抱えている多くの開発者の時間も)節約できたでしょう。
さらにややこしかったのは、enumのデフォルト値はSymbol
でも指定可能で、Symbol
値を代入することも可能なのに、String
として保存されるという事実です。そのことは理解できますが、enumの定義に含める値は文字列にしておかなければならなくなります。そういうわけで、.transform_values(&:to_s)
というトリックが必要だったのです。
「そもそも、可能なステートをシンボルで定義しないといけない理由はあるの?」と疑問に思う方もいるかもしれませんが、aasmの必須要件でそうなっていたからというのが理由です。aasmのステートは、Symbol
か、さもなければ#name
メソッドに応答するオブジェクトでなければなりません。String
は#name
メソッドに応答しないので、それにふさわしいNoMethodError
が表示されます。
もっと面白い話があるのをご存知でしょうか?
Transactions#status
を実行すると、データベースはステータスをString
で返すのです。JSONにシリアライズしたときもステータスをString
で返します(JSONにはシンボルが実装されていないため)。
Symbol
→String
変換やその逆のString
→Symbol
変換で常に行ったり来たりするシナリオは他にもいろいろ考えられます。しかし、サードパーティgemであるaasmは、アプリケーションのアーキテクチャ上でまさにこういう変換だらけの決定を下すことを後押しするのです。
🔗 enumへの移行をさらに改善する
スコープを利用しないのであれば、scopes: false
を指定するだけでスコープを無効にできます。
succesful!
やfailed?
などのインスタンスメソッドも、instance_methods: false
で同様に無効にできます。
enumに書き換えた後のモデルは多くの場合両方とも必要でしたが、私たちは可能な限り両方とも無効にしました。
aasmのModel.aasm.states.map(&:name)
は、Model.statuses.values
に置き換え可能です。
aasmのModel.aasm.states_for_select
ヘルパーは、Model.statuses.values.map { |name, value| [I18n.l(name), value] }
に置き換え可能です。このヘルパーを切り出して自分のヘルパーに置いておきましょう。おそらくI18n
を呼び出す必要はまったく生じず、シンプルなhumanize
で十分でしょう。
さあ、今度はあなたがaasmをenumに置き換える番です。
関連記事
-
編集部注: 実際には
ActiveRecord::Enum
はRails 4.1で導入されました。 ↩ - 編集部注: 実際にはRails 7.0以前から値を文字列で保存することは可能で、7.0の#43529で実際に追加されたのはAPIドキュメントとテストでした。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: ステート・マシン - IBM Documentation