Service Worker in der Produktion

Screenshot im Hochformat

Zusammenfassung

Hier erfahren Sie, wie wir mithilfe von Service Worker-Bibliotheken die Webanwendung der Google I/O 2015 schnell und offlinefähig gemacht haben.

Übersicht

Die Web-App für die Google I/O 2015 wurde vom Developer Relations-Team von Google basierend auf Designs unserer Freunde von Instrument entwickelt, die auch den praktischen audiovisuellen Test erstellt haben. Unser Team sollte dafür sorgen, dass die I/O-Web-App (die ich mit dem Codenamen IOWA bezeichne) alles bietet, was das moderne Web zu bieten hat. Eine vollständige Offline-Nutzung stand ganz oben auf der Liste der Must-Have-Funktionen.

Wenn Sie in letzter Zeit einen der anderen Artikel auf dieser Website gelesen haben, sind Sie zweifellos auf Dienstprogramme gestoßen. Es wird Sie also nicht überraschen, dass der Offlinesupport von IOWA stark auf diesen basiert. Angetrieben von den realen Anforderungen von IOWA haben wir zwei Bibliotheken entwickelt, um zwei verschiedene Offline-Anwendungsfälle zu bewältigen: sw-precache zum Automatisieren des Precachings statischer Ressourcen und sw-toolbox zum Bearbeiten von Laufzeit-Caching und Fallback-Strategien.

Die Bibliotheken ergänzen sich gut und ermöglichten uns die Implementierung einer leistungsstarken Strategie, bei der die statische „Shell“ der IOWA-Inhalte immer direkt aus dem Cache bereitgestellt wurde und dynamische oder Remote-Ressourcen aus dem Netzwerk, mit Fallbacks auf gecachte oder statische Antworten bei Bedarf.

Precaching mit sw-precache

Die statischen Ressourcen von IOWA – HTML, JavaScript, CSS und Bilder – bilden die Grundlage der Webanwendung. Beim Caching dieser Ressourcen waren zwei spezifische Anforderungen wichtig: Wir wollten dafür sorgen, dass die meisten statischen Ressourcen im Cache gespeichert und auf dem neuesten Stand gehalten wurden. sw-precache wurde genau mit diesen Anforderungen entwickelt.

Integration während der Buildzeit

sw-precache mit dem gulp-basierten Build-Prozess von IOWA und wir verwenden eine Reihe von glob-Mustern, um eine vollständige Liste aller statischen Ressourcen zu generieren, die in IOWA verwendet werden.

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 ein Array und das Erinnern daran, jedes Mal, wenn eine dieser Dateien geändert wird, die Cache-Versionsnummer zu erhöhen, waren viel zu fehleranfällig, insbesondere da wir mehrere Teammitglieder hatten, die den Code überprüften. Niemand möchte den Offlinesupport beeinträchtigen, indem er eine neue Datei in einem manuell verwalteten Array auslässt. Durch die Integration während der Buildzeit konnten wir Änderungen an vorhandenen Dateien vornehmen und neue Dateien hinzufügen, ohne uns um diese Probleme kümmern zu müssen.

Im Cache gespeicherte Ressourcen aktualisieren

sw-precache generiert ein Basis-Service Worker-Script, das einen eindeutigen MD5-Hash für jede Ressource enthält, die vorab im Cache gespeichert wird. Jedes Mal, wenn sich eine vorhandene Ressource ändert oder eine neue Ressource hinzugefügt wird, wird das Service Worker-Script neu generiert. Dadurch wird automatisch der Ablauf zum Aktualisieren von Service Workern ausgelöst, bei dem die neuen Ressourcen im Cache gespeichert und veraltete Ressourcen gelöscht werden. Vorhandene Ressourcen mit identischen MD5-Hashes bleiben unverändert. Das bedeutet, dass Nutzer, die die Website bereits besucht haben, nur die minimalen geänderten Ressourcen herunterladen müssen. Das 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 IOWA zum ersten Mal besucht. Wir haben darauf geachtet, dass nur kritische Ressourcen, die zum Rendern der Seite erforderlich sind, vorab im Cache gespeichert wurden. Sekundäre Inhalte wie die Medien, die im audiovisuellen Test verwendet wurden, oder die Profilbilder der Redner der Sitzungen wurden bewusst nicht vorab im Cache gespeichert. Stattdessen nutzten wir die sw-toolbox-Bibliothek, 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, alle Ressourcen vorab zu cachen, die für die Offlinefunktion einer Website erforderlich sind. Einige Ressourcen sind zu groß oder werden zu selten verwendet, um sich zu lohnen. Andere Ressourcen sind dynamisch, z. B. die Antworten einer Remote-API oder eines Remote-Dienstes. Aber nur weil eine Anfrage nicht vorab im Cache gespeichert wird, muss das nicht zu einer NetworkError führen. sw-toolbox gab uns die Flexibilität, Anfrage-Handler zu implementieren, die das Laufzeit-Caching für einige Ressourcen und benutzerdefinierte Fallbacks für andere Ressourcen verarbeiten. Außerdem haben wir damit unsere zuvor im Cache gespeicherten Ressourcen aufgrund von Push-Benachrichtigungen aktualisiert.

