Service workers en production

Capture d'écran en mode portrait

Résumé

Découvrez comment nous avons utilisé les bibliothèques de service workers pour rendre l'application Web Google I/O 2015 rapide et orientée hors connexion.

Présentation

L'application Web Google I/O 2015 de cette année a été écrite par l'équipe Google chargée des relations avec les développeurs, sur la base des conceptions de nos amis d'Instrument, à l'origine de l'expérience audiovisuelle étonnante. La mission de notre équipe était de s'assurer que l'application Web I/O (que j'appellerai par son nom de code, IOWA) présente tout ce que le Web moderne pouvait faire. Une expérience hors connexion complète figurait en tête de notre liste des fonctionnalités indispensables.

Si vous avez récemment lu l'un des autres articles de ce site, vous avez sans doute rencontré des service workers. Vous ne serez pas surpris d'apprendre que l'assistance hors connexion de l'IOWA dépend fortement de ceux-ci. Motivés par les besoins concrets d'IOWA, nous avons développé deux bibliothèques pour gérer deux cas d'utilisation hors connexion différents : sw-precache pour automatiser la mise en cache préalable des ressources statiques, et sw-toolbox pour gérer la mise en cache de l'environnement 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 selon 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 mises en cache ou statiques en cas de besoin.

Mise en cache préalable avec sw-precache

Les ressources statiques d'IOWA (HTML, JavaScript, CSS et images) constituent l'interface système principale de l'application Web. Deux exigences spécifiques étaient importantes lors de 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 pour répondre à ces exigences.

Intégration au moment de la compilation

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

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

D'autres approches, telles que le codage en dur d'une liste de noms de fichiers dans un tableau et le fait de ne pas oublier de remplacer un numéro de version de cache chaque fois que l'une de ces modifications de fichiers était beaucoup trop sujette aux erreurs, d'autant plus que plusieurs membres de l'équipe contribuaient à vérifier le code. Personne ne souhaite interrompre le fonctionnement hors connexion en laissant de côté 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 nous soucier de ce problème.

Mettre à jour les ressources en cache

sw-precache génère un script de service worker de base qui inclut un hachage MD5 unique pour chaque ressource mise en pré-cache. Chaque fois qu'une ressource existante est modifiée ou qu'une nouvelle ressource est ajoutée, le script du service worker est à nouveau généré. Cela 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 ayant des hachages MD5 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 est beaucoup plus efficace que si le cache entier avait expiré en masse.

Chaque fichier qui correspond à l'un des schémas glob est téléchargé et mis en cache la première fois qu'un utilisateur accède à l'IOWA. Nous avons fait en sorte que seules les ressources essentielles nécessaires à l'affichage de la page soient mises en pré-cache. Les contenus secondaires, tels que les supports utilisés dans l'expérience audiovisuelle ou les images de profil des intervenants des sessions, n'ont délibérément pas été mis en cache. Nous avons utilisé la bibliothèque sw-toolbox pour gérer les requêtes hors connexion de ces ressources.

sw-toolbox, pour tous nos besoins dynamiques

Comme indiqué précédemment, il n'est pas possible de mettre en cache toutes les ressources dont un site a besoin pour fonctionner hors connexion. Certaines ressources sont trop volumineuses ou peu utilisées pour en faire la valeur, 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 en pré-cache qu'elle doit entraîner une NetworkError. sw-toolbox nous a permis d'implémenter des gestionnaires de requêtes qui gèrent la mise en cache de l'environnement d'exécution pour certaines ressources et des solutions de remplacement personnalisées pour d'autres. Nous l'avons également utilisée pour mettre à jour nos ressources précédemment mises en cache en réponse à des notifications push.

Voici quelques exemples de gestionnaires de requêtes personnalisés basés sur sw-Toolbox. Il a été facile de les intégrer au script de base du service worker via la méthode importScripts parameter de sw-precache, qui extrait les fichiers JavaScript autonomes du champ d'application du service worker.

Expérience audiovisuelle

Pour l'expérience audiovisuelle, nous avons utilisé la stratégie de mise en cache networkFirst de sw-toolbox. Toutes les requêtes HTTP correspondant au format d'URL du test sont d'abord effectuées sur le réseau. Si une réponse positive est renvoyée, cette réponse est ensuite cachée à l'aide de l'API Cache Storage. Si une requête ultérieure a été effectuée alors que le réseau était indisponible, la réponse précédemment mise en cache sera utilisée.

Comme le cache était automatiquement mis à jour chaque fois qu'une réponse réseau réussie revenait, nous n'avons pas eu à gérer les versions spécifiques des ressources ni à expirer des entrées.

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

Images du profil de l'intervenant

Pour les images de profil d'un intervenant, 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 faire appel au réseau pour récupérer l'image si ce n'était pas le cas. Si cette requête réseau échoue, nous avons utilisé une image d'espace réservé générique qui était mise en pré-cache (et qui resterait donc toujours disponible). 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 est facile à implémenter en enchaî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 sur la page d'une session
Images de profil à partir d'une page de session.

Modification des plannings des utilisateurs

