Статьи

Event Sourcing в крайнем случае

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

Большая часть этого кода может быть найдена на GitHub . Я проверил это с помощью PHP 7.1 .

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

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

Источник событий также является частью более широкого и широкого набора тем; слабо определяется как доменно-управляемый дизайн. Источник событий — это один из многих шаблонов проектирования, и вам стоит изучить другие шаблоны, связанные с DDD. На самом деле, зачастую не очень хорошая идея извлекать только Event Sourcing из набора инструментов DDD, не понимая преимуществ других шаблонов.

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

Общий язык

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

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

Вы также обнаружите, что моделирование всей системы в словах, которые понимает ваш клиент, дает вам некоторую защиту от изменений области действия. Гораздо проще сказать; «Сначала вы просили клиентов купить мороженое до отправки счета (показано здесь в коде и по электронной почте), но теперь вы просите, чтобы счет был отправлен первым…», чем описать изменения, которые они спрашивать на языке / код только вы понимаете.

Это не означает, что клиент должен понимать весь ваш код или что вы должны использовать что-то вроде Behat для интеграционного тестирования. Но, по крайней мере, вы должны называть сущности и действия так же, как ваш клиент.

Дополнительным преимуществом этого является то, что будущие разработчики смогут понять предназначение кода (и как оно применяется к бизнес-процессу) без такой большой помощи со стороны клиента или менеджера проекта.

Я немного колеблюсь, но этот момент будет важен, когда мы начнем писать код.

Сохранение состояния против сохранения поведения

Большинство веб-сайтов, которые я создал, имели некоторую функциональность базы данных CRUD (создание, чтение, обновление и удаление). Эти операции являются преднамеренно общими, поскольку они традиционно сопоставляются с базовой реляционной базой данных, которую они используют.

Хранение государства

Возможно, мы даже привыкли использовать что-то вроде Eloquent :

 $product = new Product(); $product->title = "Chocolate"; $product->cents_per_serving = 499; $product->save(); $outlet = new Outlet(); $outlet->location = "Pismo Beach"; $outlet->save(); $outlet->products()->sync([ $product->id => [ "servings_in_stock" => 24, ], ]) 

Этого достаточно для наиболее простого представления информации о мороженом на сайте клиента. Это то, как мы создавали сайты целую вечность. Но у этого есть существенный недостаток — мы не знаем, что случилось, чтобы получить нас здесь.

Давайте подумаем о некоторых вещах, которые могут повлиять на то, как данные попадают в этот момент:

  • Когда мы начали продавать «Шоколад»? Во многих библиотеках Object Relation Mappers (ORM) будут добавлены поля, такие как created_at и updated_at , но они только так далеко говорят нам, что мы хотим знать.
  • Как мы получили столько акций? Мы получили доставку? Мы отдали немного?
  • Что происходит с нашей аналитикой, когда мы больше не хотим продавать «Шоколад» или когда мы хотим переместить все запасы в другой магазин? Добавляем ли мы логическое поле (в таблицу продуктов), чтобы указать, что продукт больше не продается, а должен оставаться в аналитике? Или, возможно, мы должны добавить метку времени, чтобы мы знали, когда все это произошло …

Хранение Поведения

Слабость такова, что мы знаем только то, на что похожи данные сейчас. Наши данные похожи на фотографию, когда нам нужно видео. Что если мы попробуем что-то другое?

 $events = []; $events[] = new ProductInvented("Chocolate"); $events[] = new ProductPriced("Chocolate", 499); $events[] = new OutletOpened("Pismo Beach"); $events[] = new OutletStocked("Pismo Beach", 24, "Chocolate"); store($events); 

Это сохраняет ту же самую возможную информацию, но каждый из шагов является автономным. Они описывают поведение покупателей, торговых точек, запасов и т. Д.

Используя этот подход, мы намного лучше контролируем сроки событий, которые привели к текущему состоянию. Мы могли бы добавить события для раздачи акций или прекращения выпуска продукции:

 $events = []; $events[] = new OutletStockGivenAway( "Pismo Beach", 2, "Chocolate" ); $events[] = new OutletDiscontinuedProduct( "Pismo Beach", "Chocolate" ); store($events); 

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

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

 $lastWeek = Product::at("Chocolate", date("-1 WEEK")); $yesterday = Product::at("Chocolate", date("-1 DAY")); printf( "Chocolate increased, from %s to %s, in one week", $lastWeek->cents_per_serving, $yesterday->cents_per_serving ); 

