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


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

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


Итак, исходно мы имеем фрэймворк Hanami (ruby) — достаточно новый фрэймворк, идеологически больше тяготеющий к Symfony, с ORM "на репозиториях". И целевой фрэймворк Laravel\Lumen (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 Lib\Bookshelf\Interactors\AddBook;

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 Lib\Bookshelf\Interactors;

use Lib\Interactor\Interactor;

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 Lib\Interactor\InteractorResult::$book

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


<?php

namespace Lib\Bookshelf\Interactors;

use Lib\Interactor\Interactor;
use Lib\Bookshelf\Book;

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 Lib\Bookshelf\Mail\BookAddedNotification;
use Illuminate\Support\Facades\Mail;

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 Lib\Bookshelf\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

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 [Lib\Bookshelf\Mail\BookAddedNotification] 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 App\Http\Controllers;

use Lib\Bookshelf\Interactors\AddBook;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

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 Lib\Bookshelf\Interactors\AddBook;

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 запроса) и вызывает интерактор, чтобы выполнить свою работу. Единственная ответственность экшена — работа с сетью. А интерактор работает с нашей реальной бизнес-логикой.


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


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


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


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


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

Комментарии (9)


  1. EvgeniiR
    03.02.2019 00:59
    +1

    Пока что не совсем понятны преимущества такого подхода даже перед самым простым решением в лоб — запилить какой-то класс сервис, который будет заниматься созданием Книги, и дерганьем других сервисов, типа Email-sender`а если его нужно отправить Email при создании книги.
    У такого сервиса будет хотя бы явно-определенный интерфейс с типизацией.

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

    Код
    <?php
    namespace Lib\Interactor;
    trait Interactor
    {
        abstract function call();
        
        public function __invoke()
        {
            $arguments = func_get_args();
            $payload = [];
            if (method_exists($this, 'validate') && !$this->validate(...$arguments)) 
            {
                return new InteractorResult($payload, false); 
            }
            static::call(...$arguments);
            foreach ((static::$expose ?? []) as $expose) {
                $payload[$expose] = $this->{$expose};
            }
            
            return new InteractorResult($payload, true);
        }
    }


    1. ZurgInq Автор
      03.02.2019 09:51

      Пока что не совсем понятны преимущества такого подхода даже перед самым простым решением в лоб — запилить какой-то класс сервис, который будет заниматься созданием Книги, и дерганьем других сервисов, типа Email-sender`а если его нужно отправить Email при создании книги.
      У такого сервиса будет хотя бы явно-определенный интерфейс с типизацией.

      Проблема «сервисов» в том, что само их определение достаточно размытое и каждый может вкладывать в этот термин свой смысл. Интерактор (или же «операция») — по факту и есть максимально узко специализированный «класс-сервис», отвечающий за конкретную операцию бизнес-процесса. Интерфейс интерактора в данном случае — один метод call. Типизация (type hinting) к сожалению потерялась в угоду другим плюшкам. Это особенность конкретной реализации интерактора. Сам же трэйт тут нужен по двум причинам — что бы примеры кода в тексте были максимально приближены к исходному тексту, и для уменьшения количества шаблонного кода плюс единообразный интерфейс всех интеракторов.

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

      Нет, валидация именно входящего запроса не предполагается. Валидация входных параметров может быть частью операции и реализует «return early» паттерн. Например, на вход может подаваться результат другого интерактора.

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

      Это всего лишь реакция «триггер» на наличие магических методов и чего то нового. Если мы имеем некий AddBookService, это точно так же ничего не говорит нам о том, что он делает и как его запустить не покопавшись в документации (или исходном коде). Когда в тоже самое время реализация интеракторов рассмотренная в тексте, сводит интерфейс класса к одному единственному публичному методу (call\__invoke). Некая «магия» конечно присутствует, но без этого не обходится ни один современный фрэймворк, так как эта магия призвана упрощать нам жизнь.

      Ну и тащить что то в продакшен тут не предлагается, цель текста — рассмотреть и обсудить паттерн, показать простейший пример разработки через тестирование.


      1. untilx
        03.02.2019 11:28

        Нет, валидация именно входящего запроса не предполагается. Валидация входных параметров может быть частью операции и реализует «return early» паттерн.

        Я не силён в этих ваших паттернах, но не нарушает ли такое поведение указанный же выше принцип единственной ответственности?


        1. ZurgInq Автор
          03.02.2019 11:52

          «return early» относится больше к coding style и говорит лишь о том, что оператор return (с негативным результатом) должен находиться как можно раньше в теле функции.
          Краткий пример (псевдокод):

          if (valid($params)) {
            doSomething()
          } else {
            ...
          }
          
          //return early
          
          if (!valid($params)) {
            return
          }
          doSomething()
          


          1. untilx
            03.02.2019 11:54

            Вопрос относится к валидации, а не к return


            1. ZurgInq Автор
              03.02.2019 12:04

              Прошу прощения. Тогда ответ будет такой — смотря как мы определим эту самую злополучную «единственную ответственность», и что будет находиться внутри валидации. В любом случае, валидация — не обязательна и мы можем делегировать её другому классу (в том числе в другому интерактору).
              Здесь же она скорей нужна для использования интеракторов в цепочке:
              result = doSomething(params) //вернул negative result
              result = doSomething2(result) // проверили, что на вход пришёл negative result и вернули его же


              1. untilx
                03.02.2019 12:27

                Здесь же она скорей нужна для использования интеракторов в цепочке:


                Почему бы просто не использовать старые добрые исключения? Будет один блок try/catch в котором точно так же цепочкой записаны все вызовы, плюс не будет лишнего передёргивания функций, если где-то пришёл отрицательный результат.


      1. EvgeniiR
        03.02.2019 19:30

        Проблема «сервисов» в том, что само их определение достаточно размытое и каждый может вкладывать в этот термин свой смысл. Интерактор (или же «операция») — по факту и есть максимально узко специализированный «класс-сервис», отвечающий за конкретную операцию бизнес-процесса.

        Я не вижу никаких проблем в обычных классах-сервисах которые аггрегируют внутри себя вызовы других сервисов, определяя какой-то сценарий. С Интеракторами у нас получается тоже самое, но с исковерканным не понятно для чего интерфейсом.
        Ну то есть да, если бы я такое увидел у меня точно рука не поднялась добавлять в эту странную штуку какие-то свои методы, помимо call(), но такие вещи должны отсекаться на уровне code review, а от добавления в Интеракторы какой-нибудь не подходщей для них логики неопытным программистом всё-равно никто нас не застрахует.
        Для пущего спокойствия можно инжектить обычные сервисы через единый интерфейс с каким-нибудь одним публичным методом типа call();


  1. untilx
    03.02.2019 11:25

    Промазал с веткой