Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: aasm gemは今すぐRailsの新しいenumに置き換えよう(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: ステート・マシン - IBM Documentation

Rails: aasm gemは今すぐRailsの新しいenumに置き換えよう(翻訳)

Railsアプリには、いわゆるステートマシン実装を提供するgemが含まれていることがよくあります。それがaasmというgem(旧名acts_as_state_machine)である可能性はさらに高いでしょう(ところでacts_as_hasselhoffというジョークgemがあったのを覚えている人っていますか?)。

それはともかく、このaasm gemをActiveRecordモデルにincludeすると、かなりいろいろなことができるようになります。問題は、それが本当に必要なのかどうかです。

aasm/aasm - GitHub

🔗 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クラス内で明示的に定義されていないこの定数が一体どこから生えてきたのかを突き止めるのに相当時間をかけました。

よく言われるように、「フリーランチ」は美味しいかもしれませんが、やがていつかは料金を支払うはめに追い込まれるのです。

最後に言っておきたいのは、statestatusという属性を使うのは「コードベースの設計がまずい」兆候であることはよくあるのですが、それはまったく別の話だということです。

🔗 出発点

大成功したビジネスを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

  • 値を整数値ではなく文字列として保存する
    これはRails 7.0から可能になりました2

  • アプリケーションには、状態遷移やガード文が1つもないのが理想です(aasmを採用しない別の理由でもあります)。しかしActiveRecord::Dirtyを利用すれば、そういう実装がかなり楽になることは容易に想像がつきます。
    あるいは、この振る舞いをもっと高レベルの抽象化として実装し、モデルはこの問題についてダミー扱いしておくとさらに良いでしょう。

  • コールバックは1つもありません、やったね!

  • aasmで定義されているSTATUS_PROCESSINGSTATUS_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値を使っていましたが、特に明確な意図があったわけではありません。しかし、このオブジェクトを保存すると、ある時点でstatusnilになってしまったのです。

この特殊なケースに対応するために、作業中のメインアプリケーションとは別にダミーアプリとテストを作成しました。この振る舞いに他の何かが影響を与えていないことを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

実際には以下が起きていました。

  1. subtype.deserialize(value)は、"successful"(文字列)を返す
  2. mappingは、"successful"値に対応するキーを返すようリクエストされるが、ハッシュにはそんなものはなく、あるのはSymbol:succesful値だった -- これだ!
  3. 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にはシンボルが実装されていないため)。

SymbolString変換やその逆のStringSymbol変換で常に行ったり来たりするシナリオは他にもいろいろ考えられます。しかし、サードパーティ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に置き換える番です。

関連記事

Railsのenumを使いこなす方法(翻訳)


  1. 編集部注: 実際にはActiveRecord::EnumRails 4.1で導入されました。 
  2. 編集部注: 実際にはRails 7.0以前から値を文字列で保存することは可能で、7.0の#43529で実際に追加されたのはAPIドキュメントとテストでした。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。