Статьи

Чистая архитектура кода и тестовая разработка в PHP

Архитектура чистого кода была представлена Робертом К. Мартином в блоге 8light . Идея заключалась в том, чтобы создать архитектуру, независимую от любого внешнего агентства. Ваша бизнес-логика не должна быть связана с платформой, базой данных или самой сетью. С независимостью у вас есть несколько преимуществ. Например, у вас есть возможность отложить технические решения на более поздний момент в процессе разработки (например, выбрать платформу и выбрать ядро ​​базы данных / поставщика). Вы также можете легко переключать реализации или сравнивать разные реализации, но самое большое преимущество заключается в том, что ваши тесты будут выполняться быстро.

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

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

Чистая архитектура кода

Я мог бы выбрать другую структуру. Возможно, было бы лучше пойти с Symfony 1 или Zend 1, но к настоящему времени эта структура также изменилась бы.

Рамки будут продолжать меняться и развиваться. С помощью composer легко устанавливать и заменять пакеты, но также легко отказаться от пакета (у composer даже есть возможность пометить пакет как потерянный), поэтому легко сделать «неправильный выбор».

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


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

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

Мы начнем со слоя UseCase, так как это слой, который содержит нашу логику для конкретного приложения. Уровень контроллера и другие внешние слои принадлежат платформе.

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

Первый тест

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

В нашем первом тесте мы хотим утвердить пустой список записей, это выглядит так:

<?php require_once __DIR__ . '/../../vendor/autoload.php' ; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists ( ) { $request = new FakeViewEntriesRequest ( ) ; $response = new FakeViewEntriesResponse ( ) ; $useCase = new ViewEntriesUseCase ( ) ; $useCase - > process ( $request , $response ) ; $this - > assertEmpty ( $response - > entries ) ; } } 

В этом тесте я использовал немного другую нотацию, чем у дяди Боба. Интеракторы — это UseCases, входные порты — это запросы, а выходные порты — это ответы.

UseCases всегда содержат процесс-метод, который имеет подсказку типа для своего конкретного интерфейса запросов и ответов.

Согласно циклам «Красный», «Зеленый» и «Рефакторинг» в TDD, этот тест должен и не пройден, поскольку классы не существуют.

После создания файлов классов, методов и свойств тест проходит.
Поскольку классы пусты, нам не нужно использовать цикл Refactor на этом этапе.

Далее, мы хотим утверждать, что мы можем видеть некоторые записи.

 <?php require_once __DIR__ . '/../../vendor/autoload.php' ; use BlackScorp \ GuestBook \ Fake \ Request \ FakeViewEntriesRequest ; use BlackScorp \ GuestBook \ Fake \ Response \ FakeViewEntriesResponse ; use BlackScorp \ GuestBook \ UseCase \ ViewEntriesUseCase ; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists ( ) { $request = new FakeViewEntriesRequest ( ) ; $response = new FakeViewEntriesResponse ( ) ; $useCase = new ViewEntriesUseCase ( ) ; $useCase - > 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. Чтобы пройти тест, мы должны добавить логику в наши UseCases.

Нарисуйте логику UseCase

Прежде чем мы начнем с логики, мы применяем подсказки типа параметра и создаем интерфейсы.

 <?php namespace BlackScorp \ GuestBook \ UseCase ; use BlackScorp \ GuestBook \ Request \ ViewEntriesRequest ; use BlackScorp \ GuestBook \ Response \ ViewEntriesResponse ; class ViewEntriesUseCase { public function process ( ViewEntriesRequest $request , ViewEntriesResponse $response ) { } } 

Это похоже на то, как часто работают художники-графики. Вместо того, чтобы рисовать всю картину от начала до конца, они обычно рисуют некоторые формы и линии, чтобы иметь представление о том, какой может быть законченная картинка. После этого они используют формы в качестве направляющих и добавляют больше деталей. Этот процесс называется « Зарисовка ».

Вместо форм и линий мы используем, например, хранилища и фабрики в качестве наших руководств.

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

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

 <?php namespace BlackScorp \ GuestBook \ UseCase ; use BlackScorp \ GuestBook \ Request \ ViewEntriesRequest ; use BlackScorp \ GuestBook \ Response \ ViewEntriesResponse ; class ViewEntriesUseCase { 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 ) ; } } } 

