Ценные объекты

В этой статье обсуждается использование объектов Value в контексте приложений PHP. В статье представлены пошаговые реализации примера Value Object (класс Person), а также использование и обсуждение библиотеки lbacik\value-object. В представленных примерах в качестве тестовой платформы используется фреймворк phpspec (в конце концов, одним PHPUnit не живешь!), а весь код доступен на GitHub. Наслаждаться!

Определение Объекта Ценности можно найти во многих публикациях, включая, конечно же, Википедию. Начать хотелось бы с цитаты из книги Domain-Driven Design in PHP (это не определение, а скорее указание на некоторые — интересные мне — качества).

Одной из основных целей Value Objects также является святой Грааль объектно-ориентированного дизайна: инкапсуляция. Следуя этому шаблону, вы получите специальное место для размещения всей логики проверки, сравнения и поведения для данной концепции.

Другие публикации, на которые вы можете ссылаться при поиске информации о Value Objects:

  • Доменно-ориентированный дизайн, Эрик Эванс
  • Внедрение доменно-ориентированного проектирования, Вон Вернон
  • Шаблоны архитектуры корпоративных приложений, Мартин Фаулер

В контексте этой статьи, из особенностей Value Objects, упомянутых в этих публикациях, я сосредоточусь на следующем:

  • Неизменяемость
  • Заменяемость
  • Равенство значений
  • Поведение без побочных эффектов

Кроме того, ключевой (на мой взгляд) особенностью Value Objects является проверка входных данных, упомянутая во вступительной цитате. Я имею в виду здесь, в частности, правило о том, что Объект-Значение не может быть создан для ошибочных (неверных) данных, т.е. если объект существует, данные правильные!

Исполнение

Форма, которую я решил принять, основана на тестировании всех обсуждаемых функций параллельно с их представлением. Для каждой функции я сначала напишу соответствующий тест, а затем представлю код, который проходит тест (мантра красный/зеленый/рефакторинг, обычно ассоциируемая с подходом TDD). Сам phpspec называется фреймворком BDD (Behaviour-Driven Development) — хотя BDD произошел от TDD, и все правила, связанные с TDD, полностью соблюдаются.

Пример Value Object — это класс Person, представляющий базовые данные, описывающие некоторого человека:

  • имя
  • возраст

Напоминаем — поскольку мы обсуждаем объекты-значения, у нас здесь нет никакой идентичности (объект Person — это не сущность, а запись в базе данных, следовательно, помимо прочего, у него нет идентификатора).

Настраивать

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

установка phpspec:

$ composer req --dev phpspec/phpspec

Давайте определим пространство имен нашего тестового приложения в composer.json (здесь: App):

