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

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

Комментарии (13)


  1. SuperKozel
    05.01.2024 09:58
    +6

    Не используйте формы для api. Это очень корявый компонент, и предназначен в первую и последнюю очередь для html форм. У нас они тоже используются, и есть понимание, что это было плохое решение.


    1. genrih_arturovich
      05.01.2024 09:58

      А можно узнать почему?


  1. DmitriyGordinskiy
    05.01.2024 09:58
    +3

    new Length(['max' => $max]),

    Просто напонмю что поддержка именованых аргументов в PHP появилась 3 года назад и была завезена в симфонийские валидаторы еще с 5.4 версии.


  1. michael_v89
    05.01.2024 09:58
    +6

    Слишком многословно. Часть бизнес-логики в контроллере, часть в обработчике SettingsMessage. Много трейтов и родительских классов.
    Можно сделать так.

    class UserController extends AbstractController
    {
      #[Route("/user/settings/update", methods: ['POST'])]
      public function update(User $user, UserSettingsDto $data): PrivateUserView
      {
        $user = $this->userService->update($user, $data);
        
        return new PrivateUserView($user);
      }
    }
    
    class UserService
    {
      public function update(User $user, UserSettingsDto $data): User
      {
        $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();
    
          $this->bus->dispatch(new SettingsMessage($user));
    
          $this->em->commit();
          
          return $user;
        } catch (\Throwable $e) {
          $this->logger->error('Error occurred while update user settings', ['error' => $e]);
          $this->em->rollback();
          
          throw $e;
        }
      }
    }
    
    class UserSettingsDto {
      #[Assert\Length(max: 255)]
      #[Assert\Expression(
        expression: "this.firstName !== '' || this.lastName != ''",
        message: 'First and last names must not be simultaneously empty'
      )]
      public string|null $firstName;
      
      #[Assert\Length(max: 255)]
      public string|null $lastName;
        
      #[Assert\Length(max: 255)]
      #[Assert\Expression(
        expression: "this.lastName === '' || this.value != ''",
        message: 'Middle name must not be empty'
      )]
      public string|null $middleName;
    }
    

    Превращение request в UserSettingsDto и PrivateUserView в response можно сделать через рефлексию и события Symfony. Это проще, чем возиться с Symfony Forms.

    Чтобы не писать везде string|null, когда null не нужен, можно сделать свой компонент валидации, который будет валидировать array по правилам из dto, и только после успешной валидации загружать данные в dto.

    Вместо вызова dispatch() можно вызывать логику напрямую. Обычно она требует зависимости, и становится понятно, что тащить их все в контроллер не стоит. Поэтому и нужен сервис для бизнес-логики. Эту же логику можно потом вызвать из какой-нибудь фоновой задачи. Не пробрасывать же веб-контроллер в консольную задачу.

    С LockMode::PESSIMISTIC_WRITE есть хитрый момент, что до него использовать объект user в логике все равно нельзя. Например, принимать решение в зависимости от статуса, делать ли какие-то действия. Сначала надо блокировать, чтобы поля не поменялись в другом процессе, потом делать проверки. Иначе может получиться, что удаленные пользователи создают заказы, какие-то данные отправляются в другую систему 2 раза из-за отсутствия статуса "отправлено", и т.д.


    1. karrakoliko
      05.01.2024 09:58

      валидация/хранение правил валидации на уровне DTO врядли хорошая (точно плохая) идея


      1. michael_v89
        05.01.2024 09:58
        +2

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


        1. karrakoliko
          05.01.2024 09:58
          +1

          потому что одна и та же модель/сущность может быть создана из десятка разных dto, и иметь при этом 90% одинаковых полей, правила валидации которых вы, приняв решение возложить валидацию на DTO, будете дублировать (user.createFromRegistrationDTO, user.createFromMailSubscribeDTO, user.createFrom...).

          dto отвечает за передачу данных и не имеет права заниматься проверкой их корректности.


          1. michael_v89
            05.01.2024 09:58

            модель/сущность

            Ага, вот дело как раз в том, что валидировать надо не модель, а параметры действия.
            Вот есть у нас фильтр по сущностям и действие "Поиск", там есть 2 поля "Дата создания: От" и "До", API должно проверять, что значение это дата. А в сущности таких полей нет, там только 1 поле "Дата создания". Или галочка "Показать только с изображениями". Или галочка "Уведомить пользователя" при редактировании статьи модератором. Или когда в форме создания сущности делают только 1 поле "Название", а в форме редактирования штук 30 полей.

            user.createFromRegistrationDTO

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

            dto отвечает за передачу данных и не имеет права заниматься проверкой их корректности

            Проверкой корректности занимается вызывающий код, который содержит вызов валидатора. Валидатор возвращает результат валидации, вызывающий код его проверяет. DTO содержит лишь список правил и само ничего не делает.


            1. karrakoliko
              05.01.2024 09:58

              Ага, вот дело как раз в том, что валидировать надо не модель, а параметры действия.

              именно этим путём вы будете дублировать логику/правила валидации во все места, откуда данные попадают в модель.

              Это нарушение DRY, со всеми присущими последствиями.

              сегодня модель создаётся из 3 мест в веб интерфейсе и в 1 консольной команде. завтра таких мест 13, послезавтра - 33. а потом приходит от бизнеса новая вводная, и вот вы уже побежали менять атрибуты в dto'шках/регулярки во всех всех 33 контроллерах/дтошках/гдевытамхранитеконстрэйнты.

              альтернативы этому:
              а) сделать так, чтобы модель не давала записать в себя невалидные данные в принципе (кидать исключение из setter'а)
              б) кидать исключение при первой же попытке использования с невалидными данными (где-то в нужный вам момент вызовете $validator->validate($entity))
              в) вводить value object'ы, и валидацию (по крайней мере, техническую, про бизнесовую давайте не будем даже тут говорить) проводить на уровне создания value object'а, не доводя до модели/entity в принципе.

              в таком случае, если у вас будет 33 места откуда данные попадают в модель, и появится изменение требований валидации этих данных, вы логику валидации измените в одном месте, а не в 33.

              это - безусловное приемущество описываемых мной подходов.


              1. michael_v89
                05.01.2024 09:58

                именно этим путём вы будете дублировать логику/правила валидации во все места, откуда данные попадают в модель.

                Вот я как раз и говорю, что во всех местах могут быть разные правила, и бизнес может менять их независимо. При создании товара поле "Описание" необязательное, при первом редактировании обязательное. Или при редактировании тоже необязательное, но должно быть заполнено при изменении статуса на "Опубликован". Как вы это сделаете через правило в поле сущности?

                сегодня модель создаётся из 3 мест в веб интерфейсе и в 1 консольной команде

                Обычно большинство полей меняется только в 1-2 местах.
                Если их больше, то во всех 3 местах в интерфейсе разный набор полей и разные правила их валидации, а в консольной команде часть полей берется из базы и валидировать их не надо.
                Поля в DTO должны соответствовать полям в интерфейсе пользователя, и валидация часто бывает зависимая от других полей. Например, может быть требование возвращать ошибку, если в фильтре значение поля "Дата от" больше "Дата до". Как вы сделаете эту валидацию через сущность?

                завтра таких мест 13, послезавтра - 33. а потом приходит от бизнеса новая вводная
                вы логику валидации измените в одном месте, а не в 33

                Да-да, завтра от бизнеса приходит новая вводная, что в месте номер 17 надо разрешить не указывать поле, а во всех остальных местах оставить обязательным.

                Это нарушение DRY

                Нет, это разные сценарии, а не повторение одного, они могут меняться независимо. Во всех сущностях есть поле id, но это не значит, что это повторение.

                сделать так, чтобы модель не давала записать в себя невалидные данные в принципе (кидать исключение из setter'а)

                Тогда модель превращается в God-object, в котором есть всё.
                Сеттер не может знать, из какого сценария его вызвали. В одних сценариях поле может быть необязательным, а в других обязательным. Например, некоторые поля могут заполняться только топ-менеджером.

                вводить value object'ы, и валидацию проводить на уровне создания value object'а

                Вот DTO для входных данных это и есть такой value object. DTO не имеет идентификатора, значит он является value object.


                1. karrakoliko
                  05.01.2024 09:58

                  Вот я как раз и говорю, что во всех местах могут быть разные правила, и бизнес может менять их независимо. При создании товара поле "Описание" необязательное, при первом редактировании обязательное. Или при редактировании тоже необязательное, но должно быть заполнено при изменении статуса на "Опубликован". Как вы это сделаете через правило в поле сущности?

                  Стоит разобраться, и преобразовать набор требований в бизнес модель, и управлять ими на уровне модели, а не на уровне DTO и контроллеров.

                  Это работа программиста, инженера.
                  Иначе вы увязаете в наборе сомнительного качества скриптов, что, зачастую, и происходит, когда ни бизнес, ни программисты уже не понимают что происходит и почему это так.

                  Как вы это сделаете через правило в поле сущности?

                  Если их действительно невозможно рассмотреть как отдельные сущности (то есть мыимеем дело с инвариантами), то поступлю следующим образом.

                  Увижу, что мы имеем дело с ситуацией, когда у нас в зависимости от контекста меняются правила валидации объекта.
                  Введу понятие контекста валидации.

                  Сделаю сущность, в которой все поля не обязательны, повешу constraint'ы, которые будут содержать правила для каждого сценария (какие поля должны быть null, какие нет), и буду по необходимости руками дергать валидатор, передавая ему нужный контекст.

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

                  В сравнении с вашим решением получу отсутствие дублирования кода, один источник правды, порядок вместо хаоса, легкость и удобство внесения изменений, тестирования, дебага.

                  Отсутствие нестандартных решений ("а вот у нас в проекте в объектах для передачи данных... валидация!") упростит поддержку и ввод новых сотрудников в проект.

                  Возможно, даже помогу бизнесу понять че они делают, если требования хаотичны, а они сами формализовать не в состоянии.

                  Тогда модель превращается в God-object, в котором есть всё.
                  Вот DTO для входных данных это и есть такой value object.

                  А ваша DTO не превращается? :)

                  В этом и ошибка, потому что это уже и не DTO (потому что наделен доп. функцональностью), и ещё не valueObject (потому что не самостоятелен), при этом собирает все негативные черты дублирования кода (копируем правила валидации из одного в другой).


                  1. michael_v89
                    05.01.2024 09:58

                    Стоит разобраться, и преобразовать набор требований в бизнес модель

                    Ну вот из-за этого и начинаются проблемы в поддержке, когда бизнес-требования выражены в коде не явно, а преобразованы неизвестно во что.
                    Бизнес-требования я уже описал, на вопрос вы не ответили.

                    Иначе вы увязаете в наборе сомнительного качества скриптов

                    Нет, я проверял, работает нормально.

                    А ваша DTO не превращается? :)

                    Конечно нет, я не делаю 1 DTO на модель, у меня разные DTO на разные сценарии. Отдельная форма в интерфейсе - отдельное DTO с правилами ее валидации. Бизнес решил убрать какой-то сценарий - удаляем DTO и код, который с ним работает.

                    копируем правила валидации из одного в другой

                    Я уже несколько раз написал, что ничего не копируется. Если у вас большинство DTO пересекаются на 90%, это ошибка проектирования. Бизнесу куча похожих форм в интерфейсе тоже не нужна, он сам в них будет путаться. Если же он требует, значит так и надо сделать. Указание #[Assert\Email] в 2 местах это не копирование, а аналог вызова функции.

                    Тут кстати непонятно, то есть вы предлагаете сначала в бизнес-сущность загрузить некорректные данные, то есть перевести ее в невалидное состояние, а потом ее валидировать? С моей точки зрения такое допускать вообще нельзя. Валидация делается не для полей модели, а для входных данных. Они могут вообще не сохраняться ни в какую модель, а влиять только на выполнение запроса (галочка "Отправить уведомление").


  1. Vic1989
    05.01.2024 09:58

    Логика в контроллере звучит очень гибко а главное тестируемо