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

Приложение
В качестве примера мы будем разрабатывать список покупок, с возможностью изменять позиции с любого устройства в реальном времени, по следующим требованиям:
- Возможность одновременно изменять и удалять позиции на нескольких устройствах в режиме реального времени
- Возможность сохранять список в локальной памяти сервера
- Возможность создавать несколько списков и иметь к ним доступ
Дабы обойтись минимальными трудозатратами, не будем делать UI для доступа ко всем спискам, а просто будем различать их по идентификатору в URL.
Можно заметить, что приложение в таком виде с точки зрения клиентского функционала и UI мало чем будет отличаться от знаменитого todo-списка. Поэтому статья в большей мере будет посвящена клиент-серверному взаимодействию, сохранению и обработке состояния на сервере.
В планах у меня также написание следующей статьи, где на примере этого же приложения мы рассмотрим, как сохранять redux-состояние в DynamoDB и выкатывать упакованное в Docker приложение в AWS.
Приступаем к разработке
Для создания среды разработки воспользуемся замечательным инструментом create-react-app. Создавать прототипы с ним довольно легко: он подготовит все необходимое для продуктивной разработки: webpack c hot-reload, начальный набор файлов, jest-тесты. Можно было бы настроить это все самостоятельно для большего контроля над процессом сборки, но в данном приложении это не принципиально.
Придумываем название для нашего приложения и создаем его, передав в качестве аргумента в create-react-app:
create-react-app deal-on-meal cd deal-on-meal
Структура проекта
create-react-app создал нам некоторую структуру проекта, но на самом деле для его корректной работы необходимо лишь, чтобы файл ./src/index.js являлся точкой входа. Поскольку же наш проект подразумевает использование как клиента, так и сервера, то изменим начальную структуру на следующую:
src └──client └──modules └──components └──index.js └──create-store.js └──socket-client.js └──action-emitter.js └──constants └──socket-endpoint-port.js └──server └──modules └──store └──utils └──bootstrap.js └──connection-handler.js └──server.js └──index.js └── registerServiceWorker.js
Добавим так же в package.json команду для старта сервера node ./src/server.js
Клиент-серверное взаимодействие
Состояние приложения redux хранит в так называемом store в виде javascript-объекта любого типа. При этом любое изменение состояния должен проводить reducer — чистая функция, на вход которой подается текущее состояние и action (тоже javascript-объект). Возвращает же она новое состояние с изменениями.
Мы можем использовать нашу клиентскую логику для списка покупок, которую реализует reducer, как на стороне браузера, так и в node.js окружении. Таким образом мы сможем хранить состояние списка независимо от клиента и сохранять его в БД.
Для работы с сервером будем использовать давно уже ставшую стандартом для работы с websockets библиотеку socket.io. Для каждого списка заведем свой room и будем посылать каждый action тем пользователям, которые находятся в этой же комнате. Помимо этого, для каждой комнаты на сервере будем хранить свой store с состоянием для этого списка.
Синхронизация клиентов с сервером будет происходить следующим образом:

