公式の意訳に適当に加筆(減筆?)/修正したものです。(ちなみに著者の英語の偏差値は30台です。)
2019-12-28 追記
以下はHanamiのバージョンが1.1の頃のものです。
URLも404になってしまいました。
URLだけは最新のものに書き換えました(Thanks @topstone)が内容は古くなっている可能性があります。
インタラクタとは
Hanamiにはコードを整理するためのオプションのツールを提供しています。それはインタラクタといい、サービスやユースケース、オペレーションなどとも呼ばれます。
以下、事例を通して使い方を見ていきます。
新しい要求: メール通知 (A New Feature: Email Notifications)
こんな要求があったとします。
管理者は本が追加されたときにメール通知を受け取りたい。
アプリケーションには認証がないため、だれでも本を追加できます。管理者のメールアドレスは環境変数で与えます。
これはインタラクタ、具体的にはHanami::Interactor
を使ういい例です。
この例は以下のような別の要求の基礎を提供することができます。
- 新しい書籍が投稿される前に管理者の承認を得る
- ユーザーは電子メールアドレスを入力し、特別なリンクを使用して本を編集することができる。
このプラクティスでは基本的な幾つかの実装のためにインタラクタを使うことができます。これは、コードベースの複雑さを管理するために、一度にいくつかのことをしたいときに特に便利です。
インタラクタは些細ではない(non-trivial)ビジネスロジックを分離するために使用されます。これは単一責任の原則に沿っています。
Webアプリケーションでは、インタラクタは一般的にコントローラのアクションから呼び出されます。これにより、関心事を分けることができます。 あなたのビジネスロジックオブジェクト、インタラクタはWebについて全く知らないでしょう。
コールバック? いやいや、クッサいコールバックなんていりません! (Callbacks? We Don't Need No Stinkin' Callbacks!)
簡単な実装方法はcallbackを追加することです。つまり、データベースに新しい本が追加されたあとにメールが送信されます。
class book < ApplicationRecord
after_create :send_mail_to_admin
end
このような仕組みをHanamiは提供していません。これは永続コールバック[^consider persistence]がアンチパターンだと考えられるからです。永続コールバックは単一責任の原則に反します。この場合、永続性とメール通知(の責任)が不適切に混在しています。
[^consider persistence]: [訳註] 多分、永続(= DBへの保存)に対するコールバックのこと
上記のような実装をした場合、テストやその他の点でこのコールバックをスキップしたくなるでしょう。また、このような実装は同じイベント(今回の場合はDBへの保存)に対する複数のコールバックが特定の順序でトリガーされるため、すぐ混乱をおこします。
代わりに、**暗黙を明示に(explicit over implicit)**しましょう。
インタラクタは特定のユースケースを表すオブジェクトです。
インタラクタは各クラスに単一の責任を持たせます。インタラクタの単一責任は特定の結果を達成するためにオブジェクトとメソッドコールを順番に結合します。1
そのためにHanami::Interactor
モジュールが提供されています。そのため、ピュアRuby(Plain Old Ruby)オブジェクトとして始めて、必要に応じてHanami::Interactor
をインクルードすることができます。
コンセプト (Concept)
インタラクタの背後にあるアイデアは機能の一部を新しいクラスに分離することです。
振舞いを単一のオブジェクトにカプセル化することでテストが簡単になります。これは複雑さを隠して、明示しないよりもコードを理解するのを容易にします。
準備 (Preparing)
bookshelf applicationにメール通知の要求を実装していきましょう。
インタラクタを作ろう (Creating Our Interactor)
インタラクタを作ってみましょう。まず、以下のディレクトリを作ってください。
% mkdir lib/bookshelf/interactors
% mkdir spec/bookshelf/interactors
なぜこれらをlib
に入れるのでしょう?それはwebアプリケーションから切り離されているからです。2
新しいスペックspec/bookshelf/interactors/add_book_spec.rb
を書いて、AddBook
インタラクタを呼びましょう。
require 'spec_helper'
describe AddBook do
let(:interactor) { AddBook.new }
it "succeeds" do
expect(interactor.call).to be_a_success
end
end
次にAddBook
クラスを作りましょう。
require 'hanami/interactor'
class AddBook
include Hanami::Interactor
def initialize
# set up the object
end
def call(book_attributes)
# get it done
end
end
このクラスには2つのメソッドしかありません。
-
initialize
: データのセットアップ -
call
: ユースケースを満たす
テストを実行してみましょう。
% bundle exec rake
すべてのテストがパスするでしょう!
本の作成 (Creating a Book)
spec/bookshelf/interactors/add_book_spec.rb
を編集します。
require 'spec_helper'
describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
describe "good input" do
let(:result) { interactor.call(attributes) }
it "succeeds" do
expect(result).to be_a_success
end
it "creates a Book with correct title and author" do
expect(result.book.title).to eq("The Fire Next Time")
expect(result.book.author).to eq("James Baldwin")
end
end
end
次に、インタラクタを実装していきます。
require 'hanami/interactor'
class AddBook
include Hanami::Interactor
expose :book
def initialize
# set up the object
end
def call(book_attributes)
@book = Book.new(book_attributes)
end
end
ここでは次の2つの重要なことが書かれています。
-
expose :book
ではメソッドの実行結果として返される@book
を公開します。 -
call
メソッドは新しいBookエンティティを@book
に割り当てて、公開します。
これでテストはパスするでしょう。
作成した本の永続化 (Persisting the Book)
さきほど新しい本をビルドしましたが、まだデータベースには存在していません。
永続化のためにBookRepository
が必要です。
require 'spec_helper'
describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
describe "good input" do
let(:result) { interactor.call(attributes) }
it "succeeds" do
expect(result).to be_a_success
end
it "creates a Book with correct title and author" do
expect(result.book.title).to eq("The Fire Next Time")
expect(result.book.author).to eq("James Baldwin")
end
it "persists the Book" do
expect(result.book.id).to_not be_nil
end
end
end
このテストをパスさせるためにインタラクタのcall
メソッドを編集しましょう。
def call
@book = BookRepository.new.create(book_attributes)
end
Book.new
の代わりにBookRepository
とcreate
を使いました。相変わらずBook
は返却されますが、レコードはデータベースに保存されます。
テストを実行すればパスするでしょう。
依存性の注入 (Dependency Injection)
依存性の注入を活用してリファクタリングしましょう。
依存性の注入はおすすめですが、必須ではありません。Hanamiのインタラクタ同様にオプションです。
先程のスペックは役割を果たしますが、リポジトリの振る舞いに依存しています(id
メソッドは永続化に成功して初めて定義されます)。それはリポジトリがどのように動作するかという実装の詳細です。例えば、もし永続化の前にUUIDを作成し、id
カラムに値を入れるのではなく、他の方法で保存が成功したことを表す場合、スペックを変更する必要があります。
スペックとインタラクタをより強固にすることができ、このファイル外の変更で壊れる可能性が低くなります。
インタラクタで依存性の注入を使用するには以下のようにします。
require 'hanami/interactor'
class AddBook
include Hanami::Interactor
expose :book
def initialize(repository: BookRepository.new)
@repository = repository
end
def call(book_attributes)
@book = @repository.create(book_attributes)
end
end
@repository
を使う以外は基本的に一緒です。
今のところのスペックはid
が作成されたことをチェックしてリポジトリの動作をテストしています(expect(result.book.id).to_not be_nil
)。
これは実装の詳細です。
代わりに、スペックを単に「リポジトリがcreate
メッセージを受け取ることを確認する」ように変更することができます。それはリポジトリが保存をちゃんと行ってくれることを信頼することです(そして、保存をちゃんと行うのはリポジトリの責任です)。
スペックからit "persists the Book"
を取り除きましょう。代わりにdescribe "persistence"
を作成します。
require 'spec_helper'
describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
describe "good input" do
let(:result) { interactor.call(attributes) }
it "succeeds" do
expect(result).to be_a_success
end
it "creates a Book with correct title and author" do
expect(result.book.title).to eq("The Fire Next Time")
expect(result.book.author).to eq("James Baldwin")
end
end
describe "persistence" do
let(:repository) { instance_double("BookRepository") }
it "persists the Book" do
expect(repository).to receive(:create)
AddBook.new(repository: repository).call(attributes)
end
end
end
これで、関心事の仕切り(the boundaries of the concern)に違反しなくなりました。
ここで行ったのはインタラクタのリポジトリの依存性の注入です。
ノート: テストしていないコード(non-test code)は何も変更する必要がありません。`repository:`キーワード引数は指定しなかった場合は新しいリポジトリオブジェクトを提供します。
module Web::Controllers::Book
class craete
include Web::Action
def call(params)
AddBook.new.call(params[:book])
end
end
end
AddBook.new
の引数を与えなかった場合、AddBook
の@repositoty
はBookRepository.new
になります。そのため、依存性の注入を行う前と後でこのコードを変更する必要がない、ということをいっています。
メール通知 (Email notification)
いよいよメール通知を追加しましょう!
ここで別のライブラリを使うこともできますが、Hanami::Mailer
を使うことにしましょう。3
% bundle exec hanami generate mailer book_added_notification
create lib/bookshelf/mailers/book_added_notification.rb
create spec/bookshelf/mailers/book_added_notification_spec.rb
create lib/bookshelf/mailers/templates/book_added_notification.txt.erb
create lib/bookshelf/mailers/templates/book_added_notification.html.erb
メーラーがどのように動作するかの詳細には立ち入りませんが、Hanami::Mailer
クラス、関連スペック、2つのテンプレート(プレーンテキストとHTML)があるシンプルな構成です。
テンプレートは空にしておきます。メールは'Book added!'
という件名の空メールになります。
メーラースペックを編集します。
RSpec.describe Mailers::BookAddedNotification, type: :mailer do
subject { Mailers::BookAddedNotification }
before { Hanami::Mailer.deliveries.clear }
it 'has correct `from` email address' do
expect(subject.from).to eq("no-reply@example.com")
end
it 'has correct `to` email address' do
expect(subject.to).to eq("admin@example.com")
end
it 'has correct `subject`' do
expect(subject.subject).to eq("Book added!")
end
it 'delivers mail' do
expect {
subject.deliver
}.to change { Hanami::Mailer.deliveries.length }.by(1)
end
end
メーラーも編集します。
class Mailers::BookAddedNotification
include Hanami::Mailer
from 'no-reply@example.com'
to 'admin@example.com'
subject 'Book added!'
end
これでテストはパスするでしょう!
しかし、このメーラはどこからも呼ばれてません。AddBook
インタラクタから呼び出す必要があります。
AddBook
のスペックをメーラーが呼ばれていることを確認するように編集しましょう。
...
describe "sending email" do
let(:mailer) { instance_double("Mailers::BookAddedNotification") }
it "send :deliver to the mailer" do
expect(mailer).to receive(:deliver)
AddBook.new(mailer: mailer).call(attributes)
end
end
...
インタラクタにmailer
キーワード引数を追加して、メーラーを統合しましょう。
require 'hanami/interactor'
class AddBook
include Hanami::Interactor
expose :book
def initialize(repository: BookRepository.new, mailer: Mailers::BookAddedNotification.new)
@repository = repository
@mailer = mailer
end
def call(title:, author:)
@book = @repository.create({title: title, author: author})
@mailer.deliver
end
end
これでインタラクタは本が追加されたときにメールを送信します。
インタラクタの部品 (Interactor parts)
インタフェース (Interface)
もうひとつ実装できる(オプションの)メソッドがあります。それはvalid?
というプライベートメソッドです。
デフォルトではvalid?
はtrue
を返します。もしvalid?
を実装してfalse
を返すようにすればcall
は決して実行されません。
これについての[APIドキュメント](http://www.rubydoc.info/gems/hanami-utils/Hanami/Interactor/Interface)を読むことができます。
require 'hanami/interactor'
class AddBook
class Validation
include Hanami::Validations
validations do
required(:title) { filled? & str? }
required(:author) { filled? & str? & size?(2..64) }
end
end
include Hanami::Interactor
expose :book
def initialize(repository: BookRepository.new, mailer: Mailers::BookAddedNotification.new)
@repository = repository
@mailer = mailer
end
def call(title:, author:)
@book = @repository.create({title: title, author: author})
@mailer.deliver
end
private
def valid?(title:, author:)
Validation.new(title: title, author: author).validate.success?
end
end
結果オブジェクト (Result)
Hanami::Interactor#call
の戻り値はHanami::Interactor::Result
のオブジェクトです。
これは公開するインタンス変数に関わらずアクセサメソッドが定義されています。
また、エラーの追跡機能も持っています。
インタラクタでは、メッセージを伴ってerror
を呼び出し、エラーを追加できます。これにより自動的に結果オブジェクトが失敗します。
(error!
メソッドというのもあります。それはerror
と同じことをし、かつ、流れをとめ、インタラクタがより多くのコードを実行するのを止めます。)
.errors
を呼びですことで、結果オブジェクトのエラーにアクセスできます。
結果オブジェクトについての詳細はAPIドキュメントを参照してください。
訳者によるまとめ
コールバックはクッサい。
-
[訳註] 訳があやしい。 原文->[An Interactor's single responsibility is to combine object and method calls in order to achieve a specific outcome.] ↩
-
[訳註] Hanamiではwebに関するコード(Controller, View, Routesなど)を
apps/web
にいれて、webに依存しないビジネスロジックに関するコード(Entity, Repositoryなど)をlib
に入れます。 ↩ -
[訳注] なぜ
mailers
がapps
以下ではなくlib
以下にあるのか疑問に思った人もいるかもしれません。安心してください。2.0で変更の可能性があります。 ↩