Hier sind einige Beispiele für benutzerdefinierte Anfrage-Handler, die wir auf der Grundlage von sw-toolbox erstellt haben. Die Integration in das Basis-Serviceworker-Script war über sw-precaches importScripts parameter ganz einfach, da damit eigenständige JavaScript-Dateien in den Bereich des Serviceworkers gezogen werden.

Audio-/Videotest

Für den audio-/visuellen Test haben wir die networkFirst-Cache-Strategie von sw-toolbox verwendet. Alle HTTP-Anfragen, die dem URL-Muster für den Test entsprechen, werden zuerst an das Netzwerk gesendet. Wenn eine erfolgreiche Antwort zurückgegeben wurde, wird diese Antwort mithilfe der Cache Storage API gespeichert. Wenn eine nachfolgende Anfrage gestellt wird, während das Netzwerk nicht verfügbar ist, 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 Ressourcen speziell versionieren oder Einträge verfallen lassen.

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

Profilbilder von Sprechern

Bei den Profilbildern der Redner wollten wir eine zuvor im Cache gespeicherte Version des Bildes eines bestimmten Redners anzeigen, sofern verfügbar. Andernfalls wurde das Bild über das Netzwerk abgerufen. Wenn diese Netzwerkanfrage fehlgeschlagen ist, wurde als letzter Fallback ein generisches Platzhalterbild verwendet, das vorab im Cache gespeichert wurde und daher immer verfügbar ist. Das ist eine gängige Strategie bei Bildern, die durch einen generischen Platzhalter ersetzt werden können. Die Implementierung war einfach, da die sw-toolbox-Handler cacheFirst und cacheOnly verschachtelt wurden.

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 von Nutzern

Eine der wichtigsten Funktionen von IOWA war es, dass angemeldete Nutzer einen Zeitplan für die Sitzungen erstellen und verwalten konnten, an denen sie teilnehmen wollten. Wie erwartet, wurden Sitzungsaktualisierungen über HTTP-POST-Anfragen an einen Backend-Server gesendet. Wir haben einige Zeit damit verbracht, die beste Methode zur Verarbeitung dieser zustandsverändernden Anfragen zu ermitteln, wenn der Nutzer offline ist. Wir haben eine Kombination aus einer Funktion entwickelt, die fehlgeschlagene Anfragen in IndexedDB in die Warteschlange stellt, und einer Logik auf der Hauptwebseite, die IndexedDB auf anstehende Anfragen überprüft und alle gefundenen Anfragen noch einmal ausführt.

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 Wiederholungen im Kontext der Hauptseite ausgeführt wurden, konnten wir sicher sein, dass sie neue Nutzeranmeldedaten enthielten. Sobald die Wiederholungen erfolgreich waren, wurde dem Nutzer eine Meldung angezeigt, dass die zuvor in der Warteschlange befindlichen 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

