Автоматическое обновление скриптов после деплоя +6


Пролог



Добрый день, уважаемые читатели. Эта статья посвящена проблеме, с которой сталкиваются разработчики более-менее серьезных веб-сайтов, а именно – проблеме автоматического обновления скриптов в браузере пользователя после деплоя.


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


Конечно, все решается нажатием на F5, но в данной статье я покажу, как можно автоматизировать это действие, избавить пользователя от головной боли и сделать это красиво.



О статье


Данная статья представляет из себя туториал и предназначена как для опытных разработчиков, так и для новичков.


Решение, представленное здесь, разработано для VueJS версии 2.6.x, но сами алгоритмы легко могут быть портированы на любой js-фреймворк на основе webpack'а.


Статья имеет веб-интерфейс, где вы сможете пощупать решение своими руками, а также исходники на github.


Ссылки для ленивых:

> Веб-интерфейс
> Проект на github


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


Идея решения


Идея решения заключается в введении версионности веб-приложения на уровне сборки.


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


Реализация


Решение представлено в виде плагина для Vue, который находится в коде проекта по пути /src/plugins/AutoReload. Отдельного пакета для npm не делал.


Демонстрационное веб-приложение представляет коробочную заготовку vue-cli, в которую добавлены стандартные фичи: store, роутер, axios для веб-запросов и dayjs для работы с датами. Интерфейс построен на Element.


Версионность сборки


Первая задача – сохранение информации о версии в сборке. Данную задачу можно выполнять разными способами:


  • Версионность на уровне бэка. Делаем запрос к API, получаем идентификатор сборки (например, версия, дата или хеш сборки). Данный вариант может быть использован, если бэк и фронт не разделены, и если на бэке есть средства автоматического инкремента версии.
  • Ручное изменение версии на уровне фронта или бэка. Все просто – ручками меняем версию, возможно, дополнительную информацию, такую как сообщение для пользователя со списком изменений. Здесь важно не забыть ничего поменять, иначе обновление не будет совершено.
  • Автоматическое обновление версии во время сборки. Именно этот вариант будет рассмотрен далее.

Итак, рассмотрим последний вариант в контексте VueJS и Webpack. Как известно, сборщик webpack выполняет серверные js-скрипты, которые собственно и выполняют сборку. В них мы можем встроить свой код, который будет формировать специальный файл, скажем version.json, который будет содержать полезную для нас информацию.


В контексте нашей задачи достаточно записать туда дату сборки, но мы пойдем чуть дальше и добавим информацию о типе сборки (development/production/etc.) и версию проекта из файла package.json. Получим примерно такой файл:


{
    "AppVersion": "1.0.0",
    "Build": "development",
    "BundleVersion": "2020-11-07T10:42:33.731Z"
}

Чтобы сформировать данный файл, нужно встроить код в скрипт vue.config.js, вот так:


const path = require('path');

// генерация файла version.json с версией сборки
const AutoReloadUtils = require('./src/plugins/AutoReload/versionGenerator');
AutoReloadUtils.generateVersionFile(path.resolve(path.join(__dirname, 'public/version.json')));

module.exports = {
    ...
};

Далее код самого генератора version.json:


versionGenerator.js
const path = require('path');
const fs = require('fs');

module.exports = {
    /**
     * генерировать файл с версией сборки
     * @param {String} filename путь к файлу версии
     */
    generateVersionFile: function (filename) {
        // извлекаем версию из файла package.json
        const packageJson = fs.readFileSync('./package.json');
        const version = JSON.parse(packageJson).version || 0;

        fs.writeFileSync(filename, `{
    "AppVersion": "${version}",
    "Build": "${process.env.NODE_ENV}",
    "BundleVersion": "${new Date().toISOString()}"
}
`);
    }
}


Теперь каждый раз при сборке приложения через команды serve, build или другие будет формироваться новая версия файла version.json, которая может быть прочитана приложением через обычный get-запрос, что нам и требуется.


Примечание №1. Файл version.json помещается в папку public, содержимое которой в VueJS копируется в выходной каталог «как есть». В других фреймворках может потребоваться другое целевое расположение.


Примечание №2. Файл version.json нужно исключить из git'а, чтобы его изменение не вызывало конфликтов при командной разработке, ведь изменяться он будет постоянно даже в процессе отладки.


Плагин AutoReload


Исходя из идеи решения плагин будет выполнять следующие функции:


  1. Внедрение события по таймеру, которое будет проверять факт изменения даты сборки и вызывать скрипт обновления.
  2. Внедрение хука vue-router, который также будет проверять изменение даты сборки при попытке перехода по маршруту. Здесь требуется пояснение. Сами разработчики Vue рекомендуют использовать ленивую загрузку маршрутов, а это значит, что в момент перехода по такому маршруту будет происходить подгрузка дополнительных скриптов с сервера, которые не будут найдены, если недавно (до срабатывания таймера автоматического обновления) был деплой. Проверка версии перед переходом решает эту проблему.
  3. Непосредственно уведомление пользователя об изменениях и последующая перезагрузка страницы.

