Add DTO
273
DTO.md
Normal file
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, как именуются поля и приходится
|
||||||
|
лезть в код и разбираться в чужом.
|
||||||
Reference in New Issue
Block a user