Service Worker in der Produktion

Screenshot im Hochformat

Zusammenfassung

Hier erfährst du, wie wir mithilfe von Service Worker-Bibliotheken die Google I/O 2015-Webanwendung schnell und offline gemacht haben.

Überblick

Die diesjährige Google I/O-Web-App 2015 wurde vom Developer Relations-Team von Google nach Entwürfen unserer Freunde bei Instrument verfasst, die das raffinierte audio/visuelle Experiment entwickelt haben. Die Mission unseres Teams bestand darin, dafür zu sorgen, dass die I/O-Webanwendung, die ich mit ihrem Codenamen IOWA benenne, alles zeigt, was das moderne Web leisten kann. Eine vollständige Offline-Erfahrung stand ganz oben auf unserer Liste der unverzichtbaren Funktionen.

Wenn Sie in letzter Zeit einen der anderen Artikel auf dieser Website gelesen haben, sind Sie zweifellos auf Servicemitarbeiter gestoßen und werden nicht überrascht sein, dass der Offline-Support von IOWA stark von ihnen abhängt. Angeregt durch die realen Anforderungen von IOWA haben wir zwei Bibliotheken für zwei verschiedene Offline-Anwendungsfälle entwickelt: sw-precache zur Automatisierung des Precaching statischer Ressourcen und sw-toolbox für das Laufzeit-Caching und Fallback-Strategien.

Die Bibliotheken ergänzen sich gut und ermöglichten es uns, eine leistungsstarke Strategie zu implementieren, bei der die statische „Shell“ von IOWA immer direkt aus dem Cache bereitgestellt wurde und dynamische oder Remote-Ressourcen aus dem Netzwerk mit Fallbacks auf zwischengespeicherte oder statische Antworten bei Bedarf.

Precaching mit sw-precache

Die statischen Ressourcen von IOWA – HTML, JavaScript, CSS und Bilder – bilden die Kern-Shell für die Webanwendung. Beim Speichern dieser Ressourcen waren zwei spezifische Anforderungen wichtig: Wir wollten sicherstellen, dass die meisten statischen Ressourcen im Cache gespeichert und auf dem neuesten Stand gehalten werden. sw-precache wurde unter Berücksichtigung dieser Anforderungen erstellt.

Integration bei der Build-Erstellung

sw-precache durch den gulp-basierten Build-Prozess von IOWA. Wir stützen uns auf eine Reihe von glob-Mustern, um sicherzustellen, dass wir eine vollständige Liste aller statischen Ressourcen generieren, die IOWA verwendet.

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

Alternative Ansätze wie das Hartcodieren einer Liste von Dateinamen in einem Array und das Erinnern, bei jeder dieser Dateiänderungen eine Cache-Versionsnummer zu ändern, waren viel zu fehleranfällig, insbesondere da mehrere Teammitglieder Code eingecheckt haben. Niemand möchte die Offline-Unterstützung unterbrechen, indem er eine neue Datei in einem manuell verwalteten Array auslässt. Dank der Integration beim Build konnten wir vorhandene Dateien problemlos ändern und neue hinzufügen.

Im Cache gespeicherte Ressourcen aktualisieren

sw-precache generiert ein Basis-Service-Worker-Skript, das einen eindeutigen MD5-Hash für jede Ressource enthält, die vorab im Cache gespeichert wird. Jedes Mal, wenn eine vorhandene Ressource geändert oder eine neue Ressource hinzugefügt wird, wird das Service Worker-Skript neu generiert. Dadurch wird automatisch der Updatevorgang für Service Worker ausgelöst, bei dem die neuen Ressourcen im Cache gespeichert und veraltete Ressourcen dauerhaft gelöscht werden. Alle vorhandenen Ressourcen mit identischen MD5-Hashes werden unverändert beibehalten. Das bedeutet, dass Nutzer, die die Website zuvor besucht haben, nur die minimalen geänderten Ressourcen herunterladen. Dies ist wesentlich effizienter, als wenn der gesamte Cache en masse abgelaufen wäre.

Jede Datei, die mit einem der glob-Muster übereinstimmt, wird heruntergeladen und im Cache gespeichert, wenn ein Nutzer zum ersten Mal IOWA aufruft. Wir haben versucht, dafür zu sorgen, dass nur die kritischen Ressourcen, die zum Rendern der Seite benötigt werden, vorab im Cache gespeichert werden. Sekundäre Inhalte wie die im audio/visuellen Experiment verwendeten Medien oder die Profilbilder der Referenten wurden absichtlich nicht vorab im Cache gespeichert. Stattdessen haben wir die Bibliothek sw-toolbox verwendet, um Offlineanfragen für diese Ressourcen zu verarbeiten.