Конфигурация плагина


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


  • Enabled – признак включения модуля, по умолчанию true.
  • CheckInterval – интервал проверки на обновление в секундах, по умолчанию 60. С такой периодичностью будет проверяться изменение даты сборки и вызываться обновление.
  • Notification – признак показа уведомления об обновлении, по умолчанию true. Если уведомление отключить, обновление страницы будет происходить «молча», что может вызвать недоумение у пользователя.
  • NotificationMessage – текст уведомления, по умолчанию «Система была обновлена, страница будет перезагружена.». Для уведомления используется Element, поэтому если вы используете другой фреймворк, нужно будет изменить соответствующий код. Тут, увы, из универсального решения только alert.

Код файла конфигурации можно найти в исходниках или под спойлером.


Config.js
import { isBoolean } from './utils';

/**
 * конфигурация модуля автоматического обновления
 */
export default class Config {
    /**
     * конструктор
     * @param {Object} origin образец
     */
    constructor(origin) {
        /**
         * признак включения модуля
         * @type {Boolean}
         */
        this.Enabled = isBoolean(origin.Enabled) ? origin.Enabled : true;

        /**
         * интервал проверки на обновление в секундах
         * @type {Number}
         */
        this.CheckInterval = origin.CheckInterval ?? 1 * 60;

        /**
         * признак показа уведомления об обновлении
         * @type {Boolean}
         */
        this.Notification = isBoolean(origin.Notification) ? origin.Notification : true;

        /**
         * текст уведомления
         * @type {String}
         */
        this.NotificationMessage = origin.NotificationMessage
            ?? 'Система была обновлена, страница будет перезагружена.';
    }
}


Использование плагина


Данный плагин имеет одну особенность, – он использует роутер и уведомления Element'а, поэтому его нужно включать в методе create главного компонента Vue:


import AutoReload from '@/plugins/AutoReload';
...
new Vue({
    router,
    store,

    created() {
        Vue.use(AutoReload, {
            config: {
                // модуль включен
                Enabled: true,
                // ежеминутное обновление
                CheckInterval: 60,
            },
            router: this.$router,
            vm: this,
        });
    },

    render: h => h(App),
}).$mount('#app');


Разбор кода плагина

AutoReload.js
import Config from './Config';
import { getVersion } from './utils';

/** @typedef {import('./Version').default} Version */

/**
 * модуль автоматического обновления
 */
export default class AutoReload {
    /**
     * конструктор
     * @param {Object} options настройки
     */
    constructor(options) {
        /** экземпляр роутера */
        this.router = options.router;

        /** экземпляр Vue */
        this.vm = options.vm;

        /** конфигурация */
        this.config = new Config(options.config);

        /**
         * предыдущее значение версии
         * @type {Version}
         */
        this.lastVersion = null;

        /**
         * таймер проверки обновления
         * @type {Number}
         */
        this.timer = null;
    }

    /** инициализировать модуль */
    async init() {
        const config = this.config;

        if (config.Enabled) {
            // получаем начальную версию сборки
            this.lastVersion = await getVersion();

            if (this.lastVersion && config.CheckInterval > 0) {
                // запускаем сервис проверки обновления
                this.timer = setInterval(async () => {
                    this.check();
                }, config.CheckInterval * 1000);
            }

            // внедряем проверку в роутер
            this.router.beforeEach(async (to, from, next) => {
                await this.check(this.router.resolve(to).href);
                next();
            });
        }
    }

    /**
     * проверить на наличие обновления
     * @param {String} href целевая страница
     */
    async check(href) {
        // получаем информацию о версии
        const version = await getVersion();

        if (this.lastVersion.BundleVersion != version.BundleVersion) {
            // версия сборки изменилась

            // останавливаем таймер
            if (this.timer) {
                clearInterval(this.timer);
                this.timer = null;
            }

            if (this.config.Notification) {
                // показываем уведомление об обновлении
                await this.vm.$alert(this.config.NotificationMessage, 'Предупреждение', {
                    type: 'warning',
                    confirmButtonText: 'OK',
                    closeOnClickModal: true,
                    closeOnPressEscape: true,
                }).catch(() => { });
            }

            // запоминаем новую версию сборки
            // повторный запрос нужен, чтобы не было двойной перезагрузки,
            // если сборка была изменена еще раз до того, как пользователь обновит страницу
            this.lastVersion = await getVersion();

            this.reload(href);
        }
    }

