Краткое содержание
Узнайте, как мы использовали библиотеки сервисных работников, чтобы сделать веб-приложение Google I/O 2015 быстрым и работать в автономном режиме.
Обзор
Веб-приложение Google I/O 2015 в этом году было написано командой Google по связям с разработчиками на основе проектов наших друзей из Instrument , которые написали отличный аудио/визуальный эксперимент . Миссия нашей команды заключалась в том, чтобы веб-приложение ввода-вывода (которое я буду называть его кодовым названием IOWA) демонстрировало все, на что способен современный Интернет. Полноценная работа в автономном режиме была в верхней части нашего списка обязательных функций.
Если вы недавно читали какие-либо другие статьи на этом сайте, вы, несомненно, сталкивались с работниками службы , и вы не удивитесь, узнав, что автономная поддержка IOWA во многом зависит от них. Руководствуясь реальными потребностями IOWA, мы разработали две библиотеки для двух разных вариантов автономного использования: sw-precache
для автоматизации предварительного кэширования статических ресурсов и sw-toolbox
для управления кэшированием во время выполнения и резервными стратегиями.
Библиотеки прекрасно дополняют друг друга и позволяют нам реализовать эффективную стратегию, в которой «оболочка» статического контента IOWA всегда обслуживается непосредственно из кеша, а динамические или удаленные ресурсы обслуживаются из сети с возможностью возврата к кэшированным или статическим ответам, когда нужный.
Предварительное кэширование с помощью sw-precache
Статические ресурсы IOWA — HTML, JavaScript, CSS и изображения — обеспечивают основную оболочку веб-приложения. При рассмотрении кэширования этих ресурсов были важны два конкретных требования: мы хотели убедиться, что большинство статических ресурсов кэшируются и поддерживаются в актуальном состоянии. sw-precache
был создан с учетом этих требований.
Интеграция во время сборки
sw-precache
с процессом сборки IOWA на основе gulp
, и мы полагаемся на ряд шаблонов glob , чтобы гарантировать создание полного списка всех статических ресурсов, которые использует IOWA.
staticFileGlobs: [
rootDir + '/bower_components/**/*.{html,js,css}',
rootDir + '/elements/**',
rootDir + '/fonts/**',
rootDir + '/images/**',
rootDir + '/scripts/**',
rootDir + '/styles/**/*.css',
rootDir + '/data-worker-scripts.js'
]
Альтернативные подходы, такие как жесткое кодирование списка имен файлов в массив и не запоминание номера версии кэша каждый раз, когда любое из этих файлов изменяется, были слишком подвержены ошибкам, особенно с учетом того, что у нас было несколько членов команды, проверяющих код. Никто не хочет отказываться от поддержки автономного режима, оставляя новый файл в массиве, поддерживаемом вручную! Интеграция во время сборки означала, что мы могли вносить изменения в существующие файлы и добавлять новые файлы, не беспокоясь об этом.
Обновление кэшированных ресурсов
sw-precache
генерирует базовый сценарий сервисного работника , который включает уникальный хэш MD5 для каждого ресурса, который предварительно кэшируется. Каждый раз, когда изменяется существующий ресурс или добавляется новый ресурс, сценарий сервисного работника создается заново. Это автоматически запускает поток обновления Service Worker , в котором новые ресурсы кэшируются, а устаревшие ресурсы удаляются. Все существующие ресурсы, имеющие идентичные хэши MD5, остаются без изменений. Это означает, что пользователи, которые посещали сайт раньше, в конечном итоге загружают только минимальный набор измененных ресурсов, что приводит к гораздо более эффективной работе, чем если бы весь кеш был массово просрочен.
Каждый файл, соответствующий одному из шаблонов glob, загружается и кэшируется при первом посещении IOWA пользователем. Мы постарались обеспечить предварительное кэширование только критически важных ресурсов, необходимых для рендеринга страницы. Вторичный контент, например медиафайлы, использованные в аудиовизуальном эксперименте , или изображения профилей докладчиков сеансов, намеренно не кэшировались заранее, и вместо этого мы использовали библиотеку sw-toolbox
для обработки автономных запросов к этим ресурсам.
sw-toolbox
для всех наших динамических потребностей
Как уже упоминалось, предварительное кэширование каждого ресурса, необходимого сайту для работы в автономном режиме, невозможно. Некоторые ресурсы слишком велики или используются редко, чтобы их можно было использовать, а другие ресурсы являются динамическими, например ответы от удаленного API или службы. Но тот факт, что запрос не предварительно кэшируется, не означает, что он должен приводить к ошибке NetworkError
. sw-toolbox
дал нам возможность реализовать обработчики запросов , которые обрабатывают кэширование во время выполнения для некоторых ресурсов и настраиваемые резервные варианты для других. Мы также использовали его для обновления наших ранее кэшированных ресурсов в ответ на push-уведомления.
Вот несколько примеров пользовательских обработчиков запросов, которые мы создали на основе sw-toolbox. Их было легко интегрировать с базовым скриптом сервис-воркера с помощью importScripts parameter
в sw-precache
, который подтягивает автономные файлы JavaScript в область действия сервис-воркера.
Аудио/визуальный эксперимент
Для аудио/визуального эксперимента мы использовали стратегию кэширования networkFirst
программы sw-toolbox
. Все HTTP-запросы, соответствующие шаблону URL-адреса для эксперимента, сначала будут отправлены в сеть, и если будет получен успешный ответ, этот ответ затем будет спрятан с помощью Cache Storage API . Если последующий запрос был сделан, когда сеть была недоступна, будет использован ранее кэшированный ответ.
Поскольку кэш автоматически обновлялся каждый раз при получении успешного сетевого ответа, нам не нужно было специально устанавливать версии ресурсов или истекать срок действия записей.
toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);
Изображения профиля докладчика
Для изображений профиля говорящего наша цель состояла в том, чтобы отобразить ранее кэшированную версию изображения данного говорящего, если она была доступна, и обратиться к сети для получения изображения, если оно было недоступно. Если этот сетевой запрос не удался, в качестве последнего запасного варианта мы использовали общее изображение-заполнитель, которое было предварительно кэшировано (и, следовательно, всегда было доступно). Это распространенная стратегия, используемая при работе с изображениями, которые можно заменить универсальным заполнителем, и ее легко реализовать путем объединения обработчиков cacheFirst
и cacheOnly
из sw-toolbox
.
var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';
function profileImageRequest(request) {
return toolbox.cacheFirst(request).catch(function() {
return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
});
}
toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
profileImageRequest,
{origin: /.*\.googleapis\.com/});
Обновления в расписаниях пользователей
Одной из ключевых особенностей IOWA было предоставление вошедшим в систему пользователям возможности создавать и поддерживать расписание сеансов, которые они планировали посетить. Как и следовало ожидать, обновления сеанса выполнялись посредством HTTP-запросов POST
к внутреннему серверу, и мы потратили некоторое время на разработку наилучшего способа обработки этих запросов на изменение состояния, когда пользователь находится в автономном режиме. Мы придумали комбинацию, которая помещала в очередь неудачные запросы в IndexedDB, в сочетании с логикой на главной веб-странице, которая проверяла IndexedDB на наличие запросов в очереди и повторяла все найденные запросы.
var DB_NAME = 'shed-offline-session-updates';
function queueFailedSessionUpdateRequest(request) {
simpleDB.open(DB_NAME).then(function(db) {
db.set(request.url, request.method);
});
}
function handleSessionUpdateRequest(request) {
return global.fetch(request).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
return response;
}).catch(function() {
queueFailedSessionUpdateRequest(request);
});
}
toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
handleSessionUpdateRequest);
Поскольку повторные попытки выполнялись из контекста главной страницы, мы могли быть уверены, что они включают новый набор учетных данных пользователя. Как только повторные попытки были успешными, мы отображали сообщение, сообщающее пользователю, что его ранее поставленные в очередь обновления были применены.
simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
var replayPromises = [];
return db.forEach(function(url, method) {
var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
return db.delete(url).then(function() {
return true;
});
});
replayPromises.push(promise);
}).then(function() {
if (replayPromises.length) {
return Promise.all(replayPromises).then(function() {
IOWA.Elements.Toast.showMessage(
'My Schedule was updated with offline changes.');
});
}
});
}).catch(function() {
IOWA.Elements.Toast.showMessage(
'Offline changes could not be applied to My Schedule.');
});
Оффлайн Google Аналитика
Аналогичным образом мы реализовали обработчик, который ставит в очередь любые неудачные запросы Google Analytics и пытается воспроизвести их позже, когда сеть, как мы надеемся, станет доступна. При таком подходе пребывание в автономном режиме не означает жертвование информацией, которую предлагает Google Analytics. Мы добавили параметр qt
к каждому запросу в очереди, для которого задано количество времени, прошедшее с момента первой попытки запроса, чтобы гарантировать, что правильное время атрибуции события дошло до серверной части Google Analytics. Google Analytics официально поддерживает значения qt
до 4 часов, поэтому мы приложили все усилия, чтобы воспроизводить эти запросы как можно скорее при каждом запуске сервисного работника.
var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;
function replayQueuedAnalyticsRequests() {
simpleDB.open(DB_NAME).then(function(db) {
db.forEach(function(url, originalTimestamp) {
var timeDelta = Date.now() - originalTimestamp;
var replayUrl = url + '&qt=' + timeDelta;
fetch(replayUrl).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
db.delete(url);
}).catch(function(error) {
if (timeDelta > EXPIRATION_TIME_DELTA) {
db.delete(url);
}
});
});
});
}
function queueFailedAnalyticsRequest(request) {
simpleDB.open(DB_NAME).then(function(db) {
db.set(request.url, Date.now());
});
}
function handleAnalyticsCollectionRequest(request) {
return global.fetch(request).then(function(response) {
if (response.status >= 500) {
return Response.error();
}
return response;
}).catch(function() {
queueFailedAnalyticsRequest(request);
});
}
toolbox.router.get('/collect',
handleAnalyticsCollectionRequest,
{origin: ORIGIN});
toolbox.router.get('/analytics.js',
toolbox.networkFirst,
{origin: ORIGIN});
replayQueuedAnalyticsRequests();
Целевые страницы push-уведомлений
Сервисные работники не только обрабатывали автономные функции IOWA — они также обеспечивали push-уведомления , которые мы использовали для уведомления пользователей об обновлениях их сеансов, добавленных в закладки. На целевой странице, связанной с этими уведомлениями, отображались обновленные сведения о сеансе. Эти целевые страницы уже кэшировались как часть всего сайта, поэтому они уже работали в автономном режиме, но нам нужно было убедиться, что сведения о сеансе на этой странице актуальны, даже при просмотре в автономном режиме. Для этого мы изменили ранее кэшированные метаданные сеанса с помощью обновлений, которые вызвали push-уведомление, и сохранили результат в кеше. Эта актуальная информация будет использоваться при следующем открытии страницы сведений о сеансе, независимо от того, происходит ли это онлайн или офлайн.
caches.open(toolbox.options.cacheName).then(function(cache) {
cache.match('api/v1/schedule').then(function(response) {
if (response) {
parseResponseJSON(response).then(function(schedule) {
sessions.forEach(function(session) {
schedule.sessions[session.id] = session;
});
cache.put('api/v1/schedule',
new Response(JSON.stringify(schedule)));
});
} else {
toolbox.cache('api/v1/schedule');
}
});
});
Ошибки и соображения
Конечно, никто, работая над проектом масштаба IOWA, не сталкивается с некоторыми ошибками. Вот некоторые из них, с которыми мы столкнулись, и то, как мы их обошли.
Устаревший контент
Всякий раз, когда вы планируете стратегию кэширования, реализованную через сервис-воркеров или с помощью стандартного кэша браузера, существует компромисс между максимально быстрой доставкой ресурсов и доставкой самых свежих ресурсов. С помощью sw-precache
мы реализовали агрессивную стратегию кэширования для оболочки нашего приложения, что означает, что наш сервисный работник не будет проверять сеть на наличие обновлений перед возвратом HTML, JavaScript и CSS на странице.
К счастью, мы смогли воспользоваться событиями жизненного цикла сервис-воркера , чтобы определить, когда новый контент стал доступен после того, как страница уже загрузилась. При обнаружении обновленного сервис-воркера мы показываем пользователю всплывающее сообщение, сообщающее ему, что ему следует перезагрузить свою страницу, чтобы увидеть новейший контент.
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.onstatechange = function(e) {
if (e.target.state === 'redundant') {
var tapHandler = function() {
window.location.reload();
};
IOWA.Elements.Toast.showMessage(
'Tap here or refresh the page for the latest content.',
tapHandler);
}
};
}
Убедитесь, что статический контент статичен!
sw-precache
использует хэш MD5 содержимого локальных файлов и извлекает только те ресурсы, хэш которых изменился. Это означает, что ресурсы доступны на странице почти сразу, но это также означает, что как только что-то будет кэшировано, оно будет оставаться в кэше до тех пор, пока ему не будет присвоен новый хэш в обновленном скрипте Service Worker.
Мы столкнулись с проблемой такого поведения во время ввода-вывода, поскольку нашему серверу необходимо динамически обновлять идентификаторы видео YouTube в прямом эфире для каждого дня конференции. Поскольку базовый файл шаблона был статическим и не менялся, поток обновления нашего сервис-воркера не запускался, и то, что должно было быть динамическим ответом сервера при обновлении видео YouTube, в конечном итоге оказалось кэшированным ответом для ряда пользователей. .
Вы можете избежать проблем такого типа, если убедитесь, что ваше веб-приложение структурировано так, что оболочка всегда статична и ее можно безопасно предварительно кэшировать, а любые динамические ресурсы, изменяющие оболочку, загружаются независимо.
Уничтожьте ваши запросы на предварительное кэширование
Когда sw-precache
делает запросы на ресурсы для предварительного кэширования, он использует эти ответы неопределенно долго, пока считает, что хэш MD5 для файла не изменился. Это означает, что особенно важно убедиться, что ответ на запрос предварительного кэширования является свежим и не возвращается из HTTP-кеша браузера. (Да, запросы fetch()
сделанные в сервис-воркере, могут отвечать данными из HTTP-кеша браузера.)
Чтобы гарантировать, что ответы, которые мы предварительно кэшируем, поступают непосредственно из сети, а не из HTTP-кеша браузера, sw-precache
автоматически добавляет параметр запроса очистки кеша к каждому запрашиваемому URL-адресу. Если вы не используете sw-precache
и используете стратегию ответа «сначала кеш», обязательно сделайте что-то подобное в своем собственном коде!
Более чистым решением проблемы очистки кеша было бы установить режим кеша для каждого Request
используемого для предварительного кеширования, на reload
, что гарантирует, что ответ придет из сети. Однако на момент написания этой статьи опция режима кэширования не поддерживается в Chrome.
Поддержка входа и выхода
IOWA позволяла пользователям входить в систему, используя свои учетные записи Google, и обновлять свои персонализированные расписания событий, но это также означало, что позже пользователи могли выйти из системы. Кэширование данных персонализированных ответов, очевидно, сложная тема, и не всегда существует единственный правильный подход.
Поскольку просмотр вашего личного расписания, даже в автономном режиме, был основой работы IOWA, мы решили, что использование кэшированных данных уместно. Когда пользователь выходит из системы, мы позаботились о том, чтобы очистить ранее кэшированные данные сеанса.
self.addEventListener('message', function(event) {
if (event.data === 'clear-cached-user-data') {
caches.open(toolbox.options.cacheName).then(function(cache) {
cache.keys().then(function(requests) {
return requests.filter(function(request) {
return request.url.indexOf('api/v1/user/') !== -1;
});
}).then(function(userDataRequests) {
userDataRequests.forEach(function(userDataRequest) {
cache.delete(userDataRequest);
});
});
});
}
});
Следите за дополнительными параметрами запроса!
Когда сервисный работник проверяет наличие кэшированного ответа, он использует URL-адрес запроса в качестве ключа. По умолчанию URL-адрес запроса должен точно совпадать с URL-адресом, используемым для хранения кэшированного ответа, включая любые параметры запроса в поисковой части URL-адреса.
В конечном итоге это вызвало у нас проблему во время разработки, когда мы начали использовать параметры URL-адресов, чтобы отслеживать, откуда поступает наш трафик. Например, мы добавили параметр utm_source=notification
к URL-адресам, которые открывались при нажатии на одно из наших уведомлений, и использовали utm_source=web_app_manifest
в start_url
для манифеста нашего веб-приложения . URL-адреса, которые ранее соответствовали кэшированным ответам, при добавлении этих параметров считались промахами.
Частично это решается опцией ignoreSearch
, которую можно использовать при вызове Cache.match()
. К сожалению, Chrome пока не поддерживает ignoreSearch
, а даже если и поддерживает, то это принцип «все или ничего». Нам нужен был способ игнорировать некоторые параметры URL-запроса, принимая во внимание другие, которые имеют смысл.
В итоге мы расширили sw-precache
чтобы исключить некоторые параметры запроса перед проверкой соответствия кэша, и позволили разработчикам настраивать, какие параметры игнорируются, с помощью опции ignoreUrlParametersMatching
. Вот базовая реализация:
function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
var url = new URL(originalUrl);
url.search = url.search.slice(1)
.split('&')
.map(function(kv) {
return kv.split('=');
})
.filter(function(kv) {
return ignoredRegexes.every(function(ignoredRegex) {
return !ignoredRegex.test(kv[0]);
});
})
.map(function(kv) {
return kv.join('=');
})
.join('&');
return url.toString();
}
Что это значит для вас
Интеграция сервисного работника в веб-приложение Google I/O, вероятно, является самым сложным и реальным использованием, которое было развернуто на данный момент. Мы с нетерпением ждем возможности сообщества веб-разработчиков использовать созданные нами инструменты sw-precache
и sw-toolbox
а также методы, которые мы описываем, для создания собственных веб-приложений. Service Workers — это прогрессивное усовершенствование , которое вы можете начать использовать уже сегодня, и при использовании его как части правильно структурированного веб-приложения преимущества скорости и автономного режима будут значительными для ваших пользователей.