Вы можете спросить, почему мы конвертируем Entity Entry в View?

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

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

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

Другой вопрос может быть «Почему фабрика?»

Если мы создадим новый экземпляр внутри цикла, такой как

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

мы бы нарушили принцип инверсии зависимости . Если позже нам потребуется другой объект представления в той же логике UseCase, нам придется изменить код. С фабрикой у нас есть простой способ реализовать различные представления, которые могут содержать различную логику форматирования, при этом все еще используя тот же UseCase.

Реализация внешних зависимостей

На данный момент мы уже знаем зависимости UseCase: $entryViewFactory и $entryRepository . Мы также знаем методы зависимостей. EntryViewFactory имеет метод create, который принимает EntryEntity , а EntryRepository имеет findAll() EntryRepository findAll() который возвращает массив EntryEntities . Теперь мы можем создавать интерфейсы с методами и применять их к UseCase.

EntryRepository будет выглядеть так:

 <?php namespace BlackScorp \ GuestBook \ Repository ; interface EntryRepository { public function findAllPaginated ( $offset , $limit ) ; } 

И UseCase вроде так

 <?php namespace BlackScorp \ GuestBook \ UseCase ; use BlackScorp \ GuestBook \ Repository \ EntryRepository ; use BlackScorp \ GuestBook \ Request \ ViewEntriesRequest ; use BlackScorp \ GuestBook \ Response \ ViewEntriesResponse ; use BlackScorp \ GuestBook \ ViewFactory \ EntryViewFactory ; class ViewEntriesUseCase { /** * @var EntryRepository */ private $entryRepository ; /** * @var EntryViewFactory */ private $entryViewFactory ; /** * ViewEntriesUseCase constructor. * @param EntryRepository $entryRepository * @param EntryViewFactory $entryViewFactory */ public function __construct ( EntryRepository $entryRepository , EntryViewFactory $entryViewFactory ) { $this - > 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 ) ; } } } 

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

 <?php require_once __DIR__ . '/../../vendor/autoload.php' ; use BlackScorp \ GuestBook \ Fake \ Request \ FakeViewEntriesRequest ; use BlackScorp \ GuestBook \ Fake \ Response \ FakeViewEntriesResponse ; use BlackScorp \ GuestBook \ UseCase \ ViewEntriesUseCase ; use BlackScorp \ GuestBook \ Entity \ EntryEntity ; use BlackScorp \ GuestBook \ Fake \ Repository \ FakeEntryRepository ; use BlackScorp \ GuestBook \ Fake \ ViewFactory \ FakeEntryViewFactory ; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists ( ) { $entryRepository = new FakeEntryRepository ( ) ; $entryViewFactory = new FakeEntryViewFactory ( ) ; $request = new FakeViewEntriesRequest ( ) ; $response = new FakeViewEntriesResponse ( ) ; $useCase = new ViewEntriesUseCase ( $entryRepository , $entryViewFactory ) ; $useCase - > 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 ) ; } } 

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

Хранилище теперь выглядит так:

 <?php namespace BlackScorp \ GuestBook \ Fake \ Repository ; use BlackScorp \ GuestBook \ Repository \ EntryRepository ; class FakeEntryRepository implements EntryRepository { private $entries = [ ] ; public function __construct ( array $entries = [ ] ) { $this - > entries = $entries ; } public function findAllPaginated ( $offset , $limit ) { return array_splice ( $this - > entries , $offset , $limit ) ; } } 

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

 <?php namespace BlackScorp \ GuestBook \ Fake \ ViewFactory ; use BlackScorp \ GuestBook \ Entity \ EntryEntity ; use BlackScorp \ GuestBook \ Fake \ View \ FakeEntryView ; use BlackScorp \ GuestBook \ View \ EntryView ; use BlackScorp \ GuestBook \ ViewFactory \ EntryViewFactory ; class FakeEntryViewFactory implements EntryViewFactory { /** * @param EntryEntity $entity * @return EntryView */ public function create ( EntryEntity $entity ) { $view = new FakeEntryView ( ) ; $view - > author = $entity - > getAuthor ( ) ; $view - > text = $entity - > getText ( ) ; return $view ; } } 

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

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

