ResultTransformer в Symfony проектах

d62b146118dbf39b176269b7d3c9cbd4.png

Symfony со стандартным набором «батареек» представляет из себя монструозное решение, способное реализовать если и не любые, то очень многие задачи бизнеса. Поделюсь подходом, о котором не каждый symfony-разработчик слышал, а если и слышал, то разве что вскользь и навряд ли использовал этот метод в разработке. Думаю, пришла пора пролить свет на эту темную сторону.

Всем привет, меня зовут Горелов Кирилл, я бэкенд-разработчик и я обожаю symfony-фреймворк. Поговорим про возможность doctrine, которая позволяет возвращать нам готовую DTO, избавляя программиста от ручного труда и делая всю работу за него. Описанный подход под названием ResultTransformer — это термин, который описывает процесс преобразования данных в DTO.

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

Стандартный подход создания DTO

Чтобы показать наглядную пользу использования ResultTransformer, возьмем для начала самую примитивную задачу. Где нам необходимо возвращать из репозитория, на примере UserRepository, массив или конкретную UserDto. Сама по себе наша DTO для примера будет представлять собой набор обычных параметров: id, name, email.

readonly class UserDTO
{
   public function __construct(
       private int $id,
       private string $name,
       private string $email,
   ) {
   }

   //getters
}

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

При императивном подходе все делает сам, лапками ручками.

class UserRepository {
    public function findAllUsersAsDTO(): array 
    {
        $users = $this->findAll();
        $userDTOs = [];
        
        foreach ($users as $user) {
            $userDTO = new UserDTO($user->getId(), $user->getName());
            $userDTOs[] = $userDTO;
        }
        
        return $userDTOs;
    }
}

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

class UserRepository {
   public function findAllUsersAsDTO(): array 
   {
       return array_map(fn(User $user) => new UserDTO($user->getId(), $user->getName()), $this->findAll());
   }
}

Оба варианта имеют права на существование. Несмотря на декларативный подход, он обладает не меньшей гибкостью и возможностями при разработке.

Создание DTO с помощью ResultTransformer

Теперь сделаем то же самое, но с применением ResultTransformer:

class UserRepository {
   public function findAllUsersAsDTO(): array
   {
       $query = $this->getEntityManager()->createQuery(
           'SELECT NEW App\DTO\User\UserDTO(u.id, u.name, u.email)
               FROM App\Entity\User u
       ));

       $users = $query->getResult();

       return $users;
   }
}

NEW App\DTO\User\UserDTO (u.id, u.name, u.email) говорит doctrine, что нужно создать новый объект UserDTO, передав в него параметры. Doctrine выполнит всю нужную работу самостоятельно и вернет нам массив наших DTOшек. В данном случае doctrine вернет нам уже не коллекцию User, а массив UserDTO.

[
   {
       "id": "1",
       "name": "user 1,
       "email": "user@test.ru"
   },
   {
       "id": "2",
       "name": "user 2",
       "email": "user2@test.ru"
   }
]

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

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

Нюансы использования ResultTransformer

Внимательный читатель заметит, что использование нативных запросов в doctrine — не лучшее решение.Так стирается грань между выгодой использования ResultTransformer и применением обычных методов генерации DTO, к которым мы все привыкли. Выходит, что мы сделали внедрение, чтобы его сделать, а не принести пользу разработке. Давайте избавимся от нативных запросов.

Для этого создадим класс и назовем его DtoResultTransformer.

class DtoResultTransformer
{
   public function transform(array $results, string $dtoClass): array
   {
       return array_map(function ($row) use ($dtoClass) {
           return new $dtoClass(...array_values($row));
       }, $results);
   }
}

Дальше подключим его в UserRepository, а благодаря PHP8 мы можем сразу в конструктор передать его как параметр.

public function __construct(
   private readonly DtoResultTransformer $dtoResultTransformer
) {
}

И теперь мы можем избавиться от нативных запросов, применяя обычный queryBuilder.

class UserRepository {
    public function getUserDTO(): array
    {
        $query = $this->createQueryBuilder('u')
            ->select('u.id, u.name, u.email')
            ->getQuery()
            ->getArrayResult();
 
        return $this->dtoResultTransformer->transform($query, UserDTO::class);
    }
 }
 

Но нам в любом случае нужно будет указать в методе select, что возвращать. Чтобы doctrine понимала, какие данные ей необходимы для передачи в конструктор нашей UserDTO. Так как нам в любом случае нужно передавать параметры, это не критично.

Если вы в душе исследователь, можно не указывать вручную, какие параметры передавать, а воспользоваться ReflectionApi PHP. Для этого изменим наш класс DtoResultTransformer.

class DtoResultTransformer
{
   public function transform(array $results, string $dtoClass): array
   {
       return array_map(function ($row) use ($dtoClass) {
           $flatRow = $this->flattenArray($row);
           return new $dtoClass(...array_values($flatRow));
       }, $results);
   }
  
   public function getRequiredFields(string $dtoClass): array
   {
       $reflection = new ReflectionClass($dtoClass);
       $constructor = $reflection->getConstructor();
       $parameters = $constructor ? $constructor->getParameters() : [];

       return array_map(fn($param) => $param->getName(), $parameters);
   }

