Отсутствие четкой структурированной архитектуры проектов — не редкость в ИТ. Одни этим пренебрегают из-за маленького масштаба проекта, другие — из-за сжатых сроков разработки, третьи — из-за отсутствия экспертизы в этом вопросе. Вместе с тем, движение по этому пути — практически всегда история с «отложенными последствиями»: со временем такие проекты становится сложно поддерживать, масштабировать, администрировать и фиксить. 

Меня зовут Никита Дергачев. Я Teamlead COOL TEAM в MedTech компании СберЗдоровье. В этой статье я расскажу, почему важно структурировано выстраивать архитектуру проектов, а также покажу на примере, с помощью каких инструментов можно отслеживать соответствие архитектуры изначальным требованиям.

Архитектура проектов и ее значимость

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

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

Так, выстраивание структурированной архитектуры проекта дает ряд преимуществ.

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

  • Упрощенная поддержка. Разработчикам проще ориентироваться в проекте, если у него регламентированная структура: так специалистам легче находить нужные модули, выявлять баги, распознавать точки роста и не только.

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

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

Есть несколько подходов к разработке приложений и выстраиванию взаимодействия между внутренними компонентами. Каждый из них предполагает свою логику построения проектов и, соответственно, имеет конкретные особенности.

Для примера подробнее рассмотрим гексагональную архитектуру.

Гексагональная архитектура

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

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

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

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

  • Слой Application (приложения). Выступает в роли обертки домена, которая изолирует бизнес-логику от внешних систем. Отвечает за вызов бизнес-логики, обработку результатов бизнес-логики, валидацию входных данных и подготовку данных для вывода. Вместе с доменом слой Application формирует основную логику приложения. 

  • Слой Presentation (представления). Является входной точкой приложения. Не содержит логики. Отвечает за конвертацию входных данных в DTO и передачу данных в приложение.

  • Слой Infrastructure (инфраструктуры). Отвечает за работу с файловой системой, БД, кэшем, брокерами сообщений. Выполняет задачи отправки API запросов во внешние сервисы и взаимодействия с фреймворком.

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

С портами приложения взаимодействует два типа адаптеров.

Первичные (или управляющие) адаптеры, которые инициализируют работу приложения. Фактически они отвечают за преобразование данных от внешних систем в запросы, понятные основной логике приложения.

Вторичные (или управляемые) адаптеры, работа которых инициализируется приложением. Например, они вызываются, когда нужно организовать подключение к внешним БД.

Итого гексагональная архитектура со всем «обвесом» имеет следующий вид:

Стоит отметить, что в данной архитектуре критическое значение имеет соблюдение инверсии зависимостей — высокоуровневые модули не должны зависеть от реализации, а внутренний слой (например, домен) не должен зависеть от внешних.

Жизненный цикл работы с запросами в такой архитектуре условно можно свести к небольшому алгоритму:

  • в приложение поступает запрос по одному из адаптеров;

  • используя адаптер, запрос приходит в слой представления;

  • далее данные передаются в слой приложения;

  • затем создается сущность;

  • через инфраструктуру, используя адаптер, например, для PostgreSQL, сущность сохраняется;

  • пользователь или система получают уведомление о выполнении запроса.

Всё просто, прозрачно и прогнозируемо.

Статические анализаторы архитектуры и их значение

Статические анализаторы архитектуры — это инструменты, которые автоматически проверяют архитектуру приложения на соответствие изначально определенным требованиям. 

Использование статических анализаторов решает сразу несколько задач.

  • Ускорение code review. Проверку кода на соответствие утвержденной архитектуре можно встроить в общий пайплайн разработки, автоматизировав ее. 

  • Ускорение разработки. Анализатор использует задокументированные правила разработки, поэтому минимизирует риск создания проектов, не соответствующих ожиданиям.

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

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