L'une des principales fonctionnalités de l'IOWA était de permettre aux utilisateurs connectés de créer et de gérer un planning des sessions auxquelles ils prévoyaient d'assister. Comme prévu, les mises à jour de sessions étaient effectuées via des requêtes HTTP POST adressées à un serveur backend. Nous avons passé un certain temps à trouver la meilleure façon de gérer ces requêtes modifiant l'état lorsque l'utilisateur est hors connexion. Nous avons créé la combinaison d'une mise en file d'attente des requêtes ayant échoué dans IndexedDB, associée à la logique de la page Web principale qui recherchait IndexedDB pour les requêtes en file d'attente et retentait 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 tentatives ont été effectuées à partir du contexte de la page principale, nous pouvons être sûrs qu'elles incluaient un nouvel ensemble d'identifiants utilisateur. Une fois les tentatives effectuées, nous avons affiché un message pour informer l'utilisateur que les mises à jour précédemment mises en file d'attente ont é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 mis en œuvre un gestionnaire pour mettre en file d'attente les requêtes Google Analytics ayant échoué et tenter de les relire ultérieurement, lorsque le réseau était normalement disponible. Avec cette approche, être hors connexion ne signifie pas sacrifier les insights qu'offre Google Analytics. Nous avons ajouté le paramètre qt à chaque requête en file d'attente, défini sur le temps écoulé depuis la première tentative, afin de nous assurer que l'heure d'attribution d'événement a été correctement effectuée sur le backend Google Analytics. Google Analytics est officiellement compatible avec les valeurs qt d'une durée maximale de quatre heures seulement. Nous avons donc fait en sorte de répéter ces requêtes dès que possible, à chaque démarrage du service worker.

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 des notifications push

Les service workers ne géraient pas seulement la fonctionnalité hors connexion de l'IOWA, ils proposaient également des notifications push que nous utilisions pour informer les utilisateurs des mises à jour de leurs sessions ajoutées aux favoris. La page de destination associée à ces notifications affichait les détails de la session mis à jour. Ces pages de destination étaient déjà mises en cache dans l'ensemble du site. Elles fonctionnaient donc 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 sera ouverte, que celle-ci ait lieu 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 sûr, personne ne travaille sur un projet à l'échelle de l'IOWA sans se heurter à des pièges. Voici quelques-unes de celles que nous avons rencontrées, ainsi que la façon dont nous les avons contournées.

Contenu obsolète

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

Heureusement, nous avons pu exploiter les événements de cycle de vie des service workers 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 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);
    }
    };
}
Toast de contenu le plus récent
Toast "dernier contenu".

Assurez-vous que le contenu statique est statique.

sw-precache utilise un hachage MD5 du contenu des fichiers locaux et ne récupère que les ressources dont le hachage a été modifié. Cela signifie que les ressources sont disponibles sur la page presque immédiatement, mais qu'une fois qu'un élément est mis en cache, il reste mis en cache 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 de la conférence I/O, car notre backend devait mettre à jour de manière dynamique les ID vidéo YouTube des diffusions en direct pour chaque jour de la conférence. Comme le fichier de modèle sous-jacent était statique et n'a pas été modifié, le flux de mise à jour du service worker n'a pas été déclenché. Ce qui était censé être une réponse dynamique du serveur avec mise à jour des vidéos YouTube a fini par ê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 l'interface système soit toujours statique et puisse être mise en cache en toute sécurité, tandis que toutes les ressources dynamiques qui le modifient sont chargées indépendamment.

Contournement du cache de vos requêtes de mise en cache

Lorsque sw-precache envoie des requêtes de ressources à mettre en pré-cache, il utilise ces réponses indéfiniment tant qu'il pense que le hachage MD5 du fichier n'a pas changé. Il est donc particulièrement important de s'assurer que la réponse à la requête de mise en cache est récente, et non renvoyée par le 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 s'assurer que les réponses que nous mettons en pré-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 employez une stratégie de réponse orientée cache, veillez à effectuer une action similaire dans votre propre code.

Une solution plus propre au cache busting consiste à définir le mode 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 du mode cache n'est pas prise en charge dans Chrome.

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

L'IOWA permettait aux utilisateurs de se connecter à l'aide de leur compte Google et de mettre à jour leurs calendriers d'événements personnalisés, mais cela signifiait également que les utilisateurs pouvaient se déconnecter par la suite. La mise en cache des données de réponse personnalisées est évidemment un sujet délicat, et il n'y a pas toujours une seule bonne approche.

L'affichage de votre planning personnel, même hors connexion, étant essentiel à l'expérience de l'IOWA, nous avons décidé qu'il était approprié d'utiliser les données mises en cache. Lorsqu'un utilisateur se déconnecte, nous avons veillé à 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);
            });
          });
        });
      }
    });

Faites 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 tous les paramètres de requête présents dans la partie search de l'URL.

Cela a fini par poser un problème pendant le développement, lorsque nous avons commencé à utiliser des paramètres d'URL pour savoir d'où provenait notre trafic. Par exemple, nous avons ajouté le paramètre utm_source=notification aux URL qui ont été ouvertes en cliquant sur l'une de nos notifications et utilisé utm_source=web_app_manifest dans start_url pour notre fichier manifeste d'application Web. Les URL qui correspondaient précédemment aux réponses mises en cache étaient considérées comme des erreurs lorsque ces paramètres ont été 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 si c'est le cas, il s'agit d'un comportement "tout ou rien". Nous avions besoin d'un moyen d'ignorer certains paramètres de requête d'URL tout en tenant compte d'autres paramètres pertinents.

Nous avons fini par étendre sw-precache pour supprimer certains paramètres de requête avant de vérifier une correspondance de cache et permettre aux développeurs de personnaliser les paramètres ignorés 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();
}

Qu'est-ce que cela signifie pour vous ?

L'intégration du service worker dans l'application Web Google I/O est probablement l'utilisation réelle la plus complexe ayant été déployée à ce stade. Nous sommes impatients de permettre à la communauté de développeurs Web d'utiliser les outils que nous avons créés sw-precache et sw-toolbox, ainsi que les techniques que nous décrivons pour optimiser vos propres applications Web. Les service workers constituent une amélioration progressive que vous pouvez commencer à utiliser dès aujourd'hui. Lorsqu'ils sont utilisés dans une application Web correctement structurée, les avantages en termes de vitesse et de fonctionnement hors connexion sont importants pour vos utilisateurs.