Ruby
DDD
hanami
サービス層
Interactor
HanamiDay 10

Hanamiのサービス(インタラクタ)層について

公式の意訳に適当に加筆(減筆?)/修正したものです。(ちなみに著者の英語の偏差値は30台です。)


インタラクタとは

Hanamiにはコードを整理するためのオプションのツールを提供しています。それはインタラクタといい、サービスやユースケース、オペレーションなどとも呼ばれます。

以下、事例を通して使い方を見ていきます。

新しい要求: メール通知 (A New Feature: Email Notifications)

こんな要求があったとします。

管理者は本が追加されたときにメール通知を受け取りたい。

アプリケーションには認証がないため、だれでも本を追加できます。管理者のメールアドレスは環境変数で与えます。

これはインタラクタ、具体的にはHanami::Interactorを使ういい例です。

この例は以下のような別の要求の基礎を提供することができます。

  • 新しい書籍が投稿される前に管理者の承認を得る
  • ユーザーは電子メールアドレスを入力し、特別なリンクを使用して本を編集することができる。

このプラクティスでは基本的な幾つかの実装のためにインタラクタを使うことができます。これは、コードベースの複雑さを管理するために、一度にいくつかのことをしたいときに特に便利です。

インタラクタは些細ではない(non-trivial)ビジネスロジックを分離するために使用されます。これは単一責任の原則に沿っています。

Webアプリケーションでは、インタラクタは一般的にコントローラのアクションから呼び出されます。これにより、関心事を分けることができます。 あなたのビジネスロジックオブジェクト、インタラクタはWebについて全く知らないでしょう。

コールバック? いやいや、クッサいコールバックなんていりません! (Callbacks? We Don't Need No Stinkin' Callbacks!)

簡単な実装方法はcallbackを追加することです。つまり、データベースに新しい本が追加されたあとにメールが送信されます。
[訳註] Railsでいうとこんな感じ?
book.rb
class book < ApplicationRecord
  after_create :send_mail_to_admin
end

このような仕組みをHanamiは提供していません。これは永続コールバック1アンチパターンだと考えられるからです。永続コールバックは単一責任の原則に反します。この場合、永続性とメール通知(の責任)が不適切に混在しています。

上記のような実装をした場合、テストやその他の点でこのコールバックをスキップしたくなるでしょう。また、このような実装は同じイベント(今回の場合はDBへの保存)に対する複数のコールバックが特定の順序でトリガーされるため、すぐ混乱をおこします。

代わりに、暗黙を明示に(explicit over implicit)しましょう。

インタラクタは特定のユースケースを表すオブジェクトです。

インタラクタは各クラスに単一の責任を持たせます。インタラクタの単一責任は特定の結果を達成するためにオブジェクトとメソッドコールを順番に結合します。2

そのために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アプリケーションから切り離されているからです。3

新しいスペックspec/bookshelf/interactors/add_book_spec.rbを書いて、AddBookインタラクタを呼びましょう。

spec/bookshelf/interactors/add_book_spec.rb
require 'spec_helper'

describe AddBook do
  let(:interactor) { AddBook.new }

  it "succeeds" do
    expect(interactor.call).to be_a_success
  end
end

次にAddBookクラスを作りましょう。

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: ユースケースを満たす

テストを実行してみましょう。

% bundle exec rake

すべてのテストがパスするでしょう!

本の作成 (Creating a Book)

spec/bookshelf/interactors/add_book_spec.rbを編集します。

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

次に、インタラクタを実装していきます。

lib/bookshelf/interactors/add_book.rb
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つの重要なことが書かれています。

  1. expose :bookではメソッドの実行結果として返される@bookを公開します。
  2. callメソッドは新しいBookエンティティを@bookに割り当てて、公開します。

これでテストはパスするでしょう。

作成した本の永続化 (Persisting the Book)

さきほど新しい本をビルドしましたが、まだデータベースには存在していません。

永続化のためにBookRepositoryが必要です。

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

    it "persists the Book" do
      expect(result.book.id).to_not be_nil
    end
  end
end

このテストをパスさせるためにインタラクタのcallメソッドを編集しましょう。

lib/bookshelf/interactors/add_book.rb
  def call
    @book = BookRepository.new.create(book_attributes)
  end

Book.newの代わりにBookRepositorycreateを使いました。相変わらずBookは返却されますが、レコードはデータベースに保存されます。

テストを実行すればパスするでしょう。

依存性の注入 (Dependency Injection)

依存性の注入を活用してリファクタリングしましょう。

依存性の注入はおすすめですが、必須ではありません。Hanamiのインタラクタ同様にオプションです。

先程のスペックは役割を果たしますが、リポジトリの振る舞いに依存しています(idメソッドは永続化に成功して初めて定義されます)。それはリポジトリがどのように動作するかという実装の詳細です。例えば、もし永続化の前にUUIDを作成し、idカラムに値を入れるのではなく、他の方法で保存が成功したことを表す場合、スペックを変更する必要があります。

スペックとインタラクタをより強固にすることができ、このファイル外の変更で壊れる可能性が低くなります。

インタラクタで依存性の注入を使用するには以下のようにします。

lib/bookshelf/interactors/add_book.rb
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"を作成します。

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

  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:キーワード引数は指定しなかった場合は新しいリポジトリオブジェクトを提供します。
[訳註] ここまでの説明ではコードが出てきていませんが、リポジトリはコントローラなどから以下のようにして使います。
apps/web/controllers/book/create.rb
module Web::Controllers::Book
  class craete
    include Web::Action

    def call(params)
      AddBook.new.call(params[:book])
    end
  end
end

AddBook.newの引数を与えなかった場合、AddBook@repositotyBookRepository.newになります。そのため、依存性の注入を行う前と後でこのコードを変更する必要がない、ということをいっています。

メール通知 (Email notification)

いよいよメール通知を追加しましょう!

ここで別のライブラリを使うこともできますが、Hanami::Mailerを使うことにしましょう。4

% 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!'という件名の空メールになります。

メーラースペックを編集します。

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
class Mailers::BookAddedNotification
  include Hanami::Mailer

  from    'no-reply@example.com'
  to      'admin@example.com'
  subject 'Book added!'
end

これでテストはパスするでしょう!

しかし、このメーラはどこからも呼ばれてません。AddBookインタラクタから呼び出す必要があります。

AddBookのスペックをメーラーが呼ばれていることを確認するように編集しましょう。

spec/bookshelf/interactors/add_book_spec.rb
  ...
  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キーワード引数を追加して、メーラーを統合しましょう。

lib/bookshelf/interactors/add_book.rb
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ドキュメントを読むことができます。
[訳註] サンプルを書いてみました。
lib/bookshelf/interactors/add_book.rb
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ドキュメントを参照してください。


訳者によるまとめ

コールバックはクッサい。


  1. [訳註] 多分、永続(= DBへの保存)に対するコールバックのこと 

  2. [訳註] 訳があやしい。 原文->[An Interactor's single responsibility is to combine object and method calls in order to achieve a specific outcome.] 

  3. [訳註] Hanamiではwebに関するコード(Controller, View, Routesなど)をapps/webにいれて、webに依存しないビジネスロジックに関するコード(Entity, Repositoryなど)をlibに入れます。 

  4. [訳注] なぜmailersapps以下ではなくlib以下にあるのか疑問に思った人もいるかもしれません。安心してください。2.0で変更の可能性があります。