Есть несколько популярных анализаторов архитектуры. Среди них:

  • Deptrac — инструмент статического анализа кода для PHP, который помогает сообщать, визуализировать и применять архитектурные решения в проектах. С его помощью можно определять архитектурные слои над классами и какие правила должны к ним применяться, а также гарантировать, что пакеты/модули/расширения в проекте действительно независимы друг от друга.

  • PHP Architecture Tester — инструмент статического анализа, предназначенный для проверки архитектурных требований. Предоставляет абстракцию естественного языка, которая позволяет определять собственные архитектурные правила и оценивать их соблюдение в коде. Позволяет контролировать соответствие зависимостей классов разрешенным пространствам имен. 

  • PHPArch — библиотека для архитектурного тестирования проектов на PHP. Позволяет выстраивать архитектурные границы в приложении и предотвращать их изменение со временем.

  • PHPUnit Application Architecture Test — расширение PHP Unit, которое предоставляет дополнительные возможности для проверки архитектуры. 

Вместе с тем, PHPArch и PHPUnit Application Architecture Test развиваются медленно или не развиваются вообще, поэтому в продовых проектах используются редко. Соответственно, алгоритм работы с анализаторами архитектуры будем разбирать на примерах Deptrac и PHP Architecture Tester.

От теории к практике: работа со статическими анализаторами архитектуры

Теперь рассмотрим алгоритм работы со статическими анализаторами и их возможности на примере решения прикладных задач. Для этого реализуем упрощенный пример проекта на Symfony и настроим анализаторы архитектуры.

Начнем с проекта.

Реализация проекта

В качестве наглядного примера реализуем интернет-магазин.

Сначала добавляем модуль с названием Shop.

Далее добавляем соответствующие слои архитектуры: Domain, Application, Presentation, Infrastructure.

В итоге получаем базовую структуру.  

Далее переходим к добавлению сущности, которой в рамках интернет-магазина будет «заказ». Для сущности добавляем два поля: «ID» и «сумма». 

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Domain\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: '`order`')]
#[ORM\HasLifecycleCallbacks]
final class Order
{
   #[ORM\Id]
   #[ORM\Column(name: 'id', type: Types::INTEGER)]
   #[ORM\GeneratedValue]
   private int $id;

   #[ORM\Column(name: 'sum', type: Types::INTEGER)]
   private int $sum;

   private function __construct()
   {
   }

   public static function create(int $sum): Order
   {
       $order = new Order();
     
       $order->sum = $sum;

       return $order;
   }
}

Примечание: Тут стоит оговориться. Исходя из правил архитектуры, домен не может зависеть от других слоев и компонентов, в том числе от фреймворка. Но, поскольку нам удобно использовать аннотации, при строгом «ручном» контроле мы можем игнорировать требования в части зависимостей.  

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

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Domain\Repository;

use Sberhealth\Demo\Shop\Domain\Entity\Order;

interface OrderRepositoryInterface
{
   public function save(Order $order): void;
}

Переходим к слою приложения. Классически здесь находится DTO и сервис или Use Case. В данном случае сервис будет принимать DTO и из него формировать сущность Order, а дальше — сохранять сущность через Repository.

Dto:

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Application\Dto;

class CreateOrderDto
{
   public function __construct(public readonly int $sum)
   {
   }
}

Сервис:

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Application\Service;

use Sberhealth\Demo\Shop\Application\Dto\CreateOrderDto;
use Sberhealth\Demo\Shop\Domain\Entity\Order;
use Sberhealth\Demo\Shop\Domain\Repository\OrderRepositoryInterface;

class OrderService
{
   public function __construct(private readonly OrderRepositoryInterface $orderRepository)
   {
   }

   public function create(CreateOrderDto $createOrderDto): void
   {
       $order = Order::create($createOrderDto->sum);

       $this->orderRepository->save($order);
   }
}

Переходим к инфраструктуре.

У нас есть интерфейс репозитория, который является портом. Нам надо создать вторичный адаптер — реализацию этого интерфейса.

В рамках примера представим, что мы работаем с PostgreSQL. 

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Infrastructure\Repository\PostgreSql;

use Sberhealth\Demo\Shop\Domain\Entity\Order;
use Sberhealth\Demo\Shop\Domain\Repository\OrderRepositoryInterface;

