Камрады, предлагаю оформлять объекты для передачи между сервисами в таком виде, писал от башки, улучшения принимаются
(я привык называть их payload хотя в терминах DDD это DTO DataTransferObject)
В них не должно быть логики, нужны только что бы переносить срез данных между сервисами + валидация и сериализации из запросов (json -> payload)
<?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
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
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 будут считатся валидными
Окей, что если мы хотим обновить партнера, но только те поля, которые мы явно передаем
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, дефолтный сериализатор их использует
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,
]);
// в данном случае отработает сеттер, если его не будет то запишется
// строка. Тут уже если требуется проверка поможет пост валидация(запустить валидатор после десериализации)
// или делать поле приватным от сделать геттер, что бы строго ограничить типы и словить эксепшен
class Payload {
/**
* @var float
*/
private $price;
// setter ...
public function getPrice(): float
{
return $this->price;
}
}
Второй момент, почему я настаиваю на тестах. Они нужны не столько для вас, сколько для остальных, если они заходят что-то поправить или работать с вашим сервисом, и вместо разбора КАК, разраб может просто посмотреть на тест что бы увидеть как ему сформировать входные данные и что ожидается получить. Накидаю простой тест
<?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, как именуются поля и приходится лезть в код и разбираться в чужом.