Структура backend сервиса на языке PHP
Наши сервисы имеют несколько отличающуюся от стандартного Laravel файловую структуру. Её цель - сделать сервисы более поддерживаемыми с течением времени и наращиванием функциональности. На техническом уровне это во многом сводится к двум вещам:
- группировка классов по предметной области (домену), а не техническому свойству (контроллеры всего приложения в одной папке App\Http\Controllers)
- классов больше, но они меньше и в итоге сильнее следуют SRP
Все эти изменения зафиксированы в Ensi Backend Skeleton (EBS), нашем шаблоне для новых backend-сервисов, который расширяет стандартный шаблон веб-приложения на фреймворке Laravel, добавляя в него дополнительное разделение кода на версии api и домены бизнес-логики.
Дальше более детально описаны отличия структуры приложения относительно стандартной, а также указаны рекомендации по именованию и расположению стандартных файлов
app/Domain
В этой директории находится доменный слой приложений, который разбит на несколько поддиректорий-доменов. Иногда такая поддиректория может быть одна. Например, в CRM покупатели, их данные и предпочтения могут лежать в домене Customers, а всё что касается правил сегментации покупателей - в домене Segmentation В ходе развития сервиса новые домены могут как появляться, так и исчезать. Если у вас в сервисе появилось слишком много доменов, то это сигнал, что возможно он стал слишком большим и его надо разбивать на несколько более мелких.
Обычно название конкретного домена указывается во множественном числе и соответствует основной сущности, с которой ведётся работа в данном домене: Orders, Customers, PaymentSystems.
Но бывают и исключения, например:
Common- домен, в котором собраны вспомогательные инструменты, например работа с файлами, настройки сервиса. Тут может бы что угодно, для чего не подходит другой доменKafka- домен, в котором собраны инструменты для работы с Кафкой: отправление, чтение событий. Подробнее о работе с kafka тутElastic- домен, в котором собраны базовые инструменты для работы с эластиком
В общем случае можно сказать что такие "вспомогательные" домены выделяются в ситуациях, когда есть некий общий код, который используется несколькими другими доменами. Соответственно такой код некорректно описывать в конкретном домене сущности и стоит выделить в отдельный
app/Domain/{Domain}
Внутри app/Domain/{Domain} уже находятся более стандартные технические поддиректории:
Actions- обычные или queueable экшеныConcerns- директория для хранения трейтовData- директория для хранения вспомогательных классов для данныхModels- Eloquent моделиJobs- jobEventsиListeners- работа с событиямиObservers- observers- другие директории реализующие бизнес-логику и работу с БД.
app/Domain/{Domain}/Actions
В сервисах Ensi код организован в т.н. Action-классы. Каждое осмысленное самостоятельное действие выделяется в отдельный класс с единственным публичным методом execute. Эти классы не реализуют никакой интерфейс, каждый action может принимать и возвращать из execute любые параметры которые ему требуются, польза от actions не техническая, а организационная - точки входа в "большие действия" сервиса расположены в ожидаемом месте и имеют предсказуемый способ вызова.
Action - это M из MVC. Важная особенность этого слоя - он не должен заниматься форматированием данных для ответа - для этого есть Resources, не должен знать про источник откуда к нему приходят "команды" будь то запрос через REST API, консольная команда, kafka или какой-то другой транспорт. Это значит, что классы домена не должны зависеть от Illuminate\Http\Request, RdKafka\Message и подобных и не должны бросать Http исключения. Следовательно, параметры запроса передаются как отдельные аргументы метода execute, как DTO или как array в тех случаях когда это идиома фреймворка, $model->fill($data) например. Следует придерживаться следующего правила:
Параметров <= 3- передаём как php-параметры в методexecute(int $arg1, bool $arg2, ?string $arg3 = null)Параметров >= 4- выделяем отдельный dto-класс. Располагать его следует в директории, где лежит сам action, в папкеData, например:
app/Domain/Foo/Actions
├── Data/
│ ├── CommonData.php
│ └── CommonResult.php
├── Deliveries/
├── Orders/
│ ├── Data/
│ │ └── CommitOrderData.php
│ └── CommitOrderAction.php
└── CommonAction.php
Далее цепочка работы выглядит следующим образом:
- в классе
Requestесть некий методconvertToObject(): FooData, который раскладывает данные из запроса в нужные поля и возвращает наш dto - контроллер передаёт этот dto в метод:
$action->execute($request->convertToObject()) - action производит работу. Далее несколько ситуаций:
- action возвращает другой, уже готовый тип данных: Model, int, bool и т.д.
- action возвращает другой dto, сделанный специально для передачи ответа, например
MassOperationResult - action обогащает этот же dto данными и возвращает его же в ответ
- Контроллер передаёт ответ в ресурс
- Ресурс преобразует данные к формату ответа
app/Domain/{Domain}/Data
В данной директории следует хранить любые классы для работы с данными, которые могут быть использованы по всему сервису. Например, бывают ситуации, когда мы храним json в модели, но на php хотим с ним работать как с объектом.
Класс Data можно оформить 2 способами:
- Наследовать класс от
Illuminate\Support\Fluentи указывать свойства в phpDoc блоке над классом. Этот способ подходит, когда нужно конвертировать легко объект в массив и обратно. - Делать просто класс с нативными php-свойствами. Этот способ предпочтителен, т.к. работают проверки типов, можно задавать значения по-умолчанию и т.д.
Также тут можно хранить классы для обогащения Enum, например
routes
Директория с роутами в EBS удалена, а роуты лежат внутри директорий app/Http/Web и app/Http/ApiV{n}. Роуты для api генерируются на основе спецификации и обычно не требуют ручных правок (подробнее об этом ниже).
app/Http/Web
В этой директории лежат некоторые вспомогательные контроллеры и роуты общего назначения которые не являются частью REST API. Например, healthcheck и метрики.
app/Http/ApiV{n}
Здесь располагается всё необходимое для реализации данной глобальной версии REST API.
Modules- директория с http-модулями. Обычно каждому домену соответствует свой http-модуль, но бывают и исключенияOpenApiGenerated- директория, в которую генерируется серверный код пакетом ensi/laravel-openapi-server-generator. Также этот пакет генерирует заготовки в рамках других директорийSupport- директория со вспомогательными классами для реализации http-слоя. Здесь есть базовые классы для пагинации, запросов, ресурсов, тестов и т.д.routes.php- файл с роутами для этой версии api. Обычно не требует правки, т.е. все роуты генерируются вOpenApiGenerated/routes.php
Стоит отметить что все версии API должны быть полностью независимыми. Удаление app/Http/ApiV1 не должно никак сказаться на функциональность /api/v2 из app/Http/ApiV2
app/Http/ApiV{n}/Modules/{Module}
Здесь располагается всё необходимое для реализации конкретного http-модуля.
ControllersPolicies- Policies для ограничения доступа к эндпоинтам в gateway-сервисе. Подробнее тутRequests- Form RequestsResources- Api Resources. Формат представления моделей для каждой версии API должен быть независимымQueries- Query Builders построенные на база пакета spatie/laravel-query-builderFilters- фильтры для Query BuildersTests- тесты для эндпоинтов- другие поддиректории
public/api-docs
Описание api сервиса в формате OAS3 интегрировано в код самого сервиса и используется для генерации серверного кода и клиентских библиотек для работы с сервисом.
Файлы спецификации располагаются в /public/api-docs/v{n}/ следующим образом:
public/api-docs
└── v1
├── orders
│ ├── enums
│ │ └── order_status_enum.yaml
│ ├── schemas
│ │ ├── deliveries.yaml
│ │ └── orders.yaml
│ └── paths.yaml
├── common_parameters.yaml
├── common_schemas.yaml
├── errors.yaml
└── index.yaml
public/api-docs/v{n}/index.yaml - главный файл для данной версии спецификации, в этом файле описаны пути эндпоинтов (но описания эндпоинтов вынесены в файлы public/api-docs/v{n}/{module}/paths.yaml). Файл index.yaml регистрируется в нескольких местах:
- в
config/serve-stoplight.phpдля показа спецификации через stoplight - в
config/openapi-server-generator.phpдля генерации серверного кода - в
config/openapi-client-generator.phpдля генерации пакетов клиента к сервису - в
ApiV{n}ComponentTestCase.phpдля тестирования спецификации
Файлы common_parameters.yaml, common_schemas.yaml и errors.yaml хранят общие для всех модулей схемы.
Внутри папки модуля есть файл paths.yaml, в котором перечисляются описания эндпоинтов, но без описания структуры объектов.
Структура объектов задаётся в файлах внутри папки schemas. Перечисления вынесены в отдельную папку enums.
Подробный пример oas расписан тут
Тесты
Подробнее об организации файлов для работы с тестами описано тут
Требования к названиям файлов
Требования к названиям файлов определены здесь