Service workers en production

Capture d'écran en mode portrait

Résumé

Découvrez comment nous avons utilisé des bibliothèques de service worker pour rendre l'application Web Google I/O 2015 rapide et prioritaire en mode hors connexion.

Présentation

L'application Web Google I/O 2015 de cette année a été écrite par l'équipe Developer Relations de Google, sur la base des conceptions de nos amis de Instrument, qui ont écrit l'expérience audio/visuelle. La mission de notre équipe était de s'assurer que l'application Web I/O (dont je parle par son nom de code, IOWA) présentait tout ce que le Web moderne pouvait faire. Une expérience hors connexion complète figurait en tête de notre liste de fonctionnalités indispensables.

Si vous avez lu l'un des autres articles de ce site récemment, vous avez sans aucun doute rencontré les service workers. Vous ne serez donc pas surpris d'apprendre que la compatibilité hors connexion d'IOWA repose fortement sur eux. Motivés par les besoins réels d'IOWA, nous avons développé deux bibliothèques pour gérer deux cas d'utilisation hors connexion différents : sw-precache pour automatiser le préchargement des ressources statiques et sw-toolbox pour gérer le cache d'exécution et les stratégies de remplacement.

Les bibliothèques se complètent parfaitement et nous ont permis de mettre en œuvre une stratégie performante dans laquelle l'interface système de contenu statique de l'IOWA était toujours diffusée directement à partir du cache, et les ressources dynamiques ou distantes étaient diffusées à partir du réseau, avec des réponses en cache ou statiques en cas de besoin.

Mise en cache préalable avec sw-precache

Les ressources statiques de l'IOWA (HTML, JavaScript, CSS et images) constituent le shell principal de l'application Web. Deux exigences spécifiques étaient importantes lorsque nous avons réfléchi à la mise en cache de ces ressources: nous voulions nous assurer que la plupart des ressources statiques étaient mises en cache et qu'elles étaient mises à jour. sw-precache a été conçu en tenant compte de ces exigences.

Intégration au moment de la compilation

sw-precache avec le processus de compilation basé sur gulp d'IOWA, et nous nous appuyons sur une série de modèles glob pour nous assurer de générer une liste complète de toutes les ressources statiques utilisées par IOWA.

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

Les autres approches, comme le codage en dur d'une liste de noms de fichiers dans un tableau et le fait de ne pas oublier d'augmenter un numéro de version de cache chaque fois que l'un de ces fichiers change, étaient beaucoup trop sujettes aux erreurs, en particulier étant donné que plusieurs membres de l'équipe vérifiaient le code. Personne ne veut interrompre la compatibilité hors connexion en omettant un nouveau fichier dans un tableau géré manuellement. L'intégration au moment de la compilation nous a permis d'apporter des modifications aux fichiers existants et d'ajouter de nouveaux fichiers sans ces soucis.

Mettre à jour les ressources mises en cache

sw-precache génère un script de service worker de base qui inclut un hachage MD5 unique pour chaque ressource préchargée. Chaque fois qu'une ressource existante est modifiée ou qu'une nouvelle ressource est ajoutée, le script du service worker est généré à nouveau. Cette opération déclenche automatiquement le flux de mise à jour du service worker, dans lequel les nouvelles ressources sont mises en cache et les ressources obsolètes sont supprimées définitivement. Toutes les ressources existantes dont les hachages MD5 sont identiques sont laissées telles quelles. Cela signifie que les utilisateurs qui ont déjà visité le site ne téléchargent que l'ensemble minimal de ressources modifiées, ce qui offre une expérience beaucoup plus efficace que si l'ensemble du cache était expiré en masse.

Chaque fichier correspondant à l'un des modèles glob est téléchargé et mis en cache la première fois qu'un utilisateur accède à IOWA. Nous nous sommes efforcés de nous assurer que seules les ressources critiques nécessaires à l'affichage de la page étaient préchargées. Le contenu secondaire, comme les contenus multimédias utilisés dans l'expérience audio/visuelle ou les images de profil des intervenants des sessions, n'a pas été préchargé intentionnellement. Nous avons plutôt utilisé la bibliothèque sw-toolbox pour gérer les requêtes hors connexion pour ces ressources.

