Add DTO

2026-06-02 14:15:37 +05:00
commit 110161f033

273
DTO.md Normal file

@@ -0,0 +1,273 @@
## Камрады, предлагаю оформлять объекты для передачи между сервисами в таком виде, писал от башки, улучшения принимаются
`(я привык называть их payload хотя в терминах DDD это DTO DataTransferObject)`
В них не должно быть логики, нужны только что бы переносить срез данных между сервисами + валидация и сериализации
из запросов (json -> payload)
```php
<?php
namespace App\Service\Partner\Entity;
use App\Entity\Main\Constant;
use DateTimeInterface;
use Symfony\Component\Validator\Constraints as Assert;
class PartnerPayload
{
/**
* @var ?string
*/
public $firstName;
}
$payload = new PartnerPayload();
$payload->firstName = null;
$partnerService->create($payload);
```
Добавим валидацию
```php
<?php
namespace App\Service\Partner\Entity;
use App\Entity\Main\Constant;
use DateTimeInterface;
use Symfony\Component\Validator\Constraints as Assert;
class PartnerPayload
{
/**
* @Assert\NotBlank()
* @Assert\Length(min="2", max="100")
* @Assert\Regex("/^[A-Za-z.\-'` ]+$/u")
* @var string
*/
public $firstName;
}
$payload = new PartnerPayload();
$payload->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
<?php
namespace App\Service\Partner\Entity;
use App\Entity\Main\Constant;
use DateTimeInterface;
use Symfony\Component\Validator\Constraints as Assert;
class PartnerPayload
{
/**
* @Assert\NotBlank(groups={"partner-payload:create"})
* @Assert\Length(min="1", max="100")
* @Assert\Regex("/^[A-Za-z.\-'` ]+$/u")
* @var ?string
*/
public $firstName;
}
$payload = new PartnerPayload();
$payload->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
<?php
namespace App\Tests\Service\Integration\Close;
class CloseLeadPartnerServiceTest extends KernelTestCase
{
public function setUp(): void
{
static::bootKernel();
}
public function testPayload()
{
$payload = new PartnerPayload();
$payload->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, как именуются поля и приходится
лезть в код и разбираться в чужом.