… и мы могли бы сделать это без каких-либо дополнительных логических / временных полей. Мы могли бы вернуться к уже сохраненным данным и создать отчет нового типа. Это так ценно!

Так что же это?

Event Sourcing — это обе эти вещи. Речь идет о захвате каждого события (которое вы можете рассматривать как каждое изменение в данных приложения) как отдельную, повторяемую вещь. Речь идет о хранении этих событий в том же порядке, в котором они произошли, чтобы мы могли по собственному желанию отправиться в любой момент времени.

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

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

Создание событий

Мы собираемся использовать классы для описания событий. Это полезные, простые контейнеры, которые мы можем определить; и они помогут нам проверить данные, которые мы вводим, и данные, которые мы получаем для каждого события.

Те, кто имеет опыт в Event Sourcing, могут испытывать желание услышать, как я описываю такие вещи, как агрегаты. Я намеренно избегаю жаргона — почти так же, как я бы избегал разграничения между издевательствами, двойниками, заглушками и подделками — если бы я учил кого-то их первому опыту тестирования. Это идея, которая важна, и идея Event Sourcing — это запись поведения.

Вот абстрактное событие, которое мы можем использовать для моделирования реальных событий:

 abstract class Event { /** * @var DateTimeImmutable */ private $date; protected function __construct() { $this->date = date("Ymd H:i:s"); } public function date(): string { return $this->date; } abstract public function payload(): array; } 

Это из events.php

Это действительно важно (на мой взгляд), что классы событий просты. Используя подсказки типа PHP 7, мы можем проверять данные, которые мы используем для определения событий. Горстка простых методов доступа поможет нам снова получить важные данные.

Поверх этого класса мы можем определить реальные типы событий, которые мы хотим записать:

 final class ProductInvented extends Event { /** * @var string */ private $name; public function __construct(string $name) { parent::__construct(); $this->name = $name; } public function payload(): array { return [ "name" => $this->name, "date" => $this->date(), ]; } } 
 final class ProductPriced extends Event { /** * @var string */ private $product; /** * @var int */ private $cents; public function __construct(string $product, int $cents) { parent::__construct(); $this->product = $product; $this->cents = $cents; } public function payload(): array { return [ "product" => $this->product, "cents" => $this->cents, "date" => $this->date(), ]; } } 
 final class OutletOpened extends Event { /** * @var string */ private $name; public function __construct(string $name) { parent::__construct(); $this->name = $name; } public function payload(): array { return [ "name" => $this->name, "date" => $this->date(), ]; } } 
 final class OutletStocked extends Event { /** * @var string */ private $outlet; /** * @var int */ private $servings; /** * @var string */ private $product; public function __construct(string $outlet,  int $servings, string $product) { parent::__construct(); $this->outlet = $outlet; $this->servings = $servings; $this->product = $product; } public function payload(): array { return [ "outlet" => $this->outlet, "servings" => $this->servings, "product" => $this->product, "date" => $this->date(), ]; } } 

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

Мне также интересно, как мы можем изолировать определение, формат и доступность дат событий: определяя $date как частную и требуя, чтобы подклассы обращались к ней через метод date . Возможно, это слишком оборонительно, но оно подчиняется закону Деметры , так как конкретным событиям не нужно знать, как дата определяется или форматируется, чтобы ее использовать.

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

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

Хранение событий

Давайте сохраним эти события в базе данных SQLite. Мы могли бы использовать ORM для этого, но, возможно, это хорошая возможность вспомнить, как работает PDO.

Использование PDO

Первый фрагмент кода для подключения к любой поддерживаемой базе данных через PDO:

 $connection = new PDO("sqlite::memory:"); $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); 

Это из sqlite-pdo.php

PDO-соединения обычно выполняются с использованием имени источника данных (DSN). Здесь мы определяем тип базы данных как sqlite , а местоположение как базу данных в памяти. Это означает, что база данных исчезнет, ​​как только скрипт завершит работу.

Также неплохо установить режим ошибок для выдачи исключений при возникновении ошибки SQL. Таким образом, мы сразу получим отзыв о наших ошибках.

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

Далее мы должны создать несколько таблиц для работы с:

 $statement = $connection->prepare(" CREATE TABLE IF NOT EXISTS product ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT ) "); $statement->execute(); 