sw-toolbox, pour tous nos besoins dynamiques

Comme indiqué, il n'est pas possible de pré-cacher toutes les ressources dont un site a besoin pour fonctionner hors connexion. Certaines ressources sont trop volumineuses ou rarement utilisées pour les rendre pertinentes, tandis que d'autres sont dynamiques, comme les réponses d'une API ou d'un service distant. Toutefois, ce n'est pas parce qu'une requête n'est pas prémise en cache qu'elle doit générer un NetworkError. sw-toolbox nous a permis d'implémenter des gestionnaires de requêtes qui gèrent le cache d'exécution pour certaines ressources et des solutions de remplacement personnalisées pour d'autres. Nous l'avons également utilisé pour mettre à jour nos ressources précédemment mises en cache en réponse aux notifications push.

Voici quelques exemples de gestionnaires de requêtes personnalisés que nous avons développés à partir de sw-toolbox. Il a été facile de les intégrer au script de service worker de base via importScripts parameter de sw-precache, qui extrait les fichiers JavaScript autonomes dans le champ d'application du service worker.

Test audio/visuel

Pour l'expérience audiovisuelle, nous avons utilisé la stratégie de cache networkFirst de sw-toolbox. Toutes les requêtes HTTP correspondant au format d'URL de l'expérience sont d'abord envoyées au réseau. Si une réponse réussie est renvoyée, cette réponse est ensuite stockée à l'aide de l'API Cache Storage. Si une requête ultérieure a été effectuée lorsque le réseau n'était pas disponible, la réponse précédemment mise en cache est utilisée.

Étant donné que le cache était automatiquement mis à jour chaque fois qu'une réponse réseau réussie était renvoyée, nous n'avions pas besoin de versionner spécifiquement les ressources ni d'expirer les entrées.

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

Images de profil des intervenants

Pour les images de profil des intervenants, notre objectif était d'afficher une version précédemment mise en cache de l'image d'un intervenant donné si elle était disponible, et de recourir au réseau pour récupérer l'image si elle ne l'était pas. Si cette requête réseau a échoué, nous utilisions une image d'espace réservé générique prémise en cache (et donc toujours disponible) en dernier recours. Il s'agit d'une stratégie courante à utiliser pour traiter des images pouvant être remplacées par un espace réservé générique. Elle a été facile à implémenter en chaînant les gestionnaires cacheFirst et cacheOnly de 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/});
Images de profil d'une page de session
Images de profil d'une page de session.

Modifications apportées aux plannings des utilisateurs

L'une des principales fonctionnalités d'Iowa était de permettre aux utilisateurs connectés de créer et de gérer un calendrier des sessions auxquelles ils prévoyaient d'assister. Comme vous vous y attendiez, les mises à jour de session ont été effectuées via des requêtes HTTP POST vers un serveur backend. Nous avons donc passé un certain temps à trouver la meilleure façon de gérer ces requêtes de modification d'état lorsque l'utilisateur était hors connexion. Nous avons trouvé une combinaison d'une file d'attente de requêtes échouées dans IndexedDB, associée à une logique sur la page Web principale qui vérifiait IndexedDB pour les requêtes en file d'attente et réessayait celles qu'elle trouvait.

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

Étant donné que les nouvelles tentatives ont été effectuées à partir du contexte de la page principale, nous pouvions être sûrs qu'elles incluaient un nouvel ensemble d'identifiants utilisateur. Une fois les nouvelles tentatives réussies, nous avons affiché un message pour informer l'utilisateur que les mises à jour précédemment mises en file d'attente avaient été appliquées.

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 hors connexion

