Бэкэнд, вдохновленный Redux

TL; DR: Я не собираюсь запускать Redux на сервере. Если вы используете React с Redux и предпочитаете функциональный подход к управлению состоянием, то эта статья покажет вам, как распространить его идеи на бэкэнд.

Преимущества React + Redux

Комбинация React и Redux представляет собой простую, но мощную концепцию однонаправленного потока данных:

Состояние - это отправная точка. Взгляд - это функция государства. Компонент React может буквально быть функцией своих свойств, что делает его функцией состояния. Все взаимодействия пользователя с представлением представлены последовательностями или потоками действий Redux. Действия обрабатываются функциями (редукторами) для вычисления новых состояний.

React + Redux обеспечивает функциональное программирование с неизменяемым состоянием и чистыми функциями. Это делает код предсказуемым и легким для тестирования и рассуждений. Он позволяет вам увидеть последовательность событий, которые привели к определенному состоянию вашего приложения. Это позволяет путешествовать во времени: с помощью инструментов разработки Redux вы можете проверять состояние своего приложения в любой момент времени.

Как распространить эти преимущества на серверную часть

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

Посмотрите на свое приложение React + Redux: что является источником истины - состояние или действия? В приложении Redux действия являются источником истины, и состояние может быть вычислено на основе действий редукторов.

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

Как сделать серверную часть на основе Redux реальностью

Вы можете сказать, что на самом деле все не так просто. Настоящие бэкенды сложны. А как насчет многопользовательского доступа, бизнес-логики, моделей предметной области, транзакций?

Давайте рассмотрим все эти проблемы и посмотрим, что мы можем сделать для их решения.

Не отправляйте все на серверную часть

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

Действия: команды или события?

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

Назовем намерение что-то изменить командой. Бизнес-логика обработает команды и создаст факт, запись о том, что система изменила свое состояние. Назовем эту запись событием.

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

Какая «бизнес-логика» должна обрабатывать команду? Это функция, которая принимает команду и создает событие в соответствии с бизнес-правилами. Я буду использовать подход Domain Driven Design (DDD), поэтому нам нужен объект домена, способный выполнять команду как транзакцию. Обычно это не один объект, а набор связанных объектов, называемый DDD Aggregate.

Как и в случае с Redux, состояние этого агрегата является функцией его предыдущих событий, рассчитываемых редукторами.

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

Существует несколько потоков действий

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

Это приводит к проблеме согласованности. Как вы выполняете команду на основе состояния системы, когда это состояние постоянно изменяется другими событиями? Здесь нам снова поможет концепция агрегирования доменов от DDD. Aggregate (или Aggregate root) представляет собой бизнес-объекты (часть состояния системы), которые можно изменить с помощью одной команды. Агрегат должен содержаться в границах транзакции / согласованности:

  • Границы согласованности: когда начинается выполнение команды, состояние агрегата согласовано. К нему были применены все ранее сгенерированные События.
  • Границы транзакции: выполнение команды транзакционное. Никакая другая Команда не может быть выполнена для того же Агрегата, пока предыдущая Команда все еще выполняется, или пока все еще обрабатываются результирующие События и изменения состояния.

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

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

Запросы и модели чтения

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

Как мы узнаем текущее состояние системы? Так же, как и любое приложение Redux: с помощью редукторов. Мы начинаем с начального состояния системы (которое обычно пусто или не существует) и вызываем редукторы с каждым событием с начала времени.

Текущее состояние системы представлено моделями чтения. Эти модели чтения рассчитываются редукторами из потока событий.

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

Имея модель чтения на месте, клиент может отправить запрос на серверную часть. Функция Query Resolver может отвечать на запрос, работая с моделью чтения.

Посмотрим на наш результат

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

Поиск событий

Мы храним События (как первоисточник истины) и вычисляем по ним состояние системы. Этот подход называется Event Sourcing. Отличный способ научиться этому - прочитать определение Мартина Фаулера и посмотреть классическое вступительное видео от Грега Янга.

CQRS

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

Агрегаты DDD представляют собой часть состояния системы, достаточную для обработки команды. Командная сторона не должна напрямую обращаться к стороне, где находятся запросы и модели чтения. Все, что ему нужно сделать, это отправить события в шину событий. Это приводит нас к шаблону Разделение ответственности команд и запросов (CQRS). Наша система может быть физически разделена на сторону записи, которая генерирует события, и сторону чтения, которая принимает события и проецирует их на модели чтения.

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

Свойства CQRS + ES

CQRS и Event Sourcing естественно сочетаются друг с другом. Давайте рассмотрим свойства архитектуры CQRS + ES:

Удобство функционального программирования: легко протестировать и обсудить

Подобно React + Redux, код в системе CQRS + ES удобен в работе. Существует односторонний поток данных, а команда, событие и запрос представляют собой простые структуры данных или объекты передачи данных. Обработчики команд и редукторы - это чистые функции без побочных эффектов. Такие функции легко протестировать без необходимости что-либо имитировать.

Путешествие во времени

Как и в случае с React + Redux, можно восстановить состояние системы на любой момент времени, чтобы воспроизвести проблемы и ошибки.

Реактивное приложение

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

Асинхронные операции

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

Система, которую легко изменять, рефакторировать и развивать

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

Полный подробный журнал аудита

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

Большинство систем баз данных хранят журналы транзакций внутри себя, но эти журналы относятся к хранилищу, а не к бизнесу (UPDATE orders SET status = 3 по сравнению с событием «Order Paid»).

