Service worker in produzione

Screenshot verticale

Riepilogo

Scopri come abbiamo utilizzato le librerie dei service worker per rendere veloce e offline l'app web Google I/O 2015.

Panoramica

L'app web Google I/O 2015 di quest'anno è stata scritta dal team Developer Relations di Google, sulla base dei progetti dei nostri amici di Instrument, autrice dell'efficace esperimento audio/visivo. La missione del nostro team era garantire che l'app web di I/O (che chiamo come nome in codice IOWA) presentasse tutto ciò che il web moderno poteva fare. Un'esperienza offline completa era in cima alla nostra lista di funzionalità imperdibili.

Se hai letto uno degli altri articoli presenti su questo sito di recente, hai indubbiamente incontrato lavoratori dei servizi e non ti sorprenderà sapere che il supporto offline di IOWA dipende in larga misura da loro. Motivati dalle esigenze reali di IOWA, abbiamo sviluppato due librerie per gestire due diversi casi d'uso offline: sw-precache per automatizzare il precaricamento delle risorse statiche e sw-toolbox per gestire la memorizzazione nella cache di runtime e le strategie di fallback.

Le librerie si completano perfettamente a vicenda e ci hanno permesso di implementare una strategia ad alte prestazioni in cui la "shell" dei contenuti statici di IOWA veniva sempre fornita direttamente dalla cache e le risorse dinamiche o remote venivano fornite dalla rete, con fallback alle risposte statiche o memorizzate nella cache quando necessario.

Pre-memorizzazione nella cache con sw-precache

Le risorse statiche di IOWA (HTML, JavaScript, CSS e immagini) forniscono la shell di base per l'applicazione web. Per la memorizzazione nella cache di queste risorse erano importanti due requisiti specifici: volevamo assicurarci che la maggior parte delle risorse statiche venissero memorizzate nella cache e che fossero sempre aggiornate. L'app sw-precache è stata creata tenendo presenti questi requisiti.

Integrazione in fase di build

sw-precache con il processo di compilazione basato su gulp di IOWA e ci affidiamo a una serie di pattern glob per assicurarci di generare un elenco completo di tutte le risorse statiche utilizzate da IOWA.

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

Approcci alternativi, come l'hardcoded di un elenco di nomi dei file in un array, e la necessità di ricordare di aumentare il numero di versione della cache ogni volta che una qualsiasi di queste modifiche ai file è troppo soggetta a errori, soprattutto dato che il controllo del codice è composto da più membri del team. Nessuno vuole interrompere il supporto offline escludendo un nuovo file in un array gestito manualmente. Grazie all'integrazione in fase di build, abbiamo potuto modificare i file esistenti e aggiungerne di nuovi senza preoccupazioni.

Aggiornamento delle risorse memorizzate nella cache

sw-precache genera uno script del service worker di base che include un hash MD5 univoco per ogni risorsa prememorizzata nella cache. Ogni volta che una risorsa esistente viene modificata o ne viene aggiunta una nuova, lo script del service worker viene rigenerato. In questo modo viene attivato automaticamente il flusso di aggiornamento dei service worker, in cui le nuove risorse vengono memorizzate nella cache e le risorse non aggiornate vengono eliminate definitivamente. Tutte le risorse esistenti con hash MD5 identici vengono lasciate così com'è. Ciò significa che gli utenti che hanno visitato il sito prima di scaricare solo l'insieme minimo di risorse modificate, offrendo un'esperienza molto più efficiente rispetto alla scadenza dell'intera cache in massa.

Ogni file che corrisponde a uno dei pattern glob viene scaricato e memorizzato nella cache la prima volta che un utente visita IOWA. Abbiamo fatto il possibile per garantire che solo le risorse critiche necessarie per il rendering della pagina fossero prememorizzate nella cache. I contenuti secondari, come i contenuti multimediali utilizzati nell'esperimento audio/visivo o le immagini del profilo dei relatori delle sessioni, non sono stati deliberatamente prememorizzati nella cache e abbiamo invece utilizzato la libreria sw-toolbox per gestire le richieste offline per queste risorse.

