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

Источник: «Value Objects in PHP 8: Building a better code»
В мире программирования поддержание чистоты и надёжности кода имеет большое значение. Паттерн Value Objects способен значительно улучшить качество вашего кода, сделав его более надёжным и удобным для сопровождения.

В этой статье я расскажу о том, как реализовать паттерн 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
}

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

Есть только два способа решить эту проблему:

3. Случайные изменения

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

function logic1(int &$age): void
{
if ($age = 42) { // Предупреждение о багах
echo "That's the answer\n";
}

echo "Your age is $age\n"; // Он всегда будет печатать 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 в набор инструментов для программирования не только улучшает внешний вид кода, но и упрощает процесс разработки и закладывает основу для создания более масштабируемой и устойчивой к ошибкам кодовой базы.

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

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

SSH3: более быстрый и безопасный шелл с использованием HTTP/3

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

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