はじめに
active_interaction というコマンドパターンを実装した gem があります。
どういうメリットがあるのか理解を深めるために、README の部分的な翻訳 + 理解しやすいように加筆をしてみます。
Active Interactionは、アプリケーション固有のビジネスロジックを管理します。
これはRubyのコマンドパターンを実装したものです。
Active Interactionは、ビジネス・ロジックを配置する場所を提供します。
また、入力が期待通りのものであるかどうかを検証することで、より安全なコードを書くことができます。ActiveModel が名詞を扱う場合、Active Interaction は動詞を扱います。
Rails での使い方
インタラクションは app/interaction
に入れることをお勧めします。
また、モデルごとにグループ化するのも非常に便利です。
例えば Account モデルに関する処理は app/interactions/accounts
配下で探すことができます。
- app/
- controllers/
- accounts_controller.rb
- interactions/
- accounts/
- create_account.rb
- destroy_account.rb
- find_account.rb
- list_accounts.rb
- update_account.rb
- models/
- account.rb
- views/
- account/
- edit.html.erb
- index.html.erb
- new.html.erb
- show.html.erb
この構造を使うためには以下を application.rb
に追加してください。
# config/application.rb
config.autoload_paths += Dir.glob("#{config.root}/app/interactions/*")
Index
# GET /accounts
def index
@accounts = ListAccounts.run!
end
class ListAccounts < ActiveInteraction::Base
def execute
Account.not_deleted.order(last_name: :asc, first_name: :asc)
end
end
ListAccounts
には何も入力を渡さないので、.run
の代わりに .run!
を使うのは理にかなっています。
これが失敗した場合、インタラクションの記述に失敗したことになります。
Show
このアクションでは、正しいエラーを発生させるためのヘルパーメソッドを定義します。
.run!
を呼び出した時、エラーが発生すると ActiveInteraction::InvalidInteractionError
が発生します。つまり、ActiveRecord::RecordNotFound
ではなくなるので、明示的に fail ActiveRecord::RecordNotFound
と書く必要があります。
# GET /accounts/:id
def show
@account = find_account!
end
private
def find_account!
outcome = FindAccount.run(params)
if outcome.valid?
outcome.result
else
fail ActiveRecord::RecordNotFound, outcome.errors.full_messages.to_sentence
end
end
これはおそらく、あなたが慣れ親しんでいるものとは少し違っているように見えます。
Railsは一般的に before_action
で @account
を定義します。
なぜこのようなインタラクションコードがすべて良いのでしょうか?
理由は2つあります。1つは、APIコントローラや Resque
タスクなど、別の場所で FindAccount
インタラクションを再利用できるからです。2つ目は、アカウントの検索方法を変更したい場合は、1つの場所 (今回の場合は FindAccount
クラス) を変更するだけで済むということです。
インタラクションの内部では、#find_by_id
の代わりに #find
を使うことができます。そうすれば、コントローラの #find_account!
ヘルパーメソッドは不要になります。しかし、インタラクションからエラーを発生させないようにしなければなりません。そうすると、結果の妥当性だけでなく例外が発生することにも対処しなければいけないからです。
class FindAccount < ActiveInteraction::Base
integer :id
def execute
account = Account.not_deleted.find_by_id(id)
if account
account
else
errors.add(:id, 'does not exist')
end
end
end
実行中にエラーを追加しても全く問題ないことに注意してください。
すべてのエラーが型チェックやバリデーションに起因するものである必要はありません。
New
New は、これまで見てきたものとは少し異なります。
.run
や .run!
を呼び出す代わりに、新しいインタラクションを初期化します。これはインタラクションが ActiveModels
のように振る舞うためです。
# GET /accounts/new
def new
@account = CreateAccount.new
end
インタラクションは ActiveModels
のように動作するので、 ActiveModel
のバリデーションを使用することができます。ここでは first_name
と last_name
が空白になっていないことを確認するためにバリデーションを使用します。
class CreateAccount < ActiveInteraction::Base
string :first_name, :last_name
validates :first_name, :last_name,
presence: true
def to_model
Account.new
end
def execute
account = Account.new(inputs)
unless account.save
errors.merge!(account.errors)
end
account
end
end
ここではいくつかの高度な機能を使用しました。to_model
メソッドは、ビューで使用する正しいフォームを決定するのに役立ちます。詳細はフォームのセクションをチェックしてください。execute
の中では、エラーをマージします。これはエラーをあるオブジェクトから別のオブジェクトに移動させるのに便利な方法です。詳しくはエラーのセクションを参照してください。
Create
createアクションはnewアクションと多くの共通点があります。どちらも CreateAccount
インタラクションを使用します。アカウントの作成に失敗した場合、このアクションは新しいアクションのレンダリングにフォールバックします。
# POST /accounts
def create
outcome = CreateAccount.run(params.fetch(:account, {}))
if outcome.valid?
redirect_to(outcome.result)
else
@account = outcome
render(:new)
end
end
.run
にハッシュを渡さなければならないことに注意してください。 nil
を渡すとエラーになります。
インタラクションを使用しているので、Strong Parameter は必要ありません。
インタラクションはフィルタで定義されていない入力を無視します。つまり、 params.require
や params.permit
は無くても大丈夫です。
Destroy
destroy
アクションは、先ほど書いた #find_account!
ヘルパーメソッドを再利用します。
# DELETE /accounts/:id
def destroy
DestroyAccount.run!(account: find_account!)
redirect_to(accounts_url)
end
この単純な例では、destroy
インタラクションはあまり何もしていません。これをインタラクションに入れることで何かを得ることは明らかではありません。しかし、将来的に account.destroy
以上のことをする必要があるときには、DestrotAccount
クラスを変更するだけで済むようになるでしょう。
Edit
destroy
アクションと同様に、編集は #find_account!
ヘルパーを使用します。
そして、フォームオブジェクトとして使用するための新しいインタラクションのインスタンスを作成します。
# GET /accounts/:id/edit
def edit
account = find_account!
@account = UpdateAccount.new(
account: account,
first_name: account.first_name,
last_name: account.last_name)
end
アカウントを更新するインタラクションは、他のインタラクションよりも複雑です。更新にはアカウントが必要ですが、その他の入力は任意です。省略された場合、その属性は無視されます。属性が存在する場合は、それらを更新します。
ActiveInteractionは入力に対して述語メソッド(#first_name?
のような)を生成します。これらのメソッドは入力が nil
の場合はfalse
を返し、そうでない場合は true
を返します。述語についての詳細は述語のセクションを確認してください。
class UpdateAccount < ActiveInteraction::Base
object :account
string :first_name, :last_name,
default: nil
validates :first_name,
presence: true,
if: :first_name?
validates :last_name,
presence: true,
if: :last_name?
def execute
account.first_name = first_name if first_name?
account.last_name = last_name if last_name?
unless account.save
errors.merge!(account.errors)
end
account
end
end
Update
もうコツがつかめたでしょう。アカウントを取得するために #find_account!
を使い、 UpdateAccount
のための入力を作成します。そしてインタラクションを実行し、更新されたアカウントにリダイレクトするか、編集ページに戻ります。
# PUT /accounts/:id
def update
inputs = { account: find_account! }.reverse_merge(params[:account])
outcome = UpdateAccount.run(inputs)
if outcome.valid?
redirect_to(outcome.result)
else
@account = outcome
render(:edit)
end
end
突っ込んだ使い方
Callbacks
ActiveModel はコールバックを定義するための強力なフレームワークを提供しています。Active Interaction はそのフレームワークにフックしてインタラクションのライフサイクルの様々な部分にフックすることができます。
class Increment < ActiveInteraction::Base
set_callback :type_check, :before, -> { puts 'before type check' }
integer :x
set_callback :validate, :after, -> { puts 'after validate' }
validates :x,
numericality: { greater_than_or_equal_to: 0 }
set_callback :execute, :around, lambda { |_interaction, block|
puts '>>>'
block.call
puts '<<<'
}
def execute
puts 'executing'
x + 1
end
end
Increment.run!(x: 1)
# before type check
# after validate
# >>>
# executing
# <<<
# => 2
利用可能なコールバックは、順に type_check
、 validate
、 execute
です。
これらのコールバックには before
、after
、around
のいずれかを設定することができます。
Composition
他のインタラクションの中から #compose
を使ってインタラクションを実行することができます。
インタラクションが成功した場合は、結果を返します(.run
で呼び出した場合と同じように)。
何か問題が発生した場合、実行はすぐに停止し、エラーは呼び出し元に移動します。
class Add < ActiveInteraction::Base
integer :x, :y
def execute
x + y
end
end
class AddThree < ActiveInteraction::Base
integer :x
def execute
compose(Add, x: x, y: 3)
end
end
AddThree.run!(x: 5)
# => 8
他のインタラクションからフィルタを導入するには、.import_filters
を使用します。入力と組み合わせれば、他のインタラクションに委任するのも簡単です。
class AddAndDouble < ActiveInteraction::Base
import_filters Add
def execute
compose(Add, inputs) * 2
end
end
コンポジションされたインタラクションのエラーには、いくつかの厄介なケースがあることに注意してください。
それらについての詳細は、エラーのセクションを参照してください。
トランザクションを貼りたい場合は? (番外編)
元々はトランザクションをサポートしていたが削除したとのこと。
私たちが元々これらを追加したのは、インタラクションが複雑なビジネスロジックを管理するために使われると考えたからです。実際には、必ずしもそうとは限りません。 ActiveRecord トランザクションの薄いラッパーを提供するより、何も提供しない方が良いかもしれません。
# As it is currently:
class AutomaticTransactionInteraction < ActiveInteraction::Base
def execute
# ...
end
end
# As it might be:
class ManualTransactionInteraction < ActiveInteraction::Base
def execute
ActiveRecord::Base.transaction do
# ...
end
end
end
トランザクションを貼りたい場合は、後者の方で書けばよくない?という話。
具体例としては、以下のようになるだろうか。
def execute
ActiveRecord::Base.transaction do
compose(A)
compose(B)
raise ActiveRecord::Rollback if invalid?
end
end
ある程度使ってみて
この記事を読んでメリットを感じた方は、ぜひ active_interaction を使ってみてください。
しかし、gem を使うとどうしても暗黙的なコードを書く必要がでてきます。この記事を読んでメリットを感じなかった場合や、チームで以下のようなルールを運用をできる場合は、普通に Plain Ruby で Service 層を作るのが良いかと思います。
・ クラス名には動詞と目的語と 「Service」 を付ける
・ 1つのサービスに public なメソッドは、原則1つにする
・ 切り分けたメソッドは全て private なメソッドとして実装する
Service の作り方がルールとして徹底されている現場で長く働いていると、gem を入れるまでもないと感じています。