Service worker in produzione

Screenshot verticale

Riepilogo

Scopri come abbiamo utilizzato le librerie di worker di servizio per rendere l'app web Google I/O 2015 rapida e offline-first.

Panoramica

L'app web di Google I/O 2015 di quest'anno è stata scritta dal team di relazioni con gli sviluppatori di Google, in base ai progetti dei nostri amici di Instrument, che hanno scritto il fantastico esperimento audio/visivo. La missione del nostro team era garantire che l'app web I/O (a cui farò riferimento con il suo nome in codice, IOWA) mostrasse tutto ciò che il web moderno poteva fare. Un'esperienza completa offline era in cima alla nostra lista di funzionalità indispensabili.

Se di recente hai letto uno degli altri articoli su questo sito, hai senza dubbio incontrato i service worker e non ti sorprenderà sapere che l'assistenza offline di IOWA si basa molto su di essi. In base alle esigenze reali di IOWA, abbiamo sviluppato due librerie per gestire due diversi casi d'uso offline: sw-precache per automatizzare il precaching delle risorse statiche e sw-toolbox per gestire la memorizzazione nella cache e le strategie di riserva in fase di esecuzione.

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

Precaricamento con sw-precache

Le risorse statiche di IOWA (HTML, JavaScript, CSS e immagini) forniscono il nucleo della shell per l'applicazione web. Per quanto riguarda la memorizzazione nella cache di queste risorse, erano importanti due requisiti specifici: volevamo assicurarci che la maggior parte delle risorse statiche fosse memorizzata nella cache e che fossero aggiornate. sw-precache è stato creato tenendo conto di questi requisiti.

Integrazione in fase di compilazione

sw-precache con il processo di compilazione basato su gulp di IOWA, e ci basiamo su una serie di pattern glob per garantire la generazione di 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'inserimento di un elenco di nomi file in un array e il ricordo di aggiornare un numero di versione della cache ogni volta che uno di questi file cambiava, erano troppo soggetti a errori, soprattutto perché avevamo più membri del team che controllavano il codice. Nessuno vuole interrompere l'assistenza offline omettendo un nuovo file in un array gestito manualmente. L'integrazione in fase di compilazione ci ha permesso di apportare modifiche ai file esistenti e di aggiungerne di nuovi senza preoccuparci di questi problemi.

Aggiornamento delle risorse memorizzate nella cache

sw-precache genera uno script di service worker di base che include un hash MD5 univoco per ogni risorsa pre-memorizzata nella cache. Ogni volta che una risorsa esistente viene modificata o viene aggiunta una nuova risorsa, lo script del servizio worker viene rigenerato. Questo attiva automaticamente il flusso di aggiornamento del worker di servizio, in cui le nuove risorse vengono memorizzate nella cache e quelle obsolete vengono eliminate. Le risorse esistenti con hash MD5 identici vengono lasciate invariate. Ciò significa che gli utenti che hanno visitato il sito in precedenza finiscono per scaricare solo l'insieme minimo di risorse modificate, il che offre un'esperienza molto più efficiente rispetto al caso in cui l'intera cache sia scaduta in blocco.

Ogni file che corrisponde a uno dei pattern glob viene scaricato e memorizzato nella cache la prima volta che un utente visita IOWA. Ci siamo adoperati per assicurarci che solo le risorse critiche necessarie per il rendering della pagina fossero memorizzate nella cache. I contenuti secondari, come i contenuti multimediali utilizzati nell'esperimento audiovisivo o le immagini del profilo degli oratori delle sessioni, non sono stati deliberatamente memorizzati nella cache e abbiamo utilizzato la raccolta sw-toolbox per gestire le richieste offline per queste risorse.

sw-toolbox, per tutte le nostre esigenze dinamiche

Come accennato, non è possibile eseguire il pre-caching di tutte le risorse di cui un sito ha bisogno per funzionare offline. Alcune risorse sono troppo grandi o vengono utilizzate di rado per essere utili, mentre altre sono dinamiche, come le risposte di un servizio o un'API remoto. Tuttavia, il fatto che una richiesta non sia memorizzata nella cache non significa che debba necessariamente generare un NetworkError. sw-toolbox ci ha dato la flessibilità di implementare gestori delle richieste che gestiscono la memorizzazione nella cache di runtime per alcune risorse e i valori predefiniti personalizzati per altre. Lo abbiamo utilizzato anche per aggiornare le risorse memorizzate nella cache in precedenza in risposta alle notifiche push.

