Принципы программирования - Часть 4: Сообщения

В предыдущих статьях этого цикла мы рассмотрели как снизить сложность методов и функций. Затем обсудили способы избавления от null. А потом рассмотрели жизненный цикл объектов и способы его инкапсуляции. Теперь наши объекты корректно создаются и изменяются. Настало время поговорить о том, как они должны взаимодействовать между собой и как это должно отразиться на нашем коде.

Взаимодействие объектов

Вызов методов

Когда один объект вызывает метод другого объекта, они взаимодействуют: есть отправитель, получатель и передаваемое сообщение. Сообщение имеет конкретный тип и содержит конкретные значения, что делает его уникальным. Когда мы вызываем doSomething() у объекта $a, мы посылаем ему сообщение, которое будет этим объектом обработано:

$a->doSomething($b, $c, $d);

Когда мы вызовем этот метод еще раз (даже есть аргументы будут такими же, как и раньше), будет создано новое сообщение. Это делает сообщения уникальными:

// Одинаковые значения, но новое сообщение:
$a->doSomething($b, $c, $d);

Это станет более понятным, если мы применим рефакторинг "Замена параметров объектом".

$message1 = new Message($b, $c, $d);
$a->doSomething($message1);

$message2 = new Message($b, $c, $d);
$a->doSomething($message2);

Помните, что даже если мы не передаем никаких аргументов, сообщение все равно передается:

$a->doSomethingElseWhichRequiresNoInformation();

Возврат значений

Когда получатель хочет отправить сообщение обратно отправителю, он может сделать это, указав возвращаемое значение:

function whereIs(Person $person) {
    ...
    return new Location(...);
}

Возвращаемое значение это тоже сообщение определенного типа и конкретного значения.

Категории сообщений

Помимо своего типа и содержащегося значения сообщения могут быть разделены на три категории.

  • Команды - это сообщения, запрашивающие изменения. Один объект просит другой сделать что-то.
  • Запросы - сообщения, запрашивающие информацию. Один объект хочет что-то получить от другого объекта.
  • Документы - сообщения, посылаемые объектом в ответ на запросы от другого объекта.

Сообщения всех трех категорий мы уже использовали в примерах выше. Мы отправляли команды, когда вызывали $a->doSomething(...). Вызывая whereIs(...), мы отправляли запросы. А возвращение методом whereIs() объекта Location было отправкой документа.

Каждая категория используется для конкретных целей:

  • команды - для изменения состояния приложения;
  • запросы - для получения информации о текущем состоянии, без его изменения;
  • документы просто отвечают на запросы.

Принцип разделения команд и запросов (CQS)

Теперь, зная разницу между категориями сообщений, мы можем улучшить дизайн наших объектов. Необходимо явно указать, какую категорию сообщений принимает метод: команды или запросы. Проектируя методы, нужно следовать двум принципам:

  • Метод-команда принимает запрос на изменение, но не возвращает никаких значений.
  • Метод-запрос принимает запрос на получение информации и возвращает эту информацию (которая представлена документом). Он не изменяет наблюдаемое состояние объекта.

Это известно как принцип разделения команд и запросов(CQS). Строгое следование этому принципу дает следующие преимущества:

  • Вы можете быть уверены, что вызов метода-запроса не повлечет за собой никаких побочных эффектов.
  • Вы можете оптимизировать методы-запросы, например кэшированием возвращаемого значения.
  • Вы можете вызывать методы-запросы в любом порядке.

Реализация команд

Методы-команды, спроектированные в соответствии с принципом CQS, соответствуют следующим требованиям:

  • Методы-команды не возвращают никаких значений.
  • Методы-команды должны всегда выполняться успешно. В случае ошибки следует выбрасывать исключения.

Функция-команда выглядит следующим образом:

function doSomething(/* любое количество аргументов */) {
    if (/* ошибочная ситуация */) {
        throw new Exception(...);
    }
    if (/* ничего не делать */) {
        return;
    }
    // основная логика
    ...
    // ничего не возвращается
}

Несколько советов по реализации функций-команд:

  • В имени функции должно быть описано действие (например "сохранить", "оплатить" и т.п.). Имя функции должно отражать тот факт, что она совершает какое-то действие.
  • Бросайте как можно более конкретные исключения.
  • При раннем возврате не пишите return null - просто return;. Ведь функция-команда ничего не возвращает.

В документации тоже необходимо отразить тот факт, что функция не имеет возвращаемого значения:

/**
 * @return void
 */

Так же будет полезно указать список исключений, которые может выбросить функция:

/**
 * @throws SomeSpecificException
 */

Реализация запросов

Методы-запросы - это противоположность методам-командам. Для них характерно:

  • Метод-запрос имеет конкретный тип возвращаемого значения.
  • Метод-запрос всегда возвращает значение этого типа. Если это не возможно, метод выбрасывает исключение.

Общая структура метода-запроса выглядит следующим образом:

/**
 * @return SomeSpecificTypeOfValue
 */
function getSomething(...) {
    if (/* мы не можем выполнить запрос */) {
        throw new Exception(...);
    }
    return $somethingOfTheExpectedType;
}

Вместо генерации исключения можно возвращать пустое значение, удовлетворяющее объявленному типу (например, пустой массив):