   private function flattenArray(array $data): array
   {
       $flat = [];
       array_walk_recursive($data, function($value, $key) use (&$flat) {
           $flat[$key] = $value;
       });
       return $flat;
   }
}


Теперь можем автоматически указывать нужные нам параметры без ручного вмешательства.

public function getUser(): array
   {
       $fields = $this->dtoResultTransformer->getRequiredFields(UserDTO::class);

       $queryBuilder = $this->createQueryBuilder('u');
       foreach ($fields as $field) {
           $queryBuilder->addSelect("u.$field");
       }

       $query = $queryBuilder->getQuery()->getArrayResult();

       return $this->dtoResultTransformer->transform($query, UserDTO::class);
   }

У нас появился новый метод, который берет параметры из нашей UserDTO и добавляет их в select QueryBuilder. Это даже добавляет чуть больше свободы, потому что теперь мы указываем необходимые параметры только в одном месте и, если потребуется обогатить данными, мы просто добавим еще один параметр в DTO.

Должен вас разочаровать и предупредить: использование такого варианта с применением ReflectionApi — не совсем прозрачное решение, и я бы рекомендовал его только в редких случаях. Тут моя задача — показать возможность, что мы можем воспользоваться средствами PHP и уменьшить ручной труд. 

Использование ResultTransformer в узких задачах 

Давайте вернемся к отправной точке. Никого не покидает ощущения, что пока пользы от такого подхода не видно, и к тому же внедрили новую зависимость в UserRepository, которая по факту делает то же самое, только с использованием array_map?

Все верно, так и есть. К тому же для большинства задач, таких как передача данных между слоями приложения, можно обойтись целыми моделями. Это дополнительно добавляет гибкости во время разработки, да и в целом генерировать DTO внутри репозиториев — сомнительное решение, ведь для этого у нас есть специальные сервисы. А для простых кейсов мы можем вообще не использовать DTO, а внедрить группы сериализации в наши модели данных, как, например, в нашей User. 

class User implements UserInterface
{
   #[Groups([
       'User.all',
       'User.id',
   ])]
   #[ORM\Id]
   #[ORM\GeneratedValue]
   #[ORM\Column]
   private ?int $id = null;

   #[Groups([
       'User.all',
       'User.email',
   ])]
   #[ORM\Column(length: 180)]
   private ?string $email = null;

   #[Groups([
       'User.all',
       'User.name',
   ])]
   #[ORM\Column(length: 255, unique: true)]
   private string $name;
}

Теперь в контроллерах и атрибутах свагера мы можем передать параметры конкретной группы сериализации.

return $this->json(
    data: $this->userRepository->getUser(),
    status: Response::HTTP_OK,
    context: [
        'groups' => [
            ‘User.id,
            ‘User.name',
            ‘User.email,
            ‘User.all’, // или только один параметр передать
        ],
    ]
 );
 
 

Чтобы увидеть пользу ResultTransformer, давайте попробуем поменять нашу первоначальную задачу. Предположим, нам нужно не просто вернуть модель User с некоторыми параметрами, а вывести статистику или агрегировать данные по конкретному пользователю. Думаю вы уже догадались, что вернуть агрегированные данные удобнее сразу в DTO, чем получить данные и создавать вручную на их основе DTO или, что еще хуже, «готовить» эти данные отдельно перед тем, как передать их….

Поэтому представим, что у нас есть еще модель Order, которая хранит заказы пользователя, и мы хотим вернуть следующую DTO. 

class UserActivityStatsDTO {

    public function __construct(
        private int $userId,
        private int $userName,
        private int $totalOrders,
        private int $totalSpent,
        private int $lastOrderDate
    ) {
    }
 
    //getters
 }

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

class UserRepository {
   public function getUserActivityStatistics(): array
   {
       $entityManager = $this->getEntityManager();
      
       $query = $entityManager->createQuery(
           'SELECT
               u.id AS userId,
               u.name AS userName,
               COUNT(o.id) AS totalOrders,
               SUM(o.amount) AS totalSpent,
               MAX(o.orderDate) AS lastOrderDate
            FROM App\Entity\Order o
            JOIN o.user u
            GROUP BY u.id'
       );

       $results = $query->getArrayResult();
      
       $userActivityStatsDTOs = [];
       foreach ($results as $result) {
           $userActivityStatsDTOs[] = new UserActivityStatsDTO(
               $result['userId'],
               $result['userName'],
               $result['totalOrders'],
               $result['totalSpent'],
               new DateTime($result['lastOrderDate'])
           );
       }

       return $userActivityStatsDTOs;
   }
}

Теперь можем воспользоваться нашим DtoResultTransformer и использовать всю прелесть этого подхода. 

public function getUserActivityStatistics(): array
   {
       $query = $this->getEntityManager->createQueryBuilder(
           'SELECT NEW App\DTO\User\UserActivityStatsDTO(
               u.id,
               u.name,
               COUNT(o.id),
               SUM(o.amount),
               MAX(o.orderDate)
           )
           FROM App\Entity\Order o
           JOIN o.user u
           GROUP BY u.id'
       );

       $results = $query->getResult();
   }

Мы избавились от ручного создания DTO, у нас нет зависимостей, использование нативных запросов для select — не что-то ужасное, наш код читаемый и легкий для понимания, его легко тестировать, и он не доставит неприятностей при анализе.

Подводя итог

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

НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

© Habrahabr.ru