Comments 44
Может не надо?
шаг 1: делаем фронт который передает на бэк объект (в виде json-строки)
шаг 2: берем эту json-строку и вставляем ее сюда JSON 2 DTO, заполняем необходимые поля, ставим галочки и вуаля перед нами готовый класс
шаг 3: на бэке ловим от фронта json-строку делаем ей json_decode и вставляем в конструктор DTO, по моему изи, плюс у spatie/data-transfer-object есть множество встроенных фишек
Я изначально старался сделать удобнее работу внутренних сервисов, а не получение данных из запроса. Допустим у вас есть бизнесовый сервис, который должен сгенерировать заявление на увольнение. В аргументы ему необходимо передать ФИО работника, должность и дату. В данном случае я могу легко привести любой набор данных, как передать это из запроса:
$dto = ClassTransformer::transform(DismissedEmployee::class, $request);
$user = $this->userService->dismiss($dto);
так и сразу сущность из базы:
$dto = ClassTransformer::transform(DismissedEmployee::class, User::find(1));
$user = $this->userService->dismiss($dto);
И дополнительно повлияло то, что среди окружения не было тех, кто бы применял такой подход, и следовательно кто бы знал о подобных решениях.
Так что пока не вижу ничего плохого в еще одном решение. Буду стараться развивать пакет, и может быть у меня появятся какие-нибудь свои фичи:)
github.com/Falseclock/dbd-php-entity
до документации руки не дошли, но все очевидно в тестах
Но ведь тут совсем другой кейс.
Мой пакет только преобразует набор данных к нужному DTO классу.
Все тоже самое. Создаете класс, делаете маппинг, скармливаете данные и получаете объект.
> При такой реализации я не вижу особо профита использования, ведь я так же могу сам прописать 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 мы фактически всегда работаем с конкретным классом.
Я не особо вижу профита в промежуточном объекте вашего 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 была дополнительная логика, например по маппингу, что в принципе не надо, но сложной логики по факту там нет.
Но в целом, исходя даже из названия, DTO используется совсем для других целей.
Если в методе transform вы ручками переписываете все, что прилетело в Request — то где же профит?
Вы переписываете только в случае если наименования параметром из запроса отличается он наименований в DTO. В большинстве же случаев наименования все таки идентичные. Что в запросе firstName, что в сервисе по созданию firstName.
А разве DTO должен реализовывать валидацию?
Если вы используете типизированный подход с declare(strict_types=1), то тогда это сделает пыха, и не даст вам присвоить не верный тип.
И ни что не мешает его поставить в любой проект «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;
}
Например:
{"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)
и не пишете в каждом методе десериализацию.резолверы тоже сервисы в 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');
}
Но, впрочем, проблемы вы так и не решили. У вас на каждый аргумент будет вызываться парсинг всего входящего документа
В 5.3.0 эта проблема исправлена
Можно сохранять результаты парсинга в аттрибуты реквеста, а в декораторе к 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); }
Рекомендую юзать symfony serializer
Замени рефлексию на замыкания, получишь прибавку к перформансу, замыкания без контекста $this через `Closure::bind` делай будет еще быстрее, но явно модель прокидывать аргументом придется.
Вопрос в том, когда код начнёт становиться чище, пока видны только усложнения?
p.s. На работе имею дело с таким же кодом — реквест, дтошка, сервис и бросаем евент, возвращаем презентер. Но нет ощущения что это чистый код, вместо спагетти кода пришли к лазанья коду.
Это не какой-то новый слой, и он никак не зависит от реквеста. Допустим у вас есть бизнесовый сервис, который должен посчитать комиссию по кредиту. В аргументы ему необходимо передать процент годовых, сумма кредита, и период на который запрашивается сумма. Если бы вы использовали подход передачи аргументов массивов, то вам пришлось бы проверять наличие каждого ключа, также, даже если ключ и будет, он может быть любого типа, что тоже нужно проверить.
При описанном подходе вы создаете DTO:
class CalcDTO
{
public float $sum;
public float $percent;
public int $period;
}
Как мы видим, объект всегда будет содержать 3 параметра нужного нам типа. Вы не сможете инициализировать его с другим типов или не указать какое то поле. И тогда вы просто передаете эту 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? Это ведь конкретная модель, с которой мы потом продолжаем работать, не пойму.
А в качестве непосредственно движка сериализации могу предложить jms serializer. У него довольно большой оверхед, но при этом он умеет работать с вложенными объектами, коллекциями и с его помощью можно сделать множество интересных штук: например частичная сериализация объекта, реализация своих движков (по умолчанию работает с json и xml, мы делали еще и csv) и так далее. Лучше сразу смотреть в документацию — www.jmsyst.com/libs/serializer
Чистим пхпшный код с помощью DTO