{
    "require-dev": {
        "phpspec/phpspec": "^7.2"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
}

Также настроим phpspec — достаточно добавить в основную директорию файл phpspec.yml со следующим содержимым:

suites:
  acme_suite:
    namespace: App
    psr4_prefix: App

Последнее, что нужно сделать, это обновить автозагрузчик:

$ composer dump-autoload

№ теста 1

С phpspec использование правила red/green/refactor становится практически второй натурой. Начнем с теста — во-первых, я сообщаю фреймворку, что в приложении должен существовать класс App\Person (наш тестовый объект-значение):

$ vendor/bin/phpspec describe 'App\Person'
Specification for App\Person created in /.../spec/PersonSpec.php.

phpspec создал тест (спецификация, доступная в каталоге spec — каталог tests здесь не используется). Созданный тест (спецификация) проверяет существование указанного класса — запустим его:

$ vendor/bin/phpspec run

Вызов завершится ошибкой — требуемый класс (Person) еще не существует (на данном этапе даже нет каталога src). Мы получим уведомление о том, что тест не пройден, а также вопрос, следует ли создать отсутствующий класс. Если мы ответим Да, будут созданы и каталог src, и файл с классом Person.

В настоящее время класс Person будет выглядеть так:

namespace App;

class Person
{
}

Основные проверки

У нас есть самые важные компоненты — спецификация, class-файл, и мы умеем запускать тесты (команда phpspec run). Кроме того, все текущие тесты (пока только проверка существования класса Person) выполняются корректно!

Давайте нарушим этот идеальный баланс. Добавим в спецификацию (spec/PersonSpec.php) следующий код:

private const NAME = 'foo';
private const AGE = 30;

public function let(): void
{
  $this->beConstructedWith(self::NAME, self::AGE);
}

Метод let является аналогом метода setUp PhpUnit (плюс/минус) — т.е. указание действия, выполняемого перед каждым тестом. Мы хотим, чтобы наш объект-значение представлял человека a и имел два параметра — name и age. Мы хотим, чтобы эти параметры передавались при создании экземпляров нашего объекта Person — отсюда и вызов beConstructedWith в нашем методе let. Те, кто знаком с PhpUnit, сразу заметят разницу в подходе к тестированию — здесь спецификация также является экземпляром тестируемого класса!

Давайте проверим нашу спецификацию:

$ vendor/bin/phpspec run

Тест не пройдет — в нашем классе нет конструктора — нужно его добавить:

class Person
{
    public function __construct(
        public string $name,
        public int $age,
    ) {
    }
}

Теперь тест станет зеленым.

Мы добавляем тест, чтобы проверить, действительно ли значения, переданные в конструкторах, были присвоены правильным свойствам (файлу PersonSpec.php):

public function it_contains_data(): void
{
  $this->name->shouldEqual(self::NAME);
  $this->age->shouldEqual(self::AGE);
}

phpspec дает нам некоторые интуитивные возможности, не так ли? Кстати, змея_case в названии метода — это соглашение phpspec. Если мы проверяем соответствие нашего кода PSR-12 с помощью phpcs, нам нужно добавить исключение из стандарта (в конфигурационном файле) для классов в каталоге spec. Выкладываю пример конфигурации phpcs в репозиторий https://github.com/lbacik/value-object-spec.

Запустим проверку. Ошибок быть не должно. Класс Person принимает значения параметров $name и $age в конструкторе и сохраняет их в соответствующих полях. "Все идет нормально".

Но! Объект-значение должен быть неизменяемым объектом (неизменяемость — это первая функция, упомянутая во введении). Пользователь не должен изменять значения в уже созданном объекте. Проиллюстрируем это следующей проверкой (в классе PersonSpec):

public function its_props_are_ro(): void
{
  try {
    $this->name = 'bar';
  } catch (\Throwable $e) {
  }
  $this->name->shouldEqual(self::NAME);
}

Попытка присвоить новое значение полю $name (вообще каждому полю, но этот тест относится только к полю $name) должна генерировать исключение (в данном конкретном тесте игнорируется — наверное, можно было бы написать… поаккуратнее, но я не нашел прочь). В последней строке теста я проверяю, действительно ли значение поля $name осталось неизменным (то есть — в данном случае — остается ли значение равным значению, переданному конструктору класса Person в методе let спецификации).

Запустим проверку (phpspec run).

Мы получаем сообщение об ошибке — наши поля доступны для редактирования — и значение в поле $name можно изменить без проблем. Как это исправить? Начиная с PHP 8.1 мы можем установить параметры конструктора класса Person как readonly:

class Person
{
    public function __construct(
        public readonly string $name,
        public readonly int $age,
    ) {
    }
}

И испытание пройдено :)

В версиях PHP до 8.1 единственное, что мы могли сделать, это установить видимость наших полей на private или protected и создать соответствующие геттеры.

Набор

Объекты-значения неизменяемы. Однако это не означает, что мы не можем выполнять операции присваивания — проще говоря, попытка присвоить новое значение уже существующему объекту должна заканчиваться созданием нового объекта! Эта функция (называемая Заменяемость) была описана в Domain-Driven Design in PHP следующим образом:

Такое поведение похоже на то, как базовые типы, такие как строки, работают в PHP. Рассмотрим функцию strtolower. Он возвращает новую строку, а не изменяет исходную. Ссылка не используется; вместо этого возвращается новое значение.

Предположим, что присваивание реализовано через метод set — напишем соответствующий тест

public function it_has_set_method(): void
{
    $newPerson = $this->set(age: 40);
    $newPerson->age->shouldEqual(40);
    $this->age->shouldEqual(self::AGE);
}

