Паттерн Интерактор (Interactor, Operation)

в 16:30, , рубрики: laravel, oop patterns, php, ооп, Программирование

Данный текст представляет собой адаптацию части руководства фрэймворка Hanami под фрэймфорк Laravel. Чем вызван интерес именно к этому материалу? В нём даётся пошаговое описание с демонстрацией таких общих для языков программирования и фрэймворков вещей как:

  • Использование паттерна "Интеракторы".
  • Демонстрация TDDBDD.

Сразу стоит отметить, что это не только разные фрэймворки с разной идеологией (в частности, что касается ORM), но и разные языки программирования, каждый из которых имеет свою специфическую культуру и сложившиеся "bests practics" в силу исторических причин. Разные языки программирования и фрэймворки тяготеют к заимствованию друг у друга наиболее удачных решений, поэтому несмотря на различия в деталях, фундаментальные вещи не различаются, если мы конечно не берём ЯП с изначально разной парадигмой. Достаточно интересно сравнить, как одну и туже задачу решают в разных экосистемах.

Итак, исходно мы имеем фрэймворк Hanami (ruby) — достаточно новый фрэймворк, идеологически больше тяготеющий к Symfony, с ORM "на репозиториях". И целевой фрэймворк LaravelLumen (php) с Active Record.

В процессе адаптации были срезаны наиболее острые углы:

  • Пропущена первая часть руководства с инициализацией проекта, описанием особенностей фрэймворка и подобных специфичных вещей.
  • ORM Eloquent натянут на глобус и выполняет в том числе роль репозитория.
  • Шаги генерации кода и шаблонов для отправки email.

Сохранено и сделан акцент на:

  • Интеракторы — сделана минимально соответствующая по интерфейсу реализация.
  • Тесты, пошаговая разработка через TDD.

Первая часть оригинального туториала Hanami на которую будет ссылаться текст ниже
Оригинал текста туториала по интеракторам
Ссылка на репозиторий с адаптированным php кодом в конце текста.

Интеракторы

Новая фича: уведомления по электронной почте

Сценарий фичи: Как администратор, при добавлении книги, я хочу получать уведомления по электронной почте.

Поскольку приложение не имеет аутентификацию, любой может добавить новую книгу. Мы укажем адрес электронной почты администратора через переменные окружения.

Это просто пример, показывающий когда следует использовать интеракторы, и в частности как использовать Ханами интерактор.

Этот пример может послужить основой для других функций, таких как подтверждение администратором новых книг перед их публикацией. Или предоставление пользователям возможности указывать адрес электронной почты чтобы редактировать книгу через специальную ссылку.

На практике вы можете использовать интеракторы для реализации любой бизнес-логики абстрагированной от сетевого слоя. Это особенно полезно когда вы хотите объединить несколько вещей, чтобы контролировать сложность кодовой базы.

Они используются для изоляции нетривиальной бизнес-логики, следуя принципу единственной ответственности (Single Responsibility Principle).

В веб приложениях они обычно используются из экшенов контроллера. Этим вы разделяете задачи, объекты бизнес-логики и интеракторы, ничего не будут знать о сетевом слое приложения.

Колбэки? Они нам не нужны!

Простейший путь реализовать email уведомление — это добавить колбэк.

То есть после создания новой записи о книге в базе данных, отправляется email.

Архитектурно Ханами не предоставляет такого механизма. Это потому, что мы считаем колбэки моделей анти-паттерном. Они нарушают принцип единственной ответственности. В нашем случае они неправильно смешивают слой персистентности с уведомлениями на электронную почту.

Во время тестирования (и скорей всего в каких то других случаях), вы захотите пропустить колбэк. Это быстро запутывает, так как несколько колбэков для одного события могут быть запущены в определённом порядке. Кроме того, вы можете пропустить некоторые колбэки. Колбэки делают код хрупким и сложным для понимания.