То есть каждый раз когда происходит какой-либо action, то:
- Клиент пропускает его через свой store
- Через middleware он передается на сервер
- Сервер по URL страницы понимает, из какой комнаты пришел action и пропускает его через соответствующий store
- А также вещает этот action всем клиентам в данной комнате
В основном в redux-мире взаимодействие с сервером происходит по http, через библиотеки вроде redux-thunk или redux-saga, которые позволяют добавить немного асинхронности и сайд-эффектов в синхронный, чистый мир redux. Хотя нам не требуется такого рода связь с сервером, мы тоже будем использовать redux-saga на клиенте, но только для одной задачи: перенаправления на только что созданный список в случае, если в URL нет идентификатора.
Пишем код клиента
Не буду заострять особого внимания на инициализации, необходимой для работы redux, это хорошо описано в официальной документации redux, скажу лишь, что нам необходимо зарегистрировать два middleware: из упомянутого пакета redux-saga и наш emitterMiddleware. Первый нужен для редиректа, как уже было упомянуто, а последний мы напишем для синхронизации экшенов с сервером через socket.io-client.
Синхронизация состояний между клиентами и сервером
Создадим файл ./src/client/action-emitter.js в котором и будет реализация упомянутого emitterMiddleware:
export const syncSocketClientWithStore = (socket, store) => { socket.on('action', action => store.dispatch({ ...action, emitterExternal: true })); }; export const createEmitterMiddleware = socket => store => next => action => { if(!action.emitterExternal) { socket.emit('action', action); } return next(action); };
createEmitterMiddlewareявляется фабрикой наших middleware и нужна для того, чтобы хранить в себе ссылку на socket, переданный снаружи. Также здесь есть еще один нюанс: экшены, которые приходят снаружи, не нужно отправлять на сервер. Для этого я предлагаю их помечать (в данном случае полемemitterExternal), и в случае такого экшена middleware ничего не должен делать. Можно было бы использовать экшен-декоратор, но нужды в этом я не вижу.syncSocketClientWithStoreсовершенно прост: он слушает сокет на сообщениеactionи просто передает принятое действие в store, помечая его уже упомянутым флагом.
Получение начального состояния списка
Как я уже упоминал, мы будем использовать на клиенте redux-saga, чтобы при первом заходе местоположение клиента менялось на соответствующее списку, который мог быть создан только что. Незамысловатым образом в ./src/client/modules/products-list/saga/index.js опишем сагу, реагирующую на получение списка продуктов и комнаты, в которой находится клиент:
import { call, takeLatest } from 'redux-saga/effects' import actionTypes from '../action-types'; export function* onSuccessGenerator(action) { yield call(window.history.replaceState.bind(window.history), {}, '', `/${action.roomId}`); } export default function* () { yield takeLatest(actionTypes.FETCH_PRODUCTS_SUCCESS, onSuccessGenerator); }
Сервер
Входной точкой для сервера будет добавленный в скрипты в package.json ./src/server.js:
require('babel-register')({ presets: ['env', 'react'], plugins: ['transform-object-rest-spread', 'transform-regenerator'] }); require('babel-polyfill'); const port = require('./constants/socket-endpoint-port').default; const clientReducer = require('./client').rootReducer; require('./server/bootstrap').start({ clientReducer, port });
Стоит обратить внимание, что при старте нашего сервера ему передается клиентский reducer: это необходимо для того, чтобы сервер тоже мог поддерживать актуальное состояние списков, получая только действия, а не все состояние целиком. Заглянем в ./src/server/bootstrap.js:
import createSocketServer from 'socket.io'; import connectionHandler from './connection-handler'; import createStore from './store'; export const start = ({ clientReducer, port }) => { const socketServer = createSocketServer(port); const store = createStore({ socketNamespace: socketServer.of('/'), clientReducer }); socketServer.on('connection', connectionHandler(store)); console.log('listening on:', port); }
Серверная логика
Приступим к специфичной для нашего сервера логике и опишем действия, которые он должен поддерживать:
- Добавление пользователя в комнату
- Создание комнаты и соответствующего ей store при добавлении пользователя в случае, если такой комнаты еще нет
- Удаление пользователя из комнаты
- Удаление store комнаты, если там не осталось пользователей
- Изменение состояние комнаты по пришедшему экшену
- Ретрансляция action всем пользователям в комнате
Все эти действия я предлагаю также описать с помощью redux и для этого сделать модуль ./src/server/modules/room-service, содержащий соответствующие saga и reducer. Там же мы сделаем простейшее хранилище для наших комнатных store ./src/server/modules/room-service/data/in-memory.js:
export default class InMemoryStorage { constructor() { this.innerStorage = {}; } getRoom(roomId) { return this.innerStorage[roomId]; } saveRoom(roomId, state) { this.innerStorage[roomId] = state; } deleteRoom(roomId) { delete this.innerStorage[roomId]; } }
Синхронизация состояний сервера и клиентов
При событии socket-сервера будем просто делать dispatch c соответствующим action из модуля room-service на серверном store. Опишем это в ./src/server/connection-handler.js:
import { actions as roomActions } from './modules/room-service'; import templateParseUrl from './utils/template-parse-url'; const getRoomId = socket => templateParseUrl('/list/{roomId}', socket.handshake.headers.referer).roomId.toString() || socket.id.toString().slice(1, 6); export default store => socket => { const roomId = getRoomId(socket); store.dispatch(roomActions.userJoin({ roomId, socketId: socket.id })); socket.on('action', action => store.dispatch(roomActions.dispatchClientAction({ roomId, clientAction: action, socketId: socket.id }))); socket.on('disconnect', () => store.dispatch(roomActions.userLeft({ roomId }))); };
Оставим обработку userJoin и userLeft на совести пытливого читателя, не поленившегося заглянуть в репозиторий, а сами посмотрим на то, как обрабатывается dispatchClientAction. Как мы помним, необходимо сделать два действия:
- Поменять состояние в соответствующем store
- Отправить этот action всем клиентам в комнате
За первое отвечает генератор ./src/server/modules/room-service/saga/dispatch-to-room.js:
import { call, put } from 'redux-saga/effects'; import actions from '../actions'; import storage from '../data'; const getRoom = storage.getRoom.bind(storage); export default function* ({ socketServer, clientReducer }, action) { const storage = yield call(getRoom, action.roomId); yield call(storage.store.dispatch.bind(storage.store), action.clientAction); yield put(actions.emitClientAction({ roomId: action.roomId, clientAction: action.clientAction, socketId: action.socketId })); };
Он же кладет следующий action модуля room-service — emitClientAction, на который реагирует ./src/server/modules/room-service/saga/emit-action.js:
import { call, select } from 'redux-saga/effects'; export default function* ({ socketNamespace }, action) { const socket = socketNamespace.connected[action.socketId]; const roomEmitter = yield call(socket.to.bind(socket), action.roomId); yield call(roomEmitter.emit.bind(roomEmitter), 'action', action.clientAction); };
Вот таким незамысловатым образом экшены попадают на остальных клиентов в комнате. В простоте, на мой взгляд, и кроется прелесть full-stack redux, а также мощь переиспользования клиентской логики для воспроизведения состояния на сервере и других клиентах.
Заключение
Хоть немного сумбурно, но мой рассказ подходит к концу. Я не акцентировал внимание на вещах, о которых уже есть множество статей и уроков (в любом случае, полный код приложения можно посмотреть в репозитории), а остановился на том, о чем информации, на мой взгляд, меньше. Так что если остались какие-то вопросы — прошу в комменты.