sw-toolbox, per tutte le nostre esigenze dinamiche

Come già detto, non è possibile memorizzare nella cache tutte le risorse necessarie per il funzionamento offline di un sito. Alcune risorse sono troppo grandi o vengono utilizzate raramente per renderle utili, mentre altre sono dinamiche, ad esempio le risposte di un'API o un servizio remoto. Tuttavia, il semplice fatto che una richiesta non venga prememorizzata nella cache non significa che dovrà generare un NetworkError. sw-toolbox ci ha offerto la flessibilità di implementare gestori di richieste che gestiscono la memorizzazione nella cache di runtime per alcune risorse e i fallback personalizzati per altre. Li abbiamo anche utilizzati per aggiornare le risorse precedentemente memorizzate nella cache in risposta alle notifiche push.

Ecco alcuni esempi di gestori di richieste personalizzati che abbiamo realizzato sulla base di sw-toolbox. È stato facile integrarli con lo script del service worker di base tramite importScripts parameter di sw-precache, che estrae i file JavaScript autonomi nell'ambito del service worker.

Esperimento audio/visivo

Per l'esperimento audio/visivo, abbiamo utilizzato la strategia relativa alla cache networkFirst di sw-toolbox. Tutte le richieste HTTP corrispondenti al pattern URL dell'esperimento vengono prima inviate alla rete e, se viene restituita una risposta positiva, la risposta viene nascosta utilizzando l'API Cache Storage. Se viene effettuata una richiesta successiva quando la rete non è disponibile, verrà utilizzata la risposta precedentemente memorizzata nella cache.

Poiché la cache veniva aggiornata automaticamente ogni volta che veniva restituita una risposta di rete riuscita, non dovevamo gestire in modo specifico le risorse o far scadere le voci.

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

Immagini del profilo del relatore

Per le immagini del profilo di chi parla, il nostro obiettivo era visualizzare una versione precedentemente memorizzata nella cache dell'immagine di un determinato speaker, se disponibile, tornando alla rete per recuperare l'immagine, in caso contrario. Se la richiesta di rete non è andata a buon fine, come ultimo passaggio abbiamo utilizzato un'immagine segnaposto generica pre-memorizzata nella cache (e quindi sempre disponibile). Si tratta di una strategia comune da utilizzare per gestire immagini che potrebbero essere sostituite con un segnaposto generico ed è stata facile da implementare collegando i gestori cacheFirst e cacheOnly di 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/});
Immagini del profilo dalla pagina di una sessione
Immagini del profilo di una pagina della sessione.

Aggiornamenti alle pianificazioni degli utenti

Una delle funzionalità principali di IOWA era consentire agli utenti che eseguivano l'accesso di creare e gestire un programma delle sessioni che prevedevano di partecipare. Come previsto, gli aggiornamenti delle sessioni sono stati effettuati tramite richieste HTTP POST a un server di backend e abbiamo dedicato del tempo a trovare il modo migliore per gestire le richieste di modifica dello stato quando l'utente è offline. Abbiamo trovato una combinazione di richieste non riuscite in coda in IndexedDB, insieme alla logica nella pagina web principale che ha verificato le richieste in coda per IndexedDB e ha ripetuto le richieste trovate.

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);

Poiché i nuovi tentativi sono stati effettuati dal contesto della pagina principale, possiamo essere sicuri che siano inclusi un nuovo insieme di credenziali utente. Una volta superati i nuovi tentativi, abbiamo visualizzato un messaggio per comunicare all'utente che gli aggiornamenti precedentemente aggiunti in coda sono stati applicati.

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

