Отсутствие четкой структурированной архитектуры проектов — не редкость в ИТ. Одни этим пренебрегают из-за маленького масштаба проекта, другие — из-за сжатых сроков разработки, третьи — из-за отсутствия экспертизы в этом вопросе. Вместе с тем, движение по этому пути — практически всегда история с «отложенными последствиями»: со временем такие проекты становится сложно поддерживать, масштабировать, администрировать и фиксить.
Меня зовут Никита Дергачев. Я 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 — максимально гибкий и универсальный инструмент, который позволяет организовывать проверки разной сложности и направленности, а также практически автоматизировать тесты на соответствие кода и архитектуры любым внутренним и внешним стандартам компании.
Что в итоге
Выстраивать структурированную архитектуру приложений с четким разграничением слоев и модулей — не просто полезно, а критически важно. Особенно для крупных проектов, которые подразумевают дальнейшее масштабирование, увеличение функциональности и подключение новых сервисов.
При этом построить правильную архитектуру — половина дела. Куда важнее и сложнее поддерживать ее соответствие требованиям на всем жизненном цикле. И в этом случае практически не обойтись без статических анализаторов архитектуры, которые позволяют свести к минимуму ручные операции, дают возможность автоматизировать проверки и предотвращают ошибки, вызванные человеческим фактором. Наш обзор в рамках статьи наглядно подтверждает это.