sw-toolbox, für alle unsere dynamischen Anforderungen

Wie bereits erwähnt, ist es nicht möglich, jede Ressource, die eine Website für den Offlinezugriff benötigt, im Cache zu speichern. Einige Ressourcen sind zu groß oder werden nur selten genutzt, um sie sinnvoll zu nutzen. Andere Ressourcen sind dynamisch, z. B. die Antworten von einer Remote-API oder einem Remote-Dienst. Nur weil eine Anfrage nicht vorab im Cache gespeichert wird, muss sie nicht zu einem NetworkError führen. Mit sw-toolbox konnten wir Anfrage-Handler flexibel implementieren, die das Laufzeit-Caching für einige Ressourcen und benutzerdefinierte Fallbacks für andere übernehmen. Außerdem haben wir damit unsere zuvor im Cache gespeicherten Ressourcen als Reaktion auf Push-Benachrichtigungen aktualisiert.

Hier sind einige Beispiele für benutzerdefinierte Anfrage-Handler, die auf sw-toolbox aufbauen. Es war einfach, sie über die importScripts parameter von sw-precache in das Basis-Service-Worker-Skript einzubinden, die eigenständige JavaScript-Dateien in den Bereich des Service Workers abruft.

Audio/visuelles Experiment

Für den audio/visuellen Test haben wir die Cache-Strategie networkFirst von sw-toolbox verwendet. Alle HTTP-Anfragen, die mit dem URL-Muster für den Test übereinstimmen, werden zuerst an das Netzwerk gesendet. Würde eine erfolgreiche Antwort zurückgegeben, wird diese Antwort mithilfe der Cache Storage API zurückgehalten. Wenn eine nachfolgende Anfrage gestellt wurde, als das Netzwerk nicht verfügbar war, wird die zuvor im Cache gespeicherte Antwort verwendet.

Da der Cache jedes Mal automatisch aktualisiert wurde, wenn eine erfolgreiche Netzwerkantwort zurückgegeben wurde, mussten wir keine spezifischen Ressourcen versionieren oder Einträge verfallen lassen.

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

Bilder des Sprecherprofils

Für die Profilbilder von Sprechern bestand unser Ziel darin, eine zuvor im Cache gespeicherte Version des Bildes eines bestimmten Sprechers anzuzeigen, falls es verfügbar war, und auf das Netzwerk zurückzugreifen, um das Bild abzurufen, falls dies nicht der Fall war. Wenn diese Netzwerkanfrage fehlgeschlagen ist, haben wir als letzten Fallback ein generisches Platzhalterbild verwendet, das vorab im Cache gespeichert wurde und daher immer verfügbar ist. Diese Strategie wird häufig bei Bildern verwendet, die durch einen generischen Platzhalter ersetzt werden können. Sie lässt sich einfach durch Verkettung der cacheFirst- und cacheOnly-Handler von sw-toolbox implementieren.

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/});
Profilbilder von einer Sitzungsseite
Profilbilder von einer Sitzungsseite.

Aktualisierungen der Zeitpläne der Nutzer

Eines der Hauptmerkmale von IOWA war es, es angemeldeten Nutzern zu ermöglichen, einen Zeitplan für Sitzungen zu erstellen und zu pflegen, an denen sie teilnehmen wollten. Wie Sie sich vorstellen können, wurden Sitzungsaktualisierungen über HTTP-POST-Anfragen an einen Back-End-Server vorgenommen. Wir haben einige Zeit investiert, um herauszufinden, wie diese Anfragen zur Statusänderung am besten verarbeitet werden können, wenn der Nutzer offline ist. Wir haben eine Kombination erstellt, die fehlgeschlagene Anfragen in IndexedDB in die Warteschlange stellt, zusammen mit der Logik auf der Hauptwebseite, die IndexedDB auf Anfragen in der Warteschlange überprüft und alle gefundenen wiederholt.

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

Da die Wiederholungsversuche vom Kontext der Hauptseite aus durchgeführt wurden, konnten wir sicher sein, dass sie neue Nutzeranmeldedaten enthielten. Nach erfolgreichen Wiederholungen wurde eine Meldung angezeigt, in der der Nutzer darüber informiert wurde, dass die zuvor in der Warteschlange gestellten Updates angewendet wurden.

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