Außerdem haben wir einen Handler implementiert, der fehlgeschlagene Google Analytics-Anfragen in die Warteschlange stellt und versucht, sie später noch einmal abzuspielen, wenn das Netzwerk hoffentlich wieder verfügbar ist. Bei diesem Ansatz müssen Sie auch im Offlinemodus nicht auf die Statistiken von Google Analytics verzichten. Wir haben jeder Anfrage in der Warteschlange den Parameter qt hinzugefügt, der auf die Zeit festgelegt ist, die seit dem ersten Versuch der Anfrage vergangen ist. So wird sichergestellt, dass eine korrekte Ereigniszuordnungszeit an das Google Analytics-Backend gesendet wird. In Google Analytics werden offiziell nur Werte für qt von bis zu 4 Stunden unterstützt. Wir haben daher versucht, diese Anfragen jedes Mal, wenn der Dienstworker gestartet wurde, 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 wurden nicht nur für die Offlinefunktionen von IOWA verwendet, sondern auch für die Push-Benachrichtigungen, mit denen wir Nutzer über Updates zu ihren gespeicherten Sitzungen informiert haben. Auf der Landingpage, die mit diesen Benachrichtigungen verknüpft ist, wurden die aktualisierten Sitzungsdetails angezeigt. Diese Landingpages wurden bereits als Teil der gesamten Website im Cache gespeichert und funktionierten daher auch offline. Wir mussten jedoch dafür sorgen, dass die Sitzungsdetails auf dieser Seite auch offline auf dem neuesten Stand waren. Dazu haben wir zuvor im Cache gespeicherte Sitzungsmetadaten mit den Aktualisierungen geändert, die die Push-Benachrichtigung ausgelöst haben, und das Ergebnis im Cache gespeichert. Diese aktuellen Informationen werden verwendet, wenn die Seite mit den Sitzungsdetails das nächste Mal geöffnet wird, unabhängig davon, ob dies online oder offline geschieht.

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

Fallstricke und Überlegungen

Natürlich stößt man bei einem Projekt in der Größenordnung von IOWA auf einige Stolpersteine. Im Folgenden finden Sie einige der Probleme, auf die wir gestoßen sind, und wie wir sie gelöst haben.

Veraltete Inhalte

Bei der Planung einer Caching-Strategie, unabhängig davon, ob sie über Dienstarbeiter oder den Standardbrowser-Cache implementiert wird, müssen Sie einen Kompromiss zwischen der Bereitstellung von Ressourcen so schnell wie möglich und der Bereitstellung der aktuellsten Ressourcen eingehen. Über sw-precache haben wir eine aggressive Cache-first-Strategie für die Shell unserer Anwendung implementiert. Das bedeutet, dass unser Service Worker das Netzwerk nicht auf Updates prüft, bevor er die HTML-, JavaScript- und CSS-Dateien auf der Seite zurückgibt.

Glücklicherweise konnten wir Service Worker-Lebenszyklusereignisse nutzen, um zu erkennen, wann neue Inhalte verfügbar waren, nachdem die Seite bereits geladen wurde. Wenn ein aktualisierter Dienst-Worker erkannt wird, wird dem Nutzer eine Toast-Nachricht angezeigt, in der er darüber informiert wird, dass er die Seite aktualisieren muss, um die neuesten Inhalte 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 Benachrichtigung zu den neuesten Inhalten
Die Benachrichtigung „Neueste Inhalte“

Achten Sie darauf, dass statische Inhalte statisch sind

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 auf der Seite fast sofort verfügbar sind. Es bedeutet aber auch, dass etwas, das einmal im Cache gespeichert wurde, dort bleibt, bis ihm in einem aktualisierten Service Worker-Script ein neuer Hash zugewiesen wird.

Bei der I/O sind wir auf ein Problem gestoßen, da unser Backend die YouTube-Video-IDs des Livestreams für jeden Tag der Konferenz dynamisch aktualisieren musste. Da die untergeordnete Vorlagendatei statisch war und sich nicht änderte, wurde unser Dienst-Worker-Aktualisierungsablauf nicht ausgelöst. Was als dynamische Antwort vom Server mit aktualisierten YouTube-Videos gedacht war, war für einige Nutzer letztendlich die im Cache gespeicherte Antwort.

Sie können diese Art von Problem vermeiden, indem Sie Ihre Webanwendung so strukturieren, dass die Shell 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-Anfragen für das Vorab-Caching aufheben

