Pull to refresh

Comments 44

Spring был изобретен в середине нулевых. Вы только начали повторять его путь.
Может не надо?
Не вижу никаких проблем у spatie/data-transfer-object
шаг 1: делаем фронт который передает на бэк объект (в виде json-строки)
шаг 2: берем эту json-строку и вставляем ее сюда JSON 2 DTO, заполняем необходимые поля, ставим галочки и вуаля перед нами готовый класс
шаг 3: на бэке ловим от фронта json-строку делаем ей json_decode и вставляем в конструктор DTO, по моему изи, плюс у spatie/data-transfer-object есть множество встроенных фишек
Да, вы правы. Первая проблема была все-таки плохого ресерча, из-за которого не смог оценить все текущие решения:( Поэтому сначала запилил свое, а потом только наткнулся на решение от spatie.

Я изначально старался сделать удобнее работу внутренних сервисов, а не получение данных из запроса. Допустим у вас есть бизнесовый сервис, который должен сгенерировать заявление на увольнение. В аргументы ему необходимо передать ФИО работника, должность и дату. В данном случае я могу легко привести любой набор данных, как передать это из запроса:

$dto = ClassTransformer::transform(DismissedEmployee::class, $request);
$user = $this->userService->dismiss($dto);


так и сразу сущность из базы:
$dto = ClassTransformer::transform(DismissedEmployee::class, User::find(1));
$user = $this->userService->dismiss($dto);


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

Так что пока не вижу ничего плохого в еще одном решение. Буду стараться развивать пакет, и может быть у меня появятся какие-нибудь свои фичи:)
> Entities annotation for PHP Database driver

Но ведь тут совсем другой кейс.
Мой пакет только преобразует набор данных к нужному DTO классу.
мало того, можно описывать вложенные объекты друг в друга бесконечно. Писалось да, под драйвер, но в целом получилась универсальная штука, которой можно описывать любые структуры и их зависимости друг от друга. Очень часто использую для обработки именно JSON данных в объекты
Честно говоря проблема spatie/data-transfer-object не совсем ясна?

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

Суть DTO не только в заполнении, но и в том, что DTO должно передавать валидные данные между слоями. Инициализировать же объект с использованием конструктора передавая туда $request->valid() или через статичный метод transform промежуточного объекта трансформера — не играет особой разницы. В частых случаях, при работе с Request'ом во входящих параметрах контроллера, а контроллер должен быть последним слоем где используется Request объект — инициализацию DTO можно делегировать методу request'a объекта не забыв заранее создать интерфейс.
Пример:
class CreateUserRequest extend FormRequest implement GetterDTO
{
.....

    public function getDTO(): DataTransferObject
    {
          return new CreateUserDTO($this->valid());
    }
}


Что позволит сделать контроллер более простым:

class UserController extends Controller {
	public function __construct(
      private UserService $userService,
	) {}

	public function createUser(CreateUserRequest $request)
	{
      $user = $this->userService->create($request->getDTO());
      return new UserResources($user);
	}
}


Я не особо вижу профита в промежуточном объекте вашего ClassTransformer'a для DTO который в целом при такой реализации выступает своего рода фабрикой DTO, при этом не обязательно, чтобы объект был DTO ведь скормить ему можно все что угодно, при этом отсутствует валидация типов.

А зачем интерфейс? В случае с Request мы фактически всегда работаем с конкретным классом.

Можно без него, однако не вижу проблему все-таки добавить мелкий интерфейс. Бывали случаи, что дополнительно Request'ы приходилось проверять на наличие DTO метода, сделать условие по интерфейсу проще.
По поводу spatie был не прав, выше уже написал об этом.

Я не особо вижу профита в промежуточном объекте вашего ClassTransformer'a для DTO который в целом при такой реализации выступает своего рода фабрикой DTO, при этом не обязательно, чтобы объект был DTO ведь скормить ему можно все что угодно, при этом отсутствует валидация типов.


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

Валидация типов зависит от вас. Если вы укажите параметру $name тип string, и в dto укажите declare(strict_types=1); то при попытке присвоить имени какой нибудь объект пхп скажет хрен что так нельзя.
Валидация типов зависит от вас. Если вы укажите параметру $name тип string, и в dto укажите declare(strict_types=1);


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

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

Зачем нужна фабрика, если есть возможность пользоваться конструктором объекта инициализируя только объект DTO, без необходимости задействовать левый объект ClassTransformer?
Ведь у нас нет необходимости создавать разные DTO в один и тот же момент, слой приложения в которое передаете DTO ждет конкретное DTO, соответсвенно лучше создавать его сразу, минуя лишний слой ClassTransformer'a.

Я понимаю если бы в ClassTransformer была дополнительная логика, например по маппингу, что в принципе не надо, но сложной логики по факту там нет.
Если в методе transform вы ручками переписываете все, что прилетело в Request — то где же профит? Сам подход рабочий, но имеет смысл только если ваши DTO-сущности описываются декларативно, а валидация и импорт данных происходит автоматически.

