9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

active_interaction でアプリケーション固有のビジネスロジックを管理する

Last updated at Posted at 2020-03-30

はじめに

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_namelast_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.requireparams.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_checkvalidateexecute です。
これらのコールバックには beforeafteraround のいずれかを設定することができます。

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

コンポジションされたインタラクションのエラーには、いくつかの厄介なケースがあることに注意してください。
それらについての詳細は、エラーのセクションを参照してください。

トランザクションを貼りたい場合は? (番外編)

元々はトランザクションをサポートしていたが削除したとのこと。

Remove transaction support?

私たちが元々これらを追加したのは、インタラクションが複雑なビジネスロジックを管理するために使われると考えたからです。実際には、必ずしもそうとは限りません。 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 を入れるまでもないと感じています。

外部リンク

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?