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


Redux


Приложение


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


  • Возможность одновременно изменять и удалять позиции на нескольких устройствах в режиме реального времени
  • Возможность сохранять список в локальной памяти сервера
  • Возможность создавать несколько списков и иметь к ним доступ

Дабы обойтись минимальными трудозатратами, не будем делать 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-serviceemitClientAction, на который реагирует ./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)


  1. mayorovp
    18.09.2017 13:20
    +1

    Проблема такой отметки emitterExternal — в том, что другие мидлвари, идущие в цепочке перед EmitterMiddleware, будут обрабатывать все action два раза — и в них тоже нужно будет дублировать эту проверку.


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


    1. glebsmagliy Автор
      18.09.2017 15:51

      Спасибо за комментарии, они абсолютно справедливы.

      Проблема такой отметки emitterExternal — в том, что другие мидлвари, идущие в цепочке перед EmitterMiddleware, будут обрабатывать все action два раза — и в них тоже нужно будет дублировать эту проверку.

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

      Тоже согласен. Выглядит хорошей идеей делать общение с сервером через саги и на клиенте тоже.


  1. AndreyRubankov
    19.09.2017 09:34

    Интересный подход, но в нем есть и недостатки:
    1. Лоадбалансинг: нужно использовать Sticky Sessions, чтобы пользователь всегда ходил на один и тот же сервер.
    2. Если этот сервер ляжет – все пользователи потеряют свой стейт или вообще потеряют возможность работать.
    3. Под возрастающей нагрузкой ответ от сервера будет дольше, а значит и отклик на действия пользователя будут дольше.
    4. Мобильный интернет 3g/4g с плохим покрытием на низкой скорости даст ощутимую просадку в реакции на действия пользователя.

    В статье я не увидел списка достоинств/недостатков данного подхода. Не могли бы Вы указать их?


    1. glebsmagliy Автор
      19.09.2017 20:07

      1. Лоадбалансинг: нужно использовать Sticky Sessions, чтобы пользователь всегда ходил на один и тот же сервер.
      2. Если этот сервер ляжет – все пользователи потеряют свой стейт или вообще потеряют возможность работать.

      Если я правильно понимаю Ваши опасения, то они связаны с использованием in-memory хранилища. Сделано это лишь в целях простоты. Я упоминал в статье, что в дальнейшем я думаю использовать DynamoDB в качестве базы данных. При этом ничего не мешает при необходимости иметь еще и in-memory кэш в каждом из запущенных node-процессов.
      3. Под возрастающей нагрузкой ответ от сервера будет дольше, а значит и отклик на действия пользователя будут дольше.
      4. Мобильный интернет 3g/4g с плохим покрытием на низкой скорости даст ощутимую просадку в реакции на действия пользователя.

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

      Что говорить о достоинствах/недостатках, то в конкретной реализации основным достоинством является простота и наглядность: мы синхронизируем состояния различных узлов, обмениваясь только действиями.

      Недостатки же Вы и сами хорошо перечислили. :) Еще можно отметить, что, в случае интернета с плохим покрытием, состояние разных клиентов может оказаться в целом неконсистентным. Как вариант решения — в случае возникновения конфликтов, обмениваться целиком состоянием (естественно, предупреждая пользователя и давая возможность сохранить куда-либо изменения).


      1. AndreyRubankov
        20.09.2017 00:42

        Я упоминал в статье, что в дальнейшем я думаю использовать DynamoDB в качестве базы данных. При этом ничего не мешает при необходимости иметь еще и in-memory кэш в каждом из запущенных node-процессов.
        Это не спасет от Sticky Sessions. Вам придется каждого пользователя направлять всегда на один и тот же сервер.

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

        Сам подход со Sticky Sessions несет за собою проблемы неравномерной загрузки серверов практически без возможности перераспределить нагрузку.

        Именно по этой Stateful сервера – это плохо, их тяжело масштабировать.