Service Workers em produção

Captura de tela em modo retrato

Resumo

Saiba como usamos bibliotecas de service workers para tornar o app da Web da Google I/O 2015 rápido e com foco no modo off-line.

Visão geral

O app da Web do Google I/O 2015 deste ano foi escrito pela equipe de relações com desenvolvedores do Google com base em designs dos nossos amigos da Instrument, que criaram a experiência de áudio/visual. A missão da nossa equipe era garantir que o app da Web do I/O (que vamos chamar de IOWA) mostrasse tudo o que a Web moderna poderia fazer. Uma experiência totalmente off-line estava no topo da nossa lista de recursos essenciais.

Se você leu algum dos outros artigos deste site recentemente, provavelmente já encontrou service workers. Não se surpreenda se o suporte off-line do IOWA depender muito deles. Motivados pelas necessidades reais do IOWA, desenvolvemos duas bibliotecas para lidar com dois casos de uso off-line diferentes: sw-precache para automatizar o pré-cache de recursos estáticos e sw-toolbox para lidar com o armazenamento em cache de execução e estratégias de fallback.

As bibliotecas se complementam e nos permitiram implementar uma estratégia eficiente em que o "shell" de conteúdo estático do IOWA era sempre veiculado diretamente do cache, e os recursos dinâmicos ou remotos eram veiculados da rede, com respostas de fallback em cache ou estáticas quando necessário.

Pré-cache com sw-precache

Os recursos estáticos do IOWA, como HTML, JavaScript, CSS e imagens, fornecem a estrutura principal do aplicativo da Web. Havia dois requisitos específicos que eram importantes ao pensar em armazenar esses recursos em cache: queríamos garantir que a maioria dos recursos estáticos fosse armazenada em cache e mantida atualizada. O sw-precache foi criado pensando nesses requisitos.

Integração em tempo de build

sw-precache com o processo de build baseado em gulp do IOWA, e contamos com uma série de padrões glob para garantir que geramos uma lista completa de todos os recursos estáticos usados pelo IOWA.

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

Abordagens alternativas, como codificar em disco uma lista de nomes de arquivos em uma matriz e lembrar de aumentar o número da versão do cache sempre que um desses arquivos é alterado, eram muito propensas a erros, especialmente porque tínhamos vários membros da equipe verificando o código. Ninguém quer interromper o suporte off-line deixando um novo arquivo em uma matriz mantida manualmente. A integração no build significa que podemos fazer alterações em arquivos existentes e adicionar novos arquivos sem preocupações.

Como atualizar recursos em cache

O sw-precache gera um script de worker de serviço básico que inclui um hash MD5 exclusivo para cada recurso que é pré-armazenado em cache. Sempre que um recurso é alterado ou um novo recurso é adicionado, o script do service worker é regenerado. Isso aciona automaticamente o fluxo de atualização do service worker, em que os novos recursos são armazenados em cache e os desatualizados são limpos. Todos os recursos existentes com hashes MD5 idênticos são mantidos. Isso significa que os usuários que visitaram o site antes só vão fazer o download do conjunto mínimo de recursos alterados, o que leva a uma experiência muito mais eficiente do que se todo o cache estivesse expirado em massa.

Cada arquivo que corresponde a um dos padrões glob é transferido por download e armazenado em cache na primeira vez que um usuário acessa o IOWA. Fizemos um esforço para garantir que apenas os recursos essenciais necessários para renderizar a página fossem pré-armazenados em cache. O conteúdo secundário, como a mídia usada no experimento audio/visual, ou as imagens de perfil dos palestrantes das sessões, não foram pré-armazenados em cache. Em vez disso, usamos a biblioteca sw-toolbox para processar solicitações off-line para esses recursos.

sw-toolbox, para todas as nossas necessidades dinâmicas

Como mencionado, não é viável pré-cachear todos os recursos necessários para que um site funcione off-line. Alguns recursos são muito grandes ou usados com pouca frequência para que isso seja vantajoso, e outros são dinâmicos, como as respostas de uma API remota ou serviço. No entanto, o fato de uma solicitação não estar pré-armazenada em cache não significa que ela precisa resultar em um NetworkError. O sw-toolbox nos deu a flexibilidade para implementar gerenciadores de solicitação que lidam com o armazenamento em cache de execução para alguns recursos e substitutos personalizados para outros. Também usamos essa abordagem para atualizar os recursos armazenados em cache em resposta a notificações push.

Confira alguns exemplos de processadores de solicitações personalizados que criamos com base no sw-toolbox. Foi fácil integrá-los ao script do service worker de base usando o importScripts parameter do sw-precache, que puxa arquivos JavaScript independentes para o escopo do service worker.

Experimento audiovisual