Вместо этого, мы рекомендуем явное, вместо неявного.

Интерактор — это объект который представляет конкретный сценарий использования.

Они позволяют каждому классу иметь единственную ответственность. Единственная ответственность интерактора — объединить объекты и вызовы методов для достижения определённого результата.

Идея

Основная идея интеракторов заключается в том, что вы извлекаете изолированные части функциональности в новый класс.

Вы должны написать только два публичных метода: __construct и call.
В php реализации интерактора метод call имеет модификатор protected и вызывается через __invoke.

Это означает, что такие объекты легко трактовать, поскольку существует только один доступный метод для их использования.

Инкапсуляция поведения в одном объекте облегчает его тестирование. Это также упрощает для понимания вашу кодовою базу, а не просто оставляет скрытую сложность в неявно выраженном виде.

Подготовка

Допустим, у нас есть наше приложение «Книжная полка» от «Приступая к работе», и мы хотим добавить фичу «уведомление по электронной почте для добавленной книги».

Пишем интерактор

Давайте создадим папку для наших интеракторов и папку для их тестов:

$ mkdir lib/bookshelf/interactors
$ mkdir tests/bookshelf/interactors

Мы поместили их в lib/bookshelf, потому что они не связаны с веб-приложением. Позже вы можете добавить книги через портал администратора, API или даже утилиту командной строки.

Добавим интерактор AddBook и напишем новый тест tests/bookshelf/interactors/AddBookTest.php:

# tests/bookshelf/interactors/AddBookTest.php

<?php

use LibBookshelfInteractorsAddBook;

class AddBookTest extends TestCase
{        
   private function interactor()
   {
       return $this->app->make(AddBook::class);
   }

   private function bookAttributes()
   {
       return [
           "author" => "James Baldwin",
           'title' => "The Fire Next Time",
       ];
   }

   private function subjectCall()
   {
       return $this->interactor()($this->bookAttributes());
   }

   public function testSucceeds()
   {
       $result = $this->subjectCall();
       $this->assertTrue($result->successful());
   }
}

Запуск набора тестов вызовет ошибку Class does not exist, потому, что нет класса AddBook. Давайте создадим этот класс в файле lib/bookshelf/interactors/AddBook.php:

<?php

namespace LibBookshelfInteractors;

use LibInteractorInteractor;

class AddBook
{
   use Interactor;

   public function __construct()
   {
   }

   protected function call()
   {
   }
}

Есть только два метода, которые должен содержать этот класс: __construct для настройки данных и call для реализации сценария.

Эти методы, особенно call, должны вызывать приватные методы, которые вы напишите.

По умолчанию результат считается успешным, так как мы явно не указали, что операция не удалось.

Давайте запустим тест:

$ phpunit

Все тесты должны пройти!

Теперь давайте сделаем так, чтобы наш интерактор AddBook действительно что-то выполнял!

Создание книги

Изменим tests/bookshelf/interactors/AddBookTest.php:

   public function testCreateBook()
   {
       $result = $this->subjectCall();
       $this->assertEquals("The Fire Next Time", $result->book->title);
       $this->assertEquals("James Baldwin", $result->book->author);
   }

Если вы запустите тесты phpunit, то увидите ошибку:

Exception: Undefined property LibInteractorInteractorResult::$book

Давайте заполним наш интерактор, затем объясним, что мы сделали:

<?php

namespace LibBookshelfInteractors;

use LibInteractorInteractor;
use LibBookshelfBook;

class AddBook
{
   use Interactor;
   protected static $expose = ["book"];
   private $book = null;

   public function __construct()
   {
   }

   protected function call($bookAttributes)
   {
       $this->book = new Book($bookAttributes);
   }
}

Здесь следует отметить две важные вещи:

Строка protected static $expose = ["book"]; добавляет свойство book в объект результата который будет возвращён при вызове интерактора.

