[Перевод] Архитектура чистого кода и разработка через тестирование в PHP

28bdb26d886643fcbc5fdf0d4518d552.png

Понятие «архитектура чистого кода» (Clean Code Architecture) ввел Роберт Мартин в блоге 8light. Смысл понятия в том, чтобы создавать архитектуру, которая не зависела бы от внешнего воздействия. Ваша бизнес-логика не должна быть объединена с фреймворком, базой данных или самим вебом. Подобная независимость даёт ряд преимуществ. К примеру, при разработке вы сможете откладывать какие-то технические решения, например выбор фреймворка, движка/поставщика БД. Также вы сможете легко переключаться между разными реализациями и сравнивать их. Но самое важное преимущество такого подхода — ваши тесты будут выполняться быстрее.

Просто подумайте об этом. Вы действительно хотите пройти роутинг, подгрузить абстрактный уровень базы данных или какое-нибудь ORM-колдовство? Или просто выполнить какой-то код, чтобы проверить (assert) те или иные результаты?
Я начал изучать такую архитектуру и практиковаться в ее создании из-за моего любимого фреймворка Kohana. Его основной разработчик однажды перестал поддерживать код, поэтому мои проекты не обновлялись и не получали патчи системы безопасности. А это означало, что мне понадобилось либо довериться версии, которая разрабатывается сообществом, либо переходить на новый фреймворк и переписывать проекты целиком.

Да, я мог бы выбрать другой фреймворк. Возможно, Symfony 1 или Zend 1. Но что бы я ни выбрал, с тех пор изменился бы и этот фреймворк. Они постоянно меняются и развиваются. Composer облегчает не только установку и замену пакетов, но и их исключение (в нём есть даже возможность помечать пакеты как исключённые), так что ошибиться довольно просто.

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

dec5f96af78042ec9110be8524d0ff5f.png

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

Самое интересное — в правом нижнем углу: поток управления. Схема объясняет, как фреймворк взаимодействует с бизнес-логикой. Контроллер передаёт данные на порт ввода, информацию с которого обрабатывает интерактор, а результат передаётся на порт вывода, содержащий данные для презентера.

Начнём со слоя сценариев использования, поскольку здесь находится наша специфическая логика приложения. Внешние слои, включая контроллер, относятся к фреймворку.

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


Обычно мы начинаем работу с пользовательского интерфейса. Что человек ожидает увидеть в гостевой книге? Наверное, форму ввода, записи других посетителей, возможно, навигационную панель с поиском по страницам записей. Если книга пуста, может отображаться сообщение «Записей нет».

В первом тесте нам нужно проверить (assert) пустой список записей:

process($request, $response);
        $this->assertEmpty($response->entries);
    }
}


Здесь я использовал немного другую нотацию по сравнению с нотацией Дяди Боба. Интеракторы — это useCase, порты ввода — request, порты вывода — response. Все useCase содержат метод, в котором есть type hint для конкретного интерфейса request и response.

Если следовать принципам разработки через тестирование (test-driven development, TDD) — красный цикл, зелёный цикл, цикл рефакторинга, — тест не будет пройден, поскольку классы не существуют. Для прохождения теста достаточно создать файлы классов, методы и свойства. Поскольку классы пусты, нам пока рано приступать к циклу рефакторинга.

Теперь нужно проверить отображение записей:

process($request, $response);
        $this->assertEmpty($response->entries);
    }
    public function testCanSeeEntries()
    {
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase();
        $useCase->process($request, $response);
        $this->assertNotEmpty($response->entries);
    }
}


Тест не пройден, мы находимся в красной части цикла TDD. Для прохождения нужно добавить логику в наши useCase.
Но сначала воспользуемся type hint«ами в качестве параметров и создадим интерфейсы:



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

Мы же вместо форм и линий используем, например, репозитории и фабрики. Репозиторий — это абстрактный уровень для получения данных из хранилища. Хранилищем может быть база данных, файл, внешний API и даже память.

Для просмотра записей в гостевой книге нам нужно найти эти записи в репозитории, конвертировать в виды (view) и вернуть.

entryRepository->findAllPaginated($request->getOffset(), $request->getLimit());

        if(!$entries){
            return;
        }

        foreach($entries as $entry){
            $entryView = $this->entryViewFactory->create($entry);
            $response->addEntry($entryView);
        }
    }
}


Наверное, вы спросите, для чего понадобилось конвертировать сущность Entry в вид? Дело в том, что сущность не должна покидать пределы уровня useCase. Мы можем найти её только с помощью репозитория, при необходимости изменить/скопировать и положить обратно в репозиторий. Когда мы начнём перемещать сущность во внешний слой, то лучше добавить дополнительные методы для улучшения взаимодействия. Однако в сущности должна присутствовать только основная бизнес-логика.