Ecco alcuni esempi di gestori delle richieste personalizzati che abbiamo creato su sw-toolbox. È stato facile integrarli con lo script del servizio worker di base tramite importScripts parameter di sw-precache, che inserisce i file JavaScript autonomi nell'ambito del servizio worker.

Esperimento audiovisivo

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

Poiché la cache veniva aggiornata automaticamente ogni volta che veniva restituita una risposta di rete positiva, non è stato necessario specificare la versione delle risorse o la scadenza delle voci.

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

Immagini del profilo dello speaker

Per le immagini del profilo degli speaker, il nostro obiettivo era mostrare una versione precedentemente memorizzata nella cache dell'immagine di un determinato speaker, se disponibile, e fare ricorso alla rete per recuperare l'immagine in caso contrario. Se la richiesta di rete non andava a buon fine, come ultima alternativa, veniva utilizzata un'immagine segnaposto generica pre-memorizzata nella cache (e quindi sempre disponibile). Questa è una strategia comune da utilizzare quando si tratta di immagini che possono essere sostituite con un segnaposto generico ed è stata facile da implementare concatenando 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 da una pagina della sessione
Immagini del profilo da una pagina della sessione.

Aggiornamenti alle pianificazioni degli utenti

Una delle funzionalità principali di IOWA era consentire agli utenti che avevano eseguito l'accesso di creare e gestire un programma delle sessioni a cui intendevano partecipare. Come previsto, gli aggiornamenti della sessione sono stati effettuati tramite richieste HTTP POST a un server di backend e abbiamo impiegato un po' di tempo per trovare il modo migliore per gestire queste richieste di modifica dello stato quando l'utente è offline. Abbiamo predisposto una combinazione di richieste non riuscite in coda in IndexedDB, abbinata a logica nella pagina web principale che controllava in IndexedDB le richieste in coda e riprovava a trovare.

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 tentativi di nuovo accesso sono stati effettuati dal contesto della pagina principale, abbiamo potuto assicurarci che includessero un nuovo set di credenziali utente. Una volta che i tentativi di ripetizione sono andati a buon fine, abbiamo mostrato un messaggio per informare l'utente che gli aggiornamenti precedentemente in coda erano 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

In modo simile, abbiamo implementato un gestore per mettere in coda le richieste di Google Analytics non riuscite e tentare di riprodurle in un secondo momento, quando la rete era sperabilmente disponibile. Con questo approccio, l'offline non significa rinunciare agli approfondimenti offerti da Google Analytics. Abbiamo aggiunto il parametro qt a ogni richiesta in coda, impostandolo sul tempo trascorso dall'inizio del primo tentativo di esecuzione della richiesta, per assicurarci che un tempo di attribuzione dell'evento corretto sia arrivato al backend di Google Analytics. Google Analytics supporta ufficialmente valori per qt fino a un massimo di 4 ore, pertanto abbiamo fatto del nostro meglio per riprodurre queste richieste il prima possibile, ogni volta che il servizio worker veniva avviato.

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 worker di servizio non gestivano solo la funzionalità offline di IOWA, ma supportavano anche le notifiche push che utilizzavamo per informare gli utenti degli aggiornamenti delle sessioni salvate come preferite. La pagina di destinazione associata a queste notifiche mostrava i dettagli aggiornati della sessione. Queste pagine di destinazione erano già memorizzate nella cache come parte del sito complessivo, quindi funzionavano già offline, ma dovevamo assicurarci che i dettagli della sessione in quella pagina fossero aggiornati, anche quando visualizzati offline. Per farlo, abbiamo modificato i metadati della sessione memorizzati nella cache in precedenza con gli aggiornamenti che hanno attivato la notifica push e abbiamo memorizzato il risultato nella cache. Queste informazioni aggiornate verranno utilizzate la prossima volta che verrà aperta la pagina dei dettagli della sessione, che si tratti di un'esperienza 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');
    }
    });
});

Problemi e considerazioni

Ovviamente, nessuno lavora a un progetto di queste dimensioni senza imbattersi in qualche problema. Ecco alcuni dei problemi che abbiamo riscontrato e come abbiamo risolto il problema.

Contenuti non aggiornati

