Продвинутые Value Objects в PHP 8

Источник: «Advanced Value Objects in PHP 8»
В предыдущей статье мы рассмотрели возможности Value Objects в повышении качества кода, надёжности системы и минимизации необходимости обширной валидации. Теперь давайте погрузимся глубже, чтобы улучшить понимание и использование этого важнейшего инструмента.

Различные виды Value Object

При работе с Value Objects полезно классифицировать их на различные типы в зависимости от степени сложности. По своему опыту я выделил три основных типа:

Может быть, и четвёртый, но по сути это будет смесь этих трёх типов.

Простой Value Object

Простой Value Objects содержат одно значение, часто представляющее примитивное значение или базовую концепцию в вашем домене. Эти объекты идеально подходят для простых атрибутов или измерений.

В качестве примера возьмём Value Object Age, представленный в предыдущей статье:

readonly final class Age
{
public function __construct(public int $value)
{
$this->validate();
}

public function validate(): void
{
($this->value >= 18)
or throw InvalidAge::adultRequired($this->value);

($this->value <= 120)
or throw InvalidAge::matusalem($this->value);
}

public function __toString(): string
{
return (string)$this->value;
}

public function equals(Age $age): bool
{
return $age->value === $this->value;
}
}

В этом примере Age — это Простой Value Object, представляющий возраст человека. Он содержит одно целочисленное значение и включает механизм валидации, чтобы убедиться, что возраст находится в разумном диапазоне.

Метод __toString позволяет легко преобразовать его в строку, а метод equals сравнивает два объекта Age на предмет равенства.

При создании объектов Простого Value Objects учитывайте следующие рекомендации:

Соблюдая эти рекомендации, вы создадите Простые Value Objects, повышающие ясность, стабильность и надёжность вашего кода.

Комплексный Value Object

В то время как Простые Value Objects содержат одно значение, Комплексные Value Objects работают с более сложными структурами или несколькими атрибутами, формируя более богатое представление в вашем домене. Эти объекты хорошо подходят для моделирования сложных концепций или совокупностей данных.

Рассмотрим Coordinates Value Object:

readonly final class Coordinates
{
public function __construct(
public float $latitude,
public float $longitude
)
{
$this->validate();
}

private function validate(): void
{
($this->latitude >= -90 && $this->latitude <= 90)
or throw InvalidCoordinates::invalidLatitude($this->latitude)
($this->longitude >= -180 && $this->longitude <= 180)
or throw InvalidCoordinates::invalidLongitude($this->longitude);
}

public function __toString(): string
{
return "Latitude: {$this->latitude}, Longitude: {$this->longitude}";
}

public function equals(Coordinates $coordinates): bool
{
return $coordinates->latitude === $this->latitude
&& $coordinates->longitude === $this->longitude;
}
}

В этом примере объект Coordinates Value Object представляет географические координаты с широтой и долготой. Конструктор обеспечивает валидность объекта, проверяя, что широта находится в диапазоне [-90, 90], а долгота — в диапазоне [-180, 180]. Метод __toString предоставляет читаемое строковое представление, а метод equals сравнивает два объекта Coordinates на предмет равенства.

При создании Комплексных Value Objects учитывайте следующие рекомендации:

Такой подход позволяет создавать Комплексные Value Objects, эффективно представляющие сложные концепции в вашем приложении, такие как географические координаты в данном случае.

Хотя в данном примере это не очевидно, Комплексные Value Objects часто требуют более детальных проверок. Речь идёт не только об отдельных значениях, но и о том, как они взаимосвязаны друг с другом.

Рассмотрим следующий пример:

readonly final class PriceRange
{
public function __construct(
public int $priceFrom,
public int $priceTo
) {
$this->validate();
}

private function validate(): void
{
($this->priceTo >= 0)
or throw InvalidPriceRange::positivePriceTo($this->priceTo);

($this->priceFrom >= 0)
or throw InvalidPriceRange::positivePriceFrom($this->priceFrom);

($this->priceTo >= $this->priceFrom)
or throw InvalidPriceRange::endBeforeStart($this->priceFrom, $this->priceTo);
}

// ...(остальные методы)
}

