API FileSystem synchrone pour les nœuds de calcul

Introduction

L'API FileSystem HTML5 et les Web Workers sont extrêmement puissants en eux-mêmes. L'API FileSystem apporte enfin un stockage hiérarchique et des E/S de fichiers aux applications Web, et les workers apportent un véritable "multithreading" asynchrone à JavaScript. Toutefois, lorsque vous utilisez ces API ensemble, vous pouvez créer des applications vraiment intéressantes.

Ce tutoriel fournit un guide et des exemples de code pour exploiter le FileSystem HTML5 dans un Web Worker. Il suppose que vous avez des connaissances pratiques sur les deux API. Si vous n'êtes pas encore prêt à vous lancer ou si vous souhaitez en savoir plus sur ces API, consultez deux excellents tutoriels qui abordent les principes de base : Explorer les API FileSystem et Principes de base des Web Workers.

API synchrones et asynchrones

Les API JavaScript asynchrones peuvent être difficiles à utiliser. Ils sont volumineux. Elles sont complexes. Mais le plus frustrant est qu'elles offrent de nombreuses occasions de se tromper. La dernière chose que vous voulez gérer est la superposition d'une API asynchrone complexe (FileSystem) dans un monde déjà asynchrone (travailleurs) ! La bonne nouvelle est que l'API FileSystem définit une version synchrone pour faciliter la tâche dans les Web Workers.

Dans la plupart des cas, l'API synchrone est exactement la même que son cousine asynchrone. Les méthodes, propriétés, fonctionnalités et fonctionnalités vous seront familières. Voici les principales divergences:

  • L'API synchrone ne peut être utilisée que dans un contexte de Web Worker, tandis que l'API asynchrone peut être utilisée dans et en dehors d'un Worker.
  • Les rappels sont obsolètes. Les méthodes d'API renvoient désormais des valeurs.
  • Les méthodes globales de l'objet fenêtre (requestFileSystem() et resolveLocalFileSystemURL()) deviennent requestFileSystemSync() et resolveLocalFileSystemSyncURL().

En dehors de ces exceptions, les API sont identiques. OK, c'est bon.

Demander un système de fichiers

Une application Web obtient l'accès au système de fichiers synchrone en demandant un objet LocalFileSystemSync à partir d'un Web Worker. requestFileSystemSync() est exposé au champ d'application global du worker:

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

Notez la nouvelle valeur renvoyée maintenant que nous utilisons l'API synchrone, ainsi que l'absence de rappels de succès et d'erreur.

Comme pour l'API FileSystem standard, les méthodes sont actuellement préfixées:

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

Gérer les quotas

Pour le moment, il n'est pas possible de demander un quota PERSISTENT dans un contexte de worker. Je vous recommande de résoudre les problèmes de quota en dehors de Workers. Le processus peut se présenter comme suit:

  1. worker.js: encapsulez tout code d'API FileSystem dans un try/catch afin de détecter les erreurs QUOTA_EXCEED_ERR.
  2. worker.js: si vous détectez un QUOTA_EXCEED_ERR, renvoyez un postMessage('get me more quota') à l'application principale.
  3. application principale: effectuez la danse window.webkitStorageInfo.requestQuota() lorsque le numéro 2 est reçu.
  4. application principale: une fois que l'utilisateur a accordé plus de quota, renvoyez postMessage('resume writes') au nœud de calcul pour l'informer de l'espace de stockage supplémentaire.

Il s'agit d'une solution de contournement assez complexe, mais elle devrait fonctionner. Pour en savoir plus sur l'utilisation du stockage PERSISTENT avec l'API FileSystem, consultez la section Demander un quota.

Utiliser des fichiers et des répertoires

La version synchrone de getFile() et getDirectory() renvoie un FileEntrySync et un DirectoryEntrySync, respectivement.

Par exemple, le code suivant crée un fichier vide appelé "log.txt" dans le répertoire racine.

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

La commande suivante crée un répertoire dans le dossier racine.

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

Traiter les erreurs

Si vous n'avez jamais eu à déboguer du code de Web Worker, je vous envie ! Il peut être très difficile de comprendre ce qui ne va pas.

L'absence de rappels d'erreur dans le monde synchrone rend la gestion des problèmes plus délicate qu'elle ne devrait l'être. Si nous ajoutons la complexité générale du débogage du code du Web Worker, vous serez rapidement frustré. Pour vous faciliter la tâche, vous pouvez encapsuler tout votre code Worker pertinent dans une instruction try/catch. Ensuite, si des erreurs se produisent, transmettez-les à l'application principale à l'aide de 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);
}