Dans le même ordre d'idées, nous avons implémenté un gestionnaire pour mettre en file d'attente les requêtes Google Analytics ayant échoué et tenter de les lire plus tard, lorsque le réseau sera probablement disponible. Avec cette approche, le fait d'être hors connexion ne signifie pas renoncer aux insights offerts par Google Analytics. Nous avons ajouté le paramètre qt à chaque requête mise en file d'attente, défini sur le temps écoulé depuis la première tentative de la requête, afin de nous assurer qu'un temps d'attribution d'événement approprié a atteint le backend Google Analytics. Google Analytics est officiellement compatible avec des valeurs de qt allant jusqu'à quatre heures. Nous avons donc fait de notre mieux pour lire ces requêtes dès que possible, chaque fois que le service worker a démarré.

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

Pages de destination pour les notifications push

Les services workers ne géraient pas seulement la fonctionnalité hors connexion d'Iowa. Ils permettaient également d'envoyer les notifications push que nous utilisions pour informer les utilisateurs des mises à jour de leurs sessions enregistrées. La page de destination associée à ces notifications affichait les informations de session mises à jour. Ces pages de destination étaient déjà mises en cache dans le cadre du site global. Elles fonctionnaient donc déjà hors connexion, mais nous devions nous assurer que les détails de la session sur cette page étaient à jour, même lorsqu'ils étaient consultés hors connexion. Pour ce faire, nous avons modifié les métadonnées de session précédemment mises en cache avec les mises à jour ayant déclenché la notification push, et nous avons stocké le résultat dans le cache. Ces informations à jour seront utilisées la prochaine fois que la page des détails de la session s'ouvrira, que ce soit en ligne ou hors connexion.

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

Problèmes et considérations

Bien entendu, personne ne travaille sur un projet de l'ampleur d'IOWA sans rencontrer quelques difficultés. Voici quelques-uns des problèmes que nous avons rencontrés et la façon dont nous les avons résolus.

Contenu obsolète

Chaque fois que vous planifiez une stratégie de mise en cache, que ce soit via des workers de service ou avec le cache du navigateur standard, vous devez trouver un compromis entre la diffusion des ressources le plus rapidement possible et la diffusion des ressources les plus récentes. Via sw-precache, nous avons mis en œuvre une stratégie agressive de mise en cache pour le shell de notre application, ce qui signifie que notre service worker ne recherche pas de mises à jour sur le réseau avant de renvoyer le code HTML, JavaScript et CSS sur la page.

Heureusement, nous avons pu tirer parti des événements de cycle de vie du service worker pour détecter quand de nouveaux contenus étaient disponibles après le chargement de la page. Lorsqu'un service worker mis à jour est détecté, nous affichons un message toast indiquant à l'utilisateur qu'il doit actualiser sa page pour afficher le contenu le plus récent.

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);
    }
    };
}
Notification de contenu récent
Notification "Dernier contenu".

Assurez-vous que le contenu statique est statique

sw-precache utilise un hachage MD5 du contenu des fichiers locaux et n'extrait que les ressources dont le hachage a changé. Cela signifie que les ressources sont disponibles sur la page presque immédiatement, mais aussi qu'une fois un élément mis en cache, il le restera jusqu'à ce qu'un nouveau hachage lui soit attribué dans un script de service worker mis à jour.

Nous avons rencontré un problème avec ce comportement lors des entrées/sorties, car notre backend doit mettre à jour dynamiquement les ID vidéo YouTube du streaming en direct pour chaque jour de la conférence. Étant donné que le fichier de modèle sous-jacent était statique et ne changeait pas, notre flux de mise à jour du service worker n'a pas été déclenché. Ce qui devait être une réponse dynamique du serveur avec la mise à jour des vidéos YouTube s'est finalement avéré être la réponse mise en cache pour un certain nombre d'utilisateurs.

Pour éviter ce type de problème, assurez-vous que votre application Web est structurée de sorte que le shell soit toujours statique et puisse être pré-mis en cache en toute sécurité, tandis que les ressources dynamiques qui le modifient sont chargées indépendamment.

Contournez le cache de vos demandes de mise en cache préalable