Здесь я использую именованные аргументы, поэтому требуется PHP 8. В методе set мы можем указать любое количество аргументов; в примере мы указываем только значение для поля $age — в этом случае во вновь созданном объекте $newPerson (метод set возвращает новый объект Person) значение в поле $name остается неизменным от объекта $this (объект со спецификацией $PersonSpec).

Проверка (phpspec run) вернет ошибку — в нашем классе Person метода set не существует. Не создавайте его автоматически! Мы будем использовать его реализацию, определенную в библиотеке lbacik/value-object. Давайте установим библиотеку:

$ composer req lbacik/value-object

Теперь мы можем обновить класс Person, чтобы он наследовал класс ValueObject, определенный в установленной библиотеке как \Sushi\ValueObject:

use Sushi\ValueObject;

class Person extends ValueObject
{
    public function __construct(
        public readonly string $name,
        public readonly int $age,
    ) {
    }
}

После повторного запуска тестов оказывается, что мы вернулись к зеленому состоянию — теперь все тесты должны быть пройдены!

В lbacik/value-object предполагается, что мы не можем создавать новые поля методом set (в новых экземплярах данного класса, созданных методом) — такие попытки будут генерировать ValueObjectException. Давайте проверим это! Вот тест:

public function it_has_the_same_keys(): void
{
    $this
        ->shouldThrow(ValueObjectException::class)
        ->during('set', ['otherName' =>'foo']);
}

Эта нотация проверяет (к сожалению, в данном конкретном случае нотация phpspec не слишком… ясна), если выполнение:

$this->set(otherName: 'foo');

сгенерирует ValueObjectException, что, конечно же, должно произойти (выполнение тестов должно подтвердить это).

равно

Еще одна функция, упомянутая во введении, — Равенство значений. Два объекта (типа объекта-значения) считаются равными, если они хранят одно и то же. значения, а не тогда, когда они идентичны. Другими словами, нас интересует сравнение значений всех свойств этих объектов. Мы не сравниваем ключи (что имеет смысл в случае с сущностями) или адреса этих объектов в памяти — мы сравниваем значение каждого их свойства!

Тест покажет это лучше:

public function it_can_be_compared(): void
{
    $person1 = new Person('Thor', 25);
    $person2 = new Person(self::NAME, self::AGE);

    $this->isEqual($person1)->shouldEqual(false);
    $this->isEqual($person2)->shouldEqual(true);
}

Тест нужно пройти сразу — метод isEqual также был определен в классе \Sushi\ValueObject.

Примечание: сравнение в рамках метода isEqual осуществляется путем сравнения значений одних и тех же свойств двух объектов. Генерация значений hash и сравнение только хэшей не используется. Оптимизация путем генерации и сравнения хэшей — тема для других версий библиотеки.

Поведение без побочных эффектов

В двух словах — после создания объекта-значения значения его аргументов не могут измениться. Это предположение устраняет любые опасения по поводу побочных эффектов (свойства нашего объекта-значения не должны подвергаться опасности изменения). Конечно, мы можем указать на проблемные ситуации — рассмотрим, например, ситуацию, когда значение одного из полей Объекта-значения в объекте не удовлетворяет тем же критериям, т.е. не наследует ( в контексте этой статьи) из класса ValueObject. В этом случае значения полей объекта не будут иметь таких же ограничений, как значения полей объекта-значения. Например, в случае определения ниже:

class Gender
{
    public const Female = 'female';
    public const Male = 'male';

    public function __construct(
	public string $value,
    ) {
    }
}

class Person extends ValueObject
{
    public function __construct(
        public readonly string $givenName,
        public readonly string $familyName,
		public readonly Gender $gender,
    ) {
    }
}

Класс Gender не наследуется от класса ValueObject — при сравнении объектов Person будет учитываться ссылка на класс Gender, а не его значение!

Вывод: в идеале в полях Объект-значение сохраняются только другие объекты-значения!

Если класс Gender в приведенном выше примере унаследован от класса ValueObject, параметр value будет включен при сравнении объектов типа Person!

Я добавил тесты, иллюстрирующие эти различия, в репозиторий GitHub, о котором я упоминал во введении (он также содержит полный код, обсуждаемый в этой статье). Тесты PersonWithGenderSpec (класс Gender, как указано выше, не наследуется от ValueObject) и PersonWithCitySpec (где класс City наследуется от класса ValueObject).

