Материал, перевод которого мы сегодня публикуем, посвящён рассказу об оптимизации новой версии настольного клиента Slack, одной из центральных особенностей которой стало ускорение загрузки.
Предыстория
В начале работы над новой версией настольного клиента Slack был создан прототип, который назвали «speedy boots». Целью этого прототипа, как несложно догадаться, было как можно более сильное ускорение загрузки. Используя HTML-файл из CDN-кэша, заранее сохранённые данные хранилища Redux и сервис-воркер, мы смогли загрузить облегчённую версию клиента менее чем за секунду (в то время обычное время загрузки для пользователей, имеющих 1-2 рабочих пространства, составляло около 5 секунд). Сервис-воркер был центром этого ускорения. Кроме того, он открыл дорогу возможности, о реализации которой нас часто просили пользователи Slack. Речь идёт об оффлайн-режиме работы клиента. Прототип позволил нам буквально одним глазком увидеть то, на что может быть способен переделанный клиент. Основываясь на вышеописанных технологиях, мы приступили к переработке клиента Slack, примерно представляя себе результат и ориентируясь на ускорение загрузки и реализацию оффлайн-режима работы программы. Расскажем о том, как работает ядро обновлённого клиента.
Что такое сервис-воркер?
Сервис-воркер (Service Worker) — это, по сути, мощный прокси-объект для сетевых запросов, который позволяет разработчику, используя небольшой объём JavaScript-кода, управлять тем, как браузер обрабатывает отдельные HTTP-запросы. Сервис-воркеры поддерживают продвинутый и гибкий API кэширования, который спроектирован так, что объекты Request (запросы) используются в нём как ключи, а объекты Response (ответы) — как значения. Сервис-воркеры, как и веб-воркеры (Web Workers), выполняются в собственных процессах, за пределами главного потока выполнения JavaScript-кода какого-бы то ни было окна браузера.
Технология Service Worker — это последователь технологии Application Cache, которая теперь признана устаревшей. Это был набор API, представленный интерфейсом AppCache
, который использовался при создании сайтов, реализующих оффлайновые возможности. При работе с AppCache
применялся статический файл-манифест, описывающий файлы, которые разработчик хотел бы кэшировать для оффлайнового использования. По большому счёту, этим возможности AppCache
и ограничивались. Механизм это был простой, но не гибкий, не дававший разработчику особого контроля над кэшем. В W3C, при разработке спецификации Service Worker, это учли. Как результат — сервис-воркеры позволяют разработчику управлять множеством деталей, касающихся каждого сеанса сетевого взаимодействия, выполняемого веб-приложением или сайтом.
Когда мы только начали работать с этой технологией, единственным браузером, поддерживающим её, был Chrome, но мы знали о том, что широкой поддержки сервис-воркеров ждать осталось недолго. Теперь же эта технология встречается повсюду, её поддерживают все основные браузеры.
Как мы используем сервис-воркеры
Когда пользователь в первый раз запускает новый клиент Slack — мы выполняем загрузку полного набора ресурсов (HTML, JavaScript, CSS, шрифты и звуки) и размещаем их в кэше сервис-воркера. Мы, кроме того, создаём копию Redux-хранилища, расположенного в памяти, и записываем эту копию в базу данных IndexedDB. Когда программу запускают в следующий раз — мы проверяем наличие соответствующих кэшей. Если они есть — мы используем их при загрузке приложения. Если пользователь подключён к интернету — мы загружаем свежие данные после запуска приложения. Если нет — клиент остаётся работоспособным.
Для того чтобы различать два вышеописанных варианта загрузки клиента — мы дали им названия: горячая (warm) и холодная (cold) загрузка. Холодная загрузка клиента чаще всего происходит тогда, когда пользователь запускает программу в самый первый раз. В такой ситуации нет ни кэшированных ресурсов, ни сохранённых Redux-данных. При горячей загрузке у нас есть всё необходимое для запуска клиента Slack на компьютере пользователя. Обратите внимание на то, что большинство бинарных ресурсов (изображения, PDF-файлы, видео, и так далее) обрабатываются средствами кэша браузера (этими ресурсами управляют обычные заголовки кэширования). Сервис-воркер не должен обрабатывать их особым образом для того чтобы мы имели бы возможность работать с ними в оффлайне.
Выбор между горячей и холодной загрузкой
Жизненный цикл сервис-воркера
Сервис-воркеры могут обрабатывать три события жизненного цикла. Это — install, fetch и activate. Ниже мы поговорим о том, как мы реагируем на каждое из этих событий, но сначала надо сказать о загрузке и регистрации самого сервис-воркера. Его жизненный цикл зависит от того, как браузер обрабатывает обновления файла сервис-воркера. Вот что можно прочесть по этому поводу на MDN: «Установка производится в том случае, если загружаемый файл признаётся новым. Это может быть либо файл, отличающийся от существующего (различие файлов определяется путём их побайтного сравнения), либо файл сервис-воркера, который впервые встретился браузеру на обрабатываемой странице».
Каждый раз, когда мы выполняем обновление соответствующего JavaScript, CSS или HTML-файла, он проходит через кастомный плагин Webpack, который создаёт манифест с описанием соответствующих файлов с уникальными хэшами (вот сокращённый пример файла-манифеста). Этот манифест внедряется в код сервис-воркера, что вызывает обновление сервис-воркера при следующей загрузке. Причём, выполняется это даже тогда, когда реализация сервис-воркера не меняется.
▍Событие install
Всегда, когда сервис-воркер обновляется, мы получаем событие install
. В ответ на него мы проходимся по файлам, описания которых содержатся во встроенном в сервис-воркер манифесте, загружаем каждый из них и размещаем их в соответствующем блоке кэша. Хранение файлов организовано с использованием нового API Cache, который является частью спецификации Service Worker. Этот API хранит объекты Response
, используя в качестве ключей объекты Request
. В результате оказывается, что хранилище устроено восхитительно просто. Оно отлично сочетается с тем, как события сервис-воркера получают запросы и возвращают ответы.
Ключи блокам кэша назначаются на основании времени развёртывания решения. Отметка времени встраивается в HTML-код, в результате её можно отправлять, как часть имени файла, в запросе на загрузку каждого ресурса. Раздельное кэширование ресурсов из каждого развёртывания важно для того, чтобы избежать совместного использования несовместимых ресурсов. Благодаря этому мы можем быть уверены в том, что изначально загруженный HTML-файл будет выполнять загрузку только совместимых ресурсов, причём, это справедливо и при их загрузке по сети, и при их загрузке из кэша.
▍Событие fetch
После того, как сервис-воркер будет зарегистрирован, он начнёт обрабатывать все сетевые запросы, принадлежащие одному и тому же источнику. Разработчик не может сделать так, чтобы одни запросы обрабатывались бы сервис-воркером, а другие — нет. Но у разработчика есть полный контроль над тем, что именно нужно делать с запросами, поступившими сервис-воркеру.
Мы, обрабатывая запрос, сначала его исследуем. Если то, что запрошено, присутствует в манифесте и есть в кэше — мы возвращаем ответ на запрос, взяв данные из кэша. Если в кэше того, что нужно, нет, мы возвращаем реальный сетевой запрос, который выполняет обращение к реальному сетевому ресурсу так, будто сервис-воркер в этом процессе совершенно не участвует. Вот упрощённая версия нашего обработчика события fetch
:
self.addEventListener('fetch', (e) => {
if (assetManifest.includes(e.request.url) {
e.respondWith(
caches
.open(cacheKey)
.then(cache => cache.match(e.request))
.then(response => {
if (response) return response;
return fetch(e.request);
});
);
} else {
e.respondWith(fetch(e.request));
}
});
В реальности подобный код содержит гораздо больше специфичной для Slack логики, но основа нашего обработчика так же проста, как в этом примере.
Ответы, возвращённые из сервис-воркера, при анализе сетевых взаимодействий страницы можно распознать по отметке ServiceWorker в колонке, в которой указывается объём данных
▍Событие activate
Событие activate
вызывается после успешной установки нового или обновлённого сервис-воркера. Мы используем его для того, чтобы проанализировать кэшированные ресурсы и инвалидировать блоки кэша, которые старше 7 дней. Это — хорошая практика поддержки системы в порядке, и, кроме того, это позволяет сделать так, чтобы при загрузке клиента не использовались бы слишком старые ресурсы.
Отставание кода клиента от самого свежего релиза
Возможно вы заметили, что наша реализация подразумевает то, что любой, кто запускает клиент Slack после самого первого запуска клиента, получит не самые свежие, а кэшированные ресурсы, загруженные в ходе предыдущей регистрации сервис-воркера. В исходной реализации клиента мы пытались обновлять сервис-воркер после каждой загрузки. Однако типичный пользователь Slack может, например, загружать программу лишь один раз в день, утром. Это может привести к тому, что он постоянно будет работать с клиентом, код которого на целый день отстаёт самого свежего релиза (мы выпускаем новые релизы несколько раз в день).
В отличие от типичного веб-сайта, который, посетив, быстро покидают, клиент Slack на компьютере пользователей открыт часами находится в открытом состоянии. В результате наш код отличается достаточно длительным сроком жизни, что требует от нас использования особых подходов для поддержания его актуальности.
При этом мы стремимся к тому, чтобы пользователи работали бы с самыми свежими версиями кода, чтобы они получали бы самые свежие возможности, исправления ошибок, улучшения производительности. Вскоре после того, как мы выпустили новый клиент, мы реализовали в нём механизм, позволяющий сократить разрыв между тем, с чем работают пользователи, и тем, что мы зарелизили. Если после последнего обновления было выполнено развёртывание новой версии системы, мы загружаем свежие ресурсы, которые будут использоваться при следующей загрузке клиента. Если ничего нового найти не удаётся — то ничего и не загружается. После того, как в клиент было внесено это изменение, среднее время «жизни» ресурсов, с которыми был загружен клиент, уменьшилось вдвое.
Новые версии кода загружаются регулярно, но при загрузке программы используется только самая свежая версия
Синхронизация флагов новых возможностей
С помощью флагов новых возможностей (Feature Flags) мы отмечаем в кодовой базе то, работа над чем пока не завершена. Это позволяет нам включать в код новые возможности до их публичных релизов. Такой подход снижает риск появления ошибок в продакшне благодаря тому, что новые возможности можно свободно тестировать вместе с остальными частями приложения, делая это задолго до того, как работа над ними будет завершена.
Новые возможности в Slack обычно выпускают тогда же, когда вносят изменения в соответствующие API. До того, как мы начали пользоваться сервис-воркерами, у нас была гарантия того, что новые возможности и изменения в API всегда будут синхронизированы. Но после того, как мы стали пользоваться кэшем, в котором может содержаться не самая свежая версия кода, оказалось, что клиент может оказаться в ситуации, когда код не синхронизирован с возможностями бэкенда. Для того чтобы справиться с этой проблемой, мы кэшируем не только ресурсы, но и некоторые ответы API.
То, что сервис-воркеры обрабатывают абсолютно все сетевые запросы, упростило решение. С каждым обновлением сервис-воркера, мы, кроме прочего, выполняем и запросы к API, кэшируя ответы в том же блоке кэша, что и соответствующие ресурсы. Это связывает возможности и экспериментальные функции с правильными ресурсами — потенциально устаревшими, но гарантированно соответствующими друг другу.
Это, на самом деле, лишь верхушка айсберга возможностей, доступных разработчику благодаря сервис-воркерам. Проблема, которую невозможно было бы решить с использованием механизма AppCache
, или такая, для решения которой потребовалось бы задействовать и клиентские и серверные механизмы, просто и естественно решается с использованием сервис-воркеров и API Cache.
Итоги
Сервис-воркер ускорил загрузку клиента Slack благодаря организации локального хранения ресурсов, которые готовы к использованию при очередной загрузке клиента. Сеть — главный источник задержек и неоднозначностей, с которыми могли бы столкнуться наши пользователи, теперь практически не влияет на ситуацию. Мы, так сказать, убрали её из уравнения. А если можно убрать из уравнения сеть, то оказывается, что в проект можно внедрить оффлайн-функционал. Прямо сейчас наша поддержка оффлайн-режима устроена весьма просто. Пользователь может загрузить клиент и может читать сообщения из загруженных бесед. Система при этом готовит к синхронизации отметки о прочитанных сообщениях. Но теперь у нас есть база для будущей реализации более продвинутых механизмов.
После многих месяцев разработки, экспериментов и оптимизации мы узнали очень многое о том, как сервис-воркеры работают на практике. Кроме того, оказалось, что эта технология хорошо подходит для масштабных проектов. Менее чем за месяц, прошедший с публичного релиза клиента с сервис-воркером, мы ежедневно успешно обслуживаем десятки миллионов запросов, поступающих от миллионов установленных сервис-воркеров. Это привело примерно к 50% уменьшению времени загрузки новых клиентов по сравнению со старыми, и к тому, что горячая загрузка оказывается примерно на 25% быстрее холодной.
Слева направо: загрузка старого клиента, холодная загрузка нового клиента, горячая загрузка нового клиента (чем меньше показатель — тем лучше)
Уважаемые читатели! Пользуетесь ли вы сервис-воркерами в своих проектах?
Автор: ru_vds