26
11

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 3 years have passed since last update.

interactor gem についてまとめてみた (1/2)

Last updated at Posted at 2021-03-12

業務で interactor gem1 を使用する機会があったのですが、日本語の情報がそれほど多くなかったためまとめました。

全2部作となっていて、

  1. Interactor 概論(今回)
  2. ActiveInteractor Gem で Interactor を使いやすく
    という構成でお届けします。
    今回は、 interactor gem の README を翻訳したもの +α の内容となっています。

Interactor とは

Interactor とは、アプリケーションのビジネスロジックをカプセル化するために使われるオブジェクトです。それぞれの Interactor はアプリケーションが行う1つの動作を表現します。

ビジネスロジックとは

ビジネスロジックとは、アプリケーションにおいてデータがどのように作られ、保存され、変更されるか( = ビジネスルール)を記述したソースコードのことです。データベースそのものの管理やUIの表示、システム構成やプログラムの様々な部分の連動などとは区別されます。

Context

それぞれの Interactor は、自身が動作するために必要な情報を集めたオブジェクトである Context を持っています。Interactor が動作すると、その Interactor が持つ Context が変更されます。

Context に情報を追加する

context.user = user

このように記述すると、 Context にユーザーの情報を追加することができます。

Context を落とす

Interactor の動作に問題が生じた場合、以下のようにして Context を落とすことができます。

context.fail!

以下のようにしてエラーメッセージを追加することもできます。

context.error = "Boom!"
context.fail!

# または
context.fail!(error: "Boom!")

Context が落ちているかどうかを確認することもできます。

context.failure? # => false
context.fail!
context.failure? # => true

# または
context.success? # => true
context.fail!
context.success? # => false

落ちた Context を処理する

context.fail! が実行されると常に例外タイプ Interactor::Failure が返りますが、この例外を認識することは通常ありません。後に述べるように、コントローラから call クラスメソッドを用いて Interactor を呼び出し、その後 context.success? メソッドを使ってチェックする方法が推奨されます。call クラスメソッドは fail! が実行されても例外を発生させることはありません。
ただし、 Interactor の単体テストにおいて call ではないビジネスロジックメソッドを呼び出している際は、fail! メソッドは Interactor::Failure を返すことに注意してください。

フック

before フック

Interactor が実行される前に Context の準備が必要な場合は、 before フックを使用することができます。引数にはブロックかシンボルを指定することができます。

before do
  context.emails_sent = 0
end

# または
before :zero_emails_sent
def zero_emails_sent
  context.emails_sent = 0
end

after フック

Interactor が実行された後の動作を指定することもできます。

after do
  context.user.reload
end

ただし、 after フックは Interactor が正常終了した場合にのみ実行されます。 fail! が実行された場合は、 after フックは実行されません。

around フック

before フックや after フックと同様に around フックを定義することもできます。 around ブロックやこれに使われるメソッドは1つの引数を受け取り、その引数の call メソッドを呼び出すことで Interactor を呼び出します。

around do |interactor|
  context.start_time = Time.now
  interactor.call
  context.finish_time = Time.now
end

# または
around :time_execution
def time_execution(interactor)
  context.start_time = Time.now
  interactor.call
  context.finish_time = Time.now
end

fail! メソッドが呼び出された場合、around フックの動作が止まり、 interactor.call の後に記述された処理は実行されません。

フックの実行順

before フックは定義された順番に、after フックは定義された順番と逆に実行されます。around フックはbeforeフックやafterフックの外側で実行されます。
例えば、

around do |interactor|
  puts "around before 1"
  interactor.call
  puts "around after 1"
end

around do |interactor|
  puts "around before 2"
  interactor.call
  puts "around after 2"
end

before do
  puts "before 1"
end

before do
  puts "before 2"
end

after do
  puts "after 1"
end

after do
  puts "after 2"
end

このように定義された場合、実行結果は以下のようになります。

around before 1
around before 2
before 1
before 2
after 2
after 1
around after 2
around after 1

Interactor の Concern

Interactor は以下のようにして、 Concern の中で共通に用いられる複数の before/after フックを定義することができます。

module InteractorTimer
  extend ActiveSupport::Concern

  included do
    around do |interactor|
      context.start_time = Time.now
      interactor.call
      context.finish_time = Time.now
    end
  end
end

Interactor の例

ユーザーの認証を行う場合、次のようにして Interactor を用いることができます。