Para o experimento de áudio/visual, usamos a estratégia de cache networkFirst do sw-toolbox. Todas as solicitações HTTP que correspondem ao padrão de URL do experimento primeiro são feitas na rede. Se uma resposta bem-sucedida for enviada, ela será armazenada usando a API Cache Storage. Se uma solicitação subsequente for feita quando a rede estiver indisponível, a resposta armazenada em cache anteriormente será usada.

Como o cache era atualizado automaticamente sempre que uma resposta de rede sucesso retornava, não precisávamos especificar recursos de versão ou expirar entradas.

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

Imagens do perfil do palestrante

Para as imagens de perfil dos palestrantes, nosso objetivo era mostrar uma versão em cache anterior da imagem de um palestrante, se ela estivesse disponível, ou retornar à rede para recuperar a imagem, se ela não estivesse. Se essa solicitação de rede falhasse, como alternativa final, usávamos uma imagem de marcador de posição genérica que era pré-armazenada em cache (e, portanto, sempre estaria disponível). Essa é uma estratégia comum para lidar com imagens que podem ser substituídas por um marcador de posição genérico. Ela foi fácil de implementar vinculando os manipuladores cacheFirst e 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/});
Imagens de perfil de uma página de sessão
Imagens de perfil de uma página de sessão.

Atualizações na programação dos usuários

Um dos principais recursos do IOWA era permitir que os usuários conectados criassem e mantivessem uma programação de sessões que planejavam participar. Como era de se esperar, as atualizações de sessão eram feitas por solicitações HTTP POST a um servidor de back-end, e passamos algum tempo trabalhando na melhor maneira de processar essas solicitações de modificação de estado quando o usuário está off-line. Criamos uma combinação de uma que enfileirou solicitações com falha no IndexedDB, com a lógica na página da Web principal que verificava o IndexedDB em busca de solicitações enfileiradas e tentava novamente qualquer uma que encontrasse.

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

Como as novas tentativas foram feitas no contexto da página principal, podemos ter certeza de que elas incluíram um novo conjunto de credenciais do usuário. Depois que as tentativas foram bem-sucedidas, mostramos uma mensagem para informar ao usuário que as atualizações anteriores em fila foram aplicadas.

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 off-line

Da mesma forma, implementamos um gerenciador para enfileirar todas as solicitações do Google Analytics com falha e tentar executá-las novamente mais tarde, quando a rede estiver disponível. Com essa abordagem, estar off-line não significa sacrificar os insights oferecidos pelo Google Analytics. Adicionamos o parâmetro qt a cada solicitação em fila, definido como o tempo decorrido desde a primeira tentativa da solicitação, para garantir que um tempo de atribuição de evento adequado fosse enviado ao back-end do Google Analytics. O Google Analytics oferece suporte oficial a valores de qt de até 4 horas. Por isso, fizemos o possível para reproduzir essas solicitações assim que possível, sempre que o worker de serviço fosse iniciado.

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

Páginas de destino de notificação push

Os workers de serviço não apenas processavam a funcionalidade off-line do IOWA, como também forneciam as notificações push que usávamos para notificar os usuários sobre atualizações nas sessões marcadas. A página de destino associada a essas notificações mostrava os detalhes da sessão atualizada. Essas páginas já estavam sendo armazenadas em cache como parte do site geral, então já funcionavam off-line, mas precisávamos garantir que os detalhes da sessão na página estivessem atualizados, mesmo quando visualizados off-line. Para fazer isso, modificamos os metadados da sessão armazenados em cache com as atualizações que acionaram a notificação push e armazenamos o resultado no cache. Essas informações atualizadas serão usadas na próxima vez que a página de detalhes da sessão for aberta, seja on-line ou off-line.

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

Problemas e considerações

É claro que ninguém trabalha em um projeto da escala do IOWA sem encontrar alguns problemas. Confira alguns dos problemas que encontramos e como os resolvemos.

Conteúdo desatualizado

Sempre que você planeja uma estratégia de armazenamento em cache, seja implementada por workers de serviço ou pelo cache padrão do navegador, há uma troca entre entregar recursos o mais rápido possível e entregar os recursos mais recentes. Com sw-precache, implementamos uma estratégia agressiva de cache-first para o shell do nosso aplicativo, o que significa que nosso worker de serviço não verifica a rede para atualizações antes de retornar o HTML, o JavaScript e o CSS na página.

Felizmente, conseguimos aproveitar os eventos do ciclo de vida do service worker para detectar quando um novo conteúdo estava disponível depois que a página já tinha sido carregada. Quando um service worker atualizado é detectado, mostramos uma mensagem de aviso ao usuário informando que ele precisa recarregar a página para conferir o conteúdo mais recente.

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);
    }
    };
}
A mensagem pop-up de conteúdo mais recente
A mensagem flutuante "Conteúdo mais recente".