Wenn sw-precache Anfragen für Ressourcen zum Vorab-Caching stellt, verwendet es diese Antworten unbegrenzt, solange der MD5-Hash für die Datei nicht geändert wurde. Daher ist es besonders wichtig, dass die Antwort auf die Pre-Caching-Anfrage aktuell ist und nicht aus dem HTTP-Cache des Browsers zurückgegeben wird. Ja, fetch()-Anfragen, die in einem Service Worker gesendet werden, können mit Daten aus dem HTTP-Cache des Browsers antworten.

Damit die von uns vorab im Cache gespeicherten Antworten direkt aus dem Netzwerk und nicht aus dem HTTP-Cache des Browsers stammen, fügt sw-precache jeder angeforderten URL automatisch einen Cache-Busting-Abfrageparameter hinzu. Wenn Sie sw-precache nicht verwenden und eine Cache-first-Antwortstrategie nutzen, sollten Sie etwas Ähnliches in Ihrem eigenen Code tun.

Eine einfachere Lösung für das Cache-Busting wäre, den Cache-Modus jeder Request, die für das Vorab-Caching verwendet wird, auf reload festzulegen. Dadurch wird sichergestellt, dass die Antwort aus dem Netzwerk stammt. Zum Zeitpunkt der Erstellung dieses Artikels wird der Cache-Modus in Chrome nicht unterstützt.

Unterstützung für Anmeldung und Abmeldung

Mit IOWA konnten sich Nutzer mit ihren Google-Konten anmelden und ihre benutzerdefinierten Terminpläne aktualisieren. Das bedeutete aber auch, dass sich Nutzer später wieder abmelden konnten. Das Caching personalisierter Antwortdaten ist natürlich ein heikles Thema und es gibt nicht immer einen einzigen richtigen Ansatz.

Da die Anzeige Ihres persönlichen Zeitplans auch offline ein zentrales Element von IOWA ist, haben wir uns dafür entschieden, die Verwendung von im Cache gespeicherten Daten zuzulassen. Wenn sich ein Nutzer abmeldet, werden zuvor im Cache gespeicherte Sitzungsdaten gelöscht.

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

Achtung: Zusätzliche Suchparameter

Wenn ein Dienstarbeiter 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 Antwort im Cache verwendet wurde, einschließlich aller Abfrageparameter im Such-Teil der URL.

Das führte während der Entwicklung zu einem Problem, als wir URL-Parameter verwenden wollten, um nachzuvollziehen, woher unsere Zugriffe kamen. So haben wir beispielsweise den Parameter utm_source=notification zu den URLs hinzugefügt, die geöffnet wurden, wenn auf eine unserer Benachrichtigungen geklickt wurde, und utm_source=web_app_manifest in der start_url für unser Web-App-Manifest verwendet. URLs, die zuvor mit im Cache gespeicherten Antworten übereinstimmten, wurden als Fehlschläge angezeigt, wenn diese Parameter angehängt wurden.

Dies wird teilweise durch die Option ignoreSearch behoben, die beim Aufrufen von Cache.match() verwendet werden kann. Leider unterstützt Chrome ignoreSearch noch nicht. Und selbst wenn, würde es nur in einem Fall aktiviert werden. Wir brauchten eine Möglichkeit, einige URL-Suchparameter zu ignorieren und andere, die relevant waren, zu berücksichtigen.

Wir haben sw-precache erweitert, um einige Abfrageparameter zu entfernen, bevor nach einer Übereinstimmung im Cache gesucht wird. Außerdem können Entwickler über die Option ignoreUrlParametersMatching festlegen, welche Parameter ignoriert werden. 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 bedeutet das für Sie?

Die Service Worker-Integration in der Google I/O-Web-App ist wahrscheinlich die komplexeste und realistischste Anwendung, die bisher implementiert wurde. Wir freuen uns, wenn die Webentwickler-Community unsere Tools sw-precache und sw-toolbox sowie die beschriebenen Techniken nutzt, um eigene Webanwendungen zu erstellen. Service Worker sind eine progressive Verbesserung, die Sie sofort verwenden können. Wenn sie in einer richtig strukturierten Webanwendung verwendet werden, sind die Geschwindigkeits- und Offlinevorteile für Ihre Nutzer erheblich.