Это из sqlite-pdo.php

В одной из этих таблиц мы будем генерировать и хранить уникальные идентификаторы продуктов. Точный синтаксис CREATE TABLE немного отличается в зависимости от типа базы данных, и вы, как правило, найдете больше столбцов в таблице.

Отличный способ узнать, как ваша база данных создает таблицы, — создать таблицу с помощью графического интерфейса и запустить SHOW CREATE TABLE my_new_table . Это создаст синтаксис CREATE TABLE во всех поддерживаемых базах данных PDO.

Подготовленные операторы (с использованием prepare и execute ) являются рекомендуемым способом выполнения SQL-запросов. Они еще более полезны, когда вам нужно передать параметры запроса:

 $statement = $connection->prepare( "INSERT INTO product (name) VALUES (:name)" ); $statement->bindValue("name", "Chocolate"); $statement->execute(); 

Это из sqlite-pdo.php

Связанные значения автоматически заключаются в кавычки и экранируются, избегая наиболее распространенных видов внедрения SQL. Мы также можем использовать подготовленные операторы для возврата строк:

 $row = $connection ->prepare("SELECT * FROM product") ->execute()->fetch(PDO::FETCH_ASSOC); $rows = $connection ->prepare("SELECT * FROM product") ->execute()->fetchAll(PDO::FETCH_ASSOC); 

Это из sqlite-pdo.php

Эти методы fetch и fetchAll будут возвращать массивы и массивы или массивы соответственно, учитывая, что мы используем тип PDO::FETCH_ASSOC .

Добавление вспомогательных функций

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

 function connect(string $dsn): PDO { $connection = new PDO($dsn); $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); return $connection; } function execute(PDO $connection, string $query,  array $bindings = []): array { $statement = $connection->prepare($query); foreach ($bindings as $key => $value) { $statement->bindValue($key, $value); } $result = $statement->execute(); return [$statement, $result]; } function rows(PDO $connection, string $query,  array $bindings = []): array { $executed = execute($connection, $query, $bindings); /** @var PDOStatement $statement */ $statement = $executed[0]; return $statement->fetchAll(PDO::FETCH_ASSOC); } function row(PDO $connection, string $query,  array $bindings = []): array { $executed = execute($connection, $query, $bindings); /** @var PDOStatement $statement */ $statement = $executed[0]; return $statement->fetch(PDO::FETCH_ASSOC); } 

Это из sqlite-pdo-helpers.php

Это почти такой же код, как мы видели раньше. Они немного приятнее в использовании, чем прямой код PDO:

 $connection = connect("sqlite::memory:"); execute( $connection, "CREATE TABLE IF NOT EXISTS product  (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT)" ); execute( $connection, "INSERT INTO product (name) VALUES (:name)", ["name" => "Chocolate"] ); $rows = rows( $connection, "SELECT * FROM product" ); $row = row( $connection, "SELECT * FROM product WHERE name = :name", ["name" => "Chocolate"] ); 

Это из sqlite-pdo-helpers.php

Вам может не понравиться идея определения глобальных функций для этих вещей. Они похожи на то, что вы видели в темные века PHP. Но они так лаконичны и просты в использовании!

Их даже не сложно проверить:

 $fake = new class("sqlite::memory:") extends PDO { private $valid = true; function prepare($statement, $options = null) { if ($statement !== "SELECT * FROM product") { $this->valid = false; } return $this; } function execute() { return; } function fetchAll() { if (!$this->valid) { throw new Exception(); } return []; } }; assert(connect("sqlite::memory:") instanceof PDO); assert(is_array(rows($fake, "SELECT * FROM product"))); 

Это из sqlite-pdo-helpers.php

Мы не тестируем все варианты вспомогательных функций, но вы поняли …

Если вы все еще в замешательстве, посмотрите этот пост более подробно на PDO.

Хранение событий

Давайте еще раз посмотрим на события, которые мы хотим сохранить:

 $events = []; $events[] = new ProductInvented("Chocolate"); $events[] = new ProductPriced("Chocolate", 499); $events[] = new OutletOpened("Pismo Beach"); $events[] = new OutletStocked("Pismo Beach", 24, "Chocolate"); 

