業務で interactor gem1 を使用する機会があったのですが、日本語の情報がそれほど多くなかったためまとめました。
全2部作となっていて、
- Interactor 概論(今回)
-
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 は全ての破壊的変更( POST
や PUT
、 DELETE
リクエスト)を伴う動作に対して使います。 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
-
Copyright (c) 2013 Collective Idea, Released under the MIT license: https://github.com/collectiveidea/interactor/blob/master/LICENSE.txt ↩
-
ネタバレになりますが、あなたのユースケースはこれほどシンプルにはならないはずです。 ↩