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 qualsiasi degli altri articoli su questo sito, indubbiamente hai incontrato lavoratori dei servizi e non ti sorprenderà sentire che l'assistenza offline di IOWA fa molto affidamento 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 affidiamo a una serie di pattern glob per 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'inserimento di un elenco di nomi di 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 service worker, in cui le nuove risorse vengono memorizzate nella cache e le risorse obsolete vengono eliminate definitivamente. 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 corrispondente 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 audio/visivo o le immagini dei profili dei relatori delle sessioni, non sono stati volutamente prememorizzati 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 abbiamo detto, non è possibile eseguire la pre-memorizzazione nella cache di ogni risorsa necessaria a un sito per il funzionamento 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 di 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 di base tramite importScripts parameter di sw-precache, che inserisce i file JavaScript autonomi nell'ambito del servizio di lavoro.

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 dei profili di chi parla, il nostro obiettivo era visualizzare una versione precedentemente memorizzata nella cache dell'immagine di un determinato speaker, se disponibile, e ricorrere alla rete per recuperare l'immagine in caso contrario. Se la richiesta di rete non è andata a buon fine, come ultima alternativa, abbiamo utilizzato un'immagine segnaposto generica che era pre-memorizzata nella cache (e che pertanto sarebbe 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 gli elaboratori 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 eseguiti tutti i nuovi tentativi, abbiamo visualizzato 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

Analogamente, abbiamo implementato un gestore per inserire in coda le richieste di Google Analytics non riuscite e tentare di riprodurle in un secondo momento, quando si sperava che la rete fosse 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 Service worker non si limitavano a gestire la funzionalità offline di IOWA, ma a utilizzavano anche le notifiche push che utilizzavamo per informare gli utenti degli aggiornamenti alle loro sessioni aggiunte ai preferiti. 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 sulla scala di IOWA senza imbattersi in alcune difficoltà. Ecco alcuni dei problemi che abbiamo riscontrato e come abbiamo risolto il problema.

Contenuti non aggiornati

Ogni volta che 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 basata sulla cache-first per la shell dell'applicazione, il che significa che il nostro service worker non controllava la presenza di aggiornamenti sulla rete 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 sulla pagina quasi immediatamente, ma anche che, una volta che qualcosa viene memorizzato nella cache, rimarrà nella cache finché non gli viene 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 dinamicamente gli ID video di YouTube in live streaming per ogni giorno della conferenza. Poiché il file del modello sottostante era statico e non cambiava, il nostro flusso di aggiornamento del servizio worker 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 pre-memorizzata in modo sicuro, 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 pre-memorizzazione nella cache sia nuova 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-memorizzate 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 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 del presente documento, 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 della tua pianificazione 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 search 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 si fa clic su una delle nostre notifiche e utilizzato utm_source=web_app_manifest nel start_url per il manifest dell'app web. Gli URL che in precedenza corrispondevano alle risposte memorizzate nella cache erano considerati errori quando tali parametri sono stati aggiunti.

Questo problema viene risolto parzialmente dall'opzione ignoreSearch che può essere utilizzata quando si chiama Cache.match(). Purtroppo Chrome non è ancora supportato ignoreSearch e, anche se lo ha fatto, si tratta di un comportamento 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 l'ora di coinvolgere la community di sviluppatori web utilizzando gli strumenti che abbiamo creato sw-precache e sw-toolbox, nonché le tecniche che stiamo descrivendo per potenziare le tue applicazioni web. I service worker sono un miglioramento progressivo che puoi iniziare a utilizzare oggi stesso. Se li utilizzi all'interno di un'app web ben strutturata, la velocità e i vantaggi offline sono significativi per i tuoi utenti.