Quando pianifichi una strategia di memorizzazione nella cache, che sia implementata tramite service worker o con la cache del browser standard, devi fare un compromesso tra la consegna delle risorse il più rapidamente possibile e la consegna delle risorse più aggiornate. Tramite sw-precache, abbiamo implementato una strategia aggressiva cache-first 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 del service worker per rilevare quando erano disponibili nuovi contenuti dopo il caricamento della pagina. Quando viene rilevato un servizio worker aggiornato, mostriamo un messaggio popup all'utente 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);
    }
    };
}
La notifica relativa ai contenuti più recenti
La notifica "Ultimi contenuti".

Assicurati che i contenuti statici siano effettivamente statici.

sw-precache utilizza un hash MD5 dei contenuti dei file locali e recupera solo le risorse di cui è cambiato l'hash. Ciò significa che le risorse sono disponibili nella pagina quasi immediatamente, ma significa anche che, una volta memorizzata nella cache, la risorsa rimane memorizzata nella cache fino a quando non viene assegnato un nuovo hash in uno script di worker di servizio aggiornato.

Abbiamo riscontrato un problema con questo comportamento durante la conferenza I/O perché il nostro backend deve aggiornare dinamicamente gli ID dei video di YouTube in live streaming per ogni giorno della conferenza. Poiché il file del modello sottostante era statico e non cambiava, il flusso di aggiornamento del nostro servizio di lavoro non è stato attivato e quella che doveva essere una risposta dinamica del server con l'aggiornamento i video di YouTube è finita per essere la risposta memorizzata nella cache per un numero di 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 prememorizzata in sicurezza, mentre le risorse dinamiche che modificano la shell vengono caricate in modo indipendente.

Evita la memorizzazione nella cache delle richieste di precaching

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

Per garantire che le risposte pre-cache provengano direttamente dalla rete e non dalla cache HTTP del browser, sw-precache aggiunge automaticamente un parametro di query per evitare la cache a ogni URL richiesto. Se non utilizzi sw-precache e utilizzi una strategia di risposta cache-first, assicurati di fare qualcosa di simile nel tuo codice.

Una soluzione più chiara per evitare la memorizzazione nella cache consiste nell'impostare la modalità cache di ogni Request utilizzato per la memorizzazione 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 consentiva agli utenti di accedere utilizzando i propri Account Google e di aggiornare le proprie programmazioni di eventi personalizzate, ma ciò significava anche che gli utenti potevano uscire in un secondo momento. La memorizzazione nella cache dei dati delle risposte personalizzate è ovviamente un argomento delicato e non esiste sempre un unico approccio corretto.

Poiché la visualizzazione del programma personale, anche offline, era fondamentale per l'esperienza IOWA, abbiamo deciso che l'utilizzo dei dati memorizzati nella cache era appropriato. Quando un utente si disconnette, abbiamo provveduto a cancellare i dati della sessione memorizzati nella cache in precedenza.

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

Fai attenzione ai parametri di query aggiuntivi.

Quando un worker del servizio controlla la presenza di 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 memorizzare la risposta memorizzata nella cache, inclusi eventuali parametri di query nella parte di 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 si aprivano facendo clic su una delle nostre notifiche e abbiamo utilizzato utm_source=web_app_manifest in start_url per il nostro manifest dell'app web. Gli URL che in precedenza corrispondevano alle risposte memorizzate nella cache non venivano visualizzati quando questi parametri venivano aggiunti.

Questo problema viene risolto parzialmente dall'opzione ignoreSearch che può essere utilizzata quando si chiama Cache.match(). Purtroppo, Chrome non supporta ancora ignoreSearch e, anche se lo supportasse, il comportamento sarebbe tutto o niente. Ci serviva un modo per ignorare alcuni parametri di query dell'URL, tenendo conto di altri significativi.

Abbiamo deciso di estendere sw-precache per rimuovere alcuni parametri di query prima di verificare la corrispondenza della cache e consentire agli sviluppatori di personalizzare i parametri da ignorare 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 di service worker nell'app web Google I/O è probabilmente l'utilizzo più complesso e reale che è stato implementato finora. Non vediamo giorno che la community di sviluppatori web utilizzi gli strumenti che abbiamo creato sw-precache e sw-toolbox, nonché le tecniche che stiamo descrivendo, per migliorare le proprie applicazioni web. I service worker sono un miglioramento progressivo che puoi iniziare a utilizzare oggi stesso e, se utilizzati all'interno di un'app web ben strutturata, la velocità e i vantaggi offline sono significativi per i tuoi utenti.