В этом случае даже если каждая цена хороша сама по себе, необходимо убедиться, что priceTo должна идти после priceFrom или совпадать с ней.

Составной Value Object

Составные Value Objects — это мощные структуры, объединяющие несколько Простых или Комплексных Value Objects в единое целое, представляющее более сложные концепции в вашем домене. Это позволяет создавать богатые и содержательные абстракции.

Давайте проиллюстрируем это на примере объекта Address Value Object:

readonly final class Address
{
public function __construct(
public Street $street,
public City $city,
public PostalCode $postalCode
) {}

public function __toString(): string
{
return "{$this->street}, {$this->city}, {$this->postalCode}";
}

public function equals(Address $address): bool
{
return $address->street->equals($this->street)
&& $address->city->equals($this->city)
&& $address->postalCode->equals($this->postalCode);
}
}

В этом примере Address Составной Value Object составлен из Street, City и PostalCode. Каждый подобъект содержит одно значение, а вместе они образуют более полное представление адреса.

Метод __toString позволяет получить читаемое строковое представление, а метод equals сравнивает два объекта Address на предмет равенства.

При создании Составных Value Objects учитывайте следующие рекомендации:

Во многих случаях валидация Составного Value Object не требуется, поскольку его валидность уже обеспечивается компонентами. Однако, как и в случае с Комплексным Value Object, возможны сценарии, когда логика требует валидацию различных свойств объекта. В таких случаях валидация, конечно, необходима.

Методы фабрики и Приватные конструкторы

В примерах, представленных до сих пор, мы изучали относительно простые объекты значений. Однако при повседневной разработке возникают сложности, когда объекты значений имеют внутренние представления, отличающиеся от внешних.

В качестве иллюстрации этой проблемы рассмотрим концепцию DateTime. Дата "24 декабря 2023, 4:09:53 PM, часовой пояс Рим" может быть представлена различными способами, например, в виде секунд с 1 января 1970 года или в виде строки RFC3339.

В отличие от таких языков, как Java или C#, в PHP отсутствует перезагрузка конструкторов. Здесь шаблон проектирования фабричных методов, использующий один или несколько статических методов, становится бесценным для контролируемого инстанцирования объектов.

Давайте рассмотрим этот объект значения подробнее:

class DateTimeValueObject
{
private DateTimeImmutable $dateTime;

private function __construct(DateTimeImmutable $dateTime)
{
$this->dateTime = $dateTime;
}

// Метод фабрики для создания из timestamp
public static function createFromTimestamp(int $timestamp): self
{
($timestamp >= 0) or InvalidDateTime::invalidTimestamp($timestamp);

$dateTime = new DateTimeImmutable();
$dateTime = $dateTime->setTimestamp($timestamp);

return new self($dateTime);
}

// Метод фабрики для создания из строки RFC3339
public static function createFromRFC3339(string $dateTimeString): self
{
$dateTime = DateTimeImmutable::createFromFormat(DateTime::RFC3339, $dateTimeString);

($dateTime !== false) or throw new InvalidDateTime::invalidRFC3339String($dateTimeString);

return new self($dateTime);
}

public static function createFromParts(int $year, int $month, int $day, int $hour, int $minute, int $second, string $timezone): self
{
(checkdate($month, $day, $year) && self::isValidTime($hour, $minute, $second)) or throw InvalidDateTime::invalidDateParts($year, $month, $day, $hour, $minute, $second, $timezone);

$dateTime = new DateTimeImmutable();

$dateTime = $dateTime
->setDate($year, $month, $day)
->setTime($hour, $minute, $second)
->setTimezone(new DateTimeZone($timezone));

return new self($dateTime);
}

private static function isValidTime(int $hour, int $minute, int $second): bool
{
return ($hour >= 0 && $hour <= 23) && ($minute >= 0 && $minute <= 59) && ($second >= 0 && $second <= 59);
}

public static function now(): self
{
return new self(new DateTimeImmutable());
}

public function getDateTime(): DateTimeImmutable
{
return $this->dateTime;
}

// Методы __toString и equals
}

