A API FileSystem síncrona para workers

Introdução

A API FileSystem do HTML5 e os Web Workers são extremamente poderosos no seu próprio sentido. A API FileSystem finalmente leva o armazenamento hierárquico e E/S de arquivos para aplicativos da Web, e os workers trazem o verdadeiro "multithreading" assíncrono para JavaScript. No entanto, ao usar essas APIs juntas, você pode criar alguns aplicativos realmente interessantes.

Este tutorial fornece um guia e exemplos de código para aproveitar o FileSystem do HTML5 dentro de um Web Worker. Para isso, é preciso ter conhecimento prático das duas APIs. Se você não estiver pronto para se aprofundar ou tiver interesse em saber mais sobre essas APIs, leia dois ótimos tutoriais que discutem os conceitos básicos: Explorar as APIs FileSystem e Noções básicas dos Web Workers.

APIs síncronas versus 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 elas oferecem muitas oportunidades para que algo dê errado. A última coisa com que você quer lidar é com 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 os Web Workers.

Em sua maioria, a API síncrona é exatamente igual à prima assíncrona. Os métodos, propriedades, recursos e funcionalidades serão familiares. Os principais desvios são:

  • A API síncrona só pode ser usada dentro de um contexto de Web Worker, enquanto a API assíncrona pode ser usada dentro e fora de um worker.
  • Não há retornos de chamada. Os métodos da API agora retornam valores.
  • Os métodos globais no objeto da 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 recebe 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, além da 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 a cota PERSISTENT em um contexto de worker. Recomendo lidar com 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 um postMessage('get me more quota') de volta para o app principal.
  3. app principal: siga a dança window.webkitStorageInfo.requestQuota() quando o número 2 for recebido.
  4. app principal: depois que o usuário conceder mais cota, envie postMessage('resume writes') de volta ao worker para informar que há espaço de armazenamento extra.

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

Trabalhar com arquivos e diretórios

A versão síncrona de getFile() e getDirectory() retorna um FileEntrySync e 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 de Web Worker, inveja! Pode ser muito difícil{101}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. Se adicionarmos a complexidade geral da depuração do código do Web Worker, você ficará frustrado em pouco tempo. Uma coisa que pode facilitar a vida é unir todo o código de worker relevante 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 entraram em cena, eles só permitiam que os 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 que tipos de dados mais complexos sejam transmitidos pelo 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 compatíveis com clonagem estruturada para workers permitem passar 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 base64 do arquivo antes de passá-lo para postMessage().

O exemplo a seguir transmite uma lista de arquivos selecionados pelo usuário para um worker dedicado. O worker simplesmente passa pela lista de arquivos (simples 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 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 assíncrona FileReader 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 desaparecem 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: 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 decorrente das limitações dos workers.

Por motivos de segurança, os dados entre o app de chamada e a linha de execução de um 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. Como você pode retornar entradas para o 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, por isso são muito fáceis de transmitir. Além disso, elas podem ser resolvidas para entradas no app principal usando resolveLocalFileSystemURL(). Isso leva você de volta 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: como fazer o download de arquivos usando XHR2

Um caso de uso comum de workers é fazer o download de vários arquivos usando XHR2 e gravá-los no FileSystem do HTML5. Essa é a 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

Web Workers são um recurso subutilizado e subestimado do HTML5. A maioria dos desenvolvedores com quem converso não precisa dos benefícios computacionais extras, mas podem ser usados para mais do que apenas computação pura. Se você é cético (como eu era), espero que este artigo tenha ajudado a mudar de ideia. O descarregamento de recursos como operações de disco (chamadas de API do sistema de arquivos) 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 do Workers abrem uma lata totalmente nova de apps da Web que muitas pessoas ainda não conhecem.