Auf ähnliche Weise haben wir einen Handler implementiert, um fehlgeschlagene Google Analytics-Anfragen in eine Warteschlange zu stellen und später zu versuchen, sie wiederzugeben, wenn das Netzwerk hoffentlich verfügbar war. Bei diesem Ansatz müssen Sie, wenn Sie offline sind, nicht auf die Informationen verzichten, die Google Analytics bietet. Wir haben jeder Anfrage in der Warteschlange den Parameter qt hinzugefügt. Er ist auf die Zeit festgelegt, die seit dem ersten Versuch der Anfrage verstrichen ist, um sicherzustellen, dass eine korrekte Zeit für die Ereignisattribution das Google Analytics-Back-End erreicht hat. Google Analytics unterstützt offiziell Werte für qt von maximal vier Stunden. Daher haben wir versucht, diese Anfragen bei jedem Start des Service Workers so schnell wie möglich zu wiederholen.

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

Landingpages für Push-Benachrichtigungen

Service Worker kümmerten sich nicht nur um die Offlinefunktionen von IOWA, sondern auch um Push-Benachrichtigungen, mit denen wir Nutzer über Updates für ihre als Lesezeichen gespeicherten Sitzungen informierten. Auf der mit diesen Benachrichtigungen verknüpften Landingpage wurden die aktualisierten Sitzungsdetails angezeigt. Diese Landingpages wurden bereits als Teil der gesamten Website im Cache gespeichert, sodass sie auch offline funktionierten. Wir mussten aber dafür sorgen, dass die Sitzungsdetails auf dieser Seite auch dann auf dem neuesten Stand sind, wenn sie offline angesehen wurden. Dazu haben wir zuvor im Cache gespeicherte Sitzungsmetadaten mit den Updates geändert, die die Push-Benachrichtigung ausgelöst haben, und das Ergebnis im Cache gespeichert. Diese aktuellen Informationen werden beim nächsten Öffnen der Seite mit den Sitzungsdetails verwendet, unabhängig davon, ob dies online oder offline stattfindet.

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

Erkenntnisse und Überlegungen

Natürlich arbeitet niemand an einem Projekt von IOWA, ohne auf ein paar Probleme zu stoßen. Hier sind einige davon und wie wir sie umgehen.

Veraltete Inhalte

Bei der Planung einer Caching-Strategie, sei es über Service-Worker oder mit dem Standard-Browser-Cache, müssen Sie einen Kompromiss zwischen der schnellstmöglichen Bereitstellung von Ressourcen und der Bereitstellung der neuesten Ressourcen berücksichtigen. Über sw-precache haben wir eine aggressive Cache-First-Strategie für die Shell unserer Anwendung implementiert, was bedeutet, dass unser Service Worker das Netzwerk nicht auf Updates prüft, bevor er den HTML-, JavaScript- und CSS-Code der Seite zurückgibt.

Glücklicherweise konnten wir Lebenszyklusereignisse von Service Workern nutzen, um zu erkennen, wann neue Inhalte verfügbar waren, nachdem die Seite bereits geladen war. Wenn ein aktualisierter Service Worker erkannt wird, wird dem Nutzer eine Toast-Nachricht mit der Information angezeigt, dass er seine Seite aktualisieren muss, um den neuesten Inhalt zu sehen.

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);
    }
    };
}
Die neuesten Inhalte
Die neuesten Inhalte

Stellen Sie sicher, dass der statische Inhalt statisch ist.

sw-precache verwendet einen MD5-Hash des Inhalts lokaler Dateien und ruft nur Ressourcen ab, deren Hash sich geändert hat. Das bedeutet, dass Ressourcen nahezu sofort auf der Seite verfügbar sind. Im Cache gespeicherte Ressourcen bleiben aber im Cache gespeichert, bis sie in einem aktualisierten Service Worker-Skript einen neuen Hashwert zugewiesen haben.

Während der E/A ist mit diesem Verhalten ein Problem aufgetreten, da unser Back-End die Livestream-Video-IDs von YouTube für jeden Tag der Konferenz dynamisch aktualisieren musste. Da die zugrunde liegende Vorlagendatei statisch war und sich nicht geändert hat, wurde der Service Worker-Aktualisierungsablauf nicht ausgelöst. Was als dynamische Antwort vom Server mit dem Aktualisieren von YouTube-Videos gedacht war, wurde schließlich als im Cache gespeicherte Antwort für eine Reihe von Nutzern verwendet.

Sie können diese Art von Problem vermeiden, indem Sie dafür sorgen, dass Ihre Webanwendung immer statisch ist und sicher vorab im Cache gespeichert werden kann, während alle dynamischen Ressourcen, die die Shell ändern, unabhängig geladen werden.

Cache-Busting Ihrer Precaching-Anfragen

