Optimistic UI, CQRS and EventSourcing


При разработке высоконагруженных веб-приложений для лучшего масштабирования часто применяется такой принцип, как CQRS. Он гласит, что метод должен быть либо командой, выполняющей какое-то действие, либо запросом, возвращающим данные, но не одновременно и тем, и другим. Другими словами, вопрос к системе не должен менять ответ. Более формально, возвращать значение можно только чистым, не имеющим побочных эффектов методам.


Но для хорошего масштабирования разделения API на чтение/запись недостаточно. Нужно разделить и базы данных, с которыми это API работает. Тут нам на помощь приходит EventSourcing. Он предлагает нам хранить всем события системы в одной базе данных, назовем ее EventStore, а все остальные базы данных и таблицы строить уже на ее основе.


Сочетание CQRS и EventSourcing очень сильно развязывает нам руки в плане балансировки нагрузки внутри системы, количестве ее узлов, количестве вспомогательных баз данных, использовании кеширования и прочего, но одновременно усложняет логику работы приложения и привносит множество ограничений.


В этой статье мы рассмотрим один из нюансов проектирования клиентской части для такой системы — оптимистические обновления в UI.


Для фронтенда возьмем модные React и Redux. Кстати, Redux и EventSourcing — очень близкие по духу технологии.


Оптимистичные обновления пользовательского интерфейса и так непросто реализовать, а CQRS и EventSourcing еще сильнее усложняют задачу.


Как же это должно работать? Давайте разберемся пошагово.


  1. Отправляем команду и, не дожидаясь ответа, диспатчим оптимистичный event в Redux Store. Оптимистичный event будет содержать ожидаемые результаты сервера. Также на этом шаге мы запоминаем текущее состояние данных, которые event будет менять.


  2. Ждем результата отправки команды. Если команда не прошла, диспатчим event, откатывающий оптимистичное обновление, на основе данных, которые запомнили на первом шаге. Если все хорошо, то ничего не делаем.


  3. Ждем, когда на клиент из шины прилетит настоящий event. Когда это случилось, откатываем оптимистическое обновление и применяем настоящий event.

Как это будет выглядеть на практике:


Успех Провал
optimistic-success optimistic-failure
optimistic-success-redux optimistic-failure-redux

Код оптимистического обновления опишем как Middleware к Redux Store:


const optimisticCalculateNextHashMiddleware = (store) => {
    const tempHashes = {};

    const api = createApi(store);

    return next => action => {
        switch (action.type) {
            case SEND_COMMAND_UPDATE_HASH_REQUEST: {
                const { aggregateId, hash } = action;

                // Save the previous data
                const { hashes } = store.getState()
                const prevHash = hashes[aggregateId].hash;
                tempHashes[aggregateId] = prevHash

                // Dispatch an optimistic action
                store.dispatch({
                    type: OPTIMISTIC_HASH_UPDATED,
                    aggregateId,
                    hash
                });

                // Send a command
                api.sendCommandCalculateNextHash(aggregateId, hash)
                    .then(
                        () => store.dispatch({
                            type: SEND_COMMAND_UPDATE_HASH_SUCCESS,
                            aggregateId,
                            hash
                        })
                    )
                    .catch(
                        (err) => store.dispatch({
                            type: SEND_COMMAND_UPDATE_HASH_FAILURE,
                            aggregateId,
                            hash
                        })
                    );             
                break;
            }
            case SEND_COMMAND_UPDATE_HASH_FAILURE: {
                const { aggregateId } = action;

                const hash = tempHashes[aggregateId];

                delete tempHashes[aggregateId];

                store.dispatch({
                    type: OPTIMISTIC_ROLLBACK_HASH_UPDATED,
                    aggregateId,
                    hash
                });
                break;
            }
            case HASH_UPDATED: {
                const { aggregateId } = action;

                const hash = tempHashes[aggregateId];

                delete tempHashes[aggregateId];

                store.dispatch({
                    type: OPTIMISTIC_ROLLBACK_HASH_UPDATED,
                    aggregateId,
                    hash
                });              
                break;
            }
        }

        next(action);
    }
}

Вживую, как всё работает, можно посмотреть тут:



Заключение


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

Комментарии (10)


  1. zloyusr
    27.02.2018 13:33

    Он гласит, что метод должен быть либо командой, выполняющей какое-то действие, либо запросом, возвращающим данные, но не одновременно и тем, и другим.
    — автор явно не понимает отличие CQS и CQRS.


    1. timbset Автор
      27.02.2018 14:33

      У CQS и CQRS, разумеется, есть различия. При этом они крайне схожи между собой. И там, и там есть разделение на выполнение команд без запроса данных и выполнение запросов без модификации состояния. При этом разница в том, что в CQS разделение находится внутри типа, а в CQRS команды и запросы выполняются в рамках разных объектов.


    1. vassabi
      27.02.2018 14:36
      +1

      автор явно не понимает отличие CQS и CQRS

      А можете рассказать об этом поподробнее, а то википедия тоже не понимает.


      UPD: пока писал вопрос, приехал ответ. Как оказалось, отличие меньше, чем я думал. (А если все методы — это объекты, то и совсем нет)


      1. timbset Автор
        27.02.2018 14:44

        На самом деле, я бы не стал полностью доверять Википедии. Есть официальный источник, в котором это различие описано. Третий пункт сверху.


        1. zloyusr
          27.02.2018 15:32

          С каких это пор cqrs.nu стал официальным источником? Где об этом говорит Грег Янг?


      1. zloyusr
        27.02.2018 15:00

        Ради интереса переключите википедию на английский язык в этой статье и поймете в чем ошибка. Не стоит верить рускоязычным источникам.


        1. vassabi
          27.02.2018 16:58

          Command query responsibility segregation (CQRS) applies the CQS principle by using separate Query and Command objects to retrieve and modify data, respectively.
          То есть отличается как «машина» от «транспортное средство».


          1. zloyusr
            27.02.2018 22:46

            Пример неплох. CQS это действительно фундаментальное «транспортное средство», он применим к почти любым приложениям. CQRS же «машина», которая едет только по определенным дорогам и не пройдет по болотам и лесам. Очень советую посмотреть доклады и видео Greg Young и Udi Dahan по теме.


  1. VolCh
    27.02.2018 20:48

    Справедливости ради, CQRS и ES ортогональны друг другу, не нуждаются друг в друге.


    А описывается вообще обычная работса с асинхронными запросами, не важно что там у сервера под капотом, хоть баш-скрипты.


  1. oxidmod
    27.02.2018 22:01

    Ну пилить ES с CQRS всеже приятней, чем без оного