La API de FileSystem síncrona para trabajadores

Introducción

La API de FileSystem y los Web Workers de HTML5 son muy potentes por sí mismos. La API de FileSystem finalmente incorpora el almacenamiento jerárquico y la E/S de archivos a las aplicaciones web, y los Workers aportan un verdadero "subprocesamiento múltiple" asíncrono a JavaScript. Sin embargo, cuando usas estas APIs juntas, puedes crear apps realmente interesantes.

En este instructivo, se proporciona una guía y ejemplos de código para aprovechar el sistema de archivos de HTML5 dentro de un Web Worker. Se supone que tienes conocimientos prácticos de ambas APIs. Si aún no estás listo para comenzar o te interesa obtener más información sobre esas APIs, lee dos instructivos excelentes que analizan los conceptos básicos: Explora las APIs de FileSystem y Conceptos básicos de los trabajadores web.

APIs síncronas vs. asíncronas

Las APIs de JavaScript asíncronas pueden ser difíciles de usar. Son grandes. Son complejas. Pero lo más frustrante es que ofrecen muchas oportunidades para que las cosas salgan mal. Lo último que quieres hacer es agregar una API asíncrona compleja (FileSystem) en un mundo que ya es asíncrono (Workers). La buena noticia es que la API de FileSystem define una versión síncrona para facilitar el trabajo en los Web Workers.

En su mayor parte, la API síncrona es exactamente igual que su prima asíncrona. Los métodos, las propiedades, las características y las funciones te resultarán familiares. Las desviaciones principales son las siguientes:

  • La API síncrona solo se puede usar dentro de un contexto de Web Worker, mientras que la API asíncrona se puede usar dentro y fuera de un Worker.
  • Las devoluciones de llamada están desactivadas. Los métodos de la API ahora devuelven valores.
  • Los métodos globales en el objeto de ventana (requestFileSystem() y resolveLocalFileSystemURL()) se convierten en requestFileSystemSync() y resolveLocalFileSystemSyncURL().

Aparte de estas excepciones, las APIs son las mismas. Muy bien, ¡ya está todo listo!

Cómo solicitar un sistema de archivos

Una aplicación web obtiene acceso al sistema de archivos síncrono solicitando un objeto LocalFileSystemSync desde un Web Worker. El requestFileSystemSync() se expone al alcance global del trabajador:

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

Observa el nuevo valor que se muestra ahora que usamos la API síncrona, así como la ausencia de devoluciones de llamada de éxito y error.

Al igual que con la API de FileSystem normal, los métodos tienen un prefijo en este momento:

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

Cómo lidiar con la cuota

Actualmente, no es posible solicitar cuota de PERSISTENT en un contexto de Worker. Te recomiendo que resuelvas los problemas de cuota fuera de los Workers. El proceso podría verse de la siguiente manera:

  1. worker.js: Se une cualquier código de la API de FileSystem en un try/catch para que se detecten los errores de QUOTA_EXCEED_ERR.
  2. worker.js: Si detectas un QUOTA_EXCEED_ERR, envía un postMessage('get me more quota') de vuelta a la app principal.
  3. app principal: Realiza el baile de window.webkitStorageInfo.requestQuota() cuando se reciba el n.º 2.
  4. app principal: Después de que el usuario otorgue más cuota, envía postMessage('resume writes') de vuelta al trabajador para informarle sobre el espacio de almacenamiento adicional.

Esa es una solución bastante compleja, pero debería funcionar. Consulta Cómo solicitar cuota para obtener más información sobre el uso del almacenamiento de PERSISTENT con la API de FileSystem.

Cómo trabajar con archivos y directorios

La versión síncrona de getFile() y getDirectory() devuelven un FileEntrySync y un DirectoryEntrySync, respectivamente.

Por ejemplo, el siguiente código crea un archivo vacío llamado "log.txt" en el directorio raíz.

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

Con el siguiente comando, se crea un directorio nuevo en la carpeta raíz.

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

Maneja los errores

Si nunca tuviste que depurar el código de un Web Worker, ¡te envidio! Puede ser un verdadero dolor de cabeza descubrir qué está fallando.

