Возможно, вы не слышали о Sunrise экосистеме, так или иначе, сегодня я поделюсь с вами опытом разработки API, используя Sunrise решения и не только.
В 2018 году передо мной встал выбор маршрутизатора с четкими требованиями:
-
Легковесность
-
В основе PSR-стандарты
-
Поддержка аннотаций
-
Интеграция со Swagger
На тот момент подходящего для себя решения я не нашел, ввиду чего появился Sunrise Router. Вторая версия, выпущенная год спустя, закрыла основные потребности, но мне было нужно нечто большее. Под влиянием Spring и других фреймворков я стремился к комплексному решению, включающему:
-
Иммутабельную архитектуру
-
Самодокументируемое API
-
Получение DTO и других атрибутов запроса сразу в параметрах экшена
-
Отправку View-объектов сразу из экшена
-
Минимальную зависимость от Request/Response
-
Обработку ошибок и локализацию из коробки
-
Гибкость и расширяемость

Цель была не просто в создании удобного маршрутизатора, а в сбалансированном решении, где, в том числе, документация является естественным продолжением кода, а не отдельным этапом работы.
Однажды устроился на работу, и мне досталась пачка «интересных» задач: нужно было вручную синхронизировать OpenAPI, оформленный в комментариях к коду, с самим кодом в десятке сервисах. Звучит как начало анекдота, но тогда мне было не до смеха…
Недавно вышла третья версия Sunrise Router, реализующая все вышеописанное. Это проверенное в продакшене решение, которое стабильно поддерживается и развивается все это время.
Демонстрационный контроллер
namespace App\Controller\Api; use App\Contract\Service\PostServiceInterface; use App\Dictionary\MediaType; use App\Dto\Post\PostCreateRequest; use App\Entity\Post; use App\View\Post\PostView; use Sunrise\Bridge\Doctrine\Integration\Router\Annotation\RequestedEntity; use Sunrise\Http\Router\Annotation\Consumes; use Sunrise\Http\Router\Annotation\EncodableResponse; use Sunrise\Http\Router\Annotation\GetApiRoute; use Sunrise\Http\Router\Annotation\PostApiRoute; use Sunrise\Http\Router\Annotation\Produces; use Sunrise\Http\Router\Annotation\RequestBody; final readonly class PostController { public function __construct( private PostServiceInterface $postService, ) { } #[PostApiRoute('api.posts.create', '/api/posts')] #[Consumes(MediaType::JSON)] public function create(#[RequestBody] PostCreateRequest $createRequest): void { $this->postService->createPost($createRequest); } #[GetApiRoute('api.posts.read', '/api/posts/{id<\d+>}')] #[Produces(MediaType::JSON)] #[EncodableResponse] public function read(#[RequestedEntity] Post $post): PostView { return PostView::create($post); } }

Маршрутизатора недостаточно. Любой API-проект требует:
-
Гибкой конфигурации
-
DI
-
CLI
Так появился Awesome Skeleton, который закрывает эти задачи и предлагается в качестве готового базового решения, где за конфигурацию и DI отвечает PHP-DI, а за CLI – Symfony Сonsole.
Обзор некоторых возможностей
Следование PSR-стандартам
Хотя в третьей версии сохранена полная совместимость с PSR-15, следование этому стандарту в контексте контроллеров при разработке API не рекомендуется. Причина проста: система не сможет понять, что ожидается на входе и что будет на выходе, из-за чего разработчику придется смещать фокус с логики на документацию, что противоречит изначальной концепции.
В то же время, для промежуточного ПО, наоборот, рекомендуется придерживаться PSR-15, чтобы обеспечить максимальную совместимость и гибкость.
Иммутабельная архитектура
Переходя к практике, я много лет использую RoadRunner для PHP-проектов. Давайте подключим его к нашему проекту.
composer require spiral/roadrunner-cli spiral/roadrunner-http
php vendor/bin/rr get-binary
Определим команду-воркер, которая будет точкой входа:
declare(strict_types=1); namespace App\Command; use Override; use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface; use Spiral\RoadRunner\Http\PSR7Worker as HttpWorker; use Spiral\RoadRunner\Worker; use Sunrise\Http\Router\RouterInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand('rr:work')] final class RoadRunnerWorker extends Command { public function __construct( private readonly RouterInterface $router, private readonly ServerRequestFactoryInterface $requestFactory, private readonly StreamFactoryInterface $streamFactory, private readonly UploadedFileFactoryInterface $uploadsFactory, ) { parent::__construct(); } #[Override] protected function execute(InputInterface $input, OutputInterface $output): int { $worker = new HttpWorker(Worker::create(), $this->requestFactory, $this->streamFactory, $this->uploadsFactory); while ($request = $worker->waitRequest()) { $worker->respond($this->router->handle($request)); } return self::SUCCESS; } }
Добавим ее в конфигурацию config/definitions/app.php
:
use App\Command\RoadRunnerWorker; use function DI\autowire; return [ 'app.commands' => add([ autowire(RoadRunnerWorker::class), ]), ];
Создадим .rr.yaml
в корне проекта:
# https://github.com/roadrunner-server/roadrunner/blob/master/.rr.yaml version: '3' server: command: php bin/app rr:work http: address: 127.0.0.1:8000 pool: num_workers: ${ROADRUNNER_NUM_WORKERS} max_jobs: ${ROADRUNNER_MAX_JOBS}
Добавим переменные в .env
и .env.dist
:
# How many worker processes will be started. # Zero (or nothing) means the number of logical CPUs. ROADRUNNER_NUM_WORKERS=1 # Maximal count of worker executions. # Zero (or nothing) means no limit. ROADRUNNER_MAX_JOBS=1
Запуск:
./rr serve --dotenv .env
Если все работает и RoadRunner вас устраивает, можно удалить public/index.php
, так как он больше не нужен.
Самодокументируемое API
В самом начале мы рассмотрели пример контроллера, который не заработает сразу — к нему мы вернемся позже. А сейчас давайте создадим контроллер для авторизации. Он не будет содержать логики и носит демонстрационный характер, хотя может послужить точкой старта.
Сначала опишем DTO, чтобы задать ожидаемую структуру данных от клиента:
declare(strict_types=1); namespace App\Dto\Auth; use SensitiveParameter; final readonly class SignInRequest { public function __construct( #[SensitiveParameter] public string $email, #[SensitiveParameter] public string $password, ) { } }
Теперь опишем сам контроллер:
declare(strict_types=1); namespace App\Controller\Api; use App\Dictionary\MediaType; use App\Dto\Auth\SignInRequest; use Sunrise\Http\Router\Annotation\Consumes; use Sunrise\Http\Router\Annotation\PostApiRoute; use Sunrise\Http\Router\Annotation\RequestBody; final readonly class AuthController { #[PostApiRoute('api.auth.signIn', '/api/auth/sign-in')] #[Consumes(MediaType::JSON)] public function signIn( #[RequestBody] SignInRequest $signInRequest, ): void { } }
Далее можно сгенерировать OpenAPI, открыть его в Swagger и протестировать метод:
php bin/app router:openapi:build-document
В результате по адресу http://localhost:8000/swagger.html мы должны увидеть что-то вроде этого:

Больше аннотаций и примеров можно найти в документации.
Обработка ошибок
По умолчанию ошибки отлавливаются и логируются. Клиент получает ошибку в формате JSON, адаптированном под его язык, в зависимости от типа ошибки. Если вы заглянете в схемы OpenAPI (Swagger), то увидите объекты-представления Error и Violation — именно они используются для отображения ошибок.
Если вам необходимо отдавать ошибки в другом формате, это легко настраивается через параметр router.error_handling_middleware.produced_media_types.
А если вы разрабатываете не только backend, но и frontend на основе скелетона, возможно, вам потребуется отображать ошибки в HTML. Это тоже легко реализовать: достаточно добавить промежуточное ПО перед ErrorHandlingMiddleware
, проверить, запрашивает ли клиент HTML-ответ, и если да — вернуть HTML. В противном случае запрос просто передается следующему middleware.
Валидация DTO
Здесь все просто: чтобы DTO автоматически валидировались, требуется интеграция с Symfony Validator. Это делается очень легко — сначала установим его:
composer require symfony/validator
Далее интегрируем в наш проект. Для этого создадим файл config/definitions/validator.php
и приведем его к следующему виду:
declare(strict_types=1); use Psr\Container\ContainerInterface; use Symfony\Component\Validator\ContainerConstraintValidatorFactory; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\ValidatorBuilder; return [ ValidatorInterface::class => static fn(ContainerInterface $container): ValidatorInterface => (new ValidatorBuilder()) ->enableAttributeMapping() ->setConstraintValidatorFactory(new ContainerConstraintValidatorFactory($container)) ->getValidator(), ];
Теперь, на примере нашего SignInController
, доработаем SignInRequest
, добавив валидацию:
declare(strict_types=1); namespace App\Dto\Auth; use SensitiveParameter; use Symfony\Component\Validator\Constraints as Assert; final readonly class SignInRequest { public function __construct( #[Assert\NotBlank] #[SensitiveParameter] public string $email, #[Assert\NotBlank] #[SensitiveParameter] public string $password, ) { } }
Интеграция с Doctrine
Интеграция с Doctrine дает ряд преимуществ, упрощающих работу с сущностями и повышающих удобство разработки.
Резолвинг сущности в свойстве DTO
Хотя смешивание слоев домена и приложения в одной точке может показаться спорным, на практике это весьма полезная возможность. Например, клиент отправляет запрос на создание товара, передавая categoryId
. Вместо того чтобы просто принять этот идентификатор, система автоматически проверит существование категории, установит её в свойство DTO или вернет ошибку, если категория отсутствует. В результате контроллер получает не просто «сырые» данные от клиента, а уже частично обработанную и валидированную структуру.
Резолвинг сущности в параметре экшена
Иногда удобно сразу получать сущность в контроллере, чтобы не загружать её вручную и не обрабатывать ситуацию, когда объект не найден. Это компромисс между строгим разделением слоев и практичностью. Лично я нахожу такой подход удобным, но в любом случае это всего лишь опция, которую можно отключить — как и большинство возможностей в системе.
Промежуточное ПО для управления транзакциями
При разработке экшенов я придерживаюсь простого, но важного принципа: контроллер должен быть транзакционно атомарным, то есть выполняться как единая операция. Идеальный сценарий — когда разработчику не нужно думать о вызове flush()
. Если в экшене вызываются разные сервисы, и каждый из них самостоятельно вызывает flush()
(например, FooService
сначала что-то делает и коммитит, затем BarService
делает что-то своё и тоже коммитит), это приводит к потере согласованности данных. В худшем случае часть операции пройдет успешно, а часть — нет. Этот механизм автоматически оборачивает выполнение контроллера в транзакцию, устраняя подобные проблемы, однако всегда помните о распределенных транзакциях.
Гарантия уникальности сущности
Разработчики Symfony знакомы с валидатором UniqueEntity
. Здесь реализован аналогичный механизм, который никак не связан с этим фреймворком и встроен в систему как отдельный «компонент».
Управление подключениями к базе данных
Классический сценарий: воркер потребляет сообщения из Kafka, но через какое-то время появляется ошибка «Lost Connection» – знакомо? В долгоработающих приложениях это критично, так как воркер — это, по сути, долгоживущий процесс. Для решения этой проблемы менеджер сущностей перед каждым использованием проверяет, не закрыто ли соединение. Если оно разорвано, происходит автоматическая перезагрузка соединения, что устраняет возможные сбои в работе.
В Awesome Skeleton всё это настраивается буквально в несколько шагов. Устанавливаем мост к Doctrine:
composer require sunrise-studio/doctrine-bridge
Обязательно добавьте PSR-18 совместимый пакет кеширования. Я рекомендую symfony/cache
:
composer require symfony/cache
Далее интегрируем Doctrine в контейнер config/container.php
, добавив перед определениями приложения следующий код:
$containerBuilder->addDefinitions( __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/doctrine.php', __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/hydrator/type_converters/map_entity_type_converter.php', __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/router/middlewares/request_termination_middleware.php', __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/router/parameter_resolvers/requested_entity_parameter_resolver.php', __DIR__ . '/../vendor/sunrise-studio/doctrine-bridge/resources/definitions/integration/validator/unique_entity_validator.php', );
Наконец, добавляем DSN в переменные окружения:
DATABASE_DSN=pdo-pgsql://user:password@localhost:5432/acme?charset=utf8
Google reCAPTCHA
Продолжая тему расширяемости Sunrise Router, давайте добавим защиту экшена входа, используя Google reCAPTCHA. Установим соответствующий пакет:
composer require sunrise/recaptcha
Также нам понадобятся пакеты, совместимые с PSR-17 и PSR-18:
Теперь интегрируем reCAPTCHA в проект. Для этого откроем config/container.php
и перед основными определениями приложения добавим следующее:
$containerBuilder->addDefinition( __DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/recaptcha_verification.php', __DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/integration/router/middleware/recaptcha_challenge_middleware.php', __DIR__ . '/../vendor/sunrise/recaptcha/resources/definitions/integration/validator/constraint/recaptcha_challenge_validator.php', );
После этого необходимо указать секретный ключ в переменных окружения:
RECAPTCHA_VERIFICATION_PRIVATE_KEY=secret
Вы можете выбрать один из двух способов валидации reCAPTCHA:
-
Через HTTP-заголовок
-
Через свойство в DTO
Конечно, есть и другие варианты. Например, можно использовать Constraint-аннотацию для валидации параметров в экшене.
Промежуточное ПО
declare(strict_types=1); namespace App\Controller\Api; use App\Dictionary\MediaType; use App\Dto\Auth\SignInRequest; use Sunrise\Http\Router\Annotation\Consumes; use Sunrise\Http\Router\Annotation\Middleware; use Sunrise\Http\Router\Annotation\PostApiRoute; use Sunrise\Http\Router\Annotation\RequestBody; use Sunrise\Recaptcha\Integration\Router\Middleware\RecaptchaChallengeMiddleware; final readonly class AuthController { #[PostApiRoute('api.auth.signIn', '/api/auth/sign-in')] #[Consumes(MediaType::JSON)] #[Middleware(RecaptchaChallengeMiddleware::class)] public function signIn( #[RequestBody] SignInRequest $signInRequest, ): void { } }
По умолчанию ожидается заголовок X-Recaptcha-Token
.
Свойства DTO
declare(strict_types=1); namespace App\Dto\Auth; use SensitiveParameter; use Sunrise\Recaptcha\Integration\Validator\Constraint\RecaptchaChallenge; use Symfony\Component\Validator\Constraints as Assert; final readonly class SignInRequest { public function __construct( #[Assert\NotBlank] #[SensitiveParameter] public string $email, #[Assert\NotBlank] #[SensitiveParameter] public string $password, #[RecaptchaChallenge] public string $recaptcha, ) { } }
Резюмируя все вышеизложенное
Мы рассмотрели ключевые возможности и примеры использования, затронув как концептуальные аспекты, так и практические примеры. В данный момент инструментарий активно развивается и документируется. Если вас заинтересовал проект — задавайте вопросы, буду рад обсуждению!
ссылка на оригинал статьи https://habr.com/ru/articles/891048/
Добавить комментарий