Skip to main content

Структура backend сервиса на языке PHP

Наши сервисы имеют несколько отличающуюся от стандартного Laravel файловую структуру. Её цель - сделать сервисы более поддерживаемыми с течением времени и наращиванием функциональности. На техническом уровне это во многом сводится к двум вещам:

  1. группировка классов по предметной области (домену), а не техническому свойству (контроллеры всего приложения в одной папке App\Http\Controllers)
  2. классов больше, но они меньше и в итоге сильнее следуют 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 - job
  • Events и 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

Далее цепочка работы выглядит следующим образом:

  1. в классе Request есть некий метод convertToObject(): FooData, который раскладывает данные из запроса в нужные поля и возвращает наш dto
  2. контроллер передаёт этот dto в метод: $action->execute($request->convertToObject())
  3. action производит работу. Далее несколько ситуаций:
    • action возвращает другой, уже готовый тип данных: Model, int, bool и т.д.
    • action возвращает другой dto, сделанный специально для передачи ответа, например MassOperationResult
    • action обогащает этот же dto данными и возвращает его же в ответ
  4. Контроллер передаёт ответ в ресурс
  5. Ресурс преобразует данные к формату ответа

app/Domain/{Domain}/Data

В данной директории следует хранить любые классы для работы с данными, которые могут быть использованы по всему сервису. Например, бывают ситуации, когда мы храним json в модели, но на php хотим с ним работать как с объектом.

Класс Data можно оформить 2 способами:

  1. Наследовать класс от Illuminate\Support\Fluent и указывать свойства в phpDoc блоке над классом. Этот способ подходит, когда нужно конвертировать легко объект в массив и обратно.
  2. Делать просто класс с нативными 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 Requests
  • Resources - Api Resources. Формат представления моделей для каждой версии API должен быть независимым
  • Queries - Query Builders построенные на база пакета spatie/laravel-query-builder
  • Filters - фильтры для Query Builders
  • Tests - тесты для эндпоинтов
  • другие поддиректории

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 расписан тут

Тесты

Подробнее об организации файлов для работы с тестами описано тут

Требования к названиям файлов

Требования к названиям файлов определены здесь