La falta de devoluciones de llamada de error en el mundo síncrono hace que lidiar con los problemas sea más difícil de lo que debería ser. Si agregamos la complejidad general de depurar el código de Web Worker, te frustrarás en poco tiempo. Una cosa que puede facilitar la vida es unir todo el código relevante del trabajador en un try/catch. Luego, si se produce algún error, reenvíalo a la app principal con 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);
}

Pasa archivos, BLOBs y ArrayBuffers

Cuando los Web Workers aparecieron por primera vez, solo permitían que se enviaran datos de cadena en postMessage(). Más adelante, los navegadores comenzaron a aceptar datos serializables, lo que significaba que era posible pasar un objeto JSON. Sin embargo, recientemente, algunos navegadores, como Chrome, aceptan que se pasen tipos de datos más complejos a través de postMessage() con el algoritmo de clonación estructurada.

¿Qué significa esto realmente? Esto significa que es mucho más fácil pasar datos binarios entre la app principal y el subproceso de trabajo. Los navegadores que admiten la clonación estructurada para los trabajadores te permiten pasar arrays con tipo, ArrayBuffer, File o Blob a los trabajadores. Aunque los datos siguen siendo una copia, poder pasar un File significa un beneficio de rendimiento con respecto al enfoque anterior, que implicaba codificar el archivo en base64 antes de pasarlo a postMessage().

En el siguiente ejemplo, se pasa una lista de archivos seleccionados por el usuario a un Worker dedicado. El trabajador simplemente pasa por la lista de archivos (es fácil mostrar que los datos devueltos son en realidad un FileList) y la app principal lee cada archivo como un ArrayBuffer.

En la muestra, también se usa una versión mejorada de la técnica de Web Worker intercalado que se describe en Conceptos básicos 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>

Lee archivos en un Worker

Es perfectamente aceptable usar la API asíncrona de FileReader para leer archivos en un Worker. Sin embargo, hay una mejor manera. En Workers, hay una API síncrona (FileReaderSync) que optimiza la lectura de archivos:

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 se esperaba, las devoluciones de llamada desaparecen con el FileReader síncrono. Esto simplifica la cantidad de anidamientos de devoluciones de llamada cuando se leen archivos. En cambio, los métodos readAs* devuelven el archivo leído.

Ejemplo: Recupera todas las entradas

En algunos casos, la API síncrona es mucho más clara para ciertas tareas. Tener menos devoluciones de llamada es bueno y, sin duda, hace que el código sea más legible. La verdadera desventaja de la API síncrona se debe a las limitaciones de los trabajadores.

Por motivos de seguridad, los datos entre la app de llamadas y un subproceso de trabajo de Web Worker nunca se comparten. Los datos siempre se copian hacia y desde el Worker cuando se llama a postMessage(). Como resultado, no se pueden pasar todos los tipos de datos.

Lamentablemente, FileEntrySync y DirectoryEntrySync no se encuentran entre los tipos aceptados. Entonces, ¿cómo puedes devolver las entradas a la app de llamadas? Una forma de evitar la limitación es mostrar una lista de URLs de filesystem: en lugar de una lista de entradas. filesystem: Las URLs son solo cadenas, por lo que es muy fácil pasarlas. Además, se pueden resolver en entradas de la app principal con resolveLocalFileSystemURL(). Eso te devuelve a un 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);
    }
};

Ejemplo: Descarga archivos con XHR2

Un caso de uso común para los trabajadores es descargar un grupo de archivos con XHR2 y escribirlos en el sistema de archivos HTML5. Esta es una tarea perfecta para un subproceso de trabajo.

En el siguiente ejemplo, solo se recupera y escribe un archivo, pero puedes imaginar que se expande para descargar un conjunto de archivos.

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);
    }
};

Conclusión

Los Web Workers son una función de HTML5 poco utilizada y poco apreciada. La mayoría de los desarrolladores con los que hablo no necesitan los beneficios de procesamiento adicionales, pero se pueden usar para algo más que solo el procesamiento puro. Si tienes dudas (como yo las tenía), espero que este artículo te haya ayudado a cambiar de opinión. La descarga de tareas como las operaciones de disco (llamadas a la API de Filesystem) o las solicitudes HTTP a un Worker es una opción natural y también ayuda a compartimentar tu código. Las APIs de archivos de HTML5 dentro de los trabajadores abren un mundo de posibilidades para las apps web que muchas personas aún no exploraron.