Самый простой подход — создать таблицу базы данных для каждого из этих типов событий:

 execute($connection, " CREATE TABLE IF NOT EXISTS product ( id INTEGER PRIMARY KEY AUTOINCREMENT ) "); execute($connection, " CREATE TABLE IF NOT EXISTS event_product_invented ( id INT, name TEXT, date TEXT ) "); execute($connection, " CREATE TABLE IF NOT EXISTS event_product_priced ( product INT, cents INT, date TEXT ) "); execute($connection, " CREATE TABLE IF NOT EXISTS outlet ( id INTEGER PRIMARY KEY AUTOINCREMENT ) "); execute($connection, " CREATE TABLE IF NOT EXISTS event_outlet_opened ( id INT, name TEXT, date TEXT ) "); execute($connection, " CREATE TABLE IF NOT EXISTS event_outlet_stocked ( outlet INT, servings INT, product INT, date TEXT ) "); 

Это из storing-events.php

В дополнение к таблице для каждого события я также добавил таблицы для хранения и генерации идентификаторов продуктов и торговых точек. Каждая таблица событий имеет поле даты, значение которого генерируется абстрактным классом Event .

Настоящее волшебство происходит в функциях store и storeOne :

 function store(PDO $connection, array $events) { foreach($events as $event) { storeOne($connection, $event); } } function storeOne(PDO $connection, Event $event) { $payload = $event->payload(); if ($event instanceof ProductInvented) { inventProduct( $connection, newProductId($connection), $payload["name"], $payload["date"] ); } if ($event instanceof ProductPriced) { priceProduct( $connection, productIdFromName($connection, $payload["name"]), $payload["cents"], $payload["date"] ); } if ($event instanceof OutletOpened) { openOutlet( $connection, newOutletId($connection), $payload["name"], $payload["date"] ); } if ($event instanceof OutletStocked) { stockOutlet( $connection, outletIdFromName( $connection, $payload["outlet_id"] ), $payload["servings"], productIdFromName( $connection, $payload["product_id"] ), $payload["date"] ); } } 

Это из storing-events.php

Функция store — это просто удобство. В PHP нет понятия типизированных массивов, поэтому мы могли бы добавить проверку во время выполнения или использовать сигнатуру storeOne чтобы проверить, что мы только пытаемся хранить экземпляры подкласса Event .

Мы можем получить конкретные данные о событии с помощью метода payload . Эти данные будут отличаться в зависимости от хранимого класса событий, поэтому мы должны принимать ключи только после того, как будем уверены, с каким типом события мы имеем дело.

Мы также используем некоторые вспомогательные методы для продуктов и торговых точек. Вот как они выглядят:

 function newProductId(PDO $connection): int { execute( $connection, "INSERT INTO product VALUES (null)" ); return $connection->lastInsertId(); } function inventProduct(PDO $connection, int $id,  string $name, string $date) { execute( $connection, "INSERT INTO event_product_invented  (id, name, date) VALUES (:id, :name, :date)", ["id" => $id, "name" => $name, "date" => $date] ); } function productIdFromName(PDO $connection, string $name): int { $row = row( $connection, "SELECT * FROM event_product_invented  WHERE name = :name", ["name" => $name] ); if (!$row) { throw new InvalidArgumentException("Product not found"); } return $row["id"]; } function priceProduct(PDO $connection, int $product,  int $cents, string $date) { execute( $connection, "INSERT INTO event_product_priced  (product, cents, date) VALUES  (:product, :cents, :date)", ["product" => $product, "cents" => $cents,  "date" => $date] ); } function newOutletId(PDO $connection): int { execute( $connection, "INSERT INTO outlet VALUES (null)" ); return $connection->lastInsertId(); } function openOutlet(PDO $connection, int $id,  string $name, string $date) { execute( $connection, "INSERT INTO event_outlet_opened (id, name, date)  VALUES (:id, :name, :date)", ["id" => $id, "name" => $name, "date" => $date] ); } function outletIdFromName(PDO $connection, string $name): int { $row = row( $connection, "SELECT * FROM event_outlet_opened  WHERE name = :name", ["name" => $name] ); if (!$row) { throw new InvalidArgumentException("Outlet not found"); } return $row["id"]; } function stockOutlet(PDO $connection, int $outlet,  int $servings, int $product, string $date) { execute( $connection, "INSERT INTO event_outlet_stocked  (outlet_id, servings, product_id, date)  VALUES (:outlet, :servings, :product, :date)", ["outlet" => $outlet, "servings" => $servings,  "product" => $product, "date" => $date] ); } 

