Оффлайн брокер на JavaScript

    В своем проекте, мне понадобился функционал, который позволил бы не терять внесенные данные, в случае разрыва интернет соединения и я придумал очень простой «Брокер», который позволял не терять данные при потере соединения, а отправлять их когда соединение будет снова восстановлено. Возможно «Брокер» для него не очень хорошее название, но не судите строго. Хочется поделиться, может кому-то станет полезным.

    О проекте


    Мой проект разработан для учета расходов и доходов или как простой вариант домашней бухгалтерии. Создан он как progressive web application, чтобы было удобно пользоваться им на мобильных устройствах, а также для открытия возможностей Push-уведомлений, доступу к камере для чтения штрих-кодов и тому подобное. Есть схожее мобильное приложение, называется ZenMoney, но хотелось чего-то своего и по-своему.

    Возникновение идеи


    Я стараюсь четко вести учет расходов и доходов, но так как часто забывается внести нужные позиции, особенно касаемые наличных средств, приходится делать это практически сразу как произошла «транзакция». Иногда я вносил данные в общественном транспорте, таком как метрополитен, где часто случаются потери соединения, даже не смотря на широко-распространенную Wi-Fi сеть. Как бывало обидно, что все зависает, и ничего не происходит, а потом данные просто терялись.

    Идея пришла на примере использования брокера очередей, такого как RabbitMQ. Конечно у меня более простое и не такое функциональное решение, но что-то схожее с его принципами есть. Я подумал, что ведь можно все сохранять например в Cache или LocalStorage в виде некоего объекта с очередью «неудовлетворенных» запросов, а при появлении соединения их спокойно выполнить. Конечно, они выполняются не в порядке очереди, что благодаря асинхронной обработке запросов в языке JS, даже лучше, учитывая, что у тебя только один «подписчик». Я столкнулся с некоторыми сложностями, возможно даже реализация этого всего покажется немного кривой, но это работающее решение. Улучшить конечно его можно, но опишу пока имеющийся «сырой» но рабочий вариант.

    Приступаем к разработке


    Первое, о чем я задумался, было, где хранить данные в условиях отсутствующего соединения?! Сервис-вокер навязанный мне PWA, хорошо работает с кэшем, но разумно ли использовать кэш?! Сложный вопрос, не буду в него вдаваться. В общем я решил, что мне лучше подходит LocalStorage. Так как LocalStorage хранит значения типа key: value, объект пришлось добавлять в виде Json-строки. В своем проекте для внешней разработки я добавил, в папку с классами, директорию с названием QueueBroker

    Структура файлов
    /**----**/
    ├── app.js
    ├── bootstrap.js
    ├── classes
    │   └── QueueBroker
    │   ├── index.js
    │   └── Library
    │   ├── Broker.js
    │   └── Storage.js
    ├── components
    /**----**/


    Мой проект сделан в стэке Laravel + VueJs поэтому требуется определенная зависимость файловой структуры. Я не знаю, как в таких случаях правильно обзывать собственные директории для классов, поэтому сделал так.

    Файл индекса создан, чтобы просто подключать модули из вложенной Library. Может не очень изящное решение, но мне хотелось сделать так, чтобы, если я вдруг передумаю использовать LocalStorage, я напишу другой класс для Storage с такими же методами, передам в конструктор брокера его, и ничего не меняя буду пользоваться иным хранилищем.

    index.js
    const Broker = require('./Library/Broker');
    const Storage = require('./Library/Storage');
    
    module.exports.Broker = Broker;
    module.exports.Storage = Storage;
    


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

    import {Storage, Broker} from '../../classes/QueueBroker/index';
    

    Чтобы мне легко было изменить класс хранилища, я сделал подобие конструктора у класса Broker, в которое в качестве аргумента можно было бы передать объект Storage, главное, чтобы он имел необходимые функции. Знаю что на ES6 я мог писать class и constructor, но решил сделать по старинке — prototype. Комментарии напишу прямо по коду:

    Broker.js
    const axios = require('axios'); //Мне нравится axios
    
    /*
    Это и есть подобие конструктора.
    Префикс нужен для того, чтобы мы могли использовать разные объекты для хранения в разных частях front-end приложения
    */
    function Broker(storage, prefix='storageKey') {
        this.storage = storage;
        this.prefix = prefix;
       
        /*
        Если наш брокер пока пустой, мы загоним в него пустой объект. Главное чтобы storage с функцией add умел преобразовать его в json
        */
        if(this.storage.get('broker') === null) {
            this.broker = {};
            this.storage.add('broker', this.broker)
        }
        else {
            //А здесь наоборот, Storage должен уметь из Json отдать нам объект который мы запишем в свойство нашего прототипного класса
            this.broker = this.storage.getObject('broker');
        }
    };
    
    //Просто счетчик, чтобы мы могли определить сколько сообщений ожидает отправки на сервер
    Broker.prototype.queueCount = function () {
        return Object.keys(this.broker).length;
    };
    
    //Метод сохранения "неудовлетворенного" запроса в наш Storage, с присвоением ключа
    Broker.prototype.saveToStorage = function (method, url, data) {
        let key = this.prefix + '_' + (Object.keys(this.broker).length + 1);
    
        this.broker[key] = {method, url, data};
    
        //Кстати здесь тоже желательно сделать разные ключи а не записывать все в broker, но для упрощения примера решил оставить так
        this.storage.add('broker', this.broker);
    };
    
    //Это метод, который будет отправлять данные, когда восстановится соединение
    Broker.prototype.run = function () {
        for (let key in this.broker) {
            this.sendToServer(this.broker[key], key)
        }
    }
    
    /*
    Метод отправки на сервер. Нам нужен объект с записанными данными для отправки, который содержит в себе method, url и data, а так же ключ элемента в нашем хранилище, чтобы удалить по нему, после успешной отправки
    */
    Broker.prototype.sendToServer = function (object, brokerKey) {
    
        axios({
            method: object.method,
            url: object.url,
            data: object.data,
        })
        .then(response => {
            if(response.data.status == 200) {
                //Удаляем объект по ключу, после успешной отправки
                delete this.broker[brokerKey];
                //Перезаписываем объект
                this.storage.add('broker', this.broker);
            }
            else {
                //оставим для дебага ;-)
                console.log(response.data)
            }
    
        })
        .catch(error => {
             /*
             Ну и кончено после всех успешных испытаний сделаем красивый отлов ошибок, но не в рамках данной статьи
            */
        });
    
    };
    
    //Не забываем сделать export
    module.exports = Broker;
    


    Далее необходим сам объект Storage, который будет успешно все сохранять и доставать из хранилища

    Storage.js
    //Возможность включить debug-режим для отладки
    function Storage(debug) {
        if(debug === true)
        {
            this.debugMode = true;
        }
    
        this.storage = window.localStorage;
    };
    
    //Специальный метод, для преобразования объекта в Json и сохранении его в хранилище
    Storage.prototype.addObjectToStorage = function (key, object) {
        this.storage.setItem(key, JSON.stringify(object));
    };
    
    //Для записи остальных параметров (чисел, булевых и строк)
    Storage.prototype.addStringToStorage = function (key, value) {
        this.storage.setItem(key, value);
    };
    
    //Получение элемента из хранилища
    Storage.prototype.get = function (key) {
        return this.storage.getItem(key);
    };
    
    //Получение объекта из нашего Json внутри, который мы записали другим методом выше
    Storage.prototype.getObject = function (key) {
        try
        {
            return JSON.parse(this.storage.getItem(key));
        }
        catch (e)
        {
            this._debug(e);
            this._debug(key + ' = ' + this.storage.getItem(key));
            return false;
        }
    };
    
    /*
    Добавление, чтобы не заморачиваться с методами, отдаем ему, а он уже сам выбирает как его записать, сериализовать в Json или записать в чистом виде
    */
    Storage.prototype.add = function (key, value) {
        try
        {
            if(typeof value === 'object') {
                this.addObjectToStorage(key, value);
            }
            else if (typeof value === 'string' || typeof value === 'number') {
                this.addStringToStorage(key, value);
            }
            else {
                //Небольшая проверка на типы
                this._debug('2 parameter does not belong to a known type')
            }
    
            return this.storage;
    
        }
        catch (e)
        {
           //Защита от переполнения хранилища встроенная, но нам нужно знать, если такое случится
            if (e === QUOTA_EXCEEDED_ERR) {
                this._debug('LocalStorage is exceeded the free space limit')
            }
            else
            {
                this._debug(e)
            }
        }
    };
    
    //Очистка хранилища
    Storage.prototype.clear = function () {
        try
        {
            this.storage.clear();
            return true;
        }
        catch (e)
        {
            this._debug(e)
            return false;
        }
    };
    
    //Удаление элемента из хранилища
    Storage.prototype.delete = function(key) {
        try
        {
            this.storage.removeItem(key);
            return true;
        }
        catch (e)
        {
            this._debug(e)
            return false;
        }
    };
    
    //Маленький дебагер, которрый мы используем по ходу
    Storage.prototype._debug = function(error) {
        if(this.debugMode)
        {
            console.error(error);
        }
        return null;
    };
    
    //Не забываем экспортировать
    module.exports = Storage;
    


    Когда все выше сказанное будет готово, это можно использовать по своему усмотрению, у меня используется так:

    Использование при сохранении
    //это внутри объекта Vue (methods)
    
    /*----*/
    
    //Здесь и объявляем наш брокер и Storage для него
    sendBroker(method, url, data) {
                let storage = new Storage(true);
                let broker = new Broker(storage, 'fundsControl');
                broker.saveToStorage(method, url, data);
            },
    
    //Здесь выполняем свой обычный запрос
    fundsSave() {
    
                let url = '/pa/funds';
                let method = '';
    
                if(this.fundsFormType === 'create') {
                    method = 'post';
                }
                else if(this.fundsFormType === 'update') {
                    method = 'put';
                }
                else if(this.fundsFormType === 'delete') {
                    method = 'delete';
                }
    
                this.$store.commit('setPreloader', true);
    
                axios({
                    method: method,
                    url: url,
                    data: this.fundsFormData,
                })
                    .then(response=> {
                        if(response.data.status == 200) {
                            this.fundsFormShow = false;
                            this.getFunds();
                            this.$store.commit('setPreloader', false);
                        }
                        else {
                            this.$store.commit('AlertError', 'Ошибка получения данных с сервера');
                        }
    
                    })
                    //А как раз здесь отлавливаем нашу ошибку соединения
                    .catch(error => {
                        this.$store.commit('setAlert',
                            {
                                type: 'warning',
                                status: true,
                                message: 'Ошибка соединения с сервером. Однако, ваши данные не будут уреряны и будут записаны, после восстановления соединения'
                            }
                            );
                        this.fundsFormShow = false;
                        this.$store.commit('setPreloader', false);
    
                       //И записываем наш "неудовлетворенный" запрос
                        this.sendBroker(method, url, this.fundsFormData);
    
                        console.error(error);
                    });
            },
    


    Использование при восстановлении соединения
    //Это код компонента Vue
    /*--*/
    
    methods: {
    
    /*----*/
    
    /*
    Инициация нашего брокера, с теми же параметрами, чтобы мы знали, что работаем с теми же ключами, с которыми записывали в брокер
    */
    brokerSendRun()
            {
                let storage = new Storage(true);
                let broker = new Broker(storage, 'fundsControl');
    
                //Проверяем, что есть что-то не отправленное
                if(broker.queueCount() > 0)
                {
                    //Запускаем метод, который все отправит
                    broker.run();
                    
                    //Выводим общий алерт приложения, с уведомлением
                    this.$store.commit('setAlert', {type: 'info', status: true, message: 'Есть сообщения не отправленные на сервер из-за ошибок соединения, проверьте, что все данные успешно сохранены сейчас'});
                }
    
            }
    }
    
    /*---*/
    
    /*
    Ну и вызываем наш метод, например, при монтировании компонента, как раз скорее всего после оторвавшегося соединения будет перезагрузка страницы, и уж если она загрузится, то и наши сообщения отправятся на сервер
    */
    mounted() {
            this.brokerSendRun();
    }
    
    /*---*/
    


    P.S.


    Мне сложно рассказывать о коде, поэтому я постарался как можно подробнее снабдить приведенный в примерах код, подробными комментариями. Если у вас есть идеи по улучшению данного решения или по улучшению данной статьи, буду рад видеть их в комментариях. Примеры я брал из собственного проекта на Vue, поясняю это для того, чтобы было понятно, почему у меня так называются методы и почему я обращаюсь к ним через this. Я не делаю в данной статье именно на Vue, поэтому не привожу другой код компонентов, оставляю просто для понимания.
    Поделиться публикацией

    Похожие публикации

    Комментарии 10
      –2
      Хочу картинки
        0
        Возможно где-то должен появится axios-retry. И странно, почему часть с «пропажей интернета»,
        не в рамках данной статьи
        .
          0
          Не совсем понял о чем речь, но если это про обработчик ошибки, то он не относится к самому «Брокеру», ошибки мы можем обрабатывать как угодно, а я делаю упор на описание функционала именно брокера. Но если интересно, есть такой вариант. Можно передать в качестве аргумента, обхект текущего контекста в котором мы работаем, например VueObject или подобное. Тогда в нем будет работать все то, что работает в конкретном Vue-компоненте, и можно сделать что-то подобное:
          VueObject.$store.commit('AlertError', 'Ошибка получения данных с сервера');
          

          Я проверял такое работает, но как то архитектурно не красиво, имхо. В своем проекте пока сам не придумал, как сделать лучше. Но нужно учитывать, что у меня есть компоненты Vue, есть Vuex (Vue-store) и т.п. поэтому это подходит моему проекту, а другому уже не пойдет, поэтому и не подходит в рамках общей статьи об идее создания такого брокера, который должен работать не зависимо от библиотеки или фреймворка. Я привожу лишь примеры использования в рамках Vue, потому что очень схоже будет и в React и возможно в Angular( это не точно, я не знаю ангуляр)
          +3
          Работаю с проектом облачной кассы, там используем синхронизацию продаж (чеков). Сначала на этапе зарождения был написан простой скрипт который каждую секунду проверяет наличие данных в хранилище и если есть то отправляет, единственное что отличается от Вашего варианта так это то что все чеки сразу попадают этому брокеру, а не как Вы описали что только не успешные. Далее спустя некоторое время начали замечать что данные теряются, сначала не могли понять почему, и этот комментарий я поэтому и пишу чтобы Вы учли эти моменты. 1) и очень важный момент это не отправлять повторный запрос не дождавшись ответа предыдущего так как при подвисании интернета иногда запросы подвисают на долго и это плодит целую кучу новых запросов, в идеальных условиях отдела разработки конечно такого не было. 2) каждый чек должен иметь свой идентификатор и сервер должен вернуть список который он принял только после этого удалять задания и только те которые принял сервер.

          Очень тонкий момент происходил при затупе интернета. Допустим есть 2 чека в заданиях — они уходят на сервер, запрос подвис, в это время совершилась третья продажа и она допустим тоже сразу в догонку пошла на сервер не дождавшись ответа предыдущего и эта третья не смогла синхронизироваться, а первая смогла и затёрла из хранилища все задания. Вот так и происходила потеря данных.
            0

            Спасибо, это очень дельное замечание. У меня не так уж и страшно конечно, что что то пойдёт не так, но для такого проекта как кассы это действительно критично. Подумаю, что можно сделать

            +1
            Не смотрели в сторону pouchdb.com?
              0

              Слышал про него. Но что-то не подумал. Изучу подробнее, спасибо!

              0
              Отсутствует самое важное — решение конфликтов. Слишком легко можно переписать обновившиеся данные старыми.
                0

                Не обязательно. Я не показываю, что у меня в дата. А там может быть отметка времени например. Это уже от реализации. И если на беке данные по метке новее, чем в присылаешь запросе, значит даём положительный ответ на запрос, Т. Е. Код 200, можно даже с сообщением, о том, что на сервере данные новее и не перезаписываем их.

                0
                Можно еще хранить данные в indexedDB, т.к. к ней есть доступ из service-worker, как обертка идеально подходит Dexie.
                Записывать действия юзера не сразу в БД, а в виде «тасков» на отправку, при наличии соединения отсылать их на сервер.

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

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