class PostgreSqlOrderRepository implements OrderRepositoryInterface
{
   public function save(Order $order): void
   {
       return;
   }
}

Остается реализовать слой представления.

Для этого создадим контроллер, который будет работать по HTTP/REST и будет выступать в роли первичного адаптера.  Контракт контроллера будет выступать портом приложения.

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Presentation\Http\Rest;

use Sberhealth\Demo\Shop\Application\Dto\CreateOrderDto;
use Sberhealth\Demo\Shop\Application\Service\OrderService;

class OrderController
{
   public function __construct(private readonly OrderService $orderService)
   {
   }

   public function createOrder(): void
   {
       $this->orderService->create(
           new CreateOrderDto(1)
       );

       return;
   }
}

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

Теперь перейдем к тестированию статических анализаторов архитектуры. 

Работа с Deptrac

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

В результате мы получаем описанные слои с регулярными выражениями, по которым понятно, какой класс к какому слою относится.

В правилах строго декларируем, что:

  • домен изолирован и ни с кем не взаимодействует;

  • слой приложения может взаимодействовать с доменом;

  • слой представления может работать со слоями приложения и домена;

  • инфраструктура может взаимодействовать с приложением и доменом.

parameters:
 paths:
   - ./src

 #Настройка слоев приложения
 layers:
   - name: Domain
     collectors:
       - type: classLike
         regex: ^Sberhealth\\Demo\\.+\\Domain\\.*

   - name: Application
     collectors:
       - type: classLike
         regex: ^Sberhealth\\Demo\\.+\\Application\\.*

   - name: Infrastructure
     collectors:
       - type: classLike
         regex: ^Sberhealth\\Demo\\.+\\Infrastructure\\.*

   - name: Presentation
     collectors:
       - type: classLike
         regex: ^Sberhealth\\Demo\\.+\\Presentation\\.*

   - name: Vendor
     collectors:
       - type: bool
         must:
           - type: classLike
             regex: ^(?!^Sberhealth\\Demo\\).*$

         # Исключение доктрины из слоя Vendor
         # для возможности использования в Domain
         must_not:
           - type: classLike
             regex:  Doctrine\\ORM\\.*

           - type: classLike
             regex: Doctrine\\DBAL\\.*

   - name: VendorDoctrine
     collectors:
       - type: classLike
         regex: Doctrine\\ORM\\.*

       - type: classLike
         regex: Doctrine\\DBAL\\.*

 # Настройка взаимодействия между слоями
 ruleset:
   Domain:
     - VendorDoctrine

   Application:
     - Domain

   Infrastructure:
     - Domain
     - Application
     - Vendor

   Presentation:
     - Application
     - Domain
     - Vendor

   Vendor:

После запуска deptrac мы увидим, что ошибок в приложении не будет.

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

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Shop\Application\Service;

use Sberhealth\Demo\Shop\Application\Dto\CreateOrderDto;
use Sberhealth\Demo\Shop\Domain\Entity\Order;
use Sberhealth\Demo\Shop\Infrastructure\Repository\PostgreSql\PostgreSqlOrderRepository;

class OrderService
{
   public function __construct(private readonly PostgreSqlOrderRepository $orderRepository)
   {
   }

   public function create(CreateOrderDto $createOrderDto): void
   {
       $order = Order::create($createOrderDto->sum);
       $this->orderRepository->save($order);
   }
}

После запуска будем видеть следующую ошибку: 

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

parameters:
 paths:
   - ./src

 layers:
   - name: Shop
     collectors:
       # В этом примере настроим определение
       # через директории
       - type: directory
         regex: src/Shop/.*

   - name: Delivery
     collectors:
       - type: directory
         regex: src/Delivery/.*

 ruleset:
   Shop:
   Delivery:

И теперь анализатор нам гарантирует, что код будет независим от соседнего модуля.

Таким образом, Deptrac — эффективный, удобный и простой инструмент, но фактически он решает только задачу проверки соблюдения правил взаимодействия между слоями. 

Работа с PHP Architecture Tester

Теперь перейдем к PHP Architecture Tester. 

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