Теперь тесты пройдены, и мы можем перейти к рефакторингу. Фактически нет ничего, что можно было бы реорганизовать в классе UseCase, только в тестовом классе.

Рефакторинг теста

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

Теперь тестовый класс должен выглядеть так

 <?php require_once __DIR__ . '/../../vendor/autoload.php' ; use BlackScorp \ GuestBook \ Entity \ EntryEntity ; use BlackScorp \ GuestBook \ Fake \ Repository \ FakeEntryRepository ; use BlackScorp \ GuestBook \ Fake \ ViewFactory \ FakeEntryViewFactory ; use BlackScorp \ GuestBook \ Fake \ Request \ FakeViewEntriesRequest ; use BlackScorp \ GuestBook \ Fake \ Response \ FakeViewEntriesResponse ; use BlackScorp \ GuestBook \ UseCase \ ViewEntriesUseCase ; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testCanSeeEntries ( ) { $entries = [ new EntryEntity ( 'testAuthor' , 'test text' ) ] ; $response = $this - > 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 ; } } 

независимость

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

Как вы можете видеть, мы можем внедрить готовый UseCase в DI-контейнер и использовать его в любой среде. Логика не зависит от структуры.

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

Мы могли бы создавать объекты запроса / ответа CLI и передавать их в тот же UseCase, который используется внутри контроллера, поэтому логика не зависит от платформы.

Мы могли бы даже выполнить разные UseCases подряд, где каждый UseCase мог бы изменить фактический объект ответа.

 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 ) ) ; } 

Этот тест не пройден, поэтому мы должны изменить метод процесса UseCase а также переименовать метод findAllPaginated в findAllPaginated .

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

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

Метод findAllPaginated хранилища findAllPaginated меняется:

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

и мы должны переместить объект запроса в тестах; Кроме того, параметр limit будет требоваться в конструкторе нашего объекта запроса. Таким образом, мы заставим установку создать ограничение с новым экземпляром.

 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 ) ) ; } 

Тест пройден, но мы должны проверить, можем ли мы увидеть следующие пять записей. Поэтому мы должны расширить объект запроса с помощью метода setPage .

 <?php namespace BlackScorp \ GuestBook \ Fake \ Request ; use BlackScorp \ GuestBook \ Request \ ViewEntriesRequest ; class FakeViewEntriesRequest implements ViewEntriesRequest { private $offset = 0 ; private $limit = 0 ; /** * FakeViewEntriesRequest constructor. * @param int $limit */ public function __construct ( $limit ) { $this - > 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 и мы закончили с этой функцией. Финальный тестовый класс выглядит так:

 <?php require_once __DIR__ . '/../../vendor/autoload.php' ; use BlackScorp \ GuestBook \ Entity \ EntryEntity ; use BlackScorp \ GuestBook \ Fake \ Repository \ FakeEntryRepository ; use BlackScorp \ GuestBook \ Fake \ Request \ FakeViewEntriesRequest ; use BlackScorp \ GuestBook \ Fake \ Response \ FakeViewEntriesResponse ; use BlackScorp \ GuestBook \ Fake \ ViewFactory \ FakeEntryViewFactory ; use BlackScorp \ GuestBook \ UseCase \ ViewEntriesUseCase ; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists ( ) { $entries = [ ] ; $request = new FakeViewEntriesRequest ( 5 ) ; $response = $this - > 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 — проверьте теги для всех различных этапов исходного кода этого поста.

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

Вопросов? Комментарии? Пожалуйста, оставьте их в разделе комментариев прямо под кнопкой «Мне нравится»!