Привет! Я, Андрей, Symfony разработчик - мы делаем сайты. Каждый день у нас уходит много ресурсов на администрирование и базовые настройки проектов. В этой статье поделюсь опытом, как можно адаптировать фреймворк Symfony для оптимизации таких затрат, какие настройки мы проводим для обеспечения быстрого функционирования, и как мы взаимодействуем с REST клиентами. Поехали (много кода).
Namespace и структура проекта
Так как наши сервисы содержат больше одной сущности User
, мы решили разбить нашу структуру проекта на «домены» или директории, которые сгруппировали по интересам. Так удобнее ориентироваться в коде и проще подключать и отключать сервисы.
На примере выше Api
— это отдельная директория, которая отвечает за взаимодействие с REST клиентами и расширяет базовую структуру из src
. Корневые разделы в этой директории по структуре похожи на src
, за исключением, что там находится всё, что необходимо для работы с клиентами.
Чтобы поддерживать такую структуру, разделы services
и routes
мы настраиваем схожим образом отдельно для каждой директории.
doctrine:
orm:
mappings:
User:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/User/Entity'
prefix: 'User\Entity'
framework:
messenger:
routing:
'User\Messenger\Message\ClientSettingsMessage': async
services:
_defaults:
autowire: true
autoconfigure: true
public: false
# base section
User\:
resource: '../../src/User'
exclude: '../../src/User/{Exception,Entity,View,Messenger/Message}'
# api section
Api\User\:
resource: '../../src/Api/User'
exclude: '../../src/Api/User/{Exception,Entity,View}'
Подобное разделение на сервисы в конфигурации не поддерживаются по умолчанию.
Для этого мы переопределили Kernel
класс, чтобы подключать настройки на основе своих файлов.
<?php
declare(strict_types=1);
class Kernel extends Symfony\Component\HttpKernel\Kernel
{
use MicroKernelTrait;
protected function configureContainer(ContainerConfigurator $container): void
{
$container->import('../config/{packages}/*.yaml');
$container->import('../config/{packages}/'.$this->environment.'/*.yaml');
$container->import('../config/{services}/*.yaml');
$container->import('../config/{services}/'.$this->environment.'/*.yaml');
}
protected function configureRoutes(RoutingConfigurator $routes): void
{
$routes->import('../config/{routes}/'.$this->environment.'/*.yaml');
$routes->import('../config/{routes}/*.yaml');
if (\is_file(\dirname(__DIR__).'/config/routes.yaml')) {
$routes->import('../config/routes.yaml');
} else {
$routes->import('../config/{routes}.php');
}
}
}
Мы используем src
директорию как корневой namespace
вместо App,
предложенного по умолчанию. Т.е. обращение к классам внутри src
выглядит следующим образом: \User\Entity\User
, \Api\User\Action\MeAction
и т.д.
Для этого нужно изменить секцию autoloader
в composer.json
и немного скорректировать bin/console.php
и public/index.php
, поменяв использование namespace.
"autoload": {
"psr-4": {
"": "src/"
}
}
При использовании оптимизированной версии composer в prod среде это не влияет на производительность:
composer install --no-dev --optimize-autoloader --classmap-authoritative --apcu-autoloader
Actions (ADR) вместо Controller
Мы работаем преимущественно с REST API и не любим большие файлы. Поэтому, придумали для себя правило: один запрос от клиента отвечает за набор определенных действий, каждый запрос — это отдельный класс, унаследованный от наших базовых экшенов GetAction
, MutationAction
, GetWithFormAction
.
Action
- это контролер, заданный, как сервис. https://symfony.com/doc/current/controller/service.html#invokable-controllers
Доступные сервисы в Action
мы переопределяем через getSubscribedServices
. Такое переопределение более компактно и контейнер, который передаётся, минимален и оптимизирован.
Базовый класс изображён ниже. Мы продолжаем использовать AbstractController
от Symfony, чтобы пользоваться методами-хелперами, которые предлагает фреймворк.
<?php
declare(strict_types=1);
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
abstract class AbstractAction extends AbstractController
{
public static function getSubscribedServices(): array
{
return [
// You can add your services here
// 'request_stack' => RequestStack::class,
];
}
}
Для всех GET запросов на получение данных мы используем отдельный GetAction
. В примере он пустой, но в зависимости от нужд он может расширяться:
<?php
declare(strict_types=1);
namespace Api\Action;
use Action\AbstractAction;
class GetAction extends AbstractAction
{
public static function getSubscribedServices(): array
{
return [
// You can add your services here
// 'request_stack' => RequestStack::class,
];
}
}
Класс для POST запросов - MutationAction
- аналогичен GET классу, но с некоторыми изменениями. MutationAction
очевидно, что метод ожидает данные в теле POST запроса, поэтому по умолчанию он поддерживает обработку форм. Про ApiFormTrait
будет ниже или даже в отдельном посте.
<?php
/** . */
class MutationAction extends \AbstractAction
{
use ApiFormTrait;
public static function getSubscribedServices(): array
{
return parent::getSubscribedServices() + [
'form.factory' => FormFactoryInterface::class,
'request_stack' => RequestStack::class,
];
}
}
В дополнение к базовым GET/POST запросам, мы часто используем ListAction
, который, в отличие от простого GetAction
, поддерживает пагинацию данных, вынесенную в отдельный сервис.
В результате, каждый новый запрос выглядит, как на примере ниже.
Данный запрос отвечает за получение данных пользователя, например, для редактирования настроек (очень просто):
<?php
/** . */
#[Route('/me', methods: 'GET')]
#[IsGranted('ROLE_USER')]
class MeAction extends GetAction
{
public function __invoke(): Response
{
return new PrivateUserView($this->getUser());
}
}
Пример для POST запросов:
<?php
/** . */
#[Route("/user/settings/update", methods: ['POST'])]
#[IsGranted("ROLE_USER")]
class UpdateUserSettingsAction extends MutationAction
{
use \EntityManagerAwareTrait;
use \MessageBusAwareTrait;
use \LoggerAwareTrait;
public function __invoke(): ViewInterface
{
return $this->handleApiCall(UserSettingsForm::class, function (UserSettingsDto $dto) {
return new PrivateUserView($this->update($this->getUser(), $dto));
});
}
private function update(User $user, UserSettingsDto $data): void
{
$this->em->beginTransaction();
try {
$this->em->lock($user, LockMode::PESSIMISTIC_WRITE);
$this->em->refresh($user);
$user->updateSettings($data);
$this->em->persist($user);
$this->em->flush();
/**
* SettingsMessage uses transactional routing,
* Therefore, the message could be processed only if the transaction has been successfully committed,
* ie, after all, real updates to the database.
*/
$this->bus->dispatch(new SettingsMessage($user));
$this->em->commit();
} catch (\Throwable $e) {
$this->logger->error('Error occurred while update user settings', ['error' => $e]);
$this->em->rollback();
throw $e;
}
}
}
Мы используем свои базовые трейты \EntityManagerAwareTrait
, \MessageBusAwareTrait
и другие, о которых расскажу позже. Они расположены в корне проекта и позволяют подключать нужные базовые зависимости.
Mutation запросы на изменение данных
Большинство запросов по изменению данных мы обрабатываем через Symfony формы. Для каждого запроса, где требуется валидация, у нас есть Data Transfer Object (DTO) + сама форма унаследования от Symfony\Component\Form\AbstractType
, где мы задаём требования по валидации.
В отличие от официальной документации, мы:
Не используем сущности, как данные для форм при обработке запросов. Потому что использование сущностей может вызвать различного рода артефакты. Именно для этих целей у нас есть DTO.
Не используем атрибуты для валидации. Нам не хватает их функционала, так как форма может быть сложной и с зависимостями.
Как и с Action
, у нас есть базовый набор форм. Например, для MutationAction
— запроса на обновление данных, мы реализовали MutationForm
, которая ожидает POST запрос и не разрешает дополнительные поля allow_extra_fields
. Для GetAction
и ListAction
напротив - ожидает GET запрос и разрешены дополнительные поля.
Преимущественно, мы работаем только с JSON запросами и убрали поддержку базовых namespace у корневых форм, которые добавляли названия полей к каждому параметру, переопределив это через getBlockPrefix
в абстрактных формах.
<?php
/** . */
class PostForm extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'method' => 'POST',
]);
}
public function getBlockPrefix(): string
{
return '';
}
}
PostForm
нужен, чтобы разрешить использование configureOptions
без вызова родительского метода в наследниках MutationForm
<?php
/** . */
abstract class MutationForm extends AbstractType
{
public function getBlockPrefix(): string
{
return '';
}
public function getParent(): string
{
return PostForm::class;
}
}
Возвращаясь к примеру с настройками пользователя, он выглядит следующим образом:
<?php
/** . */
class UserSettingsForm extends MutationForm
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserSettingsDto::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'constraints' => [
new Length(['max' => $max = 255]),
],
])
->add('lastName', TextType::class, [
'constraints' => [
new Length(['max' => $max]),
],
]);
}
}
Пример DTO объекта приведён ниже. Это простая конструкция объекта, которая может включать другие DTO зависимости. В принципе, мы бы могли обойтись массивами, но мы используем DTO с точки зрения удобства:
<?php
/** . */
class UserSettingsDto
{
public string|null $firstName;
public string|null $lastName;
}
ApiFormTrait
Для удобства обработки POST запросов мы вынесли общий код обработки форм в trait. Он подключен в MutationAction
по умолчанию.
Валидационные ошибки мы отдаём в определенном формате - для клиентов мы реализовали методы, которые позволяют выводить серверные ошибки у полей формы автоматически, это сильно упрощает взаимодейтствие. Ошибки соответствуют названию и вложенности переданных параметров.
Ниже приведен пример нашего trait, он решает большинство проблем с обработкой запроса: берёт на себя валидацию и отправку ошибок в нужном формате, если это требуется.
В примере видно разделение MutationForm
с простым JSON запросом от всех остальных, это нужно для поддержки базовых Symfony форм, если это потребуется, например, для обработки файлов:
<?php
/** . */
trait ApiFormTrait
{
use FormTrait;
protected function handleApiCall(FormInterface|string $form, callable|null $callable = null): ViewInterface
{
if (!$form instanceof FormInterface && \is_string($form)) {
$form = $this->container->get('form.factory')->create($form);
}
$request = $this->container->get('request_stack')->getCurrentRequest();
return $this->onFormSubmitted(
$form->getConfig()->getType()->getInnerType() instanceof MutationForm
? $form->submit($this->convertRequestToArray($request))
: $form->handleRequest($request),
$callable
);
}
private function convertRequestToArray(Request $request): array
{
$data = [];
if ('json' === $request->getContentTypeFormat() && $request->getContent()) {
try{
$data = $request->toArray();
} catch (\Throwable $e){
throw new BadRequestHttpException('Could not convert content into a valid JSON.', $e);
}
}
return $data;
}
}
<?php
/** . */
trait FormTrait
{
protected function createSuccessResponse(array $data = []): DataView|ResponseView
{
return $data ? new DataView($data) : new ResponseView();
}
protected function createExceptionResponse(): FailureView
{
return $this->createFailureResponse(Response::HTTP_INTERNAL_SERVER_ERROR);
}
protected function createSuccessHtmlResponse(string $view, array $parameters = []): Response|SuccessHtmlView
{
$request = $this->container->get('request_stack')->getCurrentRequest();
if ($request->isXmlHttpRequest()) {
return new SuccessHtmlView([
'html' => $this->renderView($view, $parameters),
]);
}
return $this->render($view, $parameters);
}
protected function createFailureResponse(int $status = Response::HTTP_BAD_REQUEST): FailureView
{
return new FailureView($status);
}
protected function createValidationFailedResponse(FormInterface $form): ValidationFailedView
{
return new ValidationFailedView($this->serializeFormErrors($form));
}
protected function handleFormCall(FormInterface|string $form, callable|null $callable = null): Response|ViewInterface
{
if (!\is_string($form) && !$form instanceof FormInterface) {
throw new \TypeError(\sprintf('Passed $form must be of type "%s", "%s" given.', \implode(',', ['string', FormInterface::class]), \get_debug_type($form)));
}
if (\is_string($form)) {
$form = $this->container->get('form.factory')->create($form);
}
$request = $this->container->get('request_stack')->getCurrentRequest();
$form->handleRequest($request);
if (!$form->isSubmitted()) {
throw $this->createSubmittedFormRequiredException(\get_class($form));
}
return $this->createSubmittedFormResponse($form, $callable);
}
protected function createSubmittedFormResponse(FormInterface $form, callable|null $callable = null): Response|ViewInterface
{
return $this->onFormSubmitted($form, $callable);
}
/**
* @param null|callable $callable must return @see \Dev\ViewBundle\View\ViewInterface, array or null
*/
protected function onFormSubmitted(FormInterface $form, callable|null $callable = null): Response|ViewInterface
{
if (!$form->isValid()) {
return $this->createValidationFailedResponse($form);
}
if (null === $callable || null === $response = \call_user_func($callable, $form->getData())) {
return $this->createSuccessResponse();
}
if (!\is_array($response) && !$response instanceof ViewInterface && !$response instanceof Response) {
throw new \TypeError(\sprintf('Passed closure must return %s, returned %s', \implode('|', [ViewInterface::class, Response::class, 'array']), \get_debug_type($response)));
}
return \is_array($response) ? $this->createSuccessResponse($response) : $response;
}
protected function serializeFormErrors(FormInterface $form): array
{
return $this->serialiseErrors($form->getErrors(true, false));
}
protected function createNotXmlHttpRequestException(): XmlHttpRequestRequiredException
{
return new XmlHttpRequestRequiredException();
}
protected function createSubmittedFormRequiredException(string $type): SubmittedFormRequiredException
{
return new SubmittedFormRequiredException($type);
}
private function serialiseErrors(FormErrorIterator $iterator, array $paths = []): array
{
if ('' !== $name = $iterator->getForm()->getName()) {
$paths[] = $name;
}
$id = \implode('_', $paths);
$path = \implode('.', $paths);
$violations = [];
foreach ($iterator as $formErrorIterator) {
if ($formErrorIterator instanceof FormErrorIterator) {
$violations = \array_merge($violations, $this->serialiseErrors($formErrorIterator, $paths));
continue;
}
/* @var FormError $formErrorIterator */
$violationEntry = new ViolationView(
$id,
$formErrorIterator->getMessage(),
$formErrorIterator->getMessageParameters(),
$path
);
$cause = $formErrorIterator->getCause();
if ($cause instanceof ConstraintViolation) {
if (null !== $code = $cause->getCode()) {
$violationEntry->type = \sprintf('urn:uuid:%s', $code);
}
}
$violations[] = $violationEntry;
}
return $violations;
}
}
В примере используются различные*View
классы, о них я расскажу ниже, но в целом к ним можно относиться как к массивам с заданной структурой.
Валидация данных
Как я упоминал ранее, мы не используем аннотации для валидации данных. Вместо этого описываем все валидационные правила внутри форм. Простой пример для обновления настроек:
<?php
/** . */
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'constraints' => [
new Length(['max' => $max = 255]),
],
])
->add('lastName', TextType::class, [
'constraints' => [
new Length(['max' => $max]),
],
]);
}
}
Ниже - усложненный вариант, где мы добавляем отчество и проверяем, что имя и фамилия не могут быть одновременно пустыми:
<?php
/** . */
class UserSettingsForm extends FormType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserSettingsDto::class,
'constraints' => [
new Callback(static function (UserSettingsDto|null $dto, ExecutionContextInterface $context) {
if (null === $dto) {
return;
}
if (!$dto->lastName && !$dto->firstName) {
$constraint = new NotBlank();
$context
->buildViolation('First and last names must not be simultaneously empty.')
->setCode($constraint::IS_BLANK_ERROR)
->setCause($constraint)
->atPath('firstName')
->addViolation();
}
}),
],
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'constraints' => [
new Length(['max' => $max = 255]),
],
])
->add('lastName', TextType::class, [
'constraints' => [
new Length(['max' => $max]),
],
]);
$builder->get('lastName')->addEventListener(FormEvents::POST_SUBMIT, static function (PostSubmitEvent $event): void {
if ($event->getData()) {
$form = $event->getForm()->getParent();
$form->add('middleName', TextType::class, [
'constraints' => [
new NotBlank(),
new Length(['max' => 255]),
],
]);
}
});
}
}
Достичь такой гибкости в настройке валидации, используя аннотации или аттрибуты, наверное возможно, но это займёт больше времени.