Wenn sw-precache Ressourcen zum Vorabspeichern anfordert, werden diese Antworten auf unbestimmte Zeit verwendet, solange davon ausgegangen wird, dass sich der MD5-Hash für die Datei nicht geändert hat. Daher ist es besonders wichtig, dass die Antwort auf die Precaching-Anfrage neu ist und nicht aus dem HTTP-Cache des Browsers zurückgegeben wird. (Ja, fetch()-Anfragen, die in einem Service Worker gestellt werden, können mit Daten aus dem HTTP-Cache des Browsers antworten.)

Damit die vorab im Cache gespeicherten Antworten direkt aus dem Netzwerk und nicht aus dem HTTP-Cache des Browsers stammen, fügt sw-precache automatisch einen Cache-Busting-Abfrageparameter an jede URL an, die angefordert wird. Wenn Sie nicht sw-precache und eine Cache-First-Antwortstrategie nutzen, müssen Sie etwas Ähnliches in Ihrem eigenen Code tun.

Eine sauberere Lösung für Cache-Busting wäre, den Cache-Modus jeder Request für das Precaching in reload festzulegen. Dadurch wird sichergestellt, dass die Antwort aus dem Netzwerk kommt. Allerdings wird die Option für den Cache-Modus in Chrome zum jetzigen Zeitpunkt nicht unterstützt.

Unterstützung für An- und Abmeldung

IOWA ermöglichte es Nutzern, sich mit ihren Google-Konten anzumelden und ihre benutzerdefinierten Ereignispläne zu aktualisieren. Dies bedeutete aber auch, dass sich die Nutzer später abmelden konnten. Das Caching personalisierter Antwortdaten ist offensichtlich knifflig und es gibt nicht immer einen einzigen richtigen Ansatz.

Da die Anzeige Ihrer persönlichen Termine – auch offline – der Kern des IOWA-Erlebnisses war, entschieden wir uns für die Verwendung von im Cache gespeicherten Daten. Wenn sich ein Nutzer abmeldet, löschen wir frühere Sitzungsdaten aus dem 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);
            });
          });
        });
      }
    });

Achten Sie auf zusätzliche Suchparameter.

Wenn ein Service Worker nach einer im Cache gespeicherten Antwort sucht, verwendet er eine Anfrage-URL als Schlüssel. Standardmäßig muss die Anfrage-URL genau mit der URL übereinstimmen, die zum Speichern der im Cache gespeicherten Antwort verwendet wurde, einschließlich aller Abfrageparameter im Abschnitt search der URL.

Dies stellte uns während der Entwicklung ein Problem dar, als wir mit der Verwendung von URL-Parametern begannen, den Überblick darüber zu behalten, woher unser Traffic stammte. Beispielsweise haben wir URLs, die beim Klicken auf eine unserer Benachrichtigungen geöffnet wurden, den utm_source=notification-Parameter hinzugefügt und utm_source=web_app_manifest in start_url für unser Web-App-Manifest verwendet. URLs, die zuvor mit im Cache gespeicherten Antworten übereinstimmten, traten beim Anhängen dieser Parameter als Fehler auf.

Dies wird teilweise durch die Option ignoreSearch behoben, die beim Aufrufen von Cache.match() verwendet werden kann. Leider unterstützt Chrome ignoreSearch noch nicht. Selbst wenn dies der Fall ist, ist dies eine Alles-oder-nichts-Verhalten. Wir brauchten jedoch eine Möglichkeit, einige URL-Suchparameter zu ignorieren und andere aussagekräftige Suchparameter zu berücksichtigen.

Wir haben sw-precache erweitert, um einige Abfrageparameter vor der Suche nach Cache-Übereinstimmungen zu entfernen. Außerdem können Entwickler mit der Option ignoreUrlParametersMatching anpassen, welche Parameter ignoriert werden sollen. Hier ist die zugrunde liegende Implementierung:

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

Was das für dich bedeutet

Die Service Worker-Integration in der Google I/O-Webanwendung ist wahrscheinlich die komplexeste reale Nutzung, die bisher bereitgestellt wurde. Wir freuen uns darauf, dass die Webentwickler-Community die von uns entwickelten Tools sw-precache und sw-toolbox sowie die von uns beschriebenen Techniken für Ihre eigenen Webanwendungen verwendet. Service Worker sind eine fortschrittliche Verbesserung, die Sie schon heute nutzen können. Wenn sie als Teil einer korrekt strukturierten Webanwendung eingesetzt werden, sind die Geschwindigkeit und die Offlinevorteile für Ihre Nutzer signifikant.