Всем привет! В этой статье я хочу на простом примере рассказать о том, как синхронизировать состояние 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
L--client
L--modules
L--components
L--index.js
L--create-store.js
L--socket-client.js
L--action-emitter.js
L--constants
L--socket-endpoint-port.js
L--server
L--modules
L--store
L--utils
L--bootstrap.js
L--connection-handler.js
L--server.js
L--index.js
L-- 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, а также мощь переиспользования клиентской логики для воспроизведения состояния на сервере и других клиентах.
Заключение
Хоть немного сумбурно, но мой рассказ подходит к концу. Я не акцентировал внимание на вещах, о которых уже есть множество статей и уроков (в любом случае, полный код приложения можно посмотреть в репозитории), а остановился на том, о чем информации, на мой взгляд, меньше. Так что если остались какие-то вопросы — прошу в комменты.
Комментарии (5)
AndreyRubankov
19.09.2017 09:34Интересный подход, но в нем есть и недостатки:
1. Лоадбалансинг: нужно использовать Sticky Sessions, чтобы пользователь всегда ходил на один и тот же сервер.
2. Если этот сервер ляжет – все пользователи потеряют свой стейт или вообще потеряют возможность работать.
3. Под возрастающей нагрузкой ответ от сервера будет дольше, а значит и отклик на действия пользователя будут дольше.
4. Мобильный интернет 3g/4g с плохим покрытием на низкой скорости даст ощутимую просадку в реакции на действия пользователя.
В статье я не увидел списка достоинств/недостатков данного подхода. Не могли бы Вы указать их?glebsmagliy Автор
19.09.2017 20:071. Лоадбалансинг: нужно использовать Sticky Sessions, чтобы пользователь всегда ходил на один и тот же сервер.
2. Если этот сервер ляжет – все пользователи потеряют свой стейт или вообще потеряют возможность работать.
Если я правильно понимаю Ваши опасения, то они связаны с использованием in-memory хранилища. Сделано это лишь в целях простоты. Я упоминал в статье, что в дальнейшем я думаю использовать DynamoDB в качестве базы данных. При этом ничего не мешает при необходимости иметь еще и in-memory кэш в каждом из запущенных node-процессов.
3. Под возрастающей нагрузкой ответ от сервера будет дольше, а значит и отклик на действия пользователя будут дольше.
4. Мобильный интернет 3g/4g с плохим покрытием на низкой скорости даст ощутимую просадку в реакции на действия пользователя.
Комментарием выше я согласился, что хорошей идеей будет синхронизация с сервером и другими клиентами через саги, что, в общем, не трудно сделать в текущей реализации.
Что говорить о достоинствах/недостатках, то в конкретной реализации основным достоинством является простота и наглядность: мы синхронизируем состояния различных узлов, обмениваясь только действиями.
Недостатки же Вы и сами хорошо перечислили. :) Еще можно отметить, что, в случае интернета с плохим покрытием, состояние разных клиентов может оказаться в целом неконсистентным. Как вариант решения — в случае возникновения конфликтов, обмениваться целиком состоянием (естественно, предупреждая пользователя и давая возможность сохранить куда-либо изменения).AndreyRubankov
20.09.2017 00:42Я упоминал в статье, что в дальнейшем я думаю использовать DynamoDB в качестве базы данных. При этом ничего не мешает при необходимости иметь еще и in-memory кэш в каждом из запущенных node-процессов.
Это не спасет от Sticky Sessions. Вам придется каждого пользователя направлять всегда на один и тот же сервер.
В противно случае, пользователю придется очень долго ждать, пока данные придут из DynamoDB в локальный кеш, более того, это будет расходом RAM на каждом из серверов (мелочь, но все же), и даже хуже: кеши будут не синхронизированы, а данные – не консистентны.
Сам подход со Sticky Sessions несет за собою проблемы неравномерной загрузки серверов практически без возможности перераспределить нагрузку.
Именно по этой Stateful сервера – это плохо, их тяжело масштабировать.
mayorovp
Проблема такой отметки
emitterExternal
— в том, что другие мидлвари, идущие в цепочке перед EmitterMiddleware, будут обрабатывать все action два раза — и в них тоже нужно будет дублировать эту проверку.Также важный недостаток предложенного решения я вижу в том, что любые действия в интерфейсе пользователя должны сначала пройти через сервер. В итоге приложение получается насквозь реактивным — но отзывчивость интерфейса будет страдать.
glebsmagliy Автор
Спасибо за комментарии, они абсолютно справедливы.
Полностью с Вами согласен. Здесь это таким образом реализовано лишь в целях упрощения. При возникновении потребности в развитии данного проекта, я сделал бы этот рефакторинг одним из первых.
Тоже согласен. Выглядит хорошей идеей делать общение с сервером через саги и на клиенте тоже.