Поскольку мы пока не знаем, какой формат нужно придать сущности, пропустим этот шаг.

Теперь отвечу на ваш возможный вопрос о фабриках. Создав новый экземпляр в цикле:

$entryView = new EntryView($entry);
$response->addEntry($entryView);


мы нарушим принцип инверсии зависимостей. И если потом в той же логике useCase нам понадобится ещё один объект вида (view object), то придётся переписывать код. А с помощью фабрики можно легко внедрять разные виды с разной логикой форматирования, при этом будет использоваться один и тот же useCase.
Нам уже известны зависимости useCase: $entryViewFactory и $entryRepository. Также известны и методы этих зависимостей. EntryViewFactory создаёт метод, который получает EntryEntity, а у EntryRepository есть метод findAll(), возвращающий массив EntryEntities. Теперь можно создать интерфейсы для методов и применить их к useCase.

EntryRepository выглядит так:



Тогда useCase:

entryRepository = $entryRepository;
        $this->entryViewFactory = $entryViewFactory;
    }


    public function process(ViewEntriesRequest $request, ViewEntriesResponse $response)
    {
        $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit());
        if (!$entries) {
            return;
        }

        foreach ($entries as $entry) {
            $entryView = $this->entryViewFactory->create($entry);
            $response->addEntry($entryView);
        }
    }
}


Как видите, тесты всё ещё не проходятся, так как нет реализации зависимости. Создадим несколько фальшивых объектов:

process($request, $response);
        $this->assertEmpty($response->entries);
    }
    public function testCanSeeEntries()
    {
        $entities = [];
        $entities[] = new EntryEntity();
        $entryRepository = new FakeEntryRepository($entities);
        $entryViewFactory = new FakeEntryViewFactory();
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory);
        $useCase->process($request, $response);
        $this->assertNotEmpty($response->entries);
    }
}


Поскольку мы уже создали интерфейсы для репозитория и фабрики видов, значит, можем внедрить их в фальшивые классы, а заодно реализовать интерфейсы для request/response.

Теперь репозиторий выглядит так:

entries = $entries;
    }

    public function findAllPaginated($offset, $limit)
    {
        return array_splice($this->entries, $offset, $limit);
    }
}


А фабрика видов — так:

author = $entity->getAuthor();
        $view->text = $entity->getText();
        return $view;
    }
}


Вы спросите, почему бы просто не использовать mocking-фреймворки для создания зависимостей? Тому есть две причины:

  1. С помощью редактора можно легко создать необходимые классы, поэтому фреймворки не нужны.
  2. Когда мы начинаем создавать реализацию для фреймворка, то можем использовать эти фальшивые классы в DI-контейнере и играться с шаблонами без необходимости настоящей реализации.


Теперь тесты пройдены, можем заняться рефакторингом. По сути, в классе useCase рефакторить нечего, разве только в тестовом классе.
Исполняться будет так же, просто с другой настройкой (setup) и проверкой. Можем перенести инициализацию фальшивых классов и обработку useCase в частную функцию processUseCase.

Тестовый класс выглядит так:

processUseCase($entries);
        $this->assertNotEmpty($response->entries);
    }

    public function testEntriesNotExists()
    {
        $entities = [];
        $response = $this->processUseCase($entities);
        $this->assertEmpty($response->entries);
    }

    /**
     * @param $entities
     * @return FakeViewEntriesResponse
     */
    private function processUseCase($entities)
    {
        $entryRepository = new FakeEntryRepository($entities);
        $entryViewFactory = new FakeEntryViewFactory();
        $request = new FakeViewEntriesRequest();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory);
        $useCase->process($request, $response);
        return $response;
    }
}


Теперь мы можем, например, легко создать новые тесты с неверными сущностями, переместить репозиторий и фабрику в метод setup и прогнать тесты с настоящими реализациями.

Также мы можем внедрить в DI-контейнер готовый к использованию useCase и использовать его внутри фреймворка. При этом логика не будет зависеть от фреймворка.

Кроме того, ничто не мешает создать другую реализацию репозитория, которая будет общаться с внешним API, например, и передавать его в useCase. Логика будет независима от базы данных.

При желании можно создать CLI-объекты request/response и передавать их тому же useCase, используемому внутри контроллера. В этом случае логика не будет зависеть от платформы.

Даже можно исполнять по очереди разные useCase, каждый из которых изменяет объект response.

class MainController extends BaseController
{
    public function indexAction(Request $httpRequest)
    {
        $indexActionRequest = new IndexActionRequest($httpRequest);
        $indexActionResponse = new IndexActionResponse();
        $this->getContainer('ViewNavigation')->process($indexActionRequest, $indexActionResponse);
        $this->getContainer('ViewNewsEntries')->process($indexActionRequest, $indexActionResponse);
        $this->getContainer('ViewUserAvatar')->process($indexActionRequest, $indexActionResponse);
        $this->render($indexActionResponse);

    }
}