Analogamente, abbiamo implementato un gestore per inserire in coda eventuali richieste di Google Analytics non riuscite e tentare di ripeterle in un secondo momento, quando si sperava che la rete fosse disponibile. Con questo approccio, essere offline non significa sacrificare le informazioni offerte da Google Analytics. Abbiamo aggiunto il parametro qt a ogni richiesta in coda, impostato sulla quantità di tempo trascorsa dal primo tentativo di richiesta, per garantire che l'attribuzione degli eventi venga trasmessa al backend di Google Analytics correttamente. Google Analytics supporta ufficialmente valori di qt fino a un massimo di 4 ore, quindi abbiamo tentato di ripetere queste richieste il prima possibile, a ogni avvio del 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();

Pagine di destinazione delle notifiche push

I service worker non si limitavano a gestire la funzionalità offline di IOWA, ma hanno anche generato le notifiche push utilizzate per informare gli utenti degli aggiornamenti alle sessioni aggiunte ai preferiti. La pagina di destinazione associata a queste notifiche mostrava i dettagli della sessione aggiornati. Queste pagine di destinazione erano già memorizzate nella cache come parte del sito complessivo, quindi funzionavano già offline, ma dovevamo assicurarci che i dettagli delle sessioni in quella pagina fossero aggiornati, anche quando erano visualizzati offline. A tale scopo, abbiamo modificato i metadati della sessione memorizzati nella cache in precedenza con gli aggiornamenti che attivavano la notifica push e abbiamo archiviato il risultato nella cache. Queste informazioni aggiornate verranno utilizzate alla successiva apertura della pagina dei dettagli della sessione, indipendentemente dal fatto che si verifichi online o 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');
    }
    });
});

Oggetti e considerazioni

Naturalmente, nessuno lavora su un progetto della scala IOWA senza dover trovare qualche gotch. Ecco alcune di quelle che abbiamo incontrato e come le abbiamo affrontate.

Contenuti obsoleti

Ogni volta che pianifichi una strategia di memorizzazione nella cache, sia implementata tramite i service worker o la cache del browser standard, devi trovare un compromesso tra la distribuzione delle risorse il più rapidamente possibile rispetto alla distribuzione delle risorse più recenti. Tramite sw-precache, abbiamo implementato una strategia aggressiva basata sulla cache per la shell della nostra applicazione, il che significa che il nostro service worker non controllava la rete per verificare la presenza di aggiornamenti prima di restituire HTML, JavaScript e CSS nella pagina.

Fortunatamente, siamo riusciti a sfruttare gli eventi del ciclo di vita dei service worker per rilevare quando erano disponibili nuovi contenuti dopo che la pagina era già stata caricata. Quando viene rilevato un service worker aggiornato, mostriamo all'utente un messaggio toast per informarlo che deve ricaricare la pagina per vedere i contenuti più recenti.

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);
    }
    };
}
Il toast dei contenuti più recente
Il toast "contenuti più recenti".

Assicurati che i contenuti statici siano statici.

sw-precache utilizza un hash MD5 dei contenuti dei file locali e recupera solo le risorse il cui hash è stato modificato. Ciò significa che le risorse sono disponibili nella pagina quasi immediatamente, ma significa anche che, una volta che un elemento è memorizzato nella cache, rimarrà nella cache finché non verrà assegnato un nuovo hash in uno script del service worker aggiornato.

Abbiamo riscontrato un problema con questo comportamento durante l'I/O perché il nostro backend doveva aggiornare in modo dinamico gli ID video di YouTube del live streaming per ogni giorno della conferenza. Poiché il file del modello sottostante era statico e non cambiava, il flusso di aggiornamento dei service worker non è stato attivato e quella che si intendeva essere una risposta dinamica del server con aggiornamento dei video di YouTube è stata la risposta memorizzata nella cache per diversi utenti.

Puoi evitare questo tipo di problema assicurandoti che la tua applicazione web sia strutturata in modo che la shell sia sempre statica e possa essere pre-cache in sicurezza, mentre tutte le risorse dinamiche che modificano la shell vengono caricate in modo indipendente.

Esegui il busting della cache delle richieste di pre-memorizzazione nella cache

