commit 110161f033ee16af457ac5fa9aa2a83a3b18003b Author: aleksandr Date: Tue Jun 2 14:15:37 2026 +0500 Add DTO diff --git a/DTO.md b/DTO.md new file mode 100644 index 0000000..c6b1362 --- /dev/null +++ b/DTO.md @@ -0,0 +1,273 @@ +## Камрады, предлагаю оформлять объекты для передачи между сервисами в таком виде, писал от башки, улучшения принимаются + +`(я привык называть их payload хотя в терминах DDD это DTO DataTransferObject)` + +В них не должно быть логики, нужны только что бы переносить срез данных между сервисами + валидация и сериализации +из запросов (json -> payload) +```php +firstName = null; + +$partnerService->create($payload); +``` +Добавим валидацию +```php +firstName = 'N'; +$errors = $validator->validate($payload); // This value is not valid. (length) + +$payload = new PartnerPayload(); +$payload->firstName = ''; +$errors = $validator->validate($payload); // This value is not valid. (blank + length) + +$payload = new PartnerPayload(); +$payload->firstName = '1'; +$errors = $validator->validate($payload); // This value is not valid. (length + regexp) + +$payload = new PartnerPayload(); +$payload->firstName = 'Some'; +$errors = $validator->validate($payload); +if (!$errors->count()) { + $partnerService->create($payload); +} +``` + +При таком сценарии firstName всегда должен быть устанолен, если мы хотим сделать его +обязательным при создании, но опциональным при обвлении, выход либо создать 2 `dto` `Createpayload` и `UpdatePayload` +или обойтись группами при валидации +```php +firstName = null; +// Мы не указали группу, поле побежит по всем ограничениям +// Обычно если значение NULL они будут его игнорировать +$errors = $validator->validate($payload); // valid + +$payload = new PartnerPayload(); +$payload->firstName = ''; +$errors = $validator->validate($payload); // This value is too short. значение не NULL + +//@Assert\Regex - в данном случае считает валидными все null и '' значение что бы они были опциональны + +$payload = new PartnerPayload(); +$payload->firstName = ''; +$errors = $validator->validate($payload, null, ['partner-payload:create']); // This value should not be blank. + +$payload = new PartnerPayload(); +$payload->firstName = '1'; +$errors = $validator->validate($payload, ['partner-payload:create']); // Valid так как сработает только Not Blank по группе + +// хотим что бы все валидаторы попадали в create, а в update только Length и Regexp +class PartnerPayload +{ + /** + * @Assert\NotBlank(groups={"partner-payload:create"}) + * @Assert\Length(min="2", max="100", groups={"partner-payload:create", "partner-payload:update"}) + * @Assert\Regex("/^[A-Za-z.\-’‘'` ]+$/u", groups={"partner-payload:create", "partner-payload:update"}) + * @var ?string + */ + public $firstName; +} + +$payload = new PartnerPayload(); +$payload->firstName = '1'; +$errors = $validator->validate($payload, ['partner-payload:create']); // Error Length + Regexp + + +$payload = new PartnerPayload(); +$errors = $validator->validate($payload, ['partner-payload:update']); // Valid так как имя опционально + +$payload = new PartnerPayload(); +$payload->firstName = '1'; // установили имя +$errors = $validator->validate($payload, ['partner-payload:update']); // Error Length + Regexp +//@Assert\Regex - в данном случае считает валидными '' значение что бы они были опциональны, поэтому длина +//ограничивает '' такие значения, только null будут считатся валидными +``` + + +Окей, что если мы хотим обновить партнера, но только те поля, которые мы явно передаем +```php + +class PartnerService { + + public function update(Partner $partner, PartnerPayload $dto, array $updateMask) + { + foreach ($updateMask as $field) { + // $partner->$filed = $dto->$field; - можно, но опасно, если переименовать поля огребем по полной + } + + // явное присвоение, кода больше зато все типы чекаются + рефакторинг не убьет код + if (in_array('firstName', $updateMask))) { + $partner->setName($dto->firstName); + } + if (in_array('lastName', $updateMask))) { + $partner->setLastname($dto->lastName); + } + //... + + $repository->save($partner); + } +} + +$payload = new PartnerPayload(); +$payload->firstName = 'Cool Name'; +$errors = $validator->validate($payload, ['partner-payload:update']); + +$partner = $repository->find(9999); +$partnerService->update($partner, $payload, ['firstName', 'lastName']) // мы не установили lastName в dto он будет NULL, но указали его в маске, он установится в NULL + + +$payload = new PartnerPayload(); +$payload->firstName = 'Cool Name'; +$payload->lastName = 'Some Name'; +$errors = $validator->validate($payload, ['partner-payload:update']); + +$partner = $repository->find(9999); +$partnerService->update($partner, $payload, ['firstName']) // мы установили lastName в dto он будет NULL, но НЕ указали его в маске, он проигнорируется при апдейте + +``` + +Несколько замечения, обычно их поля делают публичными, и избегают геттеров и сеттеров +это справедливо и тут но в пыхах более старших версий, где можно атрибутам класса +назначить тип. В нашем случае геттеры и сеттеры могут быть полезны при +десериализации из json, дефолтный сериализатор их использует + +```php + class Payload { + /** + * @var float + */ + public $price; + + public function setPrice($price) + { + $this->price = floatval($price); + } + } + + $resquest = '{"price": "10.22"}' + + $data = $serializer->deserialize($resquest, Payload::class, 'json'); + // The type of the "price" attribute for class must be one of "float" + // Если убрать аннотацию типа, то сериализатор отработает + + $data = $s->deserialize('{"price": "10.22"}', Payload::class, 'json', [ + // отрубаем проверку типов при нормализации объекта + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true, + ]); + + // в данном случае отработает сеттре, если его не будет то запишется + // строка. Nут уже если требуется проверка поможет пост валидация(запустить валидатор после десериализации) + // или делать поле приватным от сделать геттер, что бы строго ограничить типы и словить эксепшен + + class Payload { + /** + * @var float + */ + private $price; + + // setter ... + + public function getPrice(): float + { + return $this->price; + } +} + +``` + +Второй момент, почему я настаиваю на тестах. Они нужны не столько для вас, сколько для остальных, если они +заходят что-то поправить или работать с вашим сервисом, и вместо разбора КАК, разраб +может просто посмотреть на тест что бы увидеть как ему сформировать входные данные и что +ожидается получить. Накидаю простой тест + +```php +firstName = '1'; + + $v = static::$container->get(ValidatorInterface::class); + $errors = $v->validate($payload, null, ['partner-payload:update']); + + $this->assertCount(2, $errors); // ожидаем 2 ошибки, не верный регэксп и длинна + $this->assertEqual('This value too short', $errors->get(0)->getMessage()); // текст ошибки соотвествует + } + +} +``` +> `KernelTestCase` - это то, что называют функциональными тестами, в них доступен DI контейнер с сервисами и прогружается ядро симфони + +> `TestCase` - это обычные юнит тесты, тестируют сервис, но там все надо руками, если есть куча зависимостей лучше верхний варинт + +> `WebTestCase` - тут доступен http клиент, делать запросы как из браузера, чекать роуты и выхлоп роутов (например чекнуть что все роуты наши выдают валидный json) + +**p.s** почему не использовать обычный json, потому что никто кроме разраба, что писал этот код +не иммет представления что там должно быть в json, как именуются поля и приходится +лезть в код и разбираться в чужом. \ No newline at end of file