Service Workers em produção

Captura de tela no modo retrato

Resumo

Saiba como usamos as bibliotecas de service worker para tornar o app da Web do Google I/O 2015 mais rápido e com priorização 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 nos designs dos nossos amigos da Instrument, que escreveu o interessante experimento de áudio/visual. A missão da nossa equipe era garantir que o app da Web I/O, que vou me referir pelo codinome, IOWA, apresentasse tudo o que a Web moderna poderia fazer. Uma experiência completa que prioriza o modo off-line estava no topo da nossa lista de recursos obrigatórios.

Se você leu algum dos outros artigos no site recentemente, com certeza se deparou com service workers e não se surpreenderá ao saber que o suporte off-line do IOWA depende muito deles. Motivados pelas necessidades reais da IOWA, desenvolvemos duas bibliotecas para lidar com dois casos de uso off-line diferentes: sw-precache, para automatizar o armazenamento em cache de recursos estáticos, e sw-toolbox, para lidar com estratégias de armazenamento em cache e substituto no ambiente de execução.

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

Pré-armazenamento em cache com sw-precache

Os recursos estáticos do IOWA, como HTML, JavaScript, CSS e imagens, fornecem o shell núcleo do aplicativo da Web. Há dois requisitos específicos que eram importantes ao pensar no armazenamento em cache desses recursos: queríamos garantir que a maioria dos recursos estáticos fosse armazenada em cache e que fosse mantida atualizada. O sw-precache foi criado com esses requisitos em mente.

Integração no tempo de build

sw-precache pelo processo de build baseado em gulp do IOWA, e contamos com vários padrões de glob para garantir a geração de 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 uma lista de nomes de arquivo em uma matriz e lembrar de colocar um número de versão de cache sempre que qualquer uma dessas mudanças era muito propensa a erros, especialmente considerando que vários membros da equipe faziam check-in no código. Ninguém quer interromper o suporte off-line deixando de fora um novo arquivo em uma matriz mantida manualmente. Com a integração no tempo de build, pudemos fazer mudanças nos arquivos atuais e adicionar novos sem ter essa preocupação.

Como atualizar recursos armazenados em cache

sw-precache gera um script de service worker base que inclui um hash MD5 exclusivo para cada recurso que é pré-armazenado em cache. Sempre que um recurso existente é alterado ou um novo recurso é adicionado, o script do service worker é gerado novamente. Isso aciona automaticamente o fluxo de atualização do service worker, em que os novos recursos são armazenados em cache e os recursos desatualizados são limpos. Todos os recursos existentes que tenham hashes MD5 idênticos são deixados como estão. Isso significa que os usuários que visitaram o site antes só fazem o download do conjunto mínimo de recursos alterados, levando a uma experiência muito mais eficiente do que se o cache inteiro tivesse 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 áudio/visual, ou as imagens de perfil dos palestrantes das sessões, não foi deliberadamente pré-armazenado em cache. Em vez disso, usamos a biblioteca sw-toolbox para processar solicitações off-line desses recursos.

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

Como mencionado, não é viável armazenar em cache todos os recursos necessários para que um site funcione off-line. Alguns recursos são muito grandes ou raramente usados para que valer a pena, e outros são dinâmicos, como as respostas de uma API ou serviço remoto. Mas só porque uma solicitação não está pré-armazenada em cache, não significa que ela precisa resultar em um NetworkError. sw-toolbox nos deu a flexibilidade de implementar gerenciadores de solicitações que processam o armazenamento em cache do ambiente de execução para alguns recursos e substitutos personalizados para outros. Também o usamos para atualizar nossos recursos armazenados em cache anteriormente em resposta a notificações push.

Veja a seguir alguns exemplos de gerenciadores de solicitações personalizados que criamos com base no sw-toolkit. Foi fácil integrá-los ao script do service worker base usando o importScripts parameter de sw-precache, que extrai arquivos JavaScript autônomos 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 são feitas primeiro na rede e, se uma resposta bem-sucedida for retornada, essa resposta é armazenada usando a API Cache Storage. Se uma solicitação subsequente tiver sido feita quando a rede não estiver disponível, a resposta armazenada em cache anteriormente será usada.

Como o cache era atualizado automaticamente sempre que uma resposta de rede bem-sucedida retornava, não era preciso controlar especificamente a versão dos recursos nem expirar as entradas.

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

Imagens do perfil do apresentador

Para imagens de perfil de alto-falante, nosso objetivo era mostrar uma versão previamente armazenada em cache da imagem de um determinado apresentador, se ela estivesse disponível, retornando à rede para recuperar a imagem, caso não estivesse. Se essa solicitação de rede falhasse, como substituto final, usamos uma imagem de marcador genérica que foi pré-armazenada em cache e, portanto, estaria sempre disponível. Essa é uma estratégia comum a ser usada ao lidar com imagens que podem ser substituídas por um marcador genérico, e foi fácil de implementar encadeando os gerenciadores 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 nas programações dos usuários

