Ходим за покупками с full-stack redux

    Всем привет! В этой статье я хочу на простом примере рассказать о том, как синхронизировать состояние 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  
    └──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-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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

        Самое читаемое