Продвинутые Value Objects в 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 Object на представлении одного понятия или атрибута, часто соответствующего примитивному значению.
- Иммутабельность: После создания Простой Value Object не должен изменяться. Любые изменения должны приводить к созданию нового экземпляра.
- Валидация: Включите логику валидации в конструктор, чтобы убедиться, что объект всегда находится в валидном состоянии.
- Строковое представление: Реализуйте метод
__toString
для удобного преобразования в строку, когда это необходимо. - Проверка на равенство: Предоставьте метод
equals
для сравнения двух экземпляров на равенство.
Соблюдая эти рекомендации, вы создадите Простые 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 учитывайте следующие рекомендации:
- Структурированное представление: Моделируйте объект, чтобы отразить сложность и структуру соответствующей концепции домена.
- Валидация: Реализуйте логику валидации в конструкторе, чтобы убедиться, что объект всегда находится в валидном состоянии.
- Строковое представление: Добавьте значимый метод
__toString
для лучшей читаемости и отладки. - Проверка на равенство: Предоставьте метод
equals
для сравнения двух экземпляров на идентичность.
Такой подход позволяет создавать Комплексные 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, чтобы создать более сложную структуру.
- Абстракция: Представляйте сложные понятия в своём домене с помощью составной структуры.
- Строковое представление: Добавьте значимый метод
__toString
для лучшей читаемости и отладки. - Проверка на равенство: Предоставьте метод
equals
для сравнения двух экземпляров на идентичность.
Во многих случаях валидация Составного 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();
Давайте сосредоточимся на некоторых деталях:
- Доступность конструктора: В этом примере конструктор помечен как приватный, ограничивая инстанцирование только внутри самого класса. Однако важно отметить, что это выбор дизайна, а не строгое требование. Конструкторы могут быть и публичными, в зависимости от желаемой инкапсуляции и шаблонов использования. Приватный конструктор подчёркивает контролируемую инстанциацию через методы фабрики, предоставляя чёткий интерфейс для создания экземпляров.
- Методы фабрики с валидацией получаемых данных: Каждый метод фабрики включает валидацию получаемых данных, чтобы убедиться в целостности предоставленных данных до создания объекта
DateTimeValueObject
. Независимо от того, является ли конструктор приватным или публичным, методы фабрики действуют как привратники, обеспечивая соблюдение правил валидации. - Метод
createfromParts
: Этот метод демонстрирует гибкость создания объектаDateTimeValueObject
путём указания отдельных частей. Валидация получаемых данных в этом методе гарантирует, что созданный объект отражает корректные дату и время. - Метод
now
: Методnow
является примером общего метода фабрики для создания экземпляров, представляющих текущую дату и время. Он использует внутренний классDateTimeImmutable
для захвата текущего момента. - Методы
__toString
иequals
: Хотя в данном примере это явно не продемонстрировано, применение методов__toString
для получения строкового представления иequals
для сравнения экземпляров является типичной практикой.
Такой подход подчёркивает универсальность использования приватных или публичных конструкторов в зависимости от дизайнерских предпочтений, укрепляя идею, что шаблоны проектирования учитывают различные потребности и возможности выбора.
Ещё одно интересное применение метода фабрики — упрощение инстанцирования Составного 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
и Объединением типов зависит от потребностей вашего проекта. Оба являются ценными инструментами, обеспечивающими гибкость при выборе стратегии обработки ошибок. Главное — выбрать подходы, соответствующие контексту вашего проекта, обеспечивающие ясность, сопровождаемость и устойчивость кода. Изучая эти возможности, пусть ваш код будет сильным, абстракции — осмысленными, а решения — обоснованными. Счастливого кодинга!