Transmettre des fichiers, des blobs et des ArrayBuffers

Lorsque les nœuds de calcul Web sont apparus, ils n'autorisaient que l'envoi de données de chaîne dans postMessage(). Plus tard, les navigateurs ont commencé à accepter les données sérialisables, ce qui signifie qu'il était possible de transmettre un objet JSON. Toutefois, récemment, certains navigateurs tels que Chrome acceptent que des types de données plus complexes soient transmis via postMessage() à l'aide de l'algorithme de clonage structuré.

Qu'est-ce que cela signifie réellement ? Cela signifie qu'il est beaucoup plus facile de transmettre des données binaires entre l'application principale et le thread Worker. Les navigateurs compatibles avec le clonage structuré pour les nœuds de calcul vous permettent de transmettre des tableaux typés, des ArrayBuffer, des File ou des Blob dans les nœuds de calcul. Bien que les données soient toujours une copie, la possibilité de transmettre un File offre un avantage en termes de performances par rapport à l'ancienne approche, qui consistait à encoder le fichier en base64 avant de le transmettre à postMessage().

L'exemple suivant transmet une liste de fichiers sélectionnée par l'utilisateur à un nœud de calcul dédié. Le nœud de calcul parcourt simplement la liste de fichiers (pour montrer que les données renvoyées sont en fait un FileList) et l'application principale lit chaque fichier en tant que ArrayBuffer.

L'exemple utilise également une version améliorée de la technique de Web Worker intégré décrite dans la section Principes de base des 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>

Lire des fichiers dans un nœud de calcul

Il est tout à fait acceptable d'utiliser l'API FileReader asynchrone pour lire des fichiers dans un nœud de calcul. Toutefois, il existe une meilleure solution. Dans les nœuds de calcul, une API synchrone (FileReaderSync) simplifie la lecture des fichiers:

Application principale:

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

Comme prévu, les rappels ont disparu avec FileReader synchrone. Cela simplifie le nombre d'imbrications de rappels lors de la lecture de fichiers. À la place, les méthodes readAs* renvoient le fichier lu.

Exemple: Récupérer toutes les entrées

Dans certains cas, l'API synchrone est beaucoup plus claire pour certaines tâches. Moins de rappels est une bonne chose et rend les choses plus lisibles. Le véritable inconvénient de l'API synchrone provient des limites des nœuds de calcul.

Pour des raisons de sécurité, les données entre l'application appelante et un thread de Web Worker ne sont jamais partagées. Les données sont toujours copiées vers et depuis le nœud de calcul lorsque postMessage() est appelé. Par conséquent, tous les types de données ne peuvent pas être transmis.

Malheureusement, FileEntrySync et DirectoryEntrySync ne font pas partie des types acceptés pour le moment. Comment pouvez-vous récupérer les entrées dans l'application d'appel ? Pour contourner cette limitation, vous pouvez renvoyer une liste d'URL de système de fichiers au lieu d'une liste d'entrées. filesystem: Les URL ne sont que des chaînes. Elles sont donc très faciles à transmettre. De plus, ils peuvent être résolus en entrées dans l'application principale à l'aide de resolveLocalFileSystemURL(). Vous revenez alors à un objet FileEntrySync/DirectoryEntrySync.

Application principale:

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

Exemple: Télécharger des fichiers à l'aide de XHR2

Un cas d'utilisation courant des workers consiste à télécharger un ensemble de fichiers à l'aide de XHR2 et à les écrire dans le système de fichiers HTML5. C'est une tâche parfaite pour un thread de travail.

L'exemple suivant n'extrait et n'écrit qu'un seul fichier, mais vous pouvez l'étendre pour télécharger un ensemble de fichiers.

Application principale:

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

Conclusion

Les Web Workers sont une fonctionnalité sous-utilisée et sous-estimée de HTML5. La plupart des développeurs avec lesquels je discute n'ont pas besoin des avantages de calcul supplémentaires, mais ils peuvent être utilisés pour plus que de simples calculs. Si vous êtes sceptique (comme je l'étais), j'espère que cet article vous aura convaincu. Décharger des éléments tels que des opérations de disque (appels d'API de système de fichiers) ou des requêtes HTTP vers un worker est tout à fait approprié et permet également de compartimenter votre code. Les API de fichiers HTML5 dans les travailleurs ouvrent un tout nouveau monde d'applications Web que beaucoup de gens n'ont pas exploré.