// Примеры использования
$dateTime1 = DateTimeValueObject::createFromTimestamp(1703430593);
$dateTime2 = DateTimeValueObject::createFromRFC3339('2023-12-24T16:09:53+01:00');
$dateTime3 = DateTimeValueObject::createFromParts(2023, 12, 24, 16, 9, 53, 'Europe/Rome');
$dateTime4 = DateTimeValueObject::now();

Давайте сосредоточимся на некоторых деталях:

Такой подход подчёркивает универсальность использования приватных или публичных конструкторов в зависимости от дизайнерских предпочтений, укрепляя идею, что шаблоны проектирования учитывают различные потребности и возможности выбора.

Ещё одно интересное применение метода фабрики — упрощение инстанцирования Составного Value Object, такого как Address Value Object, рассмотренного ранее.

readonly final class Address
{
private function __construct(
public Street $street,
public City $city,
public PostalCode $postalCode
) {}

public static function create(
string $street,
string $city,
string $postalCode
): Address
{
return new Address(
new Street($street),
new City($city),
new PostalCode($postalCode)
);
}

// ... (остальные методы)
}

Как уже говорилось, приватный конструктор поддерживает порядок, заставляя разработчиков использовать только create для получения нового экземпляра Value Object.

Кроме того, в PHP 8 мы можем использовать очень крутой трюк:

$data = [
'street' => 'Via del Colosseo, 10',
'city' => 'Rome',
'postalCode' => '12345'
];

$address = Address::create(...$data);

Этот лаконичный трюк в PHP 8 использует именованные аргументы и оператор массива spread, демонстрируя лаконичный и выразительный метод инстанцирования объектов.

Альтернативы исключениям

Некоторые люди могут с сомнением относиться к использованию исключений, поскольку они прерывают поток выполнения и, если их не обрабатывать, могут вызвать проблемы.

Однако существуют альтернативные, более функциональные подходы, которые могут прийти на помощь.

Either

Для тех, кто не знаком с понятием Either, можно кратко (и не очень удачно) описать его как тип, который может быть либо правым значением, либо нет (а противоположностью правого является левый).

Если вы хотите узнать больше, посмотрите здесь или здесь.

В упрощённом варианте это может выглядеть так:

/**
* @template L
* @template R
*/

final class Either
{
/**
* @param bool $isRight
* @param L|R $value
*/

private function __construct(private bool $isRight, private mixed $value)
{
}

/**
* @param L $value
* @return Either<L, R>
*/

public static function left(mixed $value): Either
{
return new self(false, $value);
}

/**
* @param R $value
* @return Either<L, R>
*/

public static function right(mixed $value): Either
{
return new self(true, $value);
}

/**
* @return bool
*/

public function isRight(): bool
{
return $this->isRight;
}

/**
* @return L|R
*/

public function getValue(): mixed
{
return $this->value;
}
}

Теперь давайте применим Either к нашему Address Value Object:

readonly final class Address
{
// ... (остальные методы)

/**
* @returns Either<InvalidValue,Address>
*/

public static function create(
string $street,
string $city,
string $postalCode
): Either
{
try {
return Either::right(new Address(
new Street($street),
new City($city),
new PostalCode($postalCode)
));
} catch (InvalidValue $error) {
return Either::left($error);
}
}

// Методы __toString и equals
}

Обработка результата:

$address = Address::create('', '', '');

if ($address->isRight()) {
// выполняется в случае успеха
}
else {
// выполняется в случае ошибки

/** @var InvalidValue $error */
$error = $address->getValue();

echo "Error: {$error->getMessage()}";
}

Такой подход обеспечивает гибкий способ управления результатами, позволяя использовать различные пути обработки для успешных и ошибочных сценариев.

Хотя несколько библиотек реализуют Eithers в PHP, отсутствие дженериков требует активного использования инструментов статического анализа, таких как PSalm или PHPStan. Поэтому иногда работа с типами может быть затруднена.

Объединение типов

В качестве альтернативы в PHP 8.0 появилось понятие Union Types/Объединение типов. Как и в примере Either, метод create возвращает два возможных значения:

