La API de FileSystem síncrona para trabajadores

Introducción

La API de FileSystem y los Web Workers de HTML5 son muy potentes por su cuenta. La API de FileSystem finalmente trae almacenamiento jerárquico y entrada y salida de archivos a las aplicaciones web, y los trabajadores aportan un verdadero "multithreading" asíncrono a JavaScript. Sin embargo, cuando usas estas APIs en conjunto, 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 trabajador web. Se da por sentado 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 en los que se analizan los conceptos básicos: Exploración de las APIs de FileSystem 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 algo salga mal. Lo último que quieres hacer es agregar capas a una API asíncrona compleja (FileSystem) en un mundo ya asíncrono (trabajadores). La buena noticia es que la API de FileSystem define una versión síncrona para aliviar las molestias en los trabajadores web.

En general, la API síncrona es exactamente igual que su primo asíncrono. Los métodos, las propiedades, las características y la funcionalidad les resultarán familiares. Las desviaciones principales son las siguientes:

  • La API síncrona solo se puede usar dentro de un contexto de trabajador web, mientras que la API asíncrona se puede usar dentro y fuera de un trabajador.
  • No hay devoluciones de llamada. Los métodos de 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. De acuerdo, 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 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 un prefijo en este momento:

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

Cómo abordar la cuota

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

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

Esta es una solución alternativa bastante complicada, pero debería funcionar. Consulta cómo solicitar una 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 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});

El siguiente comando 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 código de Web Worker, te envidio. Averiguar qué está mal puede ser muy molesto.

La falta de devoluciones de llamada de error en el mundo síncrono hace que lidiar con los problemas sea más complicado 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 facilitarte la vida es unir todo el código de trabajador 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);
}

Pasa archivos, Blobs y ArrayBuffers

Cuando los Web Workers aparecieron por primera vez, solo permitían que se enviaran datos de cadenas en postMessage(). Más tarde, 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 tipos de datos más complejos para pasar 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 los trabajadores. Aunque los datos siguen siendo una copia, poder pasar un File significa un beneficio de rendimiento en comparación con el enfoque anterior, que implicaba convertir el archivo en base64 antes de pasarlo a postMessage().

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

La muestra también 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 trabajador

Es perfectamente aceptable usar la API de FileReader asíncrona para leer archivos en un trabajador. Sin embargo, hay una mejor manera. En los trabajadores, 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 anidamiento de devolución de llamada cuando se leen archivos. En su lugar, los métodos readAs* muestran el archivo leído.

Ejemplo: Cómo recuperar todas las entradas

En algunos casos, la API síncrona es mucho más clara para ciertas tareas. Menos devoluciones de llamada son agradables y, sin duda, hacen que todo sea más legible. El verdadero inconveniente de la API síncrona proviene 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 Web Worker. Los datos siempre se copian desde y hacia el trabajador cuando se llama a postMessage(). Como resultado, no se puede pasar cualquier tipo de datos.

Lamentablemente, FileEntrySync y DirectoryEntrySync no se encuentran entre los tipos aceptados en este momento. Entonces, ¿cómo puedes obtener entradas de vuelta a la app que realiza la llamada? Una forma de evitar la limitación es mostrar una lista de filesystem: URLs en lugar de una lista de entradas. filesystem: Las URLs son solo cadenas, por lo que son muy fáciles de pasar. Además, se pueden resolver en entradas de la app principal con resolveLocalFileSystemURL(). Eso te lleva 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 muchos 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 trabajadores web 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 computacionales adicionales, pero se pueden usar para mucho más que solo la computación pura. Si tienes dudas (como yo), espero que este artículo te haya ayudado a cambiar de opinión. Transferir tareas 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 los trabajadores abren un nuevo mundo de posibilidades para las apps web que muchas personas no han explorado.