Um dos principais recursos do IOWA foi permitir que usuários conectados criassem e mantivessem uma programação de sessões que planejavam participar. Como esperado, as atualizações de sessão foram feitas por solicitações HTTP POST para um servidor de back-end, e passamos algum tempo descobrindo a melhor maneira de lidar com essas solicitações de modificação de estado quando o usuário está off-line. Descobrimos uma combinação de uma que coloca solicitações com falha na fila no IndexedDB, com a lógica na página principal da Web que verificou o IndexedDB em busca de solicitações enfileiradas e repetimos qualquer uma que encontrou.

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, é possível garantir que elas incluíssem um novo conjunto de credenciais de usuário. Quando as novas tentativas foram bem-sucedidas, mostramos uma mensagem para informar ao usuário que as atualizações que estavam na 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

Na mesma linha, implementamos um gerenciador para enfileirar todas as solicitações com falha do Google Analytics e tentar reproduzi-las mais tarde, quando a rede estava esperando que a rede estivesse 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 na fila, definido como o tempo decorrido desde a primeira tentativa de solicitação, para garantir que o horário de atribuição do evento adequado chegue ao back-end do Google Analytics. O Google Analytics oficialmente é compatível com valores para qt de até quatro horas. Por isso, tentamos reproduzir essas solicitações o mais rápido possível sempre que o service worker foi 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ções push

Os service workers não lidavam apenas com a funcionalidade off-line do IOWA, mas também promoviam as notificações push usadas para notificar os usuários sobre atualizações nas sessões adicionadas aos favoritos. A página de destino associada a essas notificações exibia os detalhes atualizados da sessão. Essas páginas de destino 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 nessa página estivessem atualizados, mesmo quando visualizadas off-line. Para fazer isso, modificamos os metadados da sessão em cache anteriormente 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');
    }
    });
});

Dúvidas e considerações

É claro que ninguém trabalha em um projeto do mesmo nível da IOWA sem ter alguns problemas. Aqui estão alguns dos casos que encontramos e como os resolvemos.

Conteúdo desatualizado

Sempre que você planeja uma estratégia de armazenamento em cache, seja implementada por meio de service workers ou com o cache de navegador padrão, há um equilíbrio entre entregar recursos o mais rápido possível ou os mais atualizados. Por meio de sw-precache, implementamos uma estratégia agressiva de primeiro cache para o shell do nosso aplicativo, o que significa que nosso service worker não verificaria a rede em busca de atualizações antes de retornar o HTML, o JavaScript e o CSS na página.

Felizmente, os eventos de ciclo de vida do service worker detectaram quando novos conteúdos estavam disponíveis após o carregamento da página. Quando um service worker atualizado é detectado, exibimos uma mensagem de aviso ao usuário, informando que ele precisa recarregar a página para ver 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);
    }
    };
}
Aviso de conteúdo mais recente
O aviso "conteúdo mais recente".

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

sw-precache usa um hash MD5 do conteúdo de arquivos locais e busca apenas recursos com hash alterado. Isso significa que os recursos ficam disponíveis na página quase imediatamente, mas também significa que, quando algo é armazenado em cache, permanece em cache até ser atribuído um novo hash em 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 de transmissão ao vivo para cada dia da conferência. Como o arquivo de modelo subjacente era estático e não foi alterado, nosso fluxo de atualização do service worker 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 armazenada em cache para vários usuários.

Para evitar esse tipo de problema, verifique se o aplicativo da Web está estruturado para que o shell fique sempre estático e possa ser armazenado em cache com segurança, enquanto todos os recursos dinâmicos que modificam o shell são carregados de maneira independente.

Bloqueie suas solicitações de pré-armazenamento em cache

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

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

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

Suporte para login e logout

A IOWA permitiu que os usuários fizessem login usando as Contas do Google e atualizassem as programações de eventos personalizados, mas isso também significava que os usuários poderiam sair mais tarde. Armazenar dados de resposta personalizados em cache é obviamente um assunto complicado, e nem sempre há uma única abordagem certa.

Como ver sua programação pessoal, mesmo off-line, era essencial para a experiência da IOWA, decidimos que usar dados armazenados 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 os parâmetros de consulta extras.

Quando um service worker verifica se há uma resposta armazenada 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 de pesquisa do URL.

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

Isso é parcialmente resolvido pela opção ignoreSearch, que pode ser usada ao chamar Cache.match(). Infelizmente, o Chrome ainda não oferece suporte ao ignoreSearch e, mesmo que fosse, ocorre um comportamento de tudo ou nada. Precisávamos de uma maneira de ignorar alguns parâmetros de consulta de URL e considerar outros que eram significativos.

Estendemos 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 usando a 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 service worker no app da Web do Google I/O é provavelmente o uso mais complexo no mundo real que foi implantado até aqui. Estamos ansiosos para ver a comunidade de desenvolvedores da Web usando as ferramentas que criamos sw-precache e sw-toolbox, além das técnicas que estamos descrevendo para potencializar seus próprios aplicativos da Web. Os service workers são um aprimoramento progressivo que você pode começar a usar hoje mesmo e, quando usados como parte de um app da Web estruturado de forma adequada, a velocidade e os benefícios off-line são significativos para seus usuários.