Структура 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-модуля.
Controllers
Policies
- 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 расписан тут
Тесты
Подробнее об организации файлов для работы с тестами описано тут
Требования к названиям файлов
Требования к названиям файлов определены здесь