La API de FileSystem síncrona para trabajadores

Introducción

La API de FileSystem y los Web Workers de HTML5 son sumamente útiles en su caso. La API de FileSystem finalmente ofrece almacenamiento jerárquico y E/S de archivos a las aplicaciones web, y los trabajadores proporcionan un verdadero “multisubproceso” asíncrono a JavaScript. Sin embargo, cuando usas estas APIs juntas, puedes compilar algunas apps realmente interesantes.

En este instructivo, se proporcionan una guía y ejemplos de código para aprovechar el sistema de archivos HTML5 dentro de un trabajador web. Se da por sentado que tienes conocimiento práctico de ambas APIs. Si aún no estás listo para comenzar o quieres aprender más sobre esas APIs, lee dos instructivos excelentes en los que se analizan los conceptos básicos: Explora las APIs del sistema de archivos y Conceptos básicos de los trabajadores web.

APIs síncronas frente a asíncronas

Las APIs de JavaScript asíncronas pueden ser difíciles de usar. Son grandes. Son complejos. Pero lo más frustrante es que ofrecen muchas oportunidades para que las cosas salgan mal. Lo último que debes tratar es superponer 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 aliviar los problemas de los trabajadores web.

En general, la API síncrona es exactamente la misma que su prima asíncrona. Los métodos, las propiedades, las características y la funcionalidad 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.
  • Se agotaron las devoluciones de llamada. Los métodos de la API ahora muestran valores.
  • Los métodos globales del objeto window (requestFileSystem() y resolveLocalFileSystemURL()) se convierten en requestFileSystemSync() y resolveLocalFileSystemSyncURL().

Aparte de estas excepciones, las APIs son las mismas. ¡Ya estamos listos!

Solicita un sistema de archivos

Una aplicación web obtiene acceso al sistema de archivos síncrono solicitando un objeto LocalFileSystemSync a un trabajador web. 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 normal de FileSystem, los métodos tienen prefijos en este momento:

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

Trabaja con las cuotas

Por el momento, no es posible solicitar la cuota de PERSISTENT en un contexto de Worker. Recomiendo que te ocupes de los problemas de cuotas fuera de los trabajadores. El proceso podría ser similar al siguiente:

  1. worker.js: Une cualquier código de la API de FileSystem en un try/catch para que se detecten errores de QUOTA_EXCEED_ERR.
  2. worker.js: Si detectas una QUOTA_EXCEED_ERR, devuelve una postMessage('get me more quota') a la app principal.
  3. app principal: Realiza el baile window.webkitStorageInfo.requestQuota() cuando se recibe el número 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 espacio de almacenamiento adicional.

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

Cómo trabajar con archivos y directorios

La versión síncrona de getFile() y getDirectory() muestra FileEntrySync y 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});

A continuación, 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 Web Worker, te envico. Puede ser muy difícil averiguar qué está saliendo mal.

La falta de devoluciones de llamada de error en el mundo síncrono hace que abordar los problemas sea más difícil de lo que debería ser. Si agregamos la complejidad general de la depuración del código de Web Worker, te frustrarás en poco tiempo. Algo que puede facilitar la vida es unir todo tu código de Worker relevante 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);
}

Cómo pasar archivos, BLOB y ArrayBuffers

Cuando aparecieron los trabajadores web por primera vez en la escena, 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 estructurado.

¿Qué significa esto realmente? 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 trabajadores te permiten pasar arrays escritos, ArrayBuffer, File o Blob a Workers. Aunque los datos todavía son una copia, poder pasar un File implica un beneficio de rendimiento sobre el enfoque anterior, que implicaba basar en 64 el archivo antes de pasarlo a postMessage().

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

En el ejemplo, también se usa una versión mejorada de la técnica de trabajador web intercalado que se describe en Conceptos básicos de los trabajadores 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>

Cómo leer archivos en un Worker

Es perfectamente aceptable usar la API de FileReader asíncrona 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 era de esperar, las devoluciones de llamada desaparecen con el FileReader síncrono. Esto simplifica la cantidad de anidamiento de devoluciones de llamada cuando se leen archivos. En cambio, los métodos readAs* muestran el archivo de lectura.

Ejemplo: Cómo recuperar todas las entradas

En algunos casos, la API síncrona es mucho más limpia para ciertas tareas. Tener menos devoluciones de llamadas es una buena opción y, sin duda, hacer que todo sea más legible. La desventaja real de la API síncrona es su origen de las limitaciones de los trabajadores.

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

Por el momento, FileEntrySync y DirectoryEntrySync no se encuentran en los tipos aceptados. Entonces, ¿cómo puedes obtener entradas de la app que realiza la llamada? Una forma de eludir esta limitación es mostrar una lista de filesystem: URLs en lugar de una lista de entradas. Las URLs de filesystem: son solo cadenas, por lo que son muy fáciles de pasar. Además, se pueden resolver en entradas en la app principal con resolveLocalFileSystemURL(). Eso te lleva de vuelta 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 de archivos con XHR2

Un caso de uso común para los trabajadores es descargar varios archivos con XHR2 y escribir esos archivos 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 crear una imagen expandiendo ese archivo 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 trabajadores web son una función de HTML5 que se usa poco y que se aprecia. La mayoría de los desarrolladores con los que hablo no necesitan beneficios de procesamiento adicionales, pero pueden usarse para más que solo procesamiento. Si sientes escéptico (como yo), esperamos que este artículo te haya ayudado a cambiar de opinión. Descargar elementos como operaciones de disco (llamadas a la API del sistema de archivos) o solicitudes HTTP a un trabajador es una opción natural y también ayuda a compartimentar tu código. Las APIs de archivos HTML5 dentro de Workers abren una lata completamente nueva de genialidades para las aplicaciones web que mucha gente no ha explorado.