Метод call присваивает модель Book свойству book, которое будет доступно в результате.

Теперь тесты должны пройти.

Мы инициализировали модель Book, но она не сохраняется в базе данных.

Сохранение книги

У нас есть новая книга, полученная из заголовка и автора, но ее еще нет в базе данных.

Нам нужно использовать наш BookRepository, чтобы сохранить её.

// tests/bookshelf/interactors/AddBookTest.php

public function testPersistsBook()
{
   $result = $this->subjectCall();
   $this->assertNotNull($result->book->id);
}

Если вы запустите тесты, то увидите новую ошибку с сообщением Failed asserting that null is not null.

Это потому, что книга, которую мы создали, не имеет идентификатора, поскольку она получит его только тогда, когда будет сохранена.

Чтобы тест проходил, нам нужно создать сохранённую книгу. Другой, не менее правильный путь — сохранить ту книгу, которая у нас уже есть.

Отредактируйте метод call в файле интерактора lib/bookshelf/interactors/AddBook.php:

protected function call($bookAttributes)
{
   $this->book = Book::create($bookAttributes);
}

Вместо вызова new Book, мы делаем Book::create с атрибутами книги.

Метод по прежнему возвращает книгу, а также сохраняет эту запись в базе данных.

Если вы запустите тесты сейчас, вы увидите, что все тесты проходят.

Внедрение зависимостей (Dependency Injection)

Давайте проведём рефакторинг, чтобы использовать внедрение зависимостей.

Тесты до сих пор работают, но они зависят от особенностей сохранения в базу (свойство id определяется после успешного сохранения). Это деталь реализации того, как работает сохранение. Например, если вы хотите создать UUID до его сохранения и указать, что сохранение прошло успешно каким-либо иным способом, чем заполнение столбца id, вам придется изменить этот тест.

Мы можем изменить наш тест и интерактор, чтобы сделать его более надежным: он будет менее подвержен поломкам из-за изменений вне его файла.

Вот как мы можем использовать внедрение зависимостей в интеракторе:

// lib/bookshelf/interactors/AddBook.php

public function __construct(Book $repository)
{
   $this->repository = $repository;
}

protected function call($bookAttributes)
{
   $this->book = $this->repository->create($bookAttributes);
}

По сути, это то же самое, с немного большим количеством кода, для создания свойства repository.

Прямо сейчас тест проверяет поведение метода create, на то, что его идентификатор заполнен $this->assertNotNull($result->book->id).

Это деталь реализации.

Вместо этого мы можем изменить тест, чтобы просто убедиться, что у репозитория был вызван метод create, и довериться тому, что репозиторий сохранит объект (так как это его ответственность).

Давайте изменим тест testPersistsBook:

// tests/bookshelf/interactors/AddBookTest.php

public function testPersistsBook()
{
   $repository = Mockery::mock(Book::class);
   $this->app->instance(Book::class, $repository);
   $attributes = [
       "author" => "James Baldwin",
       'title' => "The Fire Next Time",
   ];

   $repository->expects()->create($attributes);
   $this->subjectCall($attributes);
}

Теперь наш тест не нарушает границы своей зоны.

Всё, что мы сделали, это добавили зависимость интерактора от репозитория.

Уведомление на электронную почту

Давайте добавим уведомление на электронную почту!

Так же вы можете сделать здесь что угодно, например, отправить SMS, отправить сообщение в чат или активировать веб-хук.

Мы оставим тело письма пустым, но в поле тема укажем «Book added!».

Создайте тест на уведомление tests/bookshelf/mail/BookAddedNotificationTest.php:

<?php

use LibBookshelfMailBookAddedNotification;
use IlluminateSupportFacadesMail;

class BookAddedNotificationTest extends TestCase
{
   public function setUp()
   {
       parent::setUp();
       Mail::fake();
       $this->mail = new BookAddedNotification();
   }