/**
 * @return SomeSpecificTypeOfValue[]
 */
function getThoseThings(...) {
    if (/* мы не можем выполнить запрос */) {
        return [];
    }
    return [...];
}

Тут вы можете использовать подходы, описанные в одной из предыдущих статей.

Реализация документов

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

Важно всегда следить за изменяемостью отправляемых документов. В общем случае, получатель не должен иметь возможности изменять сообщение. Если сообщение содержит ссылку на оригинальный объект, то через нее получатель сможет изменить его. Как говорилось в прошлой статье, изменяемыми должны быть только те объекты, которые для этого предназначены.

Разделение ответственности команд и запросов (CQRS)

Мы разобрались с CQS (принципом разделения команд и запросов): методы объекта должны были либо запросами либо командами. Другой принцип, называемый CQRS (принцип разделения ответственности команд и запросов), является его логическим "продолжением". Он выводит CQS на уровень объектов: объект должен иметь либо методы-запросы либо методы-команды. Это означает, что должны быть объекты, принимающие команды на изменение, и отдельные объекты, которые предоставляют информацию:

class Entity
{
    private $id;
    private $something;

    public function changeSomething($newValue)
    {
        $this->something = $something;
    }
    ...
}

class SummarizedRepresentationOfTheEntity
{
    private $id;
    private $something;

    public function getId()
    {
        return $this->id;
    }

    public function getSomething()
    {
        return $this->something;
    }
    ...
}

Конечная цель применения CQRS - разделение объектов, выполняющих запись, и объектов, выполняющих чтение. Конечно, возникает закономерный вопрос: как синхронизировать такие объекты? Этот вопрос решается при помощи событий.

События

События можно рассматривать как четвертую группу сообщений (отличную от команд, запросов и документов). События используются для указания того, что изменилось в результате обработки команды. Хотя события похожи на документы, они отличаются от них тем, что не являются прямым ответом на сообщения.

События используют для того, чтобы запомнить изменения, а затем опубликовать их. Обработчики событий могут реагировать на них (например проецировать эти изменения на модели чтения).

Например, внутри класса Entity, вызов метода changeSomething() генерирует событие:

class Entity
{
    private $id;
    private $events = [];

    public function changeSomething($newValue)
    {
        ...
        $this->events[] = new SomethingChanged($this->id, $newValue);
    }

    public function getRecordedEvents()
    {
        return $this->events;
    }
}

Сам класс события может выглядеть так:

class SomethingChanged
{
    private $id;
    private $something;

    public function __construct($id, $something)
    {
        $this->id = $id;
        $this->something = $something;
    }

    public function id()
    {
        return $this->id;
    }

    public function something()
    {
        return $this->something;
    }
}

Обработчик события SomethingChanged реагирует на него и обновляет соответствующую модель чтения:

class UpdateSummarizedRepresentationOfTheEntity
{
    public function whenSomethingChanged(SomethingChanged $event)
    {
        $listingObject = $this->readModelRepository
            ->getById($event->id());

        // создается новый объект SummarizedRepresentationOfTheEntity
        $replaceWith = ...;

        $this->readModelRepository
            ->save($replaceWith);
    }
}

Из приведенного примера видно несколько примечательных фактов:

  • Событие содержит исторические данные: то, что произошло (т.е. изменилось) в определенный момент. Эти изменения определяются типом и значениями, содержащимися в объекте-событии.
  • По своей природе, объекты-события неизменяемы: то, что уже произошло, не может быть изменено.
  • Объект-событие хранит изменившиеся данные. Так же он содержит информацию, которая позволяет однозначно сопоставить событие с измененным объектом.
  • Мы можем добавить сколько угодно обработчиков для события.
  • Модели чтения не мгновенно реагируют на изменения, внесенные в модель записи (ответ на запросы измениться только после обработки события). Это значит, что данные консистентны в конечном счете.

Event Sourcing

Хранение изменений, сделанных в объекте, дает возможность использовать один интересный подход. Вместо того, чтобы иметь доступ только к текущему состоянию объекта, вы можете увидеть все изменения, которые с ним происходили. Единственное, что вам нужно для этого сделать - сохранять все объекты-события. Теперь для воссоздания объекта вам нужно просто воспроизвести (обработать) внутри него все сохраненные ранее события. Тем самым вы приведете его к актуальному состоянию.

Такой подход даст вам возможность воспроизводить и анализировать исторические данные. [прим. переводчика: подробнее об Event Sourcing и его применении можно прочитать здесь.]

Заключение

В этой статье мы представили вызов методов в виде передачи сообщений. Сообщения-команды предназначены для того, чтобы вызывать изменения внутри объекта. Сообщения-запросы ничего не изменяют, а только запрашивают данные. Эти данные возвращаются в виде сообщений-документов. Мы применили принцип CQS, когда строго разделили методы-команды и методы-запросы.

Объекты могут информировать друг друга об изменениях при помощи событий. Разделение объектов, производящих изменения, и объектов, возвращающих данные, называется принципом CQRS.

Это вольный перевод статьи "Programming guidelines - Part 4: Messages", которую написал Matthias Noback.

Коментарии

Используйте Markdown

Спасибо за коментарий!
Ваше сообщение будет доступно после проверки.