Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров10K

Привет! Я, Андрей, 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]),
                   ],
               ]);
           }
       });
   }
}

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

Теги:
Хабы:
Всего голосов 12: ↑8 и ↓4+7
Комментарии15

Публикации

Истории

Работа

PHP программист
94 вакансии

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань