Ценные объекты
В этой статье обсуждается использование объектов 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
претерпел значительные метаморфозы — просто удалено большое количество кода из предыдущих версий. Не все проблемы были (пока) решены… идеальным способом. Текущая версия, тем не менее, полностью функциональна, и, как говорится: лучше сделать, чем идеально — попробуйте!