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

habr.png

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

О проекте


Мой проект разработан для учета расходов и доходов или как простой вариант домашней бухгалтерии. Создан он как 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, поэтому не привожу другой код компонентов, оставляю просто для понимания.

© Habrahabr.ru