Системы с источниками событий предоставляют вам журналы аудита для конкретного бизнеса с гарантированным 100% заполнением. Эти журналы можно использовать для полного восстановления состояния системы и анализа любой последовательности событий.

База данных только для добавления с фиксированной структурой

Хранилище событий предназначено только для добавления. Он хранит события, используя фиксированную структуру: несколько системных полей и полезные данные произвольной формы. Это делает реализацию хранилища событий простой и независимой от дизайна какой-либо конкретной предметной области. Функция только добавления позволяет легко реплицировать и синхронизировать данные о событиях.

Нет общего изменяемого состояния

«Изменчивость - это нормально, совместное использование - это хорошо, общая изменчивость - дело дьявола».

Общее изменяемое состояние - это плохо. CQRS + ES позволяет в значительной степени избежать этой проблемы.

Без потери данных

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

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

С другой стороны, хранилище событий предназначено только для добавления. Ничего не удаляется. Пока операция фиксируется в событии, данные всегда доступны для анализа.

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

Распределенные масштабируемые приложения

Приложения CQRS / ES легко распространять и масштабировать. Сторона записи может масштабироваться независимо от стороны чтения. С шиной событий обе стороны могут охватывать множество экземпляров.

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

Без сервера

Обработчики команд и функции проекции могут быть реализованы как облачные функции. Считанные модели можно хранить в облачных базах данных. Шина событий может использовать службы очередей или потоков. Поток событий может быть обработан средствами анализа потока. Поскольку нет необходимости в общих изменяемых объектах домена, мы можем развернуть систему приложений на бессерверных платформах, таких как AWS Lambda или Azure Functions.

Конечная согласованность

Изменения, внесенные пользователями, не распространяются немедленно. В этой архитектуре доступность важнее согласованности. Это то, что нам нужно иметь в виду при разработке приложений CQRS + ES при переходе с CRUD-мышления. Вот хорошая статья, объясняющая, чем системы CQRS + ES отличаются.

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

События представляют собой дизайн домена

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

Поскольку события неизменны, развитие системы событий - важный навык, заслуживающий отдельной книги. Вот один от Грега Янга.

К счастью, идентифицировать события в предметной области проще, чем проектировать модель предметной области. Все, что нам нужно сделать, это зафиксировать факт, представленный событием («Билет № 1234 закрыт»). Позже мы сможем принять решение о значении и последствиях, связанных с Событием.

Еще больше реактивных приложений с Node.js и React + Redux

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

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

Способ разработки современного приложения

Меня всегда восхищала возможность быстро разработать бизнес-приложение. «В конце концов, это просто списки и формы». В DevExpress мы создали фреймворк под названием XAF, который позволяет пользователям быстро создавать типичное бизнес-приложение (по состоянию на 2005 год). Вы знаете, база данных в центре, ORM, настольное приложение для толстого клиента. XAF упростил начало работы, позволил вам меньше заботиться о базе данных и просто кодировать бизнес-объекты знакомым способом ООП.

Единственная большая техническая проблема, которая не была решена архитектурными концепциями, использованными в XAF - и многих других приложениях, созданных примерно в то время, - заключалась в поддержании и изменении структур данных с течением времени. Если вы измените модель домена, вам необходимо перенести данные в новую схему БД или тщательно настроить правила сопоставления ORM. В обоих случаях все очень быстро усложняется.

У event-sourcing таких проблем нет. С небольшими усилиями вы даже можете запускать старую и новую модели предметной области параллельно.

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

Пример кода с использованием инфраструктуры reSolve

ReSolve - это фреймворк CQRS + ES для Node.js. С его помощью вы можете сосредоточиться на агрегатах, командах, событиях, запросах, моделях чтения, просмотре моделей и меньше заботиться о сантехнической инфраструктуре.

Начать довольно просто - просто используйте CLI `create-resolve-app`, чтобы создать пустое приложение:

$ npx create-resolve-app myApp

Если вы хотите, чтобы ваше приложение включало пример проекта, используйте переключатель `-e`:

$ npx create-resolve-app myApp -e shopping-list

Я покажу вам, какой тип кода вы должны написать для reSolve. Ознакомьтесь с учебником по reSolve Shopping List для получения полного объяснения.

Сторона записи

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

и редукторы для вычисления агрегированного состояния:

Сторона чтения

Затем вы можете определить свои модели чтения и функции проекции. reSolve предоставляет унифицированный интерфейс базы данных для моделей чтения.

Наконец, преобразователь запросов (он возвращает все списки покупок):

Это все, что вам нужно сделать, чтобы определить серверную часть. Запустите приложение, и теперь вы можете отправлять команды:

Теперь вы можете отправить запрос:

Пакет resolve-redux предоставляет компоненты более высокого порядка (HOC), которые помогают подключать модели чтения к компонентам. Команды агрегирования доступны как действия Redux в aggregateActions:

Модель представления reSolve - это особый вид модели чтения, для которой не требуется постоянное хранилище.

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

Если вы знаете Redux, вы можете использовать reSolve для создания современного реактивного бэкенда с использованием того же языка и концепций.

Заключение

Государственное управление - это сложно. Redux упрощает управление состоянием пользовательского интерфейса. Event Sourcing и CQRS построены на схожих идеях, чтобы упростить управление состоянием системы для бэкэндов.

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

С фреймворком reSolve вы можете использовать знакомый функциональный JavaScript для создания надежного реактивного бэкенда.

Дополнительная литература