readonly final class Address
{
// ... (остальные методы)

public static function create(
string $street,
string $city,
string $postalCode
): InvalidValue|Address
{
try {
return new Address(
new Street($street),
new City($city),
new PostalCode($postalCode)
);
} catch (InvalidValue $error) {
return $error;
}
}

// Методы __toString и equals
}

Обработка результата:

$address = Address::create('', '', '');

if ($address instanceof InvalidValue) {
// выполняется в случае ошибки

echo "Error: {$address->getMessage()}";
}
else {
// выполняется в случае успеха
}

Когда речь заходит об обработке ошибок в PHP, не существует универсального решения. Решение об использовании типов Either и Union зависит от конкретных потребностей вашего проекта.

Either обеспечивает гранулярный подход, позволяя вам чётко управлять различными результатами. При этом особое внимание уделяется структурированной и явной стратегии обработки ошибок.

С другой стороны, Union Types используют встроенные возможности языка и упрощают синтаксис. Такой подход может больше соответствовать философии let it fail fast, поскольку позволяет обрабатывать ошибки непосредственно там, где они возникают.

В заключение следует отметить, что выбор правильного подхода к обработке ошибок в PHP предполагает вдумчивое рассмотрение контекста и потребностей вашего проекта. Типы Either и Union являются ценными инструментами, обеспечивающими гибкость при выборе стратегии. Главное — выбрать подход, который органично вписывается в философию вашего проекта, способствуя ясности, сопровождаемости и устойчивости.

Заключение

Завершая изучение Value Object в PHP, мы рассмотрели различные аспекты, которые помогут вам лучше понять и использовать этот важный инструмент.

Мы начали с рассмотрения Простых Value Object, представляющих базовые концепции в вашем коде. Эти объекты инкапсулируют отдельные значения и содержат такие принципы, как фокусировка, неизменяемость, валидация, строковое представление и метод проверки равенства. Придерживаясь этих принципов, мы можем создавать понятный и надёжный код.

Перейдя к Комплексным Value Object, мы рассмотрели структуры с более сложной организацией. Эти объекты работают с несколькими атрибутами или сложными структурами, моделируя более богатые концепции в вашем домене. Рекомендации по созданию сложных объектов значений включают в себя хорошее представление концепции домена, реализацию валидации, значимое строковое представление и метод проверки равенства.

Пиком путешествия стали Составные Value Object, где мы увидели объединение нескольких Простых или Сложных Value Object в единую структуру. Это позволяет представлять ещё более сложные понятия, такие как адреса в нашем примере. Рекомендации здесь включают в себя сборку различных объектов значений, абстрагирование сложных концепций, обеспечение читаемого строкового представления и предоставление метода для проверки равенства.

Далее мы изучили Методы фабрики и Приватные конструкторы. Мы увидели, как они могут быть полезны при работе с объектами значений, внутренние представления которых отличаются от внешних. На примере объекта DateTimeValueObject было показано использование фабричных методов для контролируемого инстанцирования объекта. Была подчёркнута гибкость использования приватных или публичных конструкторов, что делает акцент на выборе дизайна.

В заключительном разделе мы рассмотрели Альтернативы исключениям, представив тип Either и Объединение типов из PHP 8.0. Оба эти подхода предлагают различные способы обработки ошибок. Either обеспечивает структурированную стратегию, а Объединение типов упрощает синтаксис для философии fail fast.

В заключение хочу сказать, что при PHP разработке выбор между типами Either и Объединением типов зависит от потребностей вашего проекта. Оба являются ценными инструментами, обеспечивающими гибкость при выборе стратегии обработки ошибок. Главное — выбрать подходы, соответствующие контексту вашего проекта, обеспечивающие ясность, сопровождаемость и устойчивость кода. Изучая эти возможности, пусть ваш код будет сильным, абстракции — осмысленными, а решения — обоснованными. Счастливого кодинга!

Value Objects

Дополнительные материалы

Предыдущая Статья

Value Objects в PHP 8: Создание лучшего кода

Следующая Статья

Не полагайтесь на порядок ключей в значениях MySQL JSON