Quando sw-precache effettua richieste di pre-cache per le risorse, utilizza queste risposte a tempo indeterminato, purché ritenga che l'hash MD5 del file non sia cambiato. Ciò significa che è particolarmente importante assicurarsi che la risposta alla richiesta di pre-caching nella cache sia aggiornata e non restituita dalla cache HTTP del browser. (Sì, le richieste fetch() effettuate in un service worker possono rispondere con i dati provenienti dalla cache HTTP del browser.)

Per garantire che le risposte che memorizziamo nella cache provengano direttamente dalla rete e non dalla cache HTTP del browser, sw-precache aggiunge automaticamente un parametro di query per il busting della cache a ogni URL richiesto. Se non utilizzi sw-precache e utilizzi una strategia di risposta basata sulla cache, assicurati di eseguire un'operazione simile nel tuo codice.

Una soluzione più pulita per il busting della cache consiste nell'impostare la modalità cache di ogni Request utilizzato per la prememorizzazione nella cache su reload, in modo da garantire che la risposta provenga dalla rete. Tuttavia, al momento della stesura di questo articolo, l'opzione della modalità cache non è supportata in Chrome.

Supporto per l'accesso e la disconnessione

IOWA ha consentito agli utenti di accedere con i propri Account Google e aggiornare i programmi degli eventi personalizzati, ma questo significava anche che gli utenti potevano disconnettersi in un secondo momento. Memorizzare nella cache i dati delle risposte personalizzate è ovviamente un argomento complesso e non c'è sempre un unico approccio giusto.

Poiché la visualizzazione dei tuoi impegni, anche offline, era fondamentale per l'esperienza IOWA, abbiamo deciso che l'utilizzo dei dati memorizzati nella cache era appropriato. Quando un utente esce, ci assicuravamo di cancellare i dati delle sessioni precedentemente memorizzati nella cache.

    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);
            });
          });
        });
      }
    });

Fare attenzione a parametri di query aggiuntivi.

Quando un service worker controlla una risposta memorizzata nella cache, utilizza un URL di richiesta come chiave. Per impostazione predefinita, l'URL della richiesta deve corrispondere esattamente all'URL utilizzato per archiviare la risposta memorizzata nella cache, inclusi eventuali parametri di ricerca nella porzione ricerca dell'URL.

Questo ha causato un problema durante lo sviluppo, quando abbiamo iniziato a utilizzare i parametri URL per monitorare la provenienza del traffico. Ad esempio, abbiamo aggiunto il parametro utm_source=notification agli URL che sono stati aperti quando hai fatto clic su una delle nostre notifiche e utilizzato utm_source=web_app_manifest nel start_url per il nostro file manifest dell'app web. Gli URL che in precedenza corrispondevano alle risposte memorizzate nella cache erano da considerarsi errori quando questi parametri erano stati aggiunti.

Questo problema viene parzialmente risolto dall'opzione ignoreSearch, che può essere utilizzata durante la chiamata a Cache.match(). Purtroppo Chrome non supporta ancora ignoreSearch e, anche se lo fosse, è un comportamento "tutto o niente". Quello che ci serviva era un modo per ignorare alcuni parametri di query dell'URL, prendendo in considerazione altri parametri significativi.

Abbiamo finito di estendere sw-precache per eliminare alcuni parametri di query prima di verificare la corrispondenza della cache e consentire agli sviluppatori di personalizzare i parametri ignorati tramite l'opzione ignoreUrlParametersMatching. Ecco l'implementazione di base:

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();
}

Cosa comporta tutto ciò per te

L'integrazione del service worker nell'app web Google I/O è probabilmente l'utilizzo reale più complesso di cui è stato eseguito il deployment fino a questo punto. Non vediamo l'ora che la community di sviluppatori web sfrutti gli strumenti che abbiamo creato sw-precache e sw-toolbox, oltre alle tecniche descritte in questo articolo, per potenziare le tue applicazioni web. I service worker sono un miglioramento progressivo che puoi iniziare a utilizzare fin da subito e, se utilizzati come parte di un'app web correttamente strutturata, la velocità e i vantaggi offline sono significativi per i tuoi utenti.