Architecture: インタラクター
概要
Hanamiはあなたのコードを組織化するためのオプションのツールを提供します。
これらは インタラクター で、 サービスオブジェクト 、 ユースケース 、 オペレーション とも呼ばれます。
私たちはそれらが素晴らしくて複雑さを管理するのを助けると思いますが、あなたがそれらなしでHanamiのアプリを作るのは自由です。
このガイドでは、Hanamiのインタラクターが既存のアプリケーションに小さな機能を追加することによってどのように機能するかを説明します。
これから作業する既存のアプリケーションは、 入門ガイドのbookshelfアプリケーションです。
新機能: Eメール通知
私たちの新機能のストーリー: > 管理者として、書籍が追加されたときにEメール通知を受け取りたい
アプリケーションには認証がないため、だれでも新しい書籍を追加できます。 環境変数で管理者のメールアドレスを提供します。
これはインタラクターをいつ使うべきか、そして特にHanami::Interactorをどのように使うことができるかを示すほんの一例です。
この例は、新しい書籍本が投稿される前に管理者による承認を追加したり、 ユーザーが電子メールアドレスを入力してから特別なリンクを介してその書籍を編集することを許可するなど、 他の機能の基礎を提供します。
実際には、インタラクターを使用してWebから離れて抽象化された 任意のビジネスロジック を実装できます。 コードベースの複雑さを管理するために、一度に複数のことを行いたい場合に特に役立ちます。
これは重要なビジネスロジックを分離するために使用されています。 これは単一責任原則に従います。
Webアプリケーションでは、一般的にコントローラのアクションから呼び出されます。 これにより、懸念を分けることができます。 あなたのビジネスロジックオブジェクト、インタラクターは、ウェブについてまったく知りません。
コールバック? それは必要ありません!
Eメール通知を実装する簡単な方法は、コールバックを追加することです。
つまり: データベースに新しいBookレコードが作成された後、Eメールが送信されます。
設計上、Hanamiはそのようなメカニズムを提供していません。 これは、永続化コールバックをアンチパターンと見なしているためです。 それは単一責任の原則に違反しています。 この場合、それは永続化とEメール通知を不適切に混ぜ合わせます。
テスト中(そしておそらく他でも)、あなたはそのコールバックをスキップしたくなるでしょう。 これはすぐに混乱します。同じイベントに対する複数のコールバックが特定の順序で発生するためです。 また、ある時点でいくつかのコールバックをスキップしたいと思うかもしれません。 それらはコードを理解しにくく、もろくします。
代わりに、暗黙的よりも明示的であることをお勧めします。
インタラクターは特定の ユースケース を表すオブジェクトです。
それは各クラスに一つの責任を持たせます。 インタラクターの唯一の責任は、特定の結果を達成するためにオブジェクトとメソッド呼び出しを組み合わせることです。
私たちはHanami::Interactorをモジュールとして提供しているので、
あなたは生の古いRubyオブジェクトから始め、
あなたがその機能のいくつかを必要とするときinclude Hanami::Interactorすることができます。
コンセプト
インタラクターの背後にある中心的な考え方は、機能の分離した部品を新しいクラスに抽出することです。
2つのパブリックメソッドを書くだけです: #initializeと#call。
これは、オブジェクトは簡単に推論できることを意味します。 オブジェクトが作成された後に呼び出すことができるメソッドが1つだけだからです。
動作を単一のオブジェクトにカプセル化することで、テストが簡単になります。 暗黙のうちに表現されるだけで、複雑さを隠すのではなく、コードベースを理解しやすくします。
準備
Getting Startedのbookshelfアプリケーションがあり、
「追加した書籍のEメール通知」機能を追加したいとしましょう。
インタラクターの作成
インタラクター用のフォルダーとスペック用のフォルダーを作成しましょう:
$ mkdir lib/bookshelf/interactors
$ mkdir spec/bookshelf/interactorsそれらはWebアプリケーションから切り離されているので、lib/bookshelfに入れました。
後で、管理者ポータル、API、あるいはコマンドラインユーティリティを使って書籍を追加したいと思うかもしれません。
私たちのインタラクターをAddBookと呼び、
新しいスペックspec/bookshelf/interactors/add_book_spec.rbを書きましょう:
# spec/bookshelf/interactors/add_book_spec.rb
RSpec.describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash.new }
it "succeeds" do
result = interactor.call(attributes)
expect(result.successful?).to be(true)
end
endAddBookクラスがないため、テストスイートを実行するとNameErrorが発生します。
そのクラスをlib/bookshelf/interactors/add_book.rbファイルに作成しましょう:
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(実際にユースケースを満たす)です。
これらのメソッド、特に#callはあなたが書くプライベートメソッドを呼び出すべきです。
デフォルトでは、成功したと見なされます。 明示的に失敗したとは言っていないためです。
このテストを実行しましょう:
$ bundle exec rakeすべてのテストに合格するはずです!
それでは、AddBookインタラクターに実際に何かをさせましょう!
書籍を作る
spec/bookshelf/interactors/add_book_spec.rb を編集:
# spec/bookshelf/interactors/add_book_spec.rb
RSpec.describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
context "good input" do
let(:result) { interactor.call(attributes) }
it "succeeds" do
expect(result.successful?).to be(true)
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
endbundle exec rakeしてテストを実行すると、次のエラーが表示されます:
NoMethodError: undefined method `book' for #<Hanami::Interactor::Result:0x007f94498c1718>インタラクターに記入してから、何をしたかを説明しましょう:
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エンティティを割り当てます。これは結果に公開されます。
テストは成功するはずです。
新しいBookエンティティを初期化しましたが、データベースに永続化されていません。
書籍を永続化する
タイトルと作者から渡された新しいBookが渡されましたが、データベースにはまだ存在しません。
それを持続させるためには私たちのBookRepositoryを使う必要があります。
# spec/bookshelf/interactors/add_book_spec.rb
RSpec.describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
context "good input" do
let(:result) { interactor.call(attributes) }
it "succeeds" do
expect(result.successful?).to be(true)
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テストを実行すると、新しい期待値がExpected nil to not be nil.で失敗するのがわかるでしょう。
これは、私たちが作った書籍がidを持っていないからです。
永続化されている場合はいつでも1つしか取得されないためです。
このテストに合格するには、代わりに永続化された Bookを作成する必要があります。
(もう1つの、同様に有効な選択肢は、私たちがすでに持っているBookを永続化させることです。)
私たちのlib/bookshelf/interactors/add_book.rbインタラクターのcallメソッドを編集します:
def call
@book = BookRepository.new.create(book_attributes)
endBook.newを呼び出す代わりに、
新しいBookRepositoryを作成し、それに属性を付けてcreateを送信します。
これでもBookが返されますが、このレコードもデータベースに保持されます。
今すぐテストを実行すると、すべてのテストに成功するはずです。
依存性注入
依存性注入を利用するために、実装をリファクタリングしましょう。
依存性注入を使用することをお勧めしますが、必須ではありません。
これはHanami::Interactorの完全にオプションの機能です。
specは機能しますが、リポジトリの動作に依存しています(永続性が成功した後にidメソッドが定義されるようになります)。
これがリポジトリの動作の詳細です。
たとえば、永続化する 前に UUIDを作成し、その永続化がid列を設定する以外の方法で成功したことを示す場合は、このspecを変更する必要があります。
specとインタラクターをより堅牢にするために変更することができます: ファイルの外部での変更のために壊れる可能性は低くなります。
インタラクターで依存性注入を使用する方法は次のとおりです:
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インスタンス変数を作成することは、少しコードがあるだけで、基本的に同じことです。
今のところ、私たちのspecは、idが移入されていることを確認することによって、リポジトリのふるまいをテストします
(expect(result.book.id).to_not be(nil))。
これは実装の詳細です。
代わりに、リポジトリがcreateメッセージを確実に受信するようにspecを変更し、
リポジトリがそれを永続化することを信頼することができます(それがその責務です)。
変更してit "persists the Book"という期待を取り除き、
context "persistence"ブロックを作成しましょう:
# spec/bookshelf/interactors/add_book_spec.rb
RSpec.describe AddBook do
let(:interactor) { AddBook.new }
let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
context "good input" do
let(:result) { interactor.call(attributes) }
it "succeeds" do
expect(result.successful?).to be(true)
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
context "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今私達のテストは懸念の境界に違反していません。
ここで行ったことは、インタラクターのリポジトリへの依存性を注入することです。
注: テストしていないコードでは、何も変更する必要はありません。
repository:キーワード引数のデフォルト値は、新しいリポジトリオブジェクトが渡されなかった場合にそれを提供します。
Eメール通知
Eメール通知を追加しましょう!
別のライブラリを使うこともできますが、
私たちはHanami::Mailerを使います。
(SMSの送信、チャットメッセージの送信、Webフックの呼び出しなど、何でもできます。)
$ 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クラス、関連するspec、および2つのテンプレート(プレーンテキスト用とhtml用)があります。
テンプレートは空のままにするので、 Eメールは空になり、 件名が「Book added!」となります。
メーラーspecspec/bookshelf/mailers/book_added_notification_spec.rbを編集します:
# spec/bookshelf/mailers/book_added_notification_spec.rb
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そしてメーラーlib/bookshelf/mailers/book_added_notification.rbを編集します:
# lib/bookshelf/mailers/book_added_notification.rb
class Mailers::BookAddedNotification
include Hanami::Mailer
from 'no-reply@example.com'
to 'admin@example.com'
subject 'Book added!'
endこれですべてのテストは成功するはずです!
しかし、このメーラーはどこからも呼び出されません。
AddBookインタラクターからこのメーラーを呼び出す必要があります。
私たちのメーラが呼ばれるように、AddBookspecを編集しましょう:
...
context "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
...テストスイートを実行するとエラーが表示されます: ArgumentError: unknown keyword: mailer。
これは理にかなっています、なぜなら私たちのインタラクターはただ一つのキーワード引数repositoryを持っているだけだからです。
初期化に新しいmailerキーワード引数を追加することによって、今私たちのメーラーを統合しましょう。
また、新しい@mailerインスタンス変数でdeliverを呼び出します。
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(book_attributes)
@book = @repository.create(book_attributes)
@mailer.deliver
end
endこれで私たちのインタラクターは、書籍が追加されたことを通知するEメールを配信します。
コントローラとの統合
最後に、アクションからこのインタラクターを呼び出す必要があります。
アクションファイルapps/web/controllers/books/create.rbを編集します:
def call(params)
if params.valid?
@book = AddBook.new.call(params[:book])
redirect_to routes.books_path
else
self.status = 422
end
end私たちのスペックはまだ合格しますが、小さな問題があります。
書籍作成コードを2回テストしています。
これは一般的に悪い習慣であり、インタラクターの別の利点を説明することで修正できます。
再び依存性注入を使用します。 今回は、私たちのアクションの中で。
interactor用のキーワード引数で、initializeメソッドを追加します。
しかし、最初にspecspec/web/controllers/books/create_spec.rbを編集しましょう。
BookRepositoryへの参照を削除し、
AddBookインタラクター用のdoubleを利用します:
# spec/web/controllers/books/create_spec.rb
RSpec.describe Web::Controllers::Books::Create do
let(:interactor) { instance_double('AddBook', call: nil) }
let(:action) { described_class.new(interactor: interactor) }
context 'with valid params' do
let(:params) { Hash[book: { title: '1984', author: 'George Orwell' }] }
it 'calls interactor' do
expect(interactor).to receive(:call)
response = action.call(params)
end
it 'redirects the user to the books listing' do
response = action.call(params)
expect(response[0]).to eq(302)
expect(response[1]['Location']).to eq('/books')
end
end
context 'with invalid params' do
let(:params) { Hash[book: {}] }
it 'calls interactor' do
expect(interactor).to receive(:call)
response = action.call(params)
end
it 're-renders the books#new view' do
response = action.call(params)
expect(response[0]).to eq(422)
end
it 'sets errors attribute accordingly' do
response = action.call(params)
expect(action.params.errors[:book][:title]).to eq(['is missing'])
expect(action.params.errors[:book][:author]).to eq(['is missing'])
end
end
endinitializeをオーバーライドしていないので、テストはエラーを引き起こします。
それでは、#callメソッドで新しいインスタンス変数を活用しましょう:
...
def initialize(interactor: AddBook.new)
@interactor = interactor
end
def call(params)
if params.valid?
@book = @interactor.call(params[:book])
redirect_to routes.books_path
else
self.status = 422
end
end
...今、私たちのspecは成功しており、それらははるかに堅牢です!
私たちのアクションは今や責任が少なくなっています。 それはその実際の振る舞いを私たちのインタラクターに委譲します。
アクションは(パラメータから)入力を受け取り、 実際にその仕事をするために私たちのインタラクターを呼び出します。 唯一の責任はWebを扱うことです。 私たちのインタラクターは現在、私たちの実際のビジネスロジックを扱います。
これは私たちのアクションとそのspecにとって大きな安心です。
私たちのアクションは主に私たちのビジネスロジックから解放されています。
インタラクターを変更するときに、 アクションまたはそのspecを変更する必要はありません。
(実際のアプリケーションでは、結果が成功することを確認するなど、上記のロジック以上のことをしたいと思うでしょう。 そうでなければ、失敗した場合はインタラクターからのエラーを渡したいと思うでしょう。)
インタラクター部
インタフェース
上記のように、インターフェースはかなり単純です。
(オプションで)実装できるもう1つの方法もあります。
それは#valid?という名前のプライベートメソッドです。
デフォルトでは#valid?はtrueを返します。
#valid?を定義してfalseを返した場合、
#callは実行されません。
代わりに、結果はすぐに返されます。 これも結果を(成功ではなく)失敗にします。
それについてはAPIドキュメントで読むことができます。
結果
Hanami::Interactor#callの結果はHanami::Interactor::Resultオブジェクトです。
それはあなたがexposeしたどんなインスタンス変数に対しても定義されたアクセサメソッドを持ちます。
エラーを追跡する機能もあります。
あなたのインタラクターでは、エラーを追加するためにメッセージとともにerrorを呼び出すことができます。
これは自動的に結果のオブジェクトを失敗にします。
(error!メソッドもあり、
これは同じことをし、 そして フローを中断し、
インタラクターがそれ以上コードを実行するのを止めます)。
結果のオブジェクトのエラーにアクセスするには、.errorsを呼び出します。
APIのドキュメントでResultオブジェクトについてもっと読むことができます。