Lorsque sw-precache envoie des requêtes de préchargement de ressources, il utilise ces réponses indéfiniment tant qu'il pense que le hachage MD5 du fichier n'a pas changé. Par conséquent, il est particulièrement important de s'assurer que la réponse à la requête de préchargement est récente et non renvoyée à partir du cache HTTP du navigateur. (Oui, les requêtes fetch() effectuées dans un service worker peuvent répondre avec des données provenant du cache HTTP du navigateur.)

Pour garantir que les réponses que nous prémettons en cache proviennent directement du réseau et non du cache HTTP du navigateur, sw-precache ajoute automatiquement un paramètre de requête de cache busting à chaque URL demandée. Si vous n'utilisez pas sw-precache et que vous utilisez une stratégie de réponse basée sur le cache, veillez à faire quelque chose de semblable dans votre propre code.

Une solution plus propre au cache busting consiste à définir le mode de cache de chaque Request utilisé pour la mise en cache préalable sur reload, ce qui garantit que la réponse provient du réseau. Toutefois, au moment où nous écrivons ces lignes, l'option de mode cache n'est pas compatible avec Chrome.

Prise en charge de la connexion et de la déconnexion

IOWA permettait aux utilisateurs de se connecter à l'aide de leurs comptes Google et de mettre à jour leurs calendriers d'événements personnalisés, mais cela signifiait également que les utilisateurs pouvaient se déconnecter plus tard. La mise en cache des données de réponses personnalisées est évidemment un sujet délicat, et il n'existe pas toujours une seule bonne approche.

Étant donné que l'affichage de votre planning personnel, même hors connexion, était essentiel à l'expérience IOWA, nous avons décidé que l'utilisation des données mises en cache était appropriée. Lorsqu'un utilisateur se déconnecte, nous nous assurons d'effacer les données de session précédemment mises en 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);
            });
          });
        });
      }
    });

Attention aux paramètres de requête supplémentaires !

Lorsqu'un service worker recherche une réponse mise en cache, il utilise une URL de requête comme clé. Par défaut, l'URL de la requête doit correspondre exactement à l'URL utilisée pour stocker la réponse mise en cache, y compris les paramètres de requête dans la partie recherche de l'URL.

Cela nous a posé problème lors du développement, lorsque nous avons commencé à utiliser des paramètres d'URL pour suivre la provenance de notre trafic. Par exemple, nous avons ajouté le paramètre utm_source=notification aux URL qui s'ouvrent lorsque vous cliquez sur l'une de nos notifications, et nous avons utilisé utm_source=web_app_manifest dans start_url pour notre fichier manifeste d'application Web. Les URL qui correspondaient auparavant aux réponses mises en cache étaient affichées comme des erreurs lorsque ces paramètres étaient ajoutés.

Ce problème est partiellement résolu par l'option ignoreSearch, qui peut être utilisée lors de l'appel de Cache.match(). Malheureusement, Chrome n'est pas encore compatible avec ignoreSearch. Et même s'il l'était, son comportement serait tout ou rien. Nous avions besoin d'un moyen d'ignorer certains paramètres de requête d'URL tout en tenant compte des autres qui étaient pertinents.

Nous avons fini par étendre sw-precache pour supprimer certains paramètres de requête avant de rechercher une correspondance de cache, et pour permettre aux développeurs de personnaliser les paramètres à ignorer via l'option ignoreUrlParametersMatching. Voici l'implémentation sous-jacente:

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

Ce que cela implique pour vous

L'intégration du service worker dans l'application Web Google I/O est probablement l'utilisation la plus complexe et la plus concrète qui a été déployée à ce jour. Nous espérons que la communauté des développeurs Web utilisera les outils que nous avons créés (sw-precache et sw-toolbox) ainsi que les techniques que nous décrivons pour alimenter vos propres applications Web. Les service workers sont une amélioration progressive que vous pouvez commencer à utiliser dès aujourd'hui. Lorsqu'ils sont utilisés dans le cadre d'une application Web correctement structurée, les avantages en termes de vitesse et de fonctionnement hors connexion sont importants pour vos utilisateurs.