Использование Symfony / PHP

от автора

Привет! Я, Андрей, Symfony разработчик — мы делаем сайты. Каждый день у нас уходит много ресурсов на администрирование и базовые настройки проектов. В этой статье поделюсь опытом, как можно адаптировать фреймворк Symfony для оптимизации таких затрат, какие настройки мы проводим для обеспечения быстрого функционирования, и как мы взаимодействуем с REST клиентами. Поехали (много кода).

Namespace и структура проекта

Так как наши сервисы содержат больше одной сущности User, мы решили разбить нашу структуру проекта на «домены» или директории, которые сгруппировали по интересам. Так удобнее ориентироваться в коде и проще подключать и отключать сервисы. 

Image

Пример шаблона проекта

На примере выше Apiэто отдельная директория, которая отвечает за взаимодействие с REST клиентами и расширяет базовую структуру из src. Корневые разделы в этой директории по структуре похожи на src, за исключением, что там находится всё, что необходимо для работы с клиентами. 

Чтобы поддерживать такую структуру, разделы services и routes мы настраиваем схожим образом отдельно для каждой директории.

Image

Конфигурация
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, где мы задаём требования по валидации.

В отличие от официальной документации, мы:

  1. Не используем сущности, как данные для форм при обработке запросов. Потому что использование сущностей может вызвать различного рода артефакты. Именно для этих целей у нас есть DTO.

  2. Не используем атрибуты для валидации. Нам не хватает их функционала, так как форма может быть сложной и с зависимостями.

 
Как и с 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]),                    ],                ]);            }        });    } } 

Достичь такой гибкости в настройке валидации, используя аннотации или аттрибуты, наверное возможно, но это займёт больше времени.


ссылка на оригинал статьи https://habr.com/ru/articles/784660/