Featured image of post Разделяем программу на слои

Разделяем программу на слои

Введение

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

Домен

Чем наше приложение отличается от сотен других? Оно плохо написано. Оно решает свою уникальную задачу. Настолько, что логику в ней мы называем областью знаний. Для нас это будет domain knowledge, или просто домен. В сравнении с ним, запросы к БД и общение с веб-сервером считаются уже решенными задачами: их выполняют различные библиотеки и фреймворки, уже написанные другими людьми.

Чтобы наше приложение было гибким, мы хотим как можно меньше зависеть от “внешних” по отношению к домену компонентов. Давайте посмотрим на типичные слои приложения и как они общаются друг с другом. Названия и расположения в директориях могут варьироваться, но суть остается прежней.

Логику приложения еще называют ядром. Вообще, в программировании много одинаковых вещей называются по-разному.

В центре всего располагается домен, а в нем сущности (Entity), объекты значений (ValueObject), доменные сервисы (DomainService), репозитории (Repository). Такая терминология идет из Domain Driven Development (DDD) 1 2. В DDD область знаний мы ставим на первое место. Мы называем объекты теми существительными, которые используют специалисты. Мы называем взаимодействие объектов глаголами, которые используют специалисты. Так как мы говорим с бизнесом на одном языке, нам проще понять друг друга. А разница между реальностью и кодом становится чуть меньше, что порождает меньше путаницы и ошибок.

Пример DDD и не-DDD подхода:

// Не DDD

// Что-то на прогерском.
$form = new InputInvoiceForm();
$form->sum = 10;
$form->details = 'Lunch payment';
$form->userId = $user->id;
// Мы сохранили счет, но как это связано с его оплатой?
$form->save();

// DDD

// Как называется модель счета? Счет.
$invoice = new Invoice(
    new Money(10, Money::RUB),
    'Lunch payment',
    $user
);
// Что мы делаем со счетом? Платим.
$paymentService->pay($invoice);

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

В новой терминологии Country - это сущность, по сути модель данных. CountryRepository - это репозиторий, источник данных (мы не зря использовали эту терминологию, готовились к будущему).

Сущность содержит методы, которые меняют только ее внутреннее состояние. Если же требуется повлиять на другие сущности, то на помощь приходит DomainService. В примере выше, счет (Invoice) не знает как оплатить себя. Ему помогает в этом PaymentService. Сервис знает, что нужно создать запись в бухгалтерии и отправить письмо с благодарностью покупателю.

Раньше мы не встречались только с объектом значения, ValueObject. Он самый простой из всей компании. По сути означает одно или группу связанных значений, со своими ограничениями. Мы используем его вместо примитивов: встроенных типов языка программирования.

// Без ValueObject

// Такой подход усложнит жизнь счету.
// Ему придется слишком много знать о деньгах.
// К тому же, такую логику нельзя переиспользовать.
$invoice->setSum(10);
$invoice->setCurrency('rub');

// С ValueObject

// Счету нужно знать только о своих особенностях.
// О работе с деньгами позаботятся объекты Money и Currency.
new Invoice(new Money(10, Money::RUB));

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

Инфраструктура

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

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

src/
├── Domain
│   └── Country
│       ├── Country.php
│       └── CountryRepositoryInterface.php
└── Infrastructure
    └── Country
        ├── CountryApiRepository.php
        └── CountryMariadbRepository.php

Приложение

У нас есть все необходимые ингредиенты, но нет рецепта. Кто определяет, как “приготовить” приложение, как собрать все воедино? Для этого нам понадобятся сервис уровня приложения, ApplicationService. В его методах мы опишем способы взаимодейсвия с ядром.

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

На сервисе приложения лежит много ответственности (в смысле важности, а не количестве логики), поэтому разберем его подробнее. Он синхронизирует работу всех других объектов домена. А, значит, именно на этом уровне нужно обеспечить атомарность операций, логирование и отправку событий. Так выглядит типичный метод сервиса приложения (код упрощен для краткости):

class CountryApplicationService
{
    public function allocateUser(int $countryId, int $userId): void
    {
        $this->transactionManager->transactional(function () {
            $user = $this->users->findById($userId);
            if ($user === null) {
                throw new UserNotFoundException();
            }
            $country = $this->countries->findById($countryId);
            if ($country === null) {
                throw new CountryNotFoundException();
            }
            
            $country->allocate($user);
            $user->updateFlag($country->codeName());
            
            $this->users->save($user);
            $this->countries->save($country);
            $this->eventDispatcher(new UserAllocatedEvent());
        });
    }
}