    /**
     * инициировать перезагрузку
     * @param {String} href целевая страница
     */
    reload(href) {
        if (href) {
            window.location.href = href;
        } else {
            window.location.reload(true);
        }
    }
}


Главной функцией является check с опциональным параметром href. Именно эта функция вызывается по таймеру или при переходе по маршруту, в этом случае передается адрес целевой страницы.


Функция получает текущую версию сборки из файла version.json и сравнивает дату сборки с ранее сохраненным значением. Если значение отличается, пользователю показывается уведомление, затем сохраняется новое значение версии сборки и происходит перезагрузка страницы.


Если обновление происходит по таймеру, эмулируется нажатие F5 (window.location.reload(true)). Если же обновление происходит при переходе по маршруту, пользователь направляется на целевую страницу маршрута. Это важно, т.к. исполнение кода никогда не дойдет до next() в хуке роутера после обновления страницы.


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


Бонус №1. Использование информации о версии сборки в целях отладки


Я не случайно добавил в файл version.json дополнительную информацию о версии проекта и типе сборки. В реальном проекте эта информация выводилась на специальной «скрытой» странице, в которую можно было попасть, указав ее путь в URL. В демонстрационном проекте страничка с информацией о сборке находится прямо в меню, и там можно увидеть содержимое файла version.json в удобном для человека виде.


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


Также туда можно добавить информацию о бэкенде, но в этом проекте его нет.


Прошу обратить внимание, что информация об интервале обновления формируется в соответствии с правилами русского языка: «60 секунд», а не «60 секунды». Программисты практически всегда пренебрегают такими мелочами, хотя решение лежит на поверхности и не требует глубоких знаний. Именно этому вопросу посвящен второй бонус статьи, под спойлером.


Бонус №2. Форматирование строк с числительными

Исходное решение было написано на C# много лет назад, а затем портировано на JS почти в неизменном виде. Я приведу код обоих решений и примеры использования.


Исходник на C#: https://pastebin.com/T1PsMy4N


Исходник на JS: https://pastebin.com/a4z25b1H


Примеры использования:


// получение формы слова
var windows0 = WordForm.get(0, "окон", "окно", "окна");  // "окон"
var windows1 = WordForm.get(1, "окон", "окно", "окна");  // "окно"
var windows2 = WordForm.get(2, "окон", "окно", "окна");  // "окна"

// получение формы слова в связке с числом
var totalWindows0 = WordForm.getAsCount(0, "окон", "окно", "окна");  // "0 окон"
var totalWindows1 = WordForm.getAsCount(1, "окон", "окно", "окна");  // "1 окно"
var totalWindows2 = WordForm.getAsCount(2, "окон", "окно", "окна");  // "2 окна"


Анализ решения


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


Проблема распределенного приложения

Если приложение работает через балансер, и сборка происходит на каждом из веб-серверов отдельно, то, очевидно, версия сборок будет отличаться минутами или секундами. Поэтому, если пользователь по какой-то причине будет перекинут на другой веб-сервер, у него произойдет обновление скриптов из-за различий во времени сборки.


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


Если все-таки такая проблема актуальна для вас, я вижу два варианта решения: сборка на одном сервере или введение проверки на разность дат сборок, чтобы она превышала некий лимит.


Проблема асинхронных компонентов

В плагине решена проблема подгрузки асинхронных маршрутов, но остается нерешенной проблема, когда пользователь производит действие, вызывающее подгрузку асинхронного компонента. С точки зрения браузера это загрузка обычного js-файла с сервера. Но после деплоя и до срабатывания автоматического обновления браузер будет пытаться загрузить несуществующий файл.


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


Проблема потери данных

Возможна ситуация, когда пользователь заполняет форму, и в процессе заполнения происходит обновление страницы. Данные будут потеряны, равно как и всё содержимое store. С другой стороны, есть гарантия, что пользователь не попытается загрузить старую версию формы.


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


Проблема публикации списка изменений

Честно говоря, не припомню сайты, которые публикуют какие-либо changelog'и, но было бы интересно внедрить такой функционал в модуль автообновления. Хотя лично меня всегда бесит, когда тот же телеграм пишет мне в личку список новых возможностей. Каждый раз я удаляю тот чат, но он все равно оживает при следующем обновлении. А для веба это, наверное, и не нужно вовсе.


Плагин в действии


Если вы хотите увидеть плагин в действии, воспользуйтесь демонстрационным стендом.


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


Заключение


Надеюсь, что данная статья поможет вам решить проблему с автоматическим обновлением скриптом или хотя бы даст направление исследования. Также прошу оценить, насколько вам зашли «бонусы», и стоит ли их использовать в дальнейшем?


Понравилась статья? Посмотрите другие:





К сожалению, не доступен сервер mySQL