В этой статье мы рассмотрим пример PHP-приложения, построенного в соответствии с принципами чистой архитектуры (Clean Architecture). Данное приложение не использует какие-либо PHP-фреймворки (MVC). Полный исходный код проекта доступен на GitHub.
Дисклеймер об авторских правах
Пример приложения вдохновлен статьей Кевина Смита (Kevin Smith) «Современный PHP без фреймворка» и прилагаемым к ней кодом.
Почему без MVC-фреймворка?
В своей отличной статье Кевин Смит подробно рассказывает о том, как разработать «современное» PHP-приложение без использования фреймворков. Настоятельно рекомендую ознакомиться с его материалом, а сам хочу добавить пару замечаний к его наблюдениям.
Когда дело доходит до проектирования приложения по принципам Clean Architecture (CA), оказывается, что многие популярные PHP-фреймворки приносят слишком много избыточной сложности и размывают важные архитектурные решения. Во-первых, теряется первостепенное разделение на «ядро» (core) и «инфраструктуру» (infrastructure) — ключевая идея CA, которая тонет в обилии каркасного кода фреймворка. Во-вторых, возникает проблема с разделением Controller и Presenter в типичном потоке управления CA, соблюдать которое при использовании MVC-фреймворков довольно затруднительно.
Тем не менее, существуют и отличные примеры PHP-приложений, построенных на Clean Architecture или гексагональной архитектуре (и их вариациях) с использованием фреймворков. Например, вот такое приложение от Эрберто Граса (Herberto Graça).
Сборка приложения из PHP-библиотек
Я строго последовал реализации и набору библиотек, предложенным Кевином Смитом для инициализации моего приложения. Вот основные строительные блоки проекта.
- Composer используется для управления зависимостями
- SleekDB используется в качестве локального хранилища данных
- Twig используется как шаблонизатор
- Narrowspark\HttpEmitter\SapiEmitter используется для отправки HTTP-ответов (PSR-7)
- PHP-DI — DI-контейнер для внедрения зависимостей (PSR-11)
- FastRoute используется для маршрутизации запросов к контроллерам
- Relay используется для работы с middleware (PSR-15)
Вместе эти библиотеки образуют основу для простого, модульного и расширяемого PHP-приложения. Вся инициализация и конфигурация происходят в основном классе приложения App.php . Теперь мы можем сосредоточиться непосредственно на архитектурных концепциях.
Пример приложения, предметная область
Само приложение представляет собой Hello World, выводящий приветствие пользователю. Однако здесь есть несколько дополнительных возможностей:
- по запросу пользователю отображается случайное приветствие
- у каждого приветствия есть автор
- все приветствия хранятся в локальной базе данных
- авторизованный пользователь может изменить автора приветствия
Доменная модель приложения крайне проста и состоит из одного агрегата: Greeting . Каждое приветствие содержит текст и два Value Object: GreetingId , обертывающий идентификатор объекта приветствия, и Author , инкапсулирующий имя автора.
Репозиторий
Паттерн Repository используется для обеспечения персистентности агрегата Greeting . Вторичный адаптер FilePersistenceGateway использует маппер доменных моделей в ассоциативные массивы, реализованный в PerstenceMapper , чтобы преобразовывать доменные объекты в структуры данных, сохраняемые и/или запрашиваемые из хранилища SleekDB. Использование репозитория гарантирует независимость реализации персистентности от конкретной БД. Юзкейсы взаимодействуют со всеми операциями хранения только через интерфейс PersistenceGatewayOperationsOutputPort (выходной порт).
DI-контейнер, контроллеры
Использование middleware Relay вместе с диспетчером запросов и реализацией ContainerInterface от PHP-DI дает элегантный и прозрачный способ связывания любых зависимостей в контроллерах и юзкейсах. Каждый контроллер получает полностью сконфигурированный экземпляр конкретного юзкейса. В каждый юзкейс, в свою очередь, внедряются требуемый презентер и нужные выходные порты, что позволяет юзкейсу выполнять бизнес-логику и возвращать результат пользователю. Такая архитектура строго следует типичному потоку управления в Clean Architecture, требующему четкого разделения контроллеров и презентеров. Вот как выглядит типичный вызов юзкейса из контроллера:
class WelcomeController
{
private WelcomeInputPort $useCase;
/**
* @param WelcomeInputPort $useCase
*/
public function __construct(WelcomeInputPort $useCase)
{
$this->useCase = $useCase;
}
public function __invoke(): void
{
$this->useCase->welcome();
}
}Обратите внимание: контроллер просто запускает юзкейс и не беспокоится о том, как и когда обрабатывать результат его выполнения.
Презентеры
Снова заимствуя идею Кевина Смита, презентация обрабатывается через прямой вызов PHP Server API. Экземпляр SapiResponseEmitter (обертывающий SAPI-эмиттер от Narrowspark) внедряется в каждый презентер вместе с HTTP-ответом. Также в каждый презентер внедряется обработчик шаблонов на базе Twig. Благодаря этому приложение может использовать мощный движок шаблонов, что сильно упрощает создание представлений (View). Большая часть кода презентации общая для всех презентеров и вынесена в абстрактный класс, приведенный ниже.
abstract class AbstractWebPresenter implements ErrorHandlingPresenterOutputPort
{
// PSR-7 HTTP response
protected ResponseInterface $response;
// SAPI emitter
protected ResponseEmitter $responseEmitter;
// Template engine
protected TemplatesProcessor $templatesProcessor;
// some code omitted
protected function presentTemplatedResponse(string $template, $args = [], int $status = 200,
string $contentType = 'text/html'): void
{
$response = $this->response->withHeader('Content-Type', $contentType)
->withStatus($status);
$body = $this->templatesProcessor->processTemplate($template, $args);
$response->getBody()->write($body);
$this->responseEmitter->emit($response);
}
}Ядро, инфраструктура, порты
Здесь мы переходим к самой важной части статьи. Структура приложения буквально дословно следует парадигме и терминологии чистой и гексагональной архитектур. Прежде всего, классы ядра приложения (внутри гексагона) четко отделены от инфраструктурного кода (снаружи гексагона).
Ядро и инфраструктура гексагона четко разделены
Теперь давайте подробнее рассмотрим часть ядра, отвечающую за порты.
Интерфейсы входных и выходных портов, а также презентеров
Порты (или «границы» / Boundaries, как называет их Роберт Мартин) — это интерфейсы, через которые внешняя часть гексагона взаимодействует с внутренней. Они бывают двух видов: входные порты (Input Ports) — интерфейсы, которые реализуются юзкейсами и используются первичными адаптерами, и выходные порты (Output Ports) — интерфейсы, реализуемые вторичными адаптерами. Технически говоря, интерфейсы презентеров также относятся к выходным портам, но я предпочитаю выносить их в отдельный пакет, чтобы подчеркнуть их симметрию со входными портами: каждый из них сгруппирован по юзкейсам приложения (welcome, login и т. д.), тогда как выходные порты для вторичных адаптеров в правой части гексагона сгруппированы по технологическому аспекту (конфигурация, БД и т. д.).
Юзкейсы, единый язык (Ubiquitous Language)
На мой взгляд, главное отличие Чистой архитектуры от Гексагональной заключается в том, что она ориентирована на сценарии использования (Use Cases). Юзкейсы представляют собой центральный и наиболее важный элемент проектирования. Они реализуют конкретные бизнес-сценарии, чаще всего связанные с действиями пользователя. Юзкейсы должны строго следовать единому языку (Ubiquitous Language) предметной области — в частности, их наименования должны четко и однозначно отражать соответствующий бизнес-сценарий. В нашем примере приложения определены четыре таких сценария:
- Как анонимный или авторизованный пользователь, я могу открыть экран приветствия приложения, где отображается верхнеуровневое меню.
- Как анонимный или авторизованный пользователь, я могу запросить у системы «поздороваться» со мной случайным приветственным сообщением.
- Как анонимный пользователь, я могу войти в систему, используя предустановленные учётные данные.
- Как авторизованный пользователь, я могу обновить автора любого приветствия в системе.
Обратите внимание, как структура пакетов и именование компонентов в слое Use Case отражают единый язык (Ubiquitous Language) нашей предметной области:
Use Cases точно следуют единому языку (Ubiquitous Language)
Use case, поток управления
Вот как мы реализуем сценарий использования (use case) для обновления автора приветствия:
class UpdateAuthorUseCase implements UpdateAuthorInputPort
{
// output port for presenter
private UpdateAuthorPresenterOutputPort $presenter;
// output port for security adapter
private SecurityOperationsOutputPort $securityOps;
// output port for persistence gateway
private PersistenceGatewayOperationsOutputPort $gatewayOps;
// some code omitted
public function updateAuthorOfGreeting(string|int $greetingId, string $updatedAuthorName): void
{
try {
// security check
$this->securityOps->assertUserIsLoggedIn();
// construct GreetingID value object
$greetingId = GreetingId::of($greetingId);
// find the greeting
$greeting = $this->gatewayOps->obtainGreetingById($greetingId);
// make new (updated) greeting
$updatedGreeting = $greeting->withAuthorName($updatedAuthorName);
// persist updated greeting
$this->gatewayOps->updateGreeting($updatedGreeting);
} catch (UserNotAuthenticatedError|TypeError|InvalidDomainObjectError|GreetingPersistenceError $e) {
// present erroneous outcome
$this->presenter->presentError($e);
return;
}
// present successful outcome
$this->presenter->presentAuthorUpdatedSuccessfully($greetingId);
}
}Ключевые моменты:
- Зависимости за пределами ядра абсолютно отсутствуют. Сценарии использования работают исключительно с доменными сущностями, объектами-значениями (Value Objects) и портами (Ports).
- Безопасность (проверка того, что пользователь аутентифицирован) относится к зоне ответственности use case и вызывается из него. См. эту статью для более подробного обсуждения безопасности в Clean Architecture.
- Особое внимание уделено отлову всех бизнес-исключений, которые могут возникнуть при нормальном выполнении use case, и их обработке в отдельном потоке управления (в данном случае: просто вызов презентера с результатом, содержащим ошибку). См. эту статью, где поток управления в use case рассматривается более детально.
Тестирование и анализ
Одной из определяющих характеристик приложения, следующего Clean Architecture, является то, что в нём очень легко тестировать Use Cases. В нашем приложении для иллюстрации это сделано в SayHelloUseCaseTest.php с помощью PHPUnit и Mockery.
Для PHP-разработчиков есть замечательный проект: php-clean-architecture от Валерия Четкова. Он позволяет выполнять ряд проверок и вычислять метрики, которые могут быть очень полезны при анализе архитектурных аспектов PHP-приложения. Настоятельно рекомендую воспользоваться этим инструментом: он очень гибкий и простой в использовании. Инструмент предоставляет объёмный отчёт с информацией и метриками. Ниже приведён лишь один пример диаграммы зависимостей, которую можно сгенерировать для приложения. Она показывает для нашей реализации, что все зависимости следуют разрешённому направлению («снаружи внутрь»): от инфраструктуры к ядру.
Граф зависимостей, сгенерированный инструментом
php-clean-architecture Валерия Четкова
Обсуждение
В этой статье мы рассмотрели способ проектирования и реализации PHP-приложений с применением принципов Чистой архитектуры (Clean Architecture). Пример приложения иллюстрирует наиболее важные архитектурные решения:
- моделирование предметной области с помощью DDD
- паттерн Репозиторий (Repository)
- Гексагональная архитектура с разделением на ядро и инфраструктуру
- Clean Architecture с разработкой, основанной на сценариях использования (Use Case driven development)
Спасибо за внимание.
Ссылки
- Clean PHP, исходный код примера приложения
- «Modern PHP Without a Framework», статья Кевина Смита
- php-clean-architecture, инструмент анализа Чистой архитектуры от Валерия Четкова
- «La clean architecture : pourquoi ? Comment ? Pour qui ?», очень вдохновляющий доклад Николя Де Буза на конференции AFUP PHP
- The Clean Architecture, фундаментальная и часто цитируемая статья Роберта Мартина (Uncle Bob)
Комментарии (0)
Пока нет комментариев — будьте первым.