В этой статье мы рассмотрим пример 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);
    }

}

Ядро, инфраструктура, порты

Здесь мы переходим к самой важной части статьи. Структура приложения буквально дословно следует парадигме и терминологии чистой и гексагональной архитектур. Прежде всего, классы ядра приложения (внутри гексагона) четко отделены от инфраструктурного кода (снаружи гексагона).

Z4dlu5wTZPXxUDaakAqeQV1szMB6A3RwqKaopjez.pngЯдро и инфраструктура гексагона четко разделены

Теперь давайте подробнее рассмотрим часть ядра, отвечающую за порты.

0Yry40yIiaSAeCgBEa3iKu9LnyWStR0BYxnYT8EM.pngИнтерфейсы входных и выходных портов, а также презентеров

Порты (или «границы» / Boundaries, как называет их Роберт Мартин) — это интерфейсы, через которые внешняя часть гексагона взаимодействует с внутренней. Они бывают двух видов: входные порты (Input Ports) — интерфейсы, которые реализуются юзкейсами и используются первичными адаптерами, и выходные порты (Output Ports) — интерфейсы, реализуемые вторичными адаптерами. Технически говоря, интерфейсы презентеров также относятся к выходным портам, но я предпочитаю выносить их в отдельный пакет, чтобы подчеркнуть их симметрию со входными портами: каждый из них сгруппирован по юзкейсам приложения (welcome, login и т. д.), тогда как выходные порты для вторичных адаптеров в правой части гексагона сгруппированы по технологическому аспекту (конфигурация, БД и т. д.).

Юзкейсы, единый язык (Ubiquitous Language)

На мой взгляд, главное отличие Чистой архитектуры от Гексагональной заключается в том, что она ориентирована на сценарии использования (Use Cases). Юзкейсы представляют собой центральный и наиболее важный элемент проектирования. Они реализуют конкретные бизнес-сценарии, чаще всего связанные с действиями пользователя. Юзкейсы должны строго следовать единому языку (Ubiquitous Language) предметной области — в частности, их наименования должны четко и однозначно отражать соответствующий бизнес-сценарий. В нашем примере приложения определены четыре таких сценария:

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

Обратите внимание, как структура пакетов и именование компонентов в слое Use Case отражают единый язык (Ubiquitous Language) нашей предметной области:

6wHo6hu00RfKE4mFYvDy8BSIq51G5sFiWrlbPz6M.pngUse 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-приложения. Настоятельно рекомендую воспользоваться этим инструментом: он очень гибкий и простой в использовании. Инструмент предоставляет объёмный отчёт с информацией и метриками. Ниже приведён лишь один пример диаграммы зависимостей, которую можно сгенерировать для приложения. Она показывает для нашей реализации, что все зависимости следуют разрешённому направлению («снаружи внутрь»): от инфраструктуры к ядру.

zMemYr3L3J5SBg8CQjuJ9ieeMsKF7YDvTH7Gek0G.webpГраф зависимостей, сгенерированный инструментом 

php-clean-architecture Валерия Четкова

Обсуждение

В этой статье мы рассмотрели способ проектирования и реализации PHP-приложений с применением принципов Чистой архитектуры (Clean Architecture). Пример приложения иллюстрирует наиболее важные архитектурные решения:

  • моделирование предметной области с помощью DDD
  • паттерн Репозиторий (Repository)
  • Гексагональная архитектура с разделением на ядро и инфраструктуру
  • Clean Architecture с разработкой, основанной на сценариях использования (Use Case driven development)

Спасибо за внимание.

Ссылки