A API FileSystem síncrona para workers

Introdução

A API FileSystem e os Web Workers do HTML5 são muito poderosos. A API FileSystem finalmente traz armazenamento hierárquico e E/S de arquivos para aplicativos da Web, e os workers trazem uma verdadeira "multitarefa" assíncrona para JavaScript. No entanto, quando você usa essas APIs juntas, é possível criar apps realmente interessantes.

Este tutorial fornece um guia e exemplos de código para aproveitar o FileSystem do HTML5 em um Web Worker. Ele pressupõe que você tenha conhecimento prático das duas APIs. Se você não estiver pronto para começar ou quiser saber mais sobre essas APIs, leia dois ótimos tutoriais que discutem os conceitos básicos: Como usar as APIs do FileSystem e Noções básicas de workers da Web.

APIs síncronas e assíncronas

As APIs JavaScript assíncronas podem ser difíceis de usar. Eles são grandes. Eles são complexos. Mas o mais frustrante é que eles oferecem muitas oportunidades para que as coisas dêem errado. A última coisa com que você quer lidar é a sobreposição de uma API assíncrona complexa (FileSystem) em um mundo já assíncrono (workers). A boa notícia é que a API FileSystem define uma versão síncrona para facilitar o uso de Web Workers.

Na maior parte, a API síncrona é exatamente igual à assíncrona. Os métodos, propriedades, recursos e funcionalidades serão familiares. As principais variações são:

  • A API síncrona só pode ser usada em um contexto de Web Worker, enquanto a API assíncrona pode ser usada dentro e fora de um worker.
  • Os callbacks foram desativados. Os métodos da API agora retornam valores.
  • Os métodos globais no objeto de janela (requestFileSystem() e resolveLocalFileSystemURL()) se tornam requestFileSystemSync() e resolveLocalFileSystemSyncURL().

Além dessas exceções, as APIs são iguais. Tudo pronto!

Como solicitar um sistema de arquivos

Um aplicativo da Web obtém acesso ao sistema de arquivos síncrono solicitando um objeto LocalFileSystemSync em um Web Worker. O requestFileSystemSync() é exposto ao escopo global do worker:

var fs = requestFileSystemSync(TEMPORARY, 1024*1024 /*1MB*/);

Observe o novo valor de retorno agora que estamos usando a API síncrona, bem como a ausência de callbacks de sucesso e erro.

Assim como na API FileSystem normal, os métodos têm um prefixo no momento:

self.requestFileSystemSync = self.webkitRequestFileSystemSync ||
                                 self.requestFileSystemSync;

Como lidar com a cota

No momento, não é possível solicitar a cota PERSISTENT em um contexto de worker. Recomendamos que você resolva os problemas de cota fora do Workers. O processo pode ser parecido com este:

  1. worker.js: envolva qualquer código da API FileSystem em um try/catch para que todos os erros QUOTA_EXCEED_ERR sejam detectados.
  2. worker.js: se você detectar uma QUOTA_EXCEED_ERR, envie uma postMessage('get me more quota') de volta para o app principal.
  3. app principal: faça a dança window.webkitStorageInfo.requestQuota() quando receber a mensagem 2.
  4. app principal: depois que o usuário conceder mais cota, envie postMessage('resume writes') de volta ao worker para informar sobre o espaço de armazenamento extra.

Essa é uma solução alternativa bastante complexa, mas que deve funcionar. Consulte Como solicitar cota para mais informações sobre o uso do armazenamento PERSISTENT com a API FileSystem.

Como trabalhar com arquivos e diretórios

A versão síncrona de getFile() e getDirectory() retorna um FileEntrySync e um DirectoryEntrySync, respectivamente.

Por exemplo, o código a seguir cria um arquivo vazio chamado "log.txt" no diretório raiz.

var fileEntry = fs.root.getFile('log.txt', {create: true});

O comando a seguir cria um novo diretório na pasta raiz.

var dirEntry = fs.root.getDirectory('mydir', {create: true});

Como processar os erros

Se você nunca precisou depurar o código do Web Worker, eu o invejo! Pode ser muito difícil descobrir o que está errado.

A falta de callbacks de erro no mundo síncrono torna o tratamento de problemas mais complicado do que deveria ser. Se adicionarmos a complexidade geral de depuração do código do Web Worker, você vai ficar frustrado em pouco tempo. Uma coisa que pode facilitar a vida é agrupar todo o código relevante do worker em um try/catch. Em seguida, se ocorrer algum erro, encaminhe o erro para o app principal usando postMessage():

function onError(e) {
    postMessage('ERROR: ' + e.toString());
}

try {
    // Error thrown if "log.txt" already exists.
    var fileEntry = fs.root.getFile('log.txt', {create: true, exclusive: true});
} catch (e) {
    onError(e);
}

Como transmitir arquivos, blobs e ArrayBuffers