Добавим в нашу гостевую книгу разбивку на страницы. Тест может выглядеть так:

   public function testCanSeeFiveEntries(){
        $entities = [];
        for($i = 0;$i<10;$i++){
             $entities[] = new EntryEntity('Author '.$i,'Text '.$i);
        }

        $response = $this->processUseCase($entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5,count($response->entries));
    }


Он не будет пройден, так что нужно модифицировать метод process в useCase, а заодно переименовать метод findAll в findAllPaginated.

public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){
        $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit());
//....
    }


Теперь можно применить в интерфейсе и фальшивом репозитории новые параметры, а также добавить новые методы в интерфейс request.

В репозитории немного изменится метод findAllPaginated:

    public function findAllPaginated($offset, $limit)
    {
        return array_splice($this->entries, $offset, $limit);
    }


Нужно перенести в тесты объект request. Также для конструктора объекта request понадобится параметр ограничения (limit parameter). Таким образом, мы заставим setup создать ограничение вместе с новым экземпляром.

  public function testCanSeeFiveEntries(){
        $entities = [];
        for($i = 0;$i<10;$i++){
            $entities[] = new EntryEntity();
        }
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5,count($response->entries));
    }


Тест пройден. Но нужно ещё протестировать возможность просмотра следующих пяти записей. Для этого придётся добавить в объект request метод setPage.

limit = $limit;
    }

    public function setPage($page = 1){
        $this->offset = ($page-1) * $this->limit;
    }
    public function getOffset()
    {
        return $this->offset;
    }

    public function getLimit()
    {
        return $this->limit;
    }

}


С помощью этого метода мы можем протестировать отображение следующих пяти записей:

   public function testCanSeeFiveEntriesOnSecondPage(){
        $entities = [];
        $expectedEntries = [];
        $entryViewFactory = new FakeEntryViewFactory();
        for($i = 0;$i<10;$i++){
            $entryEntity = new EntryEntity();
            if($i >= 5){
                $expectedEntries[]=$entryViewFactory->create($entryEntity);
            }
            $entities[] =$entryEntity;
        }
        $request = new FakeViewEntriesRequest(5);
        $request->setPage(2);
        $response = $this->processUseCase($request,$entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5,count($response->entries));
        $this->assertEquals($expectedEntries,$response->entries);
    }


Пройдено, можем рефакторить. Перенесём FakeEntryViewFactory в метод setup, и готово. Последний тестовый класс выглядит так:

processUseCase($request, $entries);
        $this->assertEmpty($response->entries);
    }

    public function testCanSeeEntries()
    {
        $entries = [
            new EntryEntity('testAuthor', 'test text')
        ];
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entries);
        $this->assertNotEmpty($response->entries);
    }

    public function testCanSeeFiveEntries()
    {
        $entities = [];
        for ($i = 0; $i < 10; $i++) {
            $entities[] = new EntryEntity('Author ' . $i, 'Text ' . $i);
        }
        $request = new FakeViewEntriesRequest(5);
        $response = $this->processUseCase($request, $entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5, count($response->entries));
    }

    public function testCanSeeFiveEntriesOnSecondPage()
    {
        $entities = [];
        $expectedEntries = [];
        $entryViewFactory = new FakeEntryViewFactory();
        for ($i = 0; $i < 10; $i++) {
            $entryEntity = new EntryEntity('Author ' . $i, 'Text ' . $i);
            if ($i >= 5) {
                $expectedEntries[] = $entryViewFactory->create($entryEntity);
            }
            $entities[] = $entryEntity;
        }
        $request = new FakeViewEntriesRequest(5);
        $request->setPage(2);
        $response = $this->processUseCase($request, $entities);
        $this->assertNotEmpty($response->entries);
        $this->assertSame(5, count($response->entries));
        $this->assertEquals($expectedEntries, $response->entries);
    }

    /**
     * @param $request
     * @param $entries
     * @return FakeViewEntriesResponse
     */
    private function processUseCase($request, $entries)
    {
        $repository = new FakeEntryRepository($entries);
        $factory = new FakeEntryViewFactory();
        $response = new FakeViewEntriesResponse();
        $useCase = new ViewEntriesUseCase($repository, $factory);
        $useCase->process($request, $response);
        return $response;
    }
}


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

Этот туториал демонстрирует, как для любого нового проекта можно легко применять разработку через тестирование и архитектуру чистого кода. Главное преимущество такого подхода — полная независимость логики. Такой подход также позволяет использовать сторонние библиотеки.

© Habrahabr.ru