Но в целом, исходя даже из названия, DTO используется совсем для других целей.
Если в методе transform вы ручками переписываете все, что прилетело в Request — то где же профит?

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

А разве DTO должен реализовывать валидацию?
Если вы используете типизированный подход с declare(strict_types=1), то тогда это сделает пыха, и не даст вам присвоить не верный тип.
В рамках Symfony я подобные вещи решал с помощью стандартного сериализатора.
И ни что не мешает его поставить в любой проект «composer require symfony/serializer».
/** @var Staff $data */
$data = $this->getSerializer()->deserialize(
    $request->getContent(),
    Staff::class,
    'json',
    []
);
$error = $this->getValidator()->validate($data);
if ($error->count()) {
   throw new ValidationException($error);
}


class Staff
{
    /**
     * @Assert\Valid
     * @var Person[]
     */
    protected array $person;
}


class Person implements PersonInterface
{
    /**
     * @Assert\NotBlank(message="Имя - обязательное поле")
     * @Assert\Length(
     *      min = 2,
     *      max = 100,
     *      minMessage = "Минимум 2 символа",
     *      maxMessage = "Максимум 100 символов",
     *      allowEmptyString = false
     * )
     * @Assert\Regex(
     *     pattern="/[a-zA-Z]+/i",
     *     match=false,
     *     message="Латиница запрещена для ввода"
     * )
     */
    protected string $firstName;
}
Вы про использование @ParamConverter?
@ParamConverter — это фишка SensioFrameworkExtraBundle, а так можно любой кастомный резолвер сделать.
А есть пример, кастомного резолвера для входящего json?
Например:
{"name": "test", "email": null}
class CustomValueResolver implements ArgumentValueResolverInterface
{
    private $serializer;
    private $validator;

    public function __construct(
        Serializer $serializer,
        ValidatorInterface $validator
    ) {
        $this->serializer = $serializer;
        $this->validator = $validator;
    }

    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        // определяете должен ли резолвится аргумент
    }

    public function resolve(Request $request, ArgumentMetadata $argument): Generator
    {
        $dto = $this->serializer->deserialize($request->getContent(), $argument->getType(), 'json');
        $this->validator->validate($dto);

        yield $dto;
    }
}


в метод контроллера передаете объект юзера
public function userAction(User $user)
и не пишете в каждом методе десериализацию.
value resolver — это штука годная только для контроллеров. она хоть и мощная, но ограниченная в возможностях. тот же json ей толком не попарсишь. Сериалайзер\формы — это более универсальный инструмент, но работающий уже после роутинга (что тоже ограничивает их возможности)
не очень понял, что мешает json парсить?
резолверы тоже сервисы в symfony, можно любой сериалайзер и валидатор прокинуть

Чаще всего то, что каждый вызов резолвера независим. И нужно либо делать предварительный парсинг (что в целом сводит на нет всю полезность резолвера, т.к.в этот же момент можно и в реквест заинжектить все что нужно), либо парсить в каждом резолвере (что как бы зачем).

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

Чаще всего то, что каждый вызов резолвера независим. И нужно либо делать предварительный парсинг (что в целом сводит на нет всю полезность резолвера, т.к.в этот же момент можно и в реквест заинжектить все что нужно), либо парсить в каждом резолвере (что как бы зачем).

А можете какой-то кейс привести? Проблематику понял, но пока что ощущение, что пытаетесь заставить резолвер делать то, для чего он не предназначен.

По поводу типизированных массивов да, согласен. Но тут проблема за рамки резолверов выходит.
А можете какой-то кейс привести


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

{"user": {"name": "test", "email": null}, "fruit": {"type": "apple", "color": "red"}}


И сигнатура стала

public function userAction(User $user, Fruit $fruit): void


Т.е. у вас есть прямое сопоставление полей в объекте запроса на параметры контроллера. В этом случае вам надо будет где-то организовать парсинг отдельно от резолвера, ну либо парсить его дважды (трижды, и тд, от числа аргументов), а там могут быть большие документы

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

public function userAction(UserActionRequestDto $request): void


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

Можно сделать через соглашение. Сначала искать по имени аргумента


$json = \json_decode($request->getContent(), true);
if (array_key_exists($argument->getName(), $json)) {
    $dto = $this->denormalizer->denormalize($json[$argument->getName()], $argument->getType(), 'json');
} else {
    $dto = $this->serializer->deserialize($json, $argument->getType(), 'json');
}

Можно воспользоваться благами php8


public function userAction(
    #[FromKey('user')] User $user,
    #[FromKey('fruit')] Fruit $fruit
)