Quando os Web Workers surgiram, eles só permitiam que dados de string fossem enviados em postMessage(). Mais tarde, os navegadores começaram a aceitar dados serializáveis, o que significava que era possível transmitir um objeto JSON. No entanto, recentemente, alguns navegadores como o Chrome aceitam tipos de dados mais complexos para serem transmitidos pelo postMessage() usando o algoritmo de clonagem estruturada.

O que isso quer dizer? Isso significa que é muito mais fácil transmitir dados binários entre o app principal e a linha de execução do worker. Os navegadores que oferecem suporte ao clonagem estruturada para workers permitem que você transmita matrizes tipadas, ArrayBuffers, Files ou Blobs para workers. Embora os dados ainda sejam uma cópia, a capacidade de transmitir um File significa um benefício de desempenho em relação à abordagem anterior, que envolvia a codificação do arquivo em base64 antes de transmiti-lo para postMessage().

O exemplo a seguir transmite uma lista de arquivos selecionada pelo usuário para um worker dedicado. O worker simplesmente passa pela lista de arquivos (é simples mostrar que os dados retornados são um FileList), e o app principal lê cada arquivo como um ArrayBuffer.

O exemplo também usa uma versão aprimorada da técnica de Web Worker inline descrita em Noções básicas de Web Workers.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="chrome=1">
    <title>Passing a FileList to a Worker</title>
    <script type="javascript/worker" id="fileListWorker">
    self.onmessage = function(e) {
    // TODO: do something interesting with the files.
    postMessage(e.data); // Pass through.
    };
    </script>
</head>
<body>
</body>

<input type="file" multiple>

<script>
document.querySelector('input[type="file"]').addEventListener('change', function(e) {
    var files = this.files;
    loadInlineWorker('#fileListWorker', function(worker) {

    // Setup handler to process messages from the worker.
    worker.onmessage = function(e) {

        // Read each file aysnc. as an array buffer.
        for (var i = 0, file; file = files[i]; ++i) {
        var reader = new FileReader();
        reader.onload = function(e) {
            console.log(this.result); // this.result is the read file as an ArrayBuffer.
        };
        reader.onerror = function(e) {
            console.log(e);
        };
        reader.readAsArrayBuffer(file);
        }

    };

    worker.postMessage(files);
    });
}, false);


