A API FileSystem síncrona para workers

Introdução

A API FileSystem e os Web Workers do HTML5 são extremamente poderosos por si só. A API FileSystem finalmente traz armazenamento hierárquico e E/S de arquivos para aplicativos da Web, e os Workers trazem "multithreading" assíncrono verdadeiro para o JavaScript. No entanto, quando você usa essas APIs juntas, pode criar apps realmente interessantes.

Este tutorial fornece um guia e exemplos de código para aproveitar o FileSystem do HTML5 dentro de um Web Worker. Ele pressupõe um conhecimento prático de ambas as APIs. Se você ainda 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 explorar as APIs FileSystem e Noções básicas de Web Workers.

APIs síncronas x 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 que você quer é adicionar uma API assíncrona complexa (FileSystem) em um mundo que já é assíncrono (Workers). A boa notícia é que a API FileSystem define uma versão síncrona para facilitar o trabalho em Web Workers.

Na maioria dos casos, a API síncrona é exatamente igual à assíncrona. Os métodos, as propriedades, os recursos e as funcionalidades serão familiares. Os principais desvios 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 estão fora. Os métodos de 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 as mesmas. Tudo pronto!

Como solicitar um sistema de arquivos

Um aplicativo da Web acessa o sistema de arquivos síncrono solicitando um objeto LocalFileSystemSync 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 são prefixados no momento:

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

Como lidar com cotas

No momento, não é possível solicitar cota de PERSISTENT em um contexto de worker. Recomendo resolver os problemas de cota fora dos workers. O processo pode ser assim:

  1. worker.js: envolva qualquer código da API FileSystem em um try/catch para que todos os erros de QUOTA_EXCEED_ERR sejam capturados.
  2. worker.js: se você capturar um QUOTA_EXCEED_ERR, envie um postMessage('get me more quota') de volta ao app principal.
  3. app principal: passe pela dança window.webkitStorageInfo.requestQuota() quando #2 for recebido.
  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 deve funcionar. Consulte Como solicitar cota para mais informações sobre como usar o armazenamento de 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 invejo você! 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 depurar o código do Web Worker, você vai se frustrar rapidamente. Uma coisa que pode facilitar sua vida é envolver todo o código relevante do worker em um try/catch. Em seguida, se ocorrerem erros, encaminhe-os ao 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 o envio de dados de string 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 por postMessage() usando o algoritmo de clone estruturado.

O que isso realmente significa? Isso significa que é muito mais fácil transmitir dados binários entre o app principal e a linha de execução de worker. Os navegadores que oferecem suporte à 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 codificação em base64 do arquivo 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 transmite a lista de arquivos (para 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 melhorada da técnica de worker da Web inline descrita em Noções básicas de workers da Web.

<!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. Em 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 desaparecem com o FileReader síncrono. Isso simplifica o número de callbacks aninhados ao ler arquivos. Em vez disso, os métodos readAs* retornam o arquivo lido.

Exemplo: como buscar todas as entradas

Em alguns casos, a API síncrona é muito mais limpa para determinadas tarefas. Menos callbacks são bons e certamente tornam as coisas mais legíveis. A verdadeira desvantagem da API síncrona vem 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 de e para o worker quando postMessage() é chamado. Como resultado, nem todos os tipos de dados podem ser transmitidos.

Infelizmente, FileEntrySync e DirectoryEntrySync não se enquadram nos tipos aceitos. Então, como você pode retornar as entradas para o app de ligação? Uma maneira de contornar a limitação é retornar uma lista de URLs filesystem: em vez de uma lista de entradas. Os URLs do filesystem: são apenas strings, então são muito fáceis de transmitir. Além disso, elas podem ser resolvidas para entradas no app principal usando resolveLocalFileSystemURL(). Isso retorna 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: como fazer o download de arquivos usando o 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 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 computação pura. Se você está cético (como eu estava), espero que este artigo tenha ajudado a mudar sua opinião. Transferir operações de disco (chamadas de API Filesystem) ou solicitações HTTP para um worker é uma opção natural e também ajuda a compartimentar o código. As APIs de arquivo HTML5 dentro de Workers abrem um novo mundo de possibilidades para apps da Web que muitas pessoas ainda não exploraram.