class AuthenticateUser
  include Interactor

  def call
    if user = User.authenticate(context.email, context.password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

Interactor モジュールを include したクラスを作り、その中で call インスタンスメソッドを作成するだけで Interactor を定義することができます。 Interactor は、 call メソッドの中で自身の context にアクセスすることができます。

コントローラ内での Interactor

多くの場合、 Interactor はコントローラの中で用いられます。

class SessionsController < ApplicationController
  def create
    if user = User.authenticate(session_params[:email], session_params[:password])
      session[:user_token] = user.secret_token
      redirect_to user
    else
      flash.now[:message] = "Please try again."
      render :new
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end

このようなコントローラは、先ほどの AuthenticateUser クラスを用いて次のようにリファクタリングされます。

class SessionsController < ApplicationController
  def create
    result = AuthenticateUser.call(session_params)

    if result.success?
      session[:user_token] = result.token
      redirect_to result.user
    else
      flash.now[:message] = t(result.message)
      render :new
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end

call クラスメソッドを用いることで、 Interactor を動作させることができます。 call に引数として渡したハッシュは Interactor のインスタンスである context に変換され、該当の Interactor 内で定義されたフックと共に実行され、最終的に変更が加えられた Context が返ります。

Interactor の利点

ユーザーの認証を行う先ほどの例では、コントローラは以下のようになりました。

class SessionsController < ApplicationController
  def create
    result = AuthenticateUser.call(session_params)

    if result.success?
      session[:user_token] = result.token
      redirect_to result.user
    else
      flash.now[:message] = t(result.message)
      render :new
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end

このような単純なユースケースでは、 Interactor を使った方がコードの分量は多くなります。ではなぜ Interactor を使うのでしょうか?

動作が明快になる

Interactor は全ての破壊的変更( POSTPUTDELETE リクエスト)を伴う動作に対して使います。 Interactor は app/interactors ディレクトリに作成されるため、このディレクトリを見れば全ての開発者がアプリケーションの行う動作を理解することができます。

▾ app/
  ▸ controllers/
  ▸ helpers/
  ▾ interactors/
      authenticate_user.rb
      cancel_account.rb
      publish_post.rb
      register_user.rb
      remove_post.rb
  ▸ mailers/
  ▸ models/
  ▸ views/

TIP: Interactor は実装ではなく、ビジネスロジックに由来した名前にしましょう。アカウントの削除を行う Interactor を作成する際に DestroyUser ではなく CancelAccount という名前にすることで、将来的に様々な責任を担うことができるようになります。

将来性2

今回のユーザー認証の例では、ユーザーを認証するというシンプルなタスクであっても、次のように複数の責任を担うことになるでしょう。

  • 久しぶりにログインしたユーザーに対して「おかえりなさい」と言う
  • パスワードを更新するように通知する
  • 何度もログインが失敗するユーザーを凍結する
  • 凍結した旨をメールで通知する

リストが増えていけば行くほど、コントローラの分量も増えていきます。これで長大なコントローラの出来上がりです。
その代わりに Interactor を導入することで、担うべき責任が増えたとしても、コントローラ(とそのテスト)にはほとんど(あるいは全く)変更を加える必要がなくなります。追加される責任に応じて適切に変更すべき Interactor を選ぶことで、追加される動作が変更されることを防ぐことができます。

Interactor の種類

Interactor ライブラリの中には、普通の Interactor と Organizer という2つの種類の Interactor が定義されています。

Interactor

普通の Interactor は Interactor を include し、 call メソッドを定義したクラスのことを指します。

class AuthenticateUser
  include Interactor

  def call
    if user = User.authenticate(context.email, context.password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

通常の Interactor はさしずめ建築用のブロックのようなもので、アプリケーションにおいて単一の働きをします。

Organizer

Organizer は Interactor の変種として重要な意味を持ちます。 Organizer は他の Interactor を動作させるためだけに用いられます。

class PlaceOrder
  include Interactor::Organizer

  organize CreateOrder, ChargeCard, SendThankYou
end

PlaceOrder Organizer は、コントローラ内で他の Interactor と同様に用いることができます。

class OrdersController < ApplicationController
  def create
    result = PlaceOrder.call(order_params: order_params)

    if result.success?
      redirect_to result.order
    else
      @order = result.order
      render :new
    end
  end

  private

  def order_params
    params.require(:order).permit!
  end
end

Organizer は、自身が organize する Interactor をまとめて順序通り実行します。 Organizer の中の Interactor は、動作して Context を変更した状態で次の Interactor に手渡します。

ロールバック

organize された Interactor のうちどれかの動作が失敗した場合、 Organizer は動作を停止します。 ChangeCard Interactor が失敗した場合、 SendThankYou は呼び出されません。
加えて、すでに実行された Interactor は自身の行った動作を巻き戻すことができます。 rollback メソッドを Interactor に定義し、呼び出すだけで実現できます。

class CreateOrder
  include Interactor

  def call
    order = Order.create(order_params)

    if order.persisted?
      context.order = order
    else
      context.fail!
    end
  end

  def rollback
    context.order.destroy
  end
end

動作が失敗した Interactor はロールバックされません。全ての Interactor は単一の目的を持っているはずなので、仮にその動作が失敗したとしても、その動作を巻き戻す必要はないと考えられるからです。

Interactor のテスト

適切に書かれていれば、それぞれの Interactor は1つのことを行うだけなので、 Interactor をテストするのは簡単です。

class AuthenticateUser
  include Interactor

  def call
    if user = User.authenticate(context.email, context.password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

テストする必要があるのは、この Interactor が行う単一の動作と、 Context に与える影響のみです。

describe AuthenticateUser do
  subject(:context) { AuthenticateUser.call(email: "john@example.com", password: "secret") }

  describe ".call" do
    context "when given valid credentials" do
      let(:user) { double(:user, secret_token: "token") }

      before do
        allow(User).to receive(:authenticate).with("john@example.com", "secret").and_return(user)
      end

      it "succeeds" do
        expect(context).to be_a_success
      end

      it "provides the user" do
        expect(context.user).to eq(user)
      end

      it "provides the user's secret token" do
        expect(context.token).to eq("token")
      end
    end

    context "when given invalid credentials" do
      before do
        allow(User).to receive(:authenticate).with("john@example.com", "secret").and_return(nil)
      end

      it "fails" do
        expect(context).to be_a_failure
      end

      it "provides a failure message" do
        expect(context.message).to be_present
      end
    end
  end
end

今回は RSpec で記載しましたが、どのテストフレームワークを用いても考え方は同じです。

分離

上のテストにおいて、ユーザーをデータベースに作成せずに User.authenticate をスタブ化したのは、 spec/interactors/authenticate_user_spec.rb の目的が AuthenticateUser Interactor をテストすることだけだったからです。 User.authenticate メソッドは spec/models/user_spec.rb でテストされます。

モデルに独自のインターフェースを定義するのは賢いやり方です。このようにすることで、 Interactor に属するある責任がどのモデルと関連づけられるのかがわかりやすくなり、コードを記述しやすくなります。 User.authenticate メソッドは良い例です。もしこの Interactor が

class AuthenticateUser
  include Interactor

  def call
    user = User.where(email: context.email).first

    # ユーザーを認証する処理が直接記述されている
    if user && BCrypt::Password.new(user.password_digest) == context.password
      context.user = user
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

このようになっていた場合、この Interactor をテストするのはとても難しくなります。仮にテストできたとしても、共にモデルの関心事であるORMや暗号化アルゴリズムを変更した瞬間に、ビジネスロジックの関心事であるこの Interactor は壊れてしまいます。
1行1行の目的を明確にしましょう。

統合

Interactor のテストを分離することが大事である一方で、統合テストを適切に行うことも重要です。
あるメソッドをスタブ化すると、そのメソッドが壊れたり変更されたり、もしくはそもそも存在しなかったりする場合を隠蔽することができてしまいます。
統合テストを書けば、アプリケーションの個々のパーツが期待通り動くかどうかを確かめることができ、これは Interactor のような新しいレイヤーをプログラムに追加する場合に特に重要です。
TIP: テストカバレッジを見るのであれば、統合テストを書く前に100%のカバレッジを目指しましょう。それから統合テストを書けば、あなたは夜も安心して寝ることができます。

コントローラ

Interactor を用いる利点のひとつは、コントローラやそのテストを完結にすることができることです。 Interactor は単体テストと統合テストでテストされているので(ですよね?)、コントローラのテストからビジネスロジックのテストを省くことができます。

class SessionsController < ApplicationController
  def create
    result = AuthenticateUser.call(session_params)

    if result.success?
      session[:user_token] = result.token
      redirect_to result.user
    else
      flash.now[:message] = t(result.message)
      render :new
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end
describe SessionsController do
  describe "#create" do
    before do
      expect(AuthenticateUser).to receive(:call).once.with(email: "john@doe.com", password: "secret").and_return(context)
    end

    context "when successful" do
      let(:user) { double(:user, id: 1) }
      let(:context) { double(:context, success?: true, user: user, token: "token") }

      it "saves the user's secret token in the session" do
        expect {
          post :create, session: { email: "john@doe.com", password: "secret" }
        }.to change {
          session[:user_token]
        }.from(nil).to("token")
      end

      it "redirects to the homepage" do
        response = post :create, session: { email: "john@doe.com", password: "secret" }

        expect(response).to redirect_to(user_path(user))
      end
    end

    context "when unsuccessful" do
      let(:context) { double(:context, success?: false, message: "message") }

      it "sets a flash message" do
        expect {
          post :create, session: { email: "john@doe.com", password: "secret" }
        }.to change {
          flash[:message]
        }.from(nil).to(I18n.translate("message"))
      end

      it "renders the login form" do
        response = post :create, session: { email: "john@doe.com", password: "secret" }

        expect(response).to render_template(:new)
      end
    end
  end
end

全ての魔法は Interactor の中で起きているので、このコントローラのテストは、アプリケーションが運用され続ける限り小さな変更を加え続けなければいけないでしょう。

Rails

Interactor を Rails で使うときは、 Interactor は app/interactors に格納し、次のように動詞となる名前をつけています。

  • AddProductToCart
  • AuthenticateUser
  • PlaceOrder
  • RegisterUser
  • RemoveProductFromCart

参照: interactor-rails

  1. Copyright (c) 2013 Collective Idea, Released under the MIT license: https://github.com/collectiveidea/interactor/blob/master/LICENSE.txt

  2. ネタバレになりますが、あなたのユースケースはこれほどシンプルにはならないはずです。

26
11
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
26
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?