function loadInlineWorker(selector, callback) {
    window.URL = window.URL || window.webkitURL || null;

    var script = document.querySelector(selector);
    if (script.type === 'javascript/worker') {
    var blob = new Blob([script.textContent]);
    callback(new Worker(window.URL.createObjectURL(blob));
    }
}
</script>
</html>

Como ler arquivos em um worker

É perfeitamente aceitável usar a API FileReader assíncrona para ler arquivos em um worker. No entanto, há uma alternativa melhor. Nos workers, há uma API síncrona (FileReaderSync) que simplifica a leitura de arquivos:

App principal:

<!DOCTYPE html>
<html>
<head>
    <title>Using FileReaderSync Example</title>
    <style>
    #error { color: red; }
    </style>
</head>
<body>
<input type="file" multiple />
<output id="error"></output>
<script>
    var worker = new Worker('worker.js');

    worker.onmessage = function(e) {
    console.log(e.data); // e.data should be an array of ArrayBuffers.
    };

    worker.onerror = function(e) {
    document.querySelector('#error').textContent = [
        'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message].join('');
    };

    document.querySelector('input[type="file"]').addEventListener('change', function(e) {
    worker.postMessage(this.files);
    }, false);
</script>
</body>
</html>

worker.js

self.addEventListener('message', function(e) {
    var files = e.data;
    var buffers = [];

    // Read each file synchronously as an ArrayBuffer and
    // stash it in a global array to return to the main app.
    [].forEach.call(files, function(file) {
    var reader = new FileReaderSync();
    buffers.push(reader.readAsArrayBuffer(file));
    });

    postMessage(buffers);
}, false);

Como esperado, os callbacks foram removidos com o FileReader síncrono. Isso simplifica a quantidade de aninhamento de callbacks ao ler arquivos. Em vez disso, os métodos readAs* retornam o arquivo lido.

Exemplo: buscar todas as entradas

Em alguns casos, a API síncrona é muito mais simples para determinadas tarefas. Menos callbacks são bons e certamente tornam as coisas mais legíveis. A desvantagem real da API síncrona decorre das limitações dos workers.

Por motivos de segurança, os dados entre o app de chamada e uma linha de execução do Web Worker nunca são compartilhados. Os dados são sempre copiados para e do worker quando postMessage() é chamado. Como resultado, nem todos os tipos de dados podem ser transmitidos.

No momento, FileEntrySync e DirectoryEntrySync não se enquadram nos tipos aceitos. Como você pode fazer com que as entradas voltem para o app de chamada? Uma maneira de contornar a limitação é retornar uma lista de URLs do sistema de arquivos em vez de uma lista de entradas. Os URLs filesystem: são apenas strings, portanto, são muito fáceis de transmitir. Além disso, elas podem ser resolvidas para entradas no app principal usando resolveLocalFileSystemURL(). Isso retorna a um objeto FileEntrySync/DirectoryEntrySync.

App principal:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="chrome=1">
<title>Listing filesystem entries using the synchronous API</title>
</head>
<body>
<script>
    window.resolveLocalFileSystemURL = window.resolveLocalFileSystemURL ||
                                        window.webkitResolveLocalFileSystemURL;

    var worker = new Worker('worker.js');
    worker.onmessage = function(e) {
    var urls = e.data.entries;
    urls.forEach(function(url, i) {
        window.resolveLocalFileSystemURL(url, function(fileEntry) {
        console.log(fileEntry.name); // Print out file's name.
        });
    });
    };

    worker.postMessage({'cmd': 'list'});
</script>
</body>
</html>

worker.js

self.requestFileSystemSync = self.webkitRequestFileSystemSync ||
                                self.requestFileSystemSync;

var paths = []; // Global to hold the list of entry filesystem URLs.

function getAllEntries(dirReader) {
    var entries = dirReader.readEntries();

    for (var i = 0, entry; entry = entries[i]; ++i) {
    paths.push(entry.toURL()); // Stash this entry's filesystem: URL.

    // If this is a directory, we have more traversing to do.
    if (entry.isDirectory) {
        getAllEntries(entry.createReader());
    }
    }
}

function onError(e) {
    postMessage('ERROR: ' + e.toString()); // Forward the error to main app.
}

self.onmessage = function(e) {
    var data = e.data;

    // Ignore everything else except our 'list' command.
    if (!data.cmd || data.cmd != 'list') {
    return;
    }

    try {
    var fs = requestFileSystemSync(TEMPORARY, 1024*1024 /*1MB*/);

    getAllEntries(fs.root.createReader());

    self.postMessage({entries: paths});
    } catch (e) {
    onError(e);
    }
};

Exemplo: fazer o download de arquivos usando XHR2

Um caso de uso comum para workers é fazer o download de vários arquivos usando XHR2 e gravar esses arquivos no FileSystem do HTML5. Essa é uma tarefa perfeita para uma linha de execução do worker.

O exemplo a seguir busca e grava apenas um arquivo, mas você pode expandi-lo para fazer o download de um conjunto de arquivos.

App principal:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="chrome=1">
<title>Download files using a XHR2, a Worker, and saving to filesystem</title>
</head>
<body>
<script>
    var worker = new Worker('downloader.js');
    worker.onmessage = function(e) {
    console.log(e.data);
    };
    worker.postMessage({fileName: 'GoogleLogo',
                        url: 'googlelogo.png', type: 'image/png'});
</script>
</body>
</html>

downloader.js:

self.requestFileSystemSync = self.webkitRequestFileSystemSync ||
                                self.requestFileSystemSync;

function makeRequest(url) {
    try {
    var xhr = new XMLHttpRequest();
    xhr.open('GET', url, false); // Note: synchronous
    xhr.responseType = 'arraybuffer';
    xhr.send();
    return xhr.response;
    } catch(e) {
    return "XHR Error " + e.toString();
    }
}

function onError(e) {
    postMessage('ERROR: ' + e.toString());
}

onmessage = function(e) {
    var data = e.data;

    // Make sure we have the right parameters.
    if (!data.fileName || !data.url || !data.type) {
    return;
    }
    
    try {
    var fs = requestFileSystemSync(TEMPORARY, 1024 * 1024 /*1MB*/);

    postMessage('Got file system.');

    var fileEntry = fs.root.getFile(data.fileName, {create: true});

    postMessage('Got file entry.');

    var arrayBuffer = makeRequest(data.url);
    var blob = new Blob([new Uint8Array(arrayBuffer)], {type: data.type});

    try {
        postMessage('Begin writing');
        fileEntry.createWriter().write(blob);
        postMessage('Writing complete');
        postMessage(fileEntry.toURL());
    } catch (e) {
        onError(e);
    }

    } catch (e) {
    onError(e);
    }
};

Conclusão

Os Web Workers são um recurso subutilizado e pouco apreciado do HTML5. A maioria dos desenvolvedores com quem converso não precisa dos benefícios computacionais extras, mas eles podem ser usados para mais do que apenas a computação pura. Se você tem dúvidas (como eu tinha), espero que este artigo tenha ajudado a mudar de ideia. O descarregamento de operações de disco (chamadas de API do Filesystem) ou solicitações HTTP para um worker é uma opção natural e também ajuda a compartimentar seu código. As APIs de arquivos HTML5 dentro de workers abrem um novo mundo de recursos incríveis para apps da Web que muitas pessoas ainda não conhecem.