$attribute = $argument->getAttribute();
if ($attribute instanceof FromKey) {
    $json = \json_decode($request->getContent(), true);
    $model = $this->denormalizer->denormalize($json[$attribute->key], $type, 'json');
} else {
    $model = $this->serializer->deserialize($request->getContent(), $type, 'json');
}
Мне еще не довелось близко поработать с этими атрибутами, но что если атрибут уже занят? Судя по коду он может быть только один и там может быть какой-то другой атрибут уже.

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


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


    if ($request->attributes->has('_parsed_json')) {
    $json = $request->attributes->get('_parsed_json');
    } else {
    $json = json_decode($request->getContent(), true);
    $request->attributes->set('_parsed_json', $json);
    }

В 5.3.0 эта проблема исправлена


О, вот это уже интересно, спасибо!
Какие-то проблемы с неймингами классов:) ClassTransformer — это фабрика и лучше передавать в нее Request, чем скалярный тип.
Лови еще аналог — github.com/alexpts/php-data-transformer2

Замени рефлексию на замыкания, получишь прибавку к перформансу, замыкания без контекста $this через `Closure::bind` делай будет еще быстрее, но явно модель прокидывать аргументом придется.
Вы создали лишний слой, который зависит от Request и полей в нём, использует строковые названия полей дублируя Request. Этот слой прокидывается в сервис, добавляет лишнюю зависимость и увеличивает связанность кода, усложняя тестирование. И это простой пример без лишних условий. без файлов, идентификатора пользователя, результатов работы других сервисов…

Вопрос в том, когда код начнёт становиться чище, пока видны только усложнения?
p.s. На работе имею дело с таким же кодом — реквест, дтошка, сервис и бросаем евент, возвращаем презентер. Но нет ощущения что это чистый код, вместо спагетти кода пришли к лазанья коду.
Видимо пример с использованием реквестов многих сбил с основного назначения пакета. Ну это моя первая статья, учту на будущее.

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

class CalcDTO
{
    public float $sum;
    public float $percent;
    public int $period;
}


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

Отвечая на вопрос в чем же чистота:
— Сервис становится типизированным;
— Сервис работает с валидными данными которые приходят к нему в DTO;
— Нам не нужно проверять наличие ключей;
— Исключается возможность передачи неверного типа;

Просто не всегда же все зависит от реквеста:) Поэтому тут скорее реквест требует данные которые необходимы для сервиса, а мой пакет поможет легко преобразовать запрос в нужную DTO.
DTO не для проверки наличия переменных и их типов.
Я могу просто объявить метод с переменными и код будет чище и понятнее.
function calculate(float $sum, float $percent, int $period) {}

Из-за ублюдочности недоделанной системы типизации php назад можно вернуть dto, если возвращаемых параметров больше одного. Изначально DTO и придумали для таких костылей в Java.
DTO не для проверки наличия переменных и их типов.


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

Я могу просто объявить метод с переменными и код будет чище и понятнее.
function calculate(float $sum, float $percent, int $period) {}


Согласен, с таким примером можно и так указать. Не нужно усложнять простые методы. Пример привел просто для наглядности. Также в начале статьи я специально написал, что речь идет про код, где если у метода больше 2-3 входных аргументов многие делают (array args). Т.е. подразумевая что такое решение с массивом плохое. Так что простые методы где аргументы передаются по порядку (float $sum, float $percent, int $period) я не ставил под сомнение:)

Если рассмотреть создание какого-нибудь пользователя. У нас может быть фамилия, имя, отчество, дата рождения, почта, пароль, номер телефона.
Вы же не будете все 7 аргументов указывать во входных атрибутах?
И опять же у сущности может быть их десятки. И вот в таком кейсе хорошо использовать подход с DTO.

Из-за ублюдочности недоделанной системы типизации php назад можно вернуть dto, если возвращаемых параметров больше одного


Можно уточнить, а в чем все-таки «ублюдочность» типизации? И что плохого в том, что назад мы можем получить тоже DTO? Это ведь конкретная модель, с которой мы потом продолжаем работать, не пойму.
В Symfony такое, как правильно заметили выше, удобно делать через ParamConverter, типизируя в контроллере аргументы нужными Dto и регистрируя в системе ParamConverter, обрабатывающий нужный тип данных.
А в качестве непосредственно движка сериализации могу предложить jms serializer. У него довольно большой оверхед, но при этом он умеет работать с вложенными объектами, коллекциями и с его помощью можно сделать множество интересных штук: например частичная сериализация объекта, реализация своих движков (по умолчанию работает с json и xml, мы делали еще и csv) и так далее. Лучше сразу смотреть в документацию — www.jmsyst.com/libs/serializer
Кому-то нравится что в jms легко некоторые штуки делать через аннотации. Например, можно задать формат даты при сериализации, в симфонийном сериалайзере такого простого способа из коробки нет.
Sign up to leave a comment.

Articles