Value Objects в PHP 8: Создание лучшего кода
В этой статье я расскажу о том, как реализовать паттерн Value Objects и как это позволит добавить немного "сахара" в ваш код, используя последние возможности, представленные в PHP 8.1 и PHP 8.2.
Проблемы, связанные с примитивами
Прежде чем перейти к Value Objects, давайте поговорим о проблемах с базовыми типами данных. Вот три распространённые проблемы:
1. Недопустимые значения
Простые типы данных не имеют встроенных проверок, гарантирующих валидность данных. Это может привести к неожиданным проблемам в коде.
Возраст может быть представлен целым числом, но, конечно, не может быть отрицательным или больше 120 (более или менее). Возможно, в нашем случае имеет смысл, чтобы возраст был больше или равен 18 годам.
Адрес электронной почты можно было бы представить в виде строки, но это не так. Это подмножество всех возможных строк, и для того, чтобы убедиться в его валидности, необходимо провести несколько проверок.
Возможно, в нашем коде есть много разных мест, где используются эти значения, но мы не можем доверять никому, и нам нужно было обеспечить согласованность данных. Следовательно, мы должны каждый раз проверять данные, передаваемые в качестве аргумента.
Это приводит к возникновению проблемы дублирования логики валидации. Каждая из этих дублированных логик может потенциально отличаться от других, что приведёт к несоответствиям.
function logic1(int $age): void
{
($age >= 18) or throw InvalidAge::adultRequired($age);
// Do stuff
}
function logic2(int $age): void
{
($age >= 0) or throw InvalidAge::lessThanZero($age);
($age <= 120) or throw InvalidAge::matusalem($age);
// Do stuff
}
Использование Value Objects должно помочь. Это значительно упростит ваш код, а также обеспечит согласованность данных.
readonly final class Age
{
public function __construct(public int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
}
}
function logic1(Age $age): void
{
// Do stuff
}
function logic2(Age $age): void
{
// Do stuff
}
Таким образом, вы можете быть уверены, что если экземпляр Age
существует, то он валиден и согласован везде в вашем коде, без необходимости проверять его каждый раз.
2. Смешивание аргументов
При работе с функциями, принимающими схожие типы данных, легко перепутать порядок аргументов. Это может привести к трудно обнаруживаемым багам.
function logic1(string $name, string $surname): void
{
// Логическая ошибка,
// $name подменяется $surname, непреднамеренно
logic2($name, $surname);
}
function logic2(string $surname, string $name): void {
// Do stuff
}
Этот тип ошибок особенно коварен, и в настоящее время не существует встроенной проверки, которая могла бы помочь их предотвратить.
Есть только два способа решить эту проблему:
Использование Value Objects: Сам интерпретатор или инструменты статического анализа могут легко распознать несоответствие типов.
function logic1(Name $name, Surname $surname): void
{
// Ошибка статического анализа
// Ожидается Surname, найдено Name
logic2($name, $surname);
}
function logic2(Surname $surname, Name $name): void {
// Do stuff
}Использование именованных аргументов: Введено в PHP 8.0. В этом случае порядок не имеет значения.
function logic1(string $name, string $surname): void
{
logic2(name: $name, surname: $surname);
}
3. Случайные изменения
Простые типы данных могут быть изменены без нашего ведома. Когда мы передаём их в функцию, эта функция может случайно изменить исходные данные.
function logic1(int &$age): void
{
if ($age = 42) { // Предупреждение о багах
echo "That's the answer\n";
}
echo "Your age is $age\n"; // Он всегда будет печатать 42
}
Учитывая, что аргументы по ссылке редко передаются с помощью &
, в этом примере есть не один, а два бага:
$age = 42
— это присваивание, а не сравнение. Оно перезаписывает текущее значение незаметно для нас, и все, что использует это значение после, будет затронуто этой ошибкой.- Изменение значения, переданного по ссылке, означает, что переменная вне вызова также будет изменена. Это может быть сделано намеренно, но иногда это не так.
Использование объектов Value Objects решит эту проблему, поскольку они обеспечивают неизменяемость.
final readonly class Age
{
public function __construct(public int $value)
{ // валидация }
}
function logic1(Age $age): void
{
// Ошибка интерпретатора
// Невозможно записать свойство, доступное только для чтения
if ($age->value = 42) {
echo "That's the answer\n";
}
echo "Your age is $age\n";
}
Классы как Типы
Value Objects решают эти проблемы, рассматривая классы как типы. В отличие от простых типов данных, объекты Value Objects оборачивают свои данные в класс. Это помогает лучше контролировать и проверять данные.
Ключевые качества Value Objects
Чтобы извлечь максимум пользы из Value Objects, необходимо сосредоточиться на нескольких важных вещах:
1. Их невозможно изменить (Иммутабельность)
Value Objects должны оставаться неизменными после того, как мы их создадим. Это помогает нам избежать неожиданных изменений. В прошлом, до PHP 8.1, это достигалось наличием private
/protected
свойств и только геттеров. Сеттеры были запрещены.
Если есть причина, по которой внутренние данные должны измениться, то будет создан новый экземпляр, без изменения текущего экземпляра.
PHP 8.1 многое упростил, введя readonly
свойства и продвижение свойств.
class Age // PHP 8.1
{
public function __construct(public readonly int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
}
}
До PHP 8.1
class Age // PHP < 8.1
{
private int $value;
public function __construct(int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
$this->value = $value;
}
public function value():int { return $this->value; }
}
В следующем примере показано, как работать с изменениями в Value Object.
final readonly class Money // PHP 8.2
{
public function __construct(
public int $amount,
public string $currency
) {
($amount > 0) or throw InvalidMoney::cannotBeZeroOrLess($amount);
}
public function sum (Money $money): Money
{
if ($money->currency !== $this->currency) {
throw InvalidMoney::cannotSumPearsWithApples($this->currency, $money->currency);
}
$newAmount = $this->amount + $money->amount;
return new Money($newAmount, $this->currency);
}
}
Как видите, ни одно свойство экземпляра не изменилось. Вместо этого был создан новый экземпляр.
2. Легко сравнивать (Сопоставимость)
Сопоставимость Value Objects означает, что мы можем легко проверить, одинаковые они или разные. Это пригодится при сортировке или поиске.
// Money
public function equals(Money $money): bool
{
return $this->amount === $money->amount
&& $this->currency === $money->currency;
}
// code
$thousandYen = new Money(1000, Currency::YEN);
$thousandEuro = new Money(1000, Currency::EURO);
$thousandYen->equals($thousandEuro); // false
Потому что, конечно, ¥1000
— это не то же самое, что €1000
.
3. Всегда хорошие данные (Согласованность)
Value Object всегда должен представлять собой нечто валидное. Проверяя и валидируя объект, мы убеждаемся, что он всегда в хорошей форме.
Это означает, что валидация должна выполняться внутри конструктора, чтобы убедиться, что если экземпляр существует, то он валиден. Всегда!
Внимание! Будьте осторожны с десериализаторами: иногда они строят объект без вызова конструктора.
Интересным подходом может быть наличие метода validate
, вызываемого внутри конструктора и после одного из подобных процессов десериализации.
public function __construct(public string $value)
{
$this->validate();
}
private function validate(): void
{
// Код выполняющий валидацию
}
Например, используя десериализатор Serde PHP, вы должны добавить атрибут #[PostLoad]
, чтобы он вызывался после инстанцирования.
#[PostLoad]
private function validate(): void
{
// Код выполняющий валидацию
}
Это связано с тем, что большинство из них используют этот метод отражения под капотом
public ReflectionClass::newInstanceWithoutConstructor(): object
4. Лёгкость отладки (Отлаживаемость)
Хорошей практикой является оснащение Value Objects простым способом самостоятельной отладки. В случае простого объекта значения для этого подойдёт метод __toString
. В противном случае, если речь идёт о составном Value Object (с большим количеством свойств и другими Value Objects внутри), рекомендуется использовать toArray
. Или же можно использовать сериализатор.
final readonly class Name
{
public function __construct(public string $value) {}
public function __toString(): string
{
return $this->value;
}
}
final readonly class Surname
{
public function __construct(public string $value) {}
public function __toString(): string
{
return $this->value;
}
}
final readonly class Person
{
public function __construct(
public Name $name,
public Surname $surname
){}
public function __toString(): string
{
return "{$this->name} {$this->surname}";
}
public function toArray(): array
{
return [
'name' => (string)$this->name,
'surname' => (string)$this->surname
];
}
}
Подведение итогов
В заключение следует отметить, что использование паттерна Value Object в PHP 8.2 значительно повышает качество кода, делая его более надёжным и удобным для сопровождения. Рассматривая классы как типы, уделяя особое внимание иммутабельности и всегда валидным данным, а также используя возможности, представленные в PHP 8.1 и 8.2, разработчики могут создавать более стабильные и устойчивые приложения. Добавление Value Objects в набор инструментов для программирования не только улучшает внешний вид кода, но и упрощает процесс разработки и закладывает основу для создания более масштабируемой и устойчивой к ошибкам кодовой базы.