Redux — это отличное средство для управления состоянием сложных фронтенд-приложений. Автор материала, перевод которого мы сегодня публикуем, собирается найти ответ на вопрос о том, можно ли воспользоваться возможностями Redux в серверной среде.
Зачем нужна библиотека Redux?
На домашней странице библиотеки Redux написано, что это — «предсказуемый контейнер состояния для JavaScript-приложений». О Redux обычно говорят как об инструменте для управления состоянием приложения, и, хотя эта библиотека, в основном, используется вместе с React, применять её можно в любых проектах, основанных на JavaScript.
Мы уже упомянули о том, что Redux используется для управления состоянием приложения. Поговорим теперь о том, что такое «состояние». Это понятие довольно сложно определить, но мы всё же попытаемся его описать.
Рассматривая «состояние», если речь идёт о людях или о предметах материального мира, мы стремимся описать, собственно, их состояние на тот момент времени, в который мы о них говорим, возможно, рассматривая один или несколько параметров. Например, мы можем сказать об озере: «Вода очень горячая», или: «Вода замёрзла». В этих высказываниях мы описываем состояние озера с точки зрения температуры его воды.
Когда некто говорит о себе: «Я на мели», — он рассматривает имеющуюся у него сумму денег. Ясно, что в каждом из этих примеров речь идёт лишь об одном аспекте состояния объектов. Но, в примере про деньги, высказыванием может быть и таким, описывающим несколько параметров: «Я на мели, я давно не ел, но я счастлив!». Тут очень важно отметить то, что состояние — это нечто непостоянное. Это означает, что оно может меняться. Поэтому, когда мы узнаём о текущем состоянии некоего объекта, мы понимаем, что его реальное состояние может измениться через несколько секунд или минут после того, как мы о нём узнали.
Когда мы имеем дело с программами, то с понятием «состояние» связаны некоторые особенности. Во-первых, состояние приложения представлено данными, которые где-то хранятся. Например, эти данные могут храниться в памяти (скажем, в виде JavaScript-объекта), но они могут храниться и в файле, и в базе данных, и с использованием средств некоего кэширующего механизма вроде Redis. Во-вторых, состояние приложения обычно привязано к его конкретному экземпляру. Поэтому, когда мы говорим о состоянии приложения, мы имеем в виду определённый экземпляр этого приложения, процесс, рабочую среду, организованную в приложении для конкретного пользователя. Состояние приложения может включать в себя, например, следующие сведения:
- Вошёл ли пользователь в систему или нет? Если да — то как долго длится сессия и когда она истечёт?
- Сколько очков набрал пользователь? Такой вопрос актуален, например, для некоей игры.
- Где именно пользователь поставил видео на паузу? Этот вопрос можно задать о приложении-проигрывателе видеофайлов.
Если говорить о состоянии приложений на более низком уровне, то оно может включать в себя, например, следующие сведения:
- Какие переменные заданы в текущем окружении, в котором работает приложение (это относится к так называемым «переменным окружения»).
- Какие файлы в настоящий момент использует программа?
Глядя на «моментальный снимок» (их нередко называют «снапшотами» — от snapshot) состояния приложения в любой момент времени, мы можем узнать о том, в каких условиях работало в этот момент приложение, и, возможно, при необходимости воссоздать эти условия, приведя приложение к тому состоянию, в котором оно пребывало на момент получения снапшота.
Состояние может быть модифицировано в ходе выполнения пользователем неких действий. Например, если пользователь правильно переместит игрового персонажа в простой игре, это может увеличить количество очков. В достаточно сложных приложениях может усложниться и подход к модификации состояния, изменения состояния могут исходить из разных источников.
Например, в многопользовательской игре то, сколько очков наберёт пользователь, зависит не только от его действий, но и от действий тех, кто играет с ним в одной команде. А если на игрового персонажа, которого контролирует пользователь, успешно нападёт персонаж, управляемый компьютером, пользователь может потерять некоторое количество очков.
Представим, что мы занимаемся разработкой фронтенд-приложения вроде PWA Twitter. Это — одностраничное приложение, в котором есть несколько вкладок, скажем — Домашняя страница (Home), Поиск (Search), Уведомления (Notifications) и Сообщения (Messages). С каждой такой вкладкой связана собственная рабочая область, которая предназначена как для отображения некоей информации, так и для её модификации. Все эти данные формируют состояние приложения. Так, новые твиты, уведомления и сообщения поступают в приложение каждые несколько секунд. Пользователь может работать с программой и с этими данными. Например, он может создать твит или удалить его, может ретвитнуть некий твит, он может читать уведомления, отправлять кому-то сообщения, и так далее. Всё, о чём только что шла речь, модифицирует состояние приложения.
Со всеми этими вкладками связан собственный набор компонентов пользовательского интерфейса, используемых для вывода и модификации данных. На состояние приложения могут воздействовать данные, поступающие в приложение извне, и действия пользователя
Ясно, что в подобном приложении источниками изменений состояния могут являться разные сущности, при этом изменения, инициируемые разными источниками, могут происходить практически одновременно. Если мы будем управлять состоянием вручную, может оказаться, что нам будет сложно следить за происходящим. Эти сложности ведут к противоречиям. Например, твит может быть удалён, но он всё ещё будет выводиться в ленте твитов. Или, скажем, пользователь может прочесть уведомление или сообщение, но оно всё ещё будет отображаться в программе как непросмотренное.
Пользователь может лайкнуть твит, в интерфейсе программы появится сердечко, но сетевой запрос, передающий на сервер сведения о лайке, не сработает. В результате то, что видит пользователь, будет отличаться от того, что хранится на сервере. Именно для того, чтобы не допустить подобных ситуаций, и может понадобиться Redux.
Как работает Redux?
В библиотеке Redux можно выделить три основных концепции, которые направлены на то, чтобы сделать управление состоянием приложений простым и понятным:
- Хранилище (store). Хранилище Redux — это JavaScript-объект, который представляет состояние приложения. Он играет роль «единственного источника достоверных данных». Это означает, что всё приложение должно полагаться на хранилище как на единственную сущность, ответственную за представление состояния.
- Действия (actions). Хранилище состояния предназначено только для чтения. Это означает, что его нельзя модифицировать, обращаясь к нему напрямую. Единственный способ изменить содержимое хранилища заключается в использовании действий. Любой компонент, который хочет изменить состояние, должен воспользоваться соответствующим действием.
- Редьюсеры (reducers), которые ещё называют «преобразователями». Редьюсер — это чистая функция, которая описывает то, как состояние модифицируется с помощью действий. Редьюсер принимает текущее состояние и действие, выполнение которого запросил некий компонент приложения, после чего возвращает преобразованное состояние.
Использование этих трёх концепций означает, что приложение больше не должно напрямую следить за событиями, являющимися источниками изменений состояния (действия пользователя, ответы API, возникновение событий, связанных с получением неких данных по протоколу WebSocket, и так далее) и принимать решения о том, как эти события повлияют на состояние.
Благодаря использованию модели Redux эти события могут вызывать соответствующие действия, которые и будут менять состояние. Компоненты, которым нужно пользоваться данными, хранящимися в состоянии приложения, могут просто подписаться на изменения состояния и получать интересующие их сведения. Задействуя все эти механизмы Redux стремится к тому, чтобы сделать изменения состояния приложения предсказуемыми.
Вот схематичный пример, демонстрирующий то, как можно организовать простую систему управления состоянием с использованием Redux в нашем выдуманном приложении:
import { createStore } from 'redux';
// редьюсер
const tweets = (state = {tweets: []}, action) => {
switch (action.type) {
// Мы обрабатываем лишь одно действие, выполняемое при поступлении нового твита.
case 'SHOW_NEW_TWEETS':
state.numberOfNewTweets = action.count;
return state.tweets.concat([action.tweets]);
default:
return state;
}
};
// Вспомогательная функция, с помощью которой создаётся действие. SHOW_NEW_TWEETS
const newTweetsAction = (tweets) => {
return {
type: 'SHOW_NEW_TWEETS',
tweets: tweets,
count: tweets.length
};
};
const store = createStore(tweets);
twitterApi.fetchTweets()
.then(response => {
// Вместо того, чтобы вручную добавлять твит в соответствующее место программы,
// мы отправляем действие Redux.
store.dispatch(newTweetsAction(response.data));
});
// Кроме того, мы используем действие SHOW_NEW_TWEETS когда пользователь создаёт твит
// в результате твит пользователя тоже добавляется в состояние приложения.
const postTweet = (text) => {
twitterApi.postTweet(text)
.then(response => {
store.dispatch(newTweetsAction([response.data]));
});
};
// Предположим, в приложение, по протоколу WebSocket, поступили новые твиты.
// При возникновении этого события мы тоже можем отправить действие. SHOW_NEW_TWEETS
socket.on('newTweets', (tweets) => {
store.dispatch(newTweetsAction(tweets));
};
// Если мы используем некий фреймворк, вроде React, то наши компоненты нужно подключить к хранилищу,
// нужно, чтобы они автоматически обновлялись бы для вывода новых твитов.
// В противном случае нам нужно самостоятельно настроить прослушивание событий,
// возникающих при изменении состояния.
store.subscribe(() => {
const { tweets } = store.getSTate();
render(tweets);
});
Взяв этот код за основу, мы можем оснастить нашу систему управления состоянием приложения дополнительными действиями и выполнять их отправку из различных мест приложения, не рискуя при этом безнадёжно запутаться.
Вот материал, из которого можно узнать подробности о трёх фундаментальных принципах Redux.
Теперь поговорим об использовании Redux в серверной среде.
Перенос принципов Redux в серверную среду
Мы исследовали возможности Redux, применяемые при разработке клиентских приложений. Но, так как Redux — это JavaScript-библиотека, её, теоретически, можно использовать и в серверной среде. Поразмышляем над тем, как вышеозначенные принципы могут быть применены на сервере.
Помните, как мы рассказывали о том, как выглядит состояние клиентского приложения? Нужно отметить, что между клиентскими и серверными приложениями существуют некоторые концептуальные различия. Так, клиентские приложения стремятся сохранять состояние между различными событиями, скажем, в промежутках между выполнением запросов к серверу. Такие приложения называются приложениями, хранящими состояние (stateful).
Если бы они не стремились к хранению состояния, то, например, при работе с неким веб-сервисом, требующим ввода логина и пароля, пользователю пришлось бы выполнять эту процедуру всякий раз, когда он переходит на новую страницу соответствующего веб-интерфейса.
Бэкенд-приложения, с другой стороны, стремятся к тому, чтобы состояние не хранить (их ещё называют stateless-приложениями). Здесь, говоря о «бэкенд-приложениях» мы, в основном, имеем в виду проекты, основанные на неких API, отделённых от фронтенд-приложений. Это означает, что сведения о состоянии системы должны предоставляться подобным приложениям при каждом обращении к ним. Например, API не следит за тем, вошёл пользователь в систему или нет. Оно определяет его статус, анализируя токен аутентификации в его запросах к этому API.
Это приводит нас к важной причине, по которой Redux вряд ли использовался бы на серверах в том виде, в котором мы описали его возможности выше.
Дело в том, что Redux был спроектирован для хранения временного состояния приложения. Но состояние приложения, хранящееся на сервере, обычно должно существовать достаточно долго. Если вы использовали бы хранилище Redux в своём серверном Node.js-приложении, то состояние этого приложения очищалось бы каждый раз, когда останавливается процесс node
. А если речь идёт о PHP сервере, на котором реализована похожая схема управления состоянием, то состояние очищалось бы при поступлении на сервер каждого нового запроса.
Ситуация усложняется ещё сильнее в том случае, если рассматривать серверные приложения с точки зрения возможности их масштабирования. Если бы вам пришлось масштабировать приложение горизонтально, увеличивая количество серверов, то у вас было бы множество Node.js-процессов, выполняющихся одновременно, и у каждого из них был бы собственный вариант состояния. Это означает, что при одновременном поступлении двух одинаковых запросов к бэкенду на них вполне могли бы быть даны разные ответы.
Как же применить обсуждаемые нами принципы управления состоянием на сервере? Взглянем ещё раз на концепции Redux и посмотрим на то, как их обычно используют в серверной среде:
- Хранилище. На бэкенде «единственным источником достоверных данных» обычно является некая база данных. Иногда, для того, чтобы облегчить доступ к данным, требующимся часто, или по каким-то другим причинам, может быть сделана копия какой-то части этой базы данных — в виде кэша или в виде файла. Обычно подобные копии предназначены только для чтения. Механизмы, управляющие ими, подписаны на изменения главного хранилища, и, при возникновении таких изменений, обновляют содержимое копий.
- Действия и редьюсеры. Они представляют собой единственные механизмы, используемые для изменения состояния. В большинстве бэкенд-приложений код написан в императивном стиле, который не особенно располагает к использованию концепций действий и редьюсеров.
Рассмотрим два шаблона проектирования, которые, по своей природе, схожи с тем, на что нацелена библиотека Redux. Это — CQRS и Event Sourcing. Они, на самом деле, появились раньше Redux, их реализация может быть крайне сложной, поэтому мы поговорим о них очень кратко.
CQRS и Event Sourcing
CQRS (Command Query Responsibility Segregation, разделение ответственности команд и запросов) — это шаблон проектирования, при реализации которого приложение выполняет чтение данных из хранилища только с помощью запросов, а запись — только с помощью команд.
При использовании CQRS единственным способом изменения состояния приложения является отправка команды. Команды подобны действиям Redux. Например, в Redux можно написать код, соответствующий такой схеме:
const action = { type: 'CREATE_NEW_USER', payload: ... };
store.dispatch(action);
// реализовать редьюсер для выполнения действия
const createUser = (state = {}, action) => {
//
};
При применении CQRS нечто подобное будет выглядеть так:
// базовый класс или интерфейс команды
class Command {
handle() {
}
}
class CreateUserCommand extends Command {
constructor(user) {
super();
this.user = user;
}
handle() {
// создать запись о пользователе в базе данных
}
}
const createUser = new CreateUserCommand(user);
// отправить команду (это вызовет метод handle())
dispatch(createUser);
// или здесь можно воспользоваться классом CommandHandler
commandHandler.handle(createUser);
Запросы — это механизмы чтения данных в шаблоне CQRS. Они эквивалентны конструкции store.getState()
. В простой реализации CQRS запросы будут напрямую взаимодействовать с базой данных, получая записи из неё.
Шаблон Event Sourcing (регистрация событий) разработан с прицелом на регистрацию всех изменений в состоянии приложения в виде последовательности событий. Этот шаблон лучше всего подходит для приложений, которым нужно знать не только об их текущем состоянии, но и об истории его изменений, о том, как приложение достигло его текущего состояния. В качестве примеров тут можно привести историю операций с банковскими счетами, отслеживание посылок, работу с заказами в интернет-магазинах, организацию грузоперевозок, логистику.
Вот пример реализации шаблона Event Sourcing:
// без использования шаблона Event Sourcing
function transferMoneyBetweenAccounts(amount, fromAccount, toAccount) {
BankAccount.where({ id: fromAccount.id })
.decrement({ amount });
BankAccount.where({ id: toAccount.id })
.increment({ amount });
}
function makeOnlinePayment(account, amount) {
BankAccount.where({ id: account.id })
.decrement({ amount });
}
// с использованием шаблона Event Sourcing
function transferMoneyBetweenAccounts(amount, fromAccount, toAccount) {
dispatchEvent(new TransferFrom(fromAccount, amount, toAccount));
dispatchEvent(new TransferTo(toAccount, amount, fromAccount));
}
function makeOnlinePayment(account, amount) {
dispatchEvent(new OnlinePaymentFrom(account, amount));
}
class TransferFrom extends Event {
constructor(account, amount, toAccount) {
this.account = account;
this.amount = amount;
this.toAccount = toAccount;
}
handle() {
// сохранить новое событие OutwardTransfer в базе данных
OutwardTransfer.create({ from: this.account, to: this.toAccount, amount: this.amount, date: Date.now() });
// и обновить текущее состояние счёта
BankAccount.where({ id: this.account.id })
.decrement({ amount: this.amount });
}
}
class TransferTo extends Event {
constructor(account, amount, fromAccount) {
this.account = account;
this.amount = amount;
this.fromAccount = fromAccount;
}
handle() {
// сохранить новое событие InwardTransfer в базе данных
InwardTransfer.create({ from: this.fromAccount, to: this.account, amount: this.amount, date: Date.now() });
// и обновить текущее состояние счёта
BankAccount.where({ id: this.account.id })
.increment({ amount: this.amount });
}
}
class OnlinePaymentFrom extends Event {
constructor(account, amount) {
this.account = account;
this.amount = amount;
}
handle() {
// сохранить новое событие OnlinePayment в базе данных
OnlinePayment.create({ from: this.account, amount: this.amount, date: Date.now() });
// и обновить текущее состояние счёта
BankAccount.where({ id: this.account.id })
.decrement({ amount: this.amount });
}
}
То, что тут происходит, тоже напоминает и работу с действиями Redux.
Однако механизм регистрации событий организует и долговременное хранение сведений о каждом изменении состояния, а не только хранение самого состояния. Это позволяет нам воспроизводить эти изменения до необходимого нам момента времени, восстанавливая таким образом и содержимое состояния приложения на этот момент времени. Например, если нам нужно понять то, сколько денег было на банковском счёте на определённую дату, нам нужно лишь воспроизводить события, которые происходили с банковским счётом, до тех пор, пока мы не доберёмся до нужной даты. События в данном случае представлены поступлениями средств на счёт и списаниями их с него, списанием комиссии банка и прочими подобными операциями. При возникновении ошибок (то есть — при возникновении событий, содержащих неверные данные), мы можем признать недействительным текущее состояние приложения, исправить соответствующие данные и снова выйти на текущее состояние приложения, теперь уже сформированное без ошибок.
Шаблоны CQRS и Event Sourcing часто используются совместно. И, что интересно, Redux, на самом деле, отчасти основан на этих шаблонах. Команды могут быть написаны так, чтобы они, при их вызове, отправляли бы события. Затем события взаимодействуют с хранилищем (базой данных) и обновляют состояние. В приложениях реального времени объекты-запросы могут, кроме того, прослушивать события и получать обновлённые сведения о состоянии из хранилища.
Использование любого из этих шаблонов в простом приложении может неоправданно его усложнить. Однако в случае с приложениями, построенными для решения сложных бизнес-задач, CQRS и Event Sourcing представляют собой мощные абстракции, которые помогают более качественно смоделировать предметную область таких приложений и улучшить управление их состоянием.
Обратите внимание на то, что шаблоны CQRS и Event Sourcing могут быть реализованы по-разному, при этом некоторые их реализации оказываются более сложными, чем другие. Мы рассмотрели лишь очень простые примеры их реализации. Если вы пишете серверные приложения в среде Node.js, взгляните на wolkenkit. Этот фреймворк, среди того, что удалось обнаружить в данной сфере, предоставляет разработчику один из самых простых интерфейсов для реализации шаблонов CQRS и Event Sourcing.
Итоги
Redux — это замечательный инструмент для управления состоянием приложения, для того, чтобы сделать изменения состояния предсказуемыми. В этом материале мы поговорили о ключевых концепциях этой библиотеки и выяснили, что, хотя использование Redux в серверной среде, вероятно, не лучшая идея, на сервере можно применить похожие принципы, пользуясь шаблонами CQRS и Event Sourcing.
Уважаемые читатели! Как вы организуете управление состоянием клиентских и серверных приложений?
Автор: ru_vds