Соответственно, всю настройку зависимостей и namespace здесь можно реализовать по принципу Deptrac. Единственное отличие — в PHP Architecture Tester используется двойное экранирование, но в целом настройки будут аналогичны тем, что мы уже реализовывали выше.

<?php

declare(strict_types=1);

namespace Sberhealth\Demo\Test\Architecture;

use PHPat\Selector\ClassNamespace;
use PHPat\Selector\Selector;
use PHPat\Test\Builder\Rule;
use PHPat\Test\PHPat;

final class HexagonalArchitectureTest
{
   public function testDomain(): Rule
   {
       return Phpat::rule()
           ->classes($this->getDomainClassNamespace())
           ->shouldNotDependOn()
           ->classes(
               $this->getApplicationClassNamespace(),
               $this->getInfrastructureClassNamespace(),
               $this->getPresentationClassNamespace(),
               $this->getVendorClassNamespace()
           )
           ->excluding($this->getVendorDoctrineDbalClassNamespace(), $this->getVendorDoctrineOrmClassNamespace());
   }

   public function testApplication(): Rule
   {
       return Phpat::rule()
           ->classes($this->getApplicationClassNamespace())
           ->shouldNotDependOn()
           ->classes(
               $this->getInfrastructureClassNamespace(),
               $this->getPresentationClassNamespace(),
               $this->getVendorClassNamespace()
           );
   }

   public function testInfrastructure(): Rule
   {
       return Phpat::rule()
           ->classes($this->getApplicationClassNamespace())
           ->shouldNotDependOn()
           ->classes($this->getPresentationClassNamespace());
   }

   private function getDomainClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^Sberhealth\\\\Demo\\\\.+\\\\Domain\\\\.*/', true);
   }

   private function getApplicationClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^Sberhealth\\\\Demo\\\\.+\\\\Application\\\\.*/', true);
   }

   private function getInfrastructureClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^Sberhealth\\\\Demo\\\\.+\\\\Infrastructure\\\\.*/', true);
   }

   private function getPresentationClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^Sberhealth\\\\Demo\\\\.+\\\\Presentation\\\\.*/', true);
   }

   private function getVendorClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^(?!Sberhealth\\\\Demo\\\\).*$/', true);
   }

   private function getVendorDoctrineOrmClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^Doctrine\\\\ORM\\\\.*/', true);
   }

   private function getVendorDoctrineDbalClassNamespace(): ClassNamespace
   {
       return Selector::inNamespace('/^Doctrine\\\\DBAL\\\\.*/', true);
   }
}

Для подключения дополнительных проверок в phpstan, необходимо указать класс в файле phpstan.neon

includes:
   - vendor/phpat/phpat/extension.neon

services:
   -
       class: Sberhealth\Demo\Test\Architecture\HexagonalArchitectureTest
       tags:
           - phpat.test

Если всё сделано правильно, при запуске теста через PHPStan с зашитыми проверками PHP Architecture Tester ошибок по архитектуре не будет. 

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

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

Далее можем перейти к рассмотрению дополнительных возможностей инструмента. Например, с его помощью можно проверить соответствие названия интерфейса стандарту PSR — они должны заканчиваться словом «interface». 

Реализуем соответствующую проверку. Для этого определяем все интерфейсы и через регулярные выражения задаем упомянутые критерии для их названия. 

public function testInterfaceNaming(): Rule
{
   return Phpat::rule()
       ->classes(Selector::isInterface())
       ->shouldBeNamed('/.+Interface/', true);
}

Так же можно реализовать проверку на то, все ли сущности проекта являются «final».

public function testEntityFinal(): Rule
{
   return Phpat::rule()
       ->classes($this->getEntityClassNamespace())
       ->shouldBeFinal();
}

private function getEntityClassNamespace(): ClassNamespace
{
   return Selector::inNamespace('/^Sberhealth\\\\Demo\\\\.+\\\\Domain\\\\Entity.*/', true);
}

Помимо этого, с помощью PHP Architecture Tester можно проводить еще целый ряд проверок для разных сценариев и вводных данных. 

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

Что в итоге

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

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

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