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 projektu naszych przyjaciół z Instrument, którzy stworzyli fajny eksperyment audiowizualny. Naszym zadaniem było zadbanie o to, aby aplikacja internetowa I/O (będę się do niej odnosić pod jej kryptonimem IOWA) prezentowała wszystko, co nowoczesna sieć internetowa ma do zaoferowania. Na szczycie listy niezbędnych funkcji znalazło się pełne działanie offline.
Jeśli czytałeś(-aś) ostatnio inne artykuły na tej stronie, na pewno spotkałeś(-aś) się z workerami usługi. Nie zdziwi Cię więc, że IOWA obsługuje offline w dużej mierze na ich podstawie. 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 zapasowych 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 pobieranie 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 zrywać obsługi offline przez pominięcie nowego pliku w ręcznie zarządzanym 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 usługi roboczej, który zawiera unikalny skrót MD5 dla każdego zasobu, który jest wstępnie przechowywany w pamięci podręcznej. Za każdym razem, gdy zmieni się istniejący zasób lub zostanie dodany nowy zasób, skrypt usługi zostanie ponownie wygenerowany. Automatycznie uruchamia to przebieg aktualizacji usługi w ramach usługi w tle, w którym nowe zasoby są umieszczane w pamięci podręcznej, a nieaktualne zasoby są usuwane.
Wszystkie istniejące zasoby, które mają identyczne wartości hasz MD5, pozostają bez zmian. Oznacza to, że użytkownicy, którzy wcześniej odwiedzili witrynę, pobierają tylko minimalny zestaw zmienionych zasobów, co znacznie zwiększa wydajność w porównaniu z sytuacją, gdy wygasa cała pamięć podręczna.
Każdy plik pasujący do jednego z wzorów glob jest pobierany i przechowywany w pamięci podręcznej za pierwszym razem, gdy użytkownik odwiedza IOWA. Dołożyliśmy wszelkich starań, aby do pamięci podręcznej zostały wstępnie zapisane tylko te zasoby, które są niezbędne do renderowania strony. Treści dodatkowe, takie jak media używane w eksperymencie audiowizualnym lub zdjęcia profilowe prelegentów, nie zostały celowo zarchiwizowane. Zamiast tego użyliśmy biblioteki sw-toolbox
do obsługi żądań offline dotyczących tych zasobów.
sw-toolbox
, for All Our Dynamic Needs
Jak już wspomnieliśmy, nie jest możliwe wstępne zcache’owanie 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. Jednak to, że żądanie nie jest przechowywane w cache, nie oznacza, że musi ono prowadzić do 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 wcześniej załadowanych zasobów w odpowiedzi na powiadomienia push.
Oto kilka przykładów niestandardowych elementów obsługi żądań, które zostały stworzone na podstawie biblioteki sw-toolbox. Łatwo je zintegrowaliśmy z podstawowym skryptem usługi roboczej za pomocą importScripts parameter
sw-precache
, który przeciąga samodzielne pliki JavaScript do zakresu usługi roboczej.
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, zostanie użyta odpowiedź z poprzednio zapisanej w pamięci podręcznej wersji.
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 powszechna 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. Opracowano kombinację kolejk nieudanych żądań w IndexedDB oraz logiki na głównej stronie internetowej, która sprawdzała IndexedDB pod kątem kolejk żądań i ponownie próbowała wykonać te, które znalazła.
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 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ępny. Dzięki temu rozwiązaniu brak połączenia z internetem nie oznacza rezygnacji ze statystyk 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
do 4 godzin, dlatego staramy się jak najszybciej odtwarzać te żądania za każdym razem, gdy rozpoczyna się usługa workera.
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 wywołały powiadomienie push, a potem zapisaliśmy wynik w pamięci podręcznej. Te aktualne informacje będą używane przy każdym otwarciu strony z informacjami o sesji, niezależnie od tego, czy odbywa się to 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 związane z cyklem ż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);
}
};
}
Sprawdź, czy 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 uległ zmianie. 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 mieliśmy problem z tym zachowaniem, ponieważ nasz backend musiał dynamicznie aktualizować identyfikatory filmów na żywo w YouTube dla każdego dnia konferencji. Podstawowy plik szablonu był statyczny i nie ulegał zmianie, więc nie został uruchomiony proces aktualizacji naszego serwisu. W efekcie dla wielu użytkowników zamiast dynamicznej odpowiedzi od 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.
Buforowanie żądań w ramach 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, fetch()
żądania wysyłane w ramach usługi workera mogą odpowiadać za pomocą danych 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 niż wymuszanie odświeżenia pamięci podręcznej jest ustawienie trybu pamięci podręcznej każdego Request
używanego do wstępnego przechowywania w pamięci podręcznej na reload
, co zapewni, że odpowiedź będzie pochodzić 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.
Ponieważ wyświetlanie osobistego harmonogramu, nawet w trybie offline, było kluczowe dla IOWA, uznaliśmy, że używanie danych z pamięci podręcznej jest odpowiednie. 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 z poziomu pamięci podręcznej, w tym wszelkich parametrów zapytania w części search adresu URL.
Stworzyło to problem w fazie 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 sw-precache
, aby wykluczać niektóre parametry zapytania przed sprawdzeniem 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. Z niecierpliwością czekamy na to, aż społeczność web developerów zacznie używać naszych narzędzi (sw-precache
) i sw-toolbox
oraz opisanych przez nas technik do tworzenia własnych aplikacji internetowych.
Skrypty service worker to ulepszenia progresywne, z których możesz zacząć korzystać już dziś. Jeśli są używane w ramach odpowiednio sformatowanej aplikacji internetowej, zapewniają użytkownikom znaczne korzyści w zakresie szybkości i możliwości korzystania z aplikacji offline.