Podsumowanie
Dowiedz się, jak wykorzystaliśmy biblioteki usług działających w tle, aby aplikacja internetowa na konferencję Google I/O 2015 była szybka i działała w trybie offline.
Omówienie
Tegoroczną aplikację internetową na Google I/O 2015 napisał zespół Google ds. relacji z deweloperami na podstawie projektów naszych przyjaciół z Instrument, którzy stworzyli fajny eksperyment audiowizualny. Naszym celem było stworzenie aplikacji internetowej I/O (będę się do niej odnosić jako do IOWA), która będzie zawierać wszystko, co oferuje współczesna sieć. Na szczycie listy niezbędnych funkcji znalazło się pełne działanie offline.
Jeśli po raz pierwszy przeczytałeś(-aś) ostatnio którykolwiek z pozostałych artykułów na tej stronie, to niewątpliwie napotkałeś(-aś) skrypty service worker i nie zdziwi Cię fakt, że pomoc offline IOWA w dużym stopniu polega na nich. Motywowane rzeczywistymi potrzebami IOWA opracowaliśmy 2 biblioteki do obsługi 2 różnych przypadków użycia offline:
sw-precache
– do automatyzacji wstępnego przechowywania w pamięci podręcznej zasobów statycznych,
sw-toolbox
– do obsługi strategii buforowania i zastępczego w czasie działania.
Biblioteki te świetnie się uzupełniają i umożliwiły nam wdrożenie skutecznej strategii, w ramach której „powłoka” statycznego kontentu IOWA była zawsze dostarczana bezpośrednio z poziomu pamięci podręcznej, a dynamiczne lub zdalne zasoby były dostarczane z sieci, z możliwością korzystania w razie potrzeby z odpowiednich odpowiedzi statycznych lub z pamięci podręcznej.
Wstępne buforowanie za pomocą sw-precache
Zasoby statyczne IOWA (HTML, JavaScript, CSS i obrazy) stanowią podstawową część aplikacji internetowej. Przy projektowaniu pamięci podręcznej tych zasobów musieliśmy spełnić 2 szczególne wymagania: chcieliśmy mieć pewność, że większość zasobów statycznych jest przechowywana w pamięci podręcznej i utrzymywana w aktualnym stanie.
sw-precache
został stworzony z myślą o tych wymaganiach.
Integracja w czasie kompilacji
sw-precache
z procesem kompilacji gulp
IOWA oraz korzystamy z szeregu wzorów glob, aby wygenerować pełną listę wszystkich zasobów statycznych używanych przez IOWA.
staticFileGlobs: [
rootDir + '/bower_components/**/*.{html,js,css}',
rootDir + '/elements/**',
rootDir + '/fonts/**',
rootDir + '/images/**',
rootDir + '/scripts/**',
rootDir + '/styles/**/*.css',
rootDir + '/data-worker-scripts.js'
]
Alternatywy, takie jak zakodowanie listy nazw plików w tablicy i zapamiętanie, aby za każdym razem, gdy zmienia się któryś z tych plików, zwiększać numer wersji pamięci podręcznej, były zbyt podatne na błędy, zwłaszcza że kilku członków zespołu sprawdzało kod. Nikt nie chce przerywać obsługi offline przez pominięcie nowego pliku w ręcznie obsługiwanej tablicy. Integracja na etapie kompilacji oznaczała, że możemy wprowadzać zmiany w dotychczasowych plikach i dodawać nowe bez obaw.
Aktualizowanie zasobów w pamięci podręcznej
sw-precache
generuje podstawowy skrypt skryptu service worker, który zawiera unikalny szyfr MD5 dla każdego zasobu umieszczanego w pamięci podręcznej. Za każdym razem, gdy istniejący zasób zostanie zmieniony lub dodany zostanie nowy, skrypt skryptu service worker jest ponownie generowany. Automatycznie uruchamia to przebieg aktualizacji usługi roboczej, w którym nowe zasoby są umieszczane w pamięci podręcznej, a nieaktualne zasoby są usuwane.
Wszystkie istniejące zasoby z identycznymi haszami MD5 pozostają bez zmian. Oznacza to, że użytkownicy, którzy odwiedzili witrynę przed pobraniem tylko minimalnego zestawu zmienionych zasobów, są znacznie bardziej wydajne niż w przypadku całej utraty ważności całej pamięci podręcznej.
Każdy plik pasujący do jednego z wzorów glob jest pobierany i przechowywany w pamięci podręcznej za każdym razem, gdy użytkownik odwiedza IOWA. Staraliśmy się mieć w pamięci podręcznej zapisane tylko kluczowe zasoby
potrzebne do renderowania strony. Treści dodatkowe, takie jak media używane w eksperymencie audiowizualnym lub zdjęcia profilowe prelegentów, nie zostały celowo przechowane w pamięci podręcznej. Zamiast tego użyliśmy biblioteki sw-toolbox
do obsługi żądań dotyczących tych zasobów w trybie offline.
sw-toolbox
, for All Our Dynamic Needs
Jak już wspomnieliśmy, nie jest możliwe wstępne przechowywanie w pamięci podręcznej wszystkich zasobów, których strona potrzebuje do działania offline. Niektóre zasoby są zbyt duże lub są używane zbyt rzadko, aby było to opłacalne, a inne są dynamiczne, jak odpowiedzi z zdalnego interfejsu API lub usługi. Ale to, że żądanie nie jest wstępnie buforowane, nie oznacza, że musi skutkować NetworkError
.
sw-toolbox
dał nam elastyczność w wdrażaniu obsługujących żądania, które obsługują buforowanie w czasie wykonywania w przypadku niektórych zasobów i obsługujących niestandardowe rozwiązania zastępcze w przypadku innych. Użyliśmy go też do zaktualizowania zasobów z poziomu pamięci podręcznej w odpowiedzi na powiadomienia push.
Oto kilka przykładów niestandardowych modułów obsługi żądań, które utworzyliśmy na bazie narzędzia Sw-toolbox. Łatwo je zintegrowaliśmy z podstawowym skryptem usługi instancyjnej za pomocą importScripts parameter
sw-precache
, który przeciąga samodzielne pliki JavaScript do zakresu usługi instancji.
Eksperyment audiowizualny
W eksperymencie audiowizualnym użyliśmy strategii buforowania sw-toolbox
networkFirst
. Wszystkie żądania HTTP pasujące do wzorca adresu URL eksperymentu byłyby najpierw wysyłane do sieci, a jeśli zwrócona zostałaby pomyślna odpowiedź, zostałaby ona zapisana za pomocą interfejsu Cache Storage API.
Jeśli kolejne żądanie zostało wysłane, gdy sieć była niedostępna, używana jest odpowiedź z pamięci podręcznej.
Ponieważ pamięć podręczna była automatycznie aktualizowana za każdym razem, gdy z sieci wracała odpowiedź, nie musieliśmy specjalnie wersjonować zasobów ani wygaszać wpisów.
toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);
Obrazy w profilu głośnika
W przypadku zdjęć w profilach mówców naszym celem było wyświetlanie wcześniej zapisanej w pamięci podręcznej wersji zdjęcia danego mówcy, jeśli jest ona dostępna, a w przeciwnym razie pobieranie obrazu z sieci. Jeśli żądanie sieci nie powiodło się, jako ostateczne rozwiązanie zastępcze użyliśmy ogólnego obrazu zastępczego, który został wcześniej zapisany w pamięci podręcznej (a zatem zawsze jest dostępny). Jest to typowa strategia stosowana w przypadku obrazów, które można zastąpić ogólnym miejscem substytutowym. Można ją łatwo zaimplementować, łącząc w łańcuch elementy obsługi sw-toolbox
cacheFirst
i cacheOnly
.
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/});
Aktualizacje harmonogramów użytkowników
Jedną z kluczowych funkcji IOWA było umożliwienie zalogowanym użytkownikom tworzenia i utrzymywania harmonogramu sesji, w których chcieli wziąć udział. Jak można się spodziewać, aktualizacje sesji były wysyłane za pomocą żądań HTTP POST
do serwera backendu. Poświęciliśmy trochę czasu na znalezienie najlepszego sposobu obsługi tych żądań modyfikujących stan, gdy użytkownik jest offline. Okazało się, że nie udało nam się umieścić w kolejce nieudanych żądań w IndexedDB, połączonej z logiką na głównej stronie internetowej, która sprawdzała IndexedDB pod kątem żądań w kolejce i powtórzyła wszystkie znalezione wyniki.
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);
Ponieważ próby zostały wykonane w kontekście strony głównej, mogliśmy mieć pewność, że zawierają one nowy zestaw danych logowania użytkownika. Po pomyślnym wykonaniu ponownych prób wyświetliliśmy użytkownikowi komunikat z informacją, że wcześniej oczekujące aktualizacje zostały zastosowane.
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 Analytics offline
W podobny sposób wprowadziliśmy też mechanizm, który umieszcza w kole wszystkie nieudane żądania Google Analytics i próbuje je powtórzyć później, gdy sieć będzie już dostępna. Dzięki temu podejściu offline nie oznacza to poświęcania
statystyk, które oferuje Google Analytics. Do każdego żądania w kole dodaliśmy parametr qt
, który jest ustawiony na czas, jaki upłynął od momentu pierwszego przesłania żądania. Dzięki temu backend Google Analytics będzie miał prawidłowy czas atrybucji zdarzenia. Google Analytics oficjalnie obsługuje wartości qt
trwające do maksymalnie 4 godzin, dlatego dołożyliśmy wszelkich starań, aby jak najszybciej odtworzyć te żądania ponownie przy każdym uruchomieniu skryptu service worker.
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();
Strony docelowe powiadomień push
Serwisy worker nie tylko obsługiwały funkcje offline IOWA, ale też umożliwiały powiadomienia push, które służyły do informowania użytkowników o aktualizacjach ich sesji z zakładkami. Strona docelowa powiązana z tymi powiadomieniami wyświetlała zaktualizowane szczegóły sesji. Te strony docelowe były już przechowywane w pamięci podręcznej jako część całej witryny, więc działały już offline, ale musieliśmy się upewnić, że szczegóły sesji na tej stronie są aktualne, nawet w trybie offline. W tym celu zmodyfikowaliśmy wcześniej zapisane w pamięci podręcznej metadane sesji, dodając do nich aktualizacje, które spowodowały wysłanie powiadomienia push, a następnie zapisaliśmy wynik w pamięci podręcznej. Te aktualne informacje zostaną użyte przy następnym otwarciu strony z informacjami o sesji, niezależnie od tego, czy ma ona miejsce online czy offline.
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');
}
});
});
Ważne informacje
Oczywiście nikt nie pracuje nad projektem o rozmiarze IOWA bez napotkania kilku problemów. Poniżej znajdziesz kilka z nich oraz sposoby ich rozwiązania.
Nieaktualne treści
Gdy planujesz strategię buforowania, niezależnie od tego, czy jest ona implementowana za pomocą service workers czy standardowego buforowania przeglądarki, musisz znaleźć kompromis między dostarczaniem zasobów tak szybko, jak to możliwe, a dostarczaniem najnowszych zasobów. W ramach usługi sw-precache
wdrożyliśmy dla powłoki aplikacji agresywną strategię stawiającą na pamięć podręczną. Oznacza to, że nasz mechanizm Service Worker nie sprawdza sieci przed zwróceniem kodu HTML, JavaScript i CSS na stronie.
Na szczęście udało nam się wykorzystać wydarzenia cyklu życia usługi, aby wykrywać, kiedy po załadowaniu strony pojawiły się nowe treści. Gdy wykryjemy zaktualizowanego pracownika usługi, wyświetlamy użytkownikowi wiadomość typu toast, aby poinformować go, że musi ponownie załadować stronę, aby zobaczyć najnowsze treści.
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);
}
};
}
Upewnij się, że treści statyczne są statyczne.
sw-precache
używa skrótu MD5 zawartości lokalnych plików i pobiera tylko zasoby, których skrót się zmienił. Oznacza to, że zasoby są dostępne na stronie niemal natychmiast, ale oznacza to też, że gdy coś zostanie zapisane w pamięci podręcznej, będzie tam przechowywane, dopóki nie zostanie mu przypisany nowy hasz w zaktualizowanym skrypcie usługi.
Podczas konferencji I/O wystąpił problem z tym zachowaniem, ponieważ nasz backend musiał dynamicznie aktualizować identyfikatory filmów z YouTube na żywo na każdy dzień konferencji. Ponieważ plik szablonu podrzędnego był statyczny i nie ulegał zmianie, nie został uruchomiony proces aktualizacji naszego interfejsu usługi. W efekcie dla niektórych użytkowników zamiast dynamicznej odpowiedzi z serwera z aktualnymi filmami w YouTube wyświetlała się odpowiedź z poziomu pamięci podręcznej.
Aby uniknąć tego typu problemów, zadbaj o to, aby aplikacja internetowa była skonstruowana tak, aby powłoka była zawsze statyczna i można było bezpiecznie ją wstępnie przechowywać w pamięci podręcznej, a wszystkie zasoby dynamiczne, które modyfikują powłokę, były ładowane niezależnie.
Omijanie pamięci podręcznej żądań wstępnego buforowania
Gdy sw-precache
wysyła żądania zasobów do wstępnego buforowania, używa tych odpowiedzi przez nieokreślony czas, dopóki nie stwierdzi, że hasz MD5 pliku nie uległ zmianie. Oznacza to, że szczególnie ważne jest, aby odpowiedź na żądanie wstępnego pobierania była nowa, a nie zwracana z bufora HTTP przeglądarki. (tak, żądania fetch()
wysyłane w ramach usługi workera mogą zwracać dane z pamięci podręcznej HTTP przeglądarki).
Aby mieć pewność, że odpowiedzi, które wcześniej zapisujemy w pamięci podręcznej, pochodzą bezpośrednio z sieci, a nie z pamięci podręcznej HTTP przeglądarki, sw-precache
automatycznie dodaje parametr zapytania, który powoduje wyczyszczanie pamięci podręcznej, do każdego adresu URL, którego dotyczy żądanie. Jeśli nie używasz sw-precache
i korzystasz ze strategii odpowiedzi „najpierw bufor”, zrób coś podobnego w swoim kodzie.
Lepszym rozwiązaniem do pomijania pamięci podręcznej jest ustawienie trybu pamięci podręcznej każdego obiektu Request
używanego do wstępnego buforowania na reload
, dzięki czemu odpowiedź będzie pochodziła z sieci. W momencie pisania tego tekstu opcja trybu pamięci podręcznej nie jest obsługiwana w Chrome.
Obsługa logowania i wylogowywania
IOWA umożliwiała użytkownikom logowanie się na swoje konta Google i aktualizowanie spersonalizowanych harmonogramów wydarzeń, ale oznaczało to też, że użytkownicy mogli się później wylogować. Buforowanie danych o spersonalizowanych odpowiedziach to oczywiście trudny temat, a nie zawsze istnieje jeden właściwy sposób postępowania.
Wyświetlanie osobistego harmonogramu, nawet w trybie offline, było podstawowym elementem interfejsu IOWA, dlatego uznaliśmy, że korzystanie z danych przechowywanych w pamięci podręcznej jest uzasadnione. Gdy użytkownik się wyloguje, usuwamy wcześniej zapisane w pamięci podręcznej dane sesji.
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);
});
});
});
}
});
Uważaj na dodatkowe parametry zapytania
Gdy usługa sprawdza, czy odpowiedź jest w pamięci podręcznej, używa adresu URL żądania jako klucza. Domyślnie adres URL żądania musi dokładnie odpowiadać adresowi URL używanemu do przechowywania odpowiedzi w pamięci podręcznej, w tym wszelkim parametrom zapytania w części search adresu URL.
Utrudniło to nam pracę w trakcie programowania, gdy zaczęliśmy używać parametrów adresu URL do śledzenia, skąd pochodzi ruch. Na przykład dodaliśmy parametr utm_source=notification
do adresów URL, które zostały otwarte po kliknięciu jednego z naszych powiadomień, i użyliśmy parametru utm_source=web_app_manifest
w start_url
w pliku manifestu aplikacji internetowej.
Adresy URL, które wcześniej pasowały do odpowiedzi z pamięci podręcznej, były wyświetlane jako nieudane po dodaniu tych parametrów.
Problem ten jest częściowo rozwiązywany przez opcję ignoreSearch
, która może być używana podczas dzwonienia do Cache.match()
. Chrome jeszcze nie obsługuje ignoreSearch
, a nawet jeśli tak, to działa to w trybie wszystko albo nic. Potrzebowaliśmy sposobu na ignorowanie niektórych parametrów zapytania w adresie URL, a jednocześnie uwzględnianie innych, które były istotne.
W konsekwencji rozszerzyliśmy funkcję sw-precache
, aby wykluczać niektóre parametry zapytania przed sprawdzaniem dopasowania w pamięci podręcznej, oraz umożliwić deweloperom dostosowanie parametrów, które mają być ignorowane, za pomocą opcji ignoreUrlParametersMatching
.
Oto implementacja:
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();
}
Co to oznacza dla Ciebie
Integracja service workera w aplikacji internetowej Google I/O jest prawdopodobnie najbardziej złożonym, praktycznym zastosowaniem, jakie do tej pory zostało wdrożone. Chętnie poznamy społeczność programistów stron internetowych korzystających z narzędzi, które opracowaliśmy sw-precache
i sw-toolbox
, a także omówionych przez nas technik dotyczących tworzenia własnych aplikacji internetowych.
Skrypty service worker to stopniowe udoskonalenie, z którego możesz zacząć korzystać już dziś. Kiedy używasz ich jako część odpowiednio uporządkowanej aplikacji internetowej, ich szybkość działania i korzyści w trybie offline mają istotne znaczenie dla użytkowników.