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ê ainda não estiver com tudo pronto para começar ou tiver interesse em aprender mais sobre essas APIs, leia dois ótimos tutoriais que discutem os conceitos básicos: Conheça as APIs FileSystem e Noções básicas sobre web workers.

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 deem errado. A última coisa com que você quer lidar é a criação de camadas em 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.

Em sua maioria, a API síncrona é exatamente igual à sub-rede assíncrona. Os métodos, propriedades, atributos e funcionalidade serão familiares. As principais variações são:

  • A API síncrona só pode ser usada em um contexto de worker da Web, 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().

Com exceção dessas exceções, as APIs são as mesmas. 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 de dentro de 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. Recomendo que você cuide dos problemas de cota fora dos workers. O processo pode ser parecido com este:

  1. worker.js: una 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ê capturar uma QUOTA_EXCEED_ERR, envie uma postMessage('get me more quota') de volta ao 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 um tanto complexa, mas 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 teve que depurar código do Web Worker, invejo de você! Pode ser muito difícil descobrir o que está errado.

A falta de callbacks de erro no mundo síncrono torna os problemas mais complicados do que deveriam 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 Files, 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. Recentemente, no entanto, alguns navegadores, como o Chrome, aceitam tipos de dados mais complexos para serem transmitidos por postMessage() usando o algoritmo de clone estruturado.

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 a clonagem estruturada para workers permitem que você transmita matrizes tipadas, ArrayBuffers, Files ou Blobs para workers. Embora os dados ainda sejam uma cópia, poder transmitir um File significa um benefício de desempenho em relação à abordagem anterior, que envolvia a base64 do arquivo antes de transmiti-lo para postMessage().

O exemplo a seguir passa uma lista de arquivos selecionados pelo usuário para um worker dedicado. O worker simplesmente passa pela lista de arquivos (é simples mostrar que os dados retornados são, na verdade, 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 maneira 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 real desvantagem da API síncrona ocorre nas 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 ao app de chamada? Uma maneira de contornar a limitação é retornar uma lista de filesystem: URLs 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 de worker.

O exemplo a seguir busca e grava apenas um arquivo, mas é possível expandir a imagem 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 arquivo HTML5 dentro do Workers abrem uma nova lata de coisas incríveis para aplicativos da Web que muitas pessoas ainda não exploram.