Структура метода типична:

  1. извлекаем данные из репозитория,
  2. производим действия,
  3. сохраняем данные.

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

Где расположить сервис приложения? Он может располагаться как на одном уровне с доменом, так и внутри него. Внутри конкретного проекта как правило выбирают один подход и придерживаются его. Вот как можно расположить небольшой сервис:

src/
├── Application
│   └── CountryApplicationService.php
├── Domain
│   └── ...
└── Infrastructure
    └── ...

Если действий много, ApplicationService станет очень большим. В таком случае мы можем извлечь методы в отдельные классы. Такие классы мы называем действиями или сценариями (UseCase):

src/
├── Domain
│   └── Country
│       ├── ...
│       └── UseCases
│           └── AllocateUser
│               └── Handler.php
└── Infrastructure
    └── ...

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

Внешний мир

У нас есть готовое приложение, но как с ним взаимодействовать? Если мы все сделали грамотно, то можем выбрать любой способ взаимодейсвия. Давайте возьмем любой популярный web-фреймворк и запустим на нем наше приложение, например Yii2 с шаблоном basic. Не будем повторять инструкцию по установке, она уже есть на официальном сайте Yii2.

Создадим модуль приложения. Не модуль фреймворка, а именно нашего собственного приложения. За основу возьмем пример со странами из предыдущей статьи. В composer.json укажем отдельный namespace, так как Yii2 еще не знает, как реализовать автозагрузку в соответствии с PSR-4:

"autoload": {
    "psr-4": {
        "Countries\\": "modules/Countries"
    }
}

Распределим классы по слоям:

  • Country и CountryRepositoryInterface в домен,
  • CountryService становится CountryApplicationService и отправляется на уровень Application,
  • CountryRepository в инфраструктуру.

Создадим отдельные контроллеры для доступа через HTTP и CLI.

Получилась такая структура:

.
├── codeception.yml
├── commands
│   └── CountryController.php
├── controllers
│   └── CountryController.php
├── modules
│   └── Countries
│       ├── Application
│       │   └── CountryApplicationService.php
│       ├── Domain
│       │   └── Country
│       │       ├── Country.php
│       │       └── CountryRepositoryInterface.php
│       └── Infrastructure
│           └── Country
│               └── CountryRepository.php
└── yii

Полный пример на GitHub: malchikovma/layers-post.

Раскомментируем prettyUrl в файле конфигурации config/web.php. Запустим встроенный веб-сервер PHP в директории web: php -S localhost:8080. Постучимся к нашему приложению через эндпоинт “/country/density?name=japan”. Про дизайн API поговорим в другой раз, а сейчас увидим, что все работает

curl http://localhost:8080/country/density?name=japan
high

Попробуем обратиться к приложению через терминал.

./yii country/density japan
high

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

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

Фоновые задачи являются разновидностью запуска через терминал. Например, cron может выполнять нашу команду (или делать что-то более осмысленное) с любой периодичностью, вплоть до 1 раза в минуту.

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

Заключение

Мы разобрали, как можно написать приложение, которое не будет зависеть от фреймворка. Но фреймворки используют для ускорения разработки. Зачем уходить от них?

К сожалению, фреймворки хороши только для самых тривиальных задач, например, прочитать и обновить запись в БД. Если хоть немного отступить от официальной документации, начнется “темный лес”. А их подход ограничивает нас: нужно все делать с оглядкой на задумку автора.

“Чистая архитектура”, как ее иногда называют, позволяет нам освободиться от оков одного фреймворка и даже языка программирования и начать писать настоящий объектно-ориентированный код. Общие принципы используются в PHP, C#, Java и других языках, и не только на бэкенде. Понимая их, можно считать себя не программистом на условном Laravel, а настоящим инженером.


  1. Эрик Эванс. Предметно-ориентированное проектирование (DDD): структуризация сложных программных систем. ↩︎

  2. Вон Вернон. Реализация методов предметно-ориентированного проектирования. ↩︎

Сайт не хранит данные пользователей
Malchikovma.ru на GitHub
Создано при помощи Hugo
Тема Stack, дизайн Jimmy