   public function testCorrectAttributes()
   {
       $this->mail->build();
       $this->assertEquals('no-reply@example.com', $this->mail->from[0]['address']);
       $this->assertEquals('admin@example.com', $this->mail->to[0]['address']);
       $this->assertEquals('Book added!', $this->mail->subject);
   }
}

Добавим класс уведомления lib/Bookshelf/Mail/BookAddedNotification.php:

<?php

namespace LibBookshelfMail;
use IlluminateMailMailable;
use IlluminateQueueSerializesModels;

class BookAddedNotification extends Mailable
{
   use SerializesModels;
   public function build() {
       $this->from('no-reply@example.com')
           ->to('admin@example.com')
           ->subject('Book added!');

       return $this->view('emails.book_added_notification');
   }
}

Теперь все наши тесты проходят!

Но уведомление ещё не отправляется. Нам нужно вызвать отправку из нашего интерактора AddBook.

Отредактируем тест AddBook, чтобы убедиться, что почтовик будет вызван:

public function testSendMail()
{
   Mail::fake();
   $this->subjectCall();
   Mail::assertSent(BookAddedNotification::class, 1);
}

Если запустить тесты, мы получим ошибку: The expected [LibBookshelfMailBookAddedNotification] mailable was sent 0 times instead of 1 times..

Теперь интегрируем отправку уведомления в интерактор.

public function __construct(Book $repository, BookAddedNotification $mail)
{
   $this->repository = $repository;
   $this->mail = $mail;
}

protected function call($bookAttributes)
{
   $this->book = $this->repository->create($bookAttributes);
   Mail::send($this->mail);
}

В результате интерактор отправит уведомление о добавлении книги на электронную почту.

Интеграция с контроллером

Наконец, нам нужно вызвать интерактор из экшена.

Отредактируем экшен файл app/Http/Controllers/BooksCreateController.php:

<?php

namespace AppHttpControllers;

use LibBookshelfInteractorsAddBook;
use IlluminateHttpRequest;
use IlluminateHttpResponse;

class BooksCreateController extends Controller
{   
   /**
    * Create a new controller instance.
    *
    * @return void
    */
   public function __construct(AddBook $addBook)
   {
       $this->addBook = $addBook;
   }

   public function call(Request $request)
   {
       $input = $request->all();
       ($this->addBook)($input);
       return (new Response(null, 201));
   }
}

Наши тесты проходят, но есть небольшая проблема.

Мы дважды тестируем код создания книги.

Как правило, это плохая практика, и мы можем исправить это, проиллюстрировав еще одно преимущество интеракторов.

Мы собираемся удалить упоминание на BookRepository в тестах и использовать мок для нашего интерактора AddBook:

<?php

use LibBookshelfInteractorsAddBook;

class BooksCreateControllerTest extends TestCase
{
   public function testCallsInteractor()
   {
       $attributes = ['title' => '1984', 'author' => 'George Orwell'];       

       $addBook = Mockery::mock(AddBook::class);
       $this->app->instance(AddBook::class, $addBook);
       $addBook->expects()->__invoke($attributes);

       $response = $this->call('POST', '/books', $attributes);
   }
}

Теперь наши тесты проходят и они намного надежнее!

Экшен принимает входные данные (из параметров http запроса) и вызывает интерактор, чтобы выполнить свою работу. Единственная ответственность экшена — работа с сетью. А интерактор работает с нашей реальной бизнес-логикой.

Это значительно упрощает экшены и их тесты.

Экшены практически освобождены от бизнес-логики.

Когда мы модифицируем интерактор, нам уже не нужно изменять экшен или его тест.

Обратите внимание, что в реальном приложении вы, вероятно, захотите сделать больше, чем указанная выше логика, например убедиться, что результат успешен. А если произошел сбой, вы захотите вернуть ошибки из интерактора.

Репозиторий с кодом

Автор: Игорь

Источник

* - обязательные к заполнению поля