Инварианты

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

В случае обсуждаемой библиотеки lbacik/value-object термин инвариант был принят для обозначения правила, которому должны удовлетворять входные данные для создаваемого объекта.

Каждый класс типа ValueObject может содержать любое количество инвариантов.

Давайте проанализируем тест ниже:

public function it_is_an_adult_person(): void
{
    $this
        ->shouldThrow(\Throwable::class)
    	->during('set', ['age' => 12]);
}

Мы тестируем следующий вызов:

$person->set(age: 12);

И ожидайте, что вызов сгенерирует исключение, потому что мы хотим добавить инвариант к нашему объекту Person Value, который гарантирует, что все созданные объекты представляют людей старше 18 лет (люди, которым ровно 18 лет, также будут выполнить критерий).

Посмотрим на реализацию:

use Sushi\ValueObject;
use Sushi\ValueObject\Exceptions\InvariantException;
use Sushi\ValueObject\Invariant;

class Person extends ValueObject
{
    private const MIN_AGE_IN_YEARS = 18;

    public function __construct(
        public readonly string $name,
        public readonly int $age,
    ) {
        parent::__construct();
    }

    #[Invariant]
    protected function onlyAdults(): void
    {
        if ($this->age < self::MIN_AGE_IN_YEARS) {
            throw InvariantException::violation(
                'The age is below ' . self::MIN_AGE_IN_YEARS
	    );
        }
    }
}

Обратите внимание на конструктор — он должен содержать вызов конструктора базового класса — это гарантирует проверку инвариантов!

Инварианты сами по себе являются методами с назначенным #[Invariant] тегом (атрибутом). Если такой метод выдает (любое) исключение, значит, тестируемый критерий не был выполнен при создании объекта.

Вдохновением послужила библиотека Python simple-value-object. Конечно, атрибуты (используемые здесь) работают иначе, чем декораторы Python (используемые в sample-value-object), но эффект (практически) идентичен — у нас есть тег (#[Invariant]), которым мы можем отметить все методы, которые следует вызывать при создании объекта. Мы можем создать любое количество инвариантов, свободно определяя их правила.

Давайте посмотрим на другой пример:

public function its_name_is_not_too_short(): void
{
    $this
        ->shouldThrow(\Throwable::class)
        ->during('set', ['name' =>'a']);
}

Предположим, мы не хотим, чтобы атрибут name был короче 3 символов. Конечно, мы можем определить инвариант аналогично предыдущему, но давайте рассмотрим другую возможность.

Давайте добавим phpunit в наше приложение:

$ composer req phpunit/phpunit

Важно! Мы добавляем библиотеку phpunit без параметра --dev команды composer — таким образом, мы добавляем ее как часть приложения, а не элемент среды разработки. Это может показаться странным, хотя, вероятно, не всем — использование утверждений в коде приложения (не только в тестах) кажется довольно популярным (хотя я не проводил никаких исследований по этой теме). Однако он (вероятно) недостаточно популярен для делегирования утверждений в какую-то меньшую библиотеку (чтобы избежать добавления всего PHPUnit, когда мы хотим использовать приложение для этого одного элемента).

Давайте посмотрим, как мы можем использовать утверждения в классе Person:

use function PHPUnit\Framework\assertGreaterThanOrEqual

    ...

    private const NAME_MIN_LENGTH = 3;

    ...    

    #[Invariant]
    public function checkName(): void
    {
        assertGreaterThanOrEqual(
	    self::NAME_MIN_LENGTH, 
	    mb_strlen($this->name)
	);
    }

Проведем тесты — все должно быть в полном порядке!

Дает ли использование утверждений какое-либо конкретное преимущество? Оставлю это на суд читателя.

Краткое содержание

Использование объектов-значений может значительно повысить читабельность нашего кода. Конечно, это решение не будет работать каждый раз, но в большинстве случаев его стоит учитывать.

В версии 1.x lbacik/value-object претерпел значительные метаморфозы — просто удалено большое количество кода из предыдущих версий. Не все проблемы были (пока) решены… идеальным способом. Текущая версия, тем не менее, полностью функциональна, и, как говорится: лучше сделать, чем идеально — попробуйте!