Verifique se o conteúdo estático é estático

O sw-precache usa um hash MD5 do conteúdo dos arquivos locais e só busca os recursos cujo hash mudou. Isso significa que os recursos ficam disponíveis na página quase imediatamente, mas também significa que, depois que algo é armazenado em cache, ele permanece em cache até que um novo hash seja atribuído a um script atualizado do service worker.

Encontramos um problema com esse comportamento durante a I/O porque nosso back-end precisava atualizar dinamicamente os IDs dos vídeos do YouTube da transmissão ao vivo para cada dia da conferência. Como o arquivo de modelo subjacente era estático e não mudou, o fluxo de atualização do worker de serviço não foi acionado, e o que deveria ser uma resposta dinâmica do servidor com a atualização de vídeos do YouTube acabou sendo a resposta em cache para vários usuários.

Para evitar esse tipo de problema, verifique se o aplicativo da Web está estruturado de modo que o shell seja sempre estático e possa ser pré-armazenado com segurança, enquanto os recursos dinâmicos que modificam o shell são carregados de forma independente.

Quebre o cache das suas solicitações de pré-cache

Quando sw-precache faz solicitações de recursos para pré-cache, ele usa essas respostas indefinidamente, desde que ele ache que o hash MD5 do arquivo não mudou. Isso significa que é particularmente importante garantir que a resposta à solicitação de pré-cache seja nova e não retornada do cache HTTP do navegador. Sim, as solicitações fetch() feitas em um worker de serviço podem responder com dados do cache HTTP do navegador.

Para garantir que as respostas que armazenamos em cache sejam diretamente da rede, e não do cache HTTP do navegador, o sw-precache automaticamente anexa um parâmetro de consulta de quebra de cache a cada URL solicitado. Se você não estiver usando sw-precache e estiver usando uma estratégia de resposta de cache-first, faça algo semelhante no seu próprio código.

Uma solução mais limpa para a quebra de cache seria definir o modo de cache de cada Request usado para pré-armazenamento em cache para reload, o que garante que a resposta venha da rede. No entanto, no momento, a opção de modo de cache não é compatível com o Chrome.

Suporte para login e logout

O IOWA permitia que os usuários fizessem login usando as Contas do Google e atualizassem as programações de eventos personalizadas, mas também significava que eles poderiam sair mais tarde. O armazenamento em cache de dados de respostas personalizadas é obviamente um assunto complicado, e não há sempre uma única abordagem correta.

Como a visualização da sua programação pessoal, mesmo off-line, era essencial para a experiência da IOWA, decidimos que o uso de dados em cache era adequado. Quando um usuário sai, limpamos os dados da sessão armazenados em cache anteriormente.

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

Cuidado com parâmetros de consulta extras

Quando um worker de serviço verifica uma resposta em cache, ele usa um URL de solicitação como chave. Por padrão, o URL da solicitação precisa corresponder exatamente ao URL usado para armazenar a resposta em cache, incluindo todos os parâmetros de consulta na parte search do URL.

Isso acabou causando um problema durante o desenvolvimento, quando começamos a usar parâmetros de URL para acompanhar de onde vinha nosso tráfego. Por exemplo, adicionamos o parâmetro utm_source=notification aos URLs que foram abertos ao clicar em uma das notificações e usamos utm_source=web_app_manifest no start_url para o manifesto do app da Web. Os URLs que correspondiam às respostas armazenadas em cache apareciam como falhas quando esses parâmetros eram anexados.

Isso é parcialmente abordado pela opção ignoreSearch, que pode ser usada ao chamar Cache.match(). Infelizmente, o Chrome ainda não tem suporte a ignoreSearch. Mesmo que tivesse, o comportamento seria tudo ou nada. O que precisávamos era uma maneira de ignorar alguns parâmetros de consulta de URL, mas considerar outros que fossem importantes.

Ampliamos sw-precache para remover alguns parâmetros de consulta antes de verificar uma correspondência de cache e permitir que os desenvolvedores personalizem quais parâmetros são ignorados pela opção ignoreUrlParametersMatching. Confira a implementação:

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

O que isso significa para você

A integração do worker de serviço no app da Web do Google I/O é provavelmente o uso mais complexo e real que foi implantado até o momento. Estamos ansiosos para que a comunidade de desenvolvedores da Web use as ferramentas que criamos sw-precache e sw-toolbox, além das técnicas que estamos descrevendo para impulsionar seus próprios aplicativos da Web. Os service workers são uma melhoria progressiva que você pode começar a usar hoje. Quando usados como parte de um app da Web estruturado corretamente, a velocidade e os benefícios off-line são significativos para os usuários.