Это из storing-events.php

inventProduct priceProduct , openOutlet priceProduct , openOutlet и stockOutlet все stockOutlet сами за себя. Чтобы получить идентификаторы, на которые они ссылаются, нам нужны функции newProductId и newOutletId . Они вставляют пустые строки, так что уникальные идентификаторы будут сгенерированы и могут быть возвращены (используя метод $connection->lastInsertId() ).

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

Мы можем проверить их, используя шаблон, похожий на:

 store($connection, [ new ProductInvented("Cheesecake"), ]); $row = row( $connection, "SELECT * FROM event_product_invented WHERE name = :name", ["name" => "Cheesecake"] ); assert(!is_null($row)); 

Это из storing-events.php

Проектирование событий

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

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

Я считаю, что одно из самых больших препятствий на пути новичков в Event Sourcing заключается в том, что они не знают, как реально применить его в своей ситуации. Это не поможет нам поговорить о теории Event Sourcing, если мы не будем говорить о том, как правильно ее использовать!

Ранее мы видели такие функции, как:

 Product::at("Chocolate", date("-1 WEEK")); // → ["id" => 1, "name" => "Chocolate", ...] 

В идеале у нас также были бы следующие методы:

 Product::latest(); // → [["id" => 1, "name" => "Chocolate", ...], ...] Product::latest("Chocolate"); // → ["id" => 1, "name" => "Chocolate", ...] 

Сначала нам нужно загрузить все события, хранящиеся в базе данных:

 function fetch(PDO $connection): array { $events = []; $tables = [ ProductInvented::class => "event_product_invented", ProductPriced::class => "event_product_priced", OutletOpened::class => "event_outlet_opened", OutletStocked::class => "event_outlet_stocked", ]; foreach ($tables as $type => $table) { $rows = rows($connection, "SELECT * FROM {$table}"); $rows = array_map( function($row) use ($connection, $type) { return $type::from($connection, $row); }, $rows ); $events = array_merge($events, $rows); } usort($events, function(Event $a, Event $b) { return strtotime($a->date()) - strtotime($b->date()); }); return $events; } 

Это из projecting-events.php

Здесь происходит довольно много, поэтому давайте разберемся с этим:

  1. Мы определяем список таблиц событий для получения строк.
  2. Мы выбираем строки для каждого типа / таблицы и преобразовываем полученные ассоциативные массивы в экземпляры событий.
  3. Мы используем date каждого события, чтобы отсортировать их в хронологическом порядке.

Нам нужно добавить эти новые методы from в каждое из наших событий:

 abstract class Event { // ...snip public function withDate(string $date): self { $new = clone $this; $new->date = $date; return $new; } abstract public static function from(PDO $connection, array $data); } 
 final class ProductInvented extends Event { // ...snip public static function from(PDO $connection, array $data) { $new = new static( $data["name"] ); return $new->withDate($data["date"]); } } 
 final class ProductPriced extends Event { // ...snip public static function from(PDO $connection, array $data) { $new = new static( productNameFromId($connection, $data["product"]), $data["cents"] ); return $new->withDate($data["date"]); } } 
 final class OutletOpened extends Event { // ...snip public static function from(PDO $connection, array $data) { $new = new static( $data["name"] ); return $new->withDate($data["date"]); } } 
 final class OutletStocked extends Event { // ...snip public static function from(PDO $connection, array $data) { $new = new static( outletNameFromId($connection, $data["outlet"]), $data["servings"], productNameFromId($connection, $data["product"]) ); return $new->withDate($data["date"]); } } 

Это из events.php

Мы также используем несколько новых глобальных функций:

 function productNameFromId(PDO $connection, int $id): string { $row = row( $connection, "SELECT * FROM event_product_invented WHERE id = :id", ["id" => $id] ); if (!$row) { throw new InvalidArgumentException("Product not found"); } return $row["name"]; } function outletNameFromId(PDO $connection, int $id): string { $row = row( $connection, "SELECT * FROM event_outlet_opened WHERE id = :id", ["id" => $id] ); if (!$row) { throw new InvalidArgumentException("Outlet not found"); } return $row["name"]; } 

Это из projecting-events.php

Причина, по которой нам нужна любая из этих *NameFromId и *IdFromName заключается в том, что мы хотим создавать и представлять события с использованием имен сущностей, но мы хотим сохранить их как внешние ключи в базе данных. Это мое личное предпочтение, и вы можете определять / представлять / хранить их, однако имеет смысл для вас.

Теперь мы можем превратить список событий в строки базы данных и обратно:

 $events = []; $events[] = new ProductInvented("Chocolate"); $events[] = new ProductPriced("Chocolate", 499); $events[] = new OutletOpened("Pismo Beach"); $events[] = new OutletStocked("Pismo Beach", 24, "Chocolate"); store($connection, $events); // ← events stored in database $stored = fetch($connection); // ← events loaded from database assert(json_encode($events) === json_encode($stored)); 

Теперь, как мы можем преобразовать это в нечто полезное? Нам нужно определить еще несколько вспомогательных функций:

 function project(PDO $connection, array $events): array { $entities = [ "products" => [], "outlets" => [], ]; foreach ($events as $event) { $entities = projectOne($connection, $entities, $event); } return $entities; } function projectOne(PDO $connection, array $entities,  Event $event): array { if ($event instanceof ProductInvented) { $entities = projectProductInvented( $connection, $entities, $event ); } if ($event instanceof ProductPriced) { $entities = projectProductPriced( $connection, $entities, $event ); } if ($event instanceof OutletOpened) { $entities = projectOutletOpened( $connection, $entities, $event ); } if ($event instanceof OutletStocked) { $entities = projectOutletStocked( $connection, $entities, $event ); } return $entities; } 

Это из projecting-events.php

Этот код похож на код, который мы используем для хранения событий. Для каждого типа события мы модифицируем массив объектов. После того, как все события были спроектированы, у нас должно быть последнее состояние. Вот как выглядят другие методы проектора:

 function projectProductInvented(PDO $connection,  array $entities, ProductInvented $event): array { $payload = $event->payload(); $entities["products"][] = [ "id" => productIdFromName($connection, $payload["name"]), "name" => $payload["name"], ]; return $entities; } function projectProductPriced(PDO $connection,  array $entities, ProductPriced $event): array { $payload = $event->payload(); foreach ($entities["products"] as $i => $product) { if ($product["name"] === $payload["product"]) { $entities["products"][$i]["price"] =  $payload["cents"]; } } return $entities; } function projectOutletOpened(PDO $connection,  array $entities, OutletOpened $event): array { $payload = $event->payload(); $entities["outlets"][] = [ "id" => outletIdFromName($connection, $payload["name"]), "name" => $payload["name"], "stock" => [], ]; return $entities; } function projectOutletStocked(PDO $connection,  array $entities, OutletStocked $event): array { $payload = $event->payload(); foreach ($entities["outlets"] as $i => $outlet) { if ($outlet["name"] === $payload["outlet"]) { foreach ($entities["products"] as $j => $product) { if ($product["name"] === $payload["product"]) { $entities["outlets"][$i]["stock"][] = [ "product" => &$product, "servings" => $payload["servings"], ]; } } } } return $entities; } 

Это из projecting-events.php

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

Проектирование событий

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

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

Таким образом, вы можете захватывать события (через такие вещи, как запросы API или посты форм) и по-прежнему запрашивать «обычные» таблицы базы данных при отображении данных в приложении.

Проектирование конкретно

До сих пор мы видели, как мы можем описывать, хранить и проектировать (до последнего состояния). Проецирование на определенный момент времени — это всего лишь вопрос настройки функций проекции, чтобы они применяли события до или после определенной временной отметки.

Мы рассмотрели намного больше, чем я планировал, поэтому я оставлю это последнее упражнение для вас. Подумайте, как вы могли бы смоделировать традиционную (для приложений CMS) модель черновой / рабочей и опубликованной версий контента.

Резюме

Если вам удалось зайти так далеко; отлично сработано! Это было долгое путешествие, но оно того стоит. Дайте нам знать, что вам нравится или не нравится в этом шаблоне дизайна. Если вы хотите узнать больше о Event Sourcing (или DDD в целом), обязательно ознакомьтесь с книгами, связанными с самого начала.

Код, полный массивов, может стать уродливым, быстрым! Я настоятельно рекомендую вам ознакомиться с книгой Адама Уотана о рефакторинге кода цикла в коллекции .