API FileSystem synchrone pour les nœuds de calcul

Introduction

L'API FileSystem HTML5 et les Web Workers sont extrêmement puissants. L'API FileSystem apporte enfin un stockage hiérarchique et des E/S de fichiers aux applications Web, et les nœuds de calcul apportent un véritable "multithread" asynchrone à JavaScript. Cependant, 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 système de fichiers HTML5 dans un worker Web. Il part du principe que vous avez une connaissance pratique des deux API. Si vous n'êtes pas tout à fait prêt à vous lancer ou si vous souhaitez en savoir plus sur ces API, consultez deux tutoriels de qualité qui abordent les bases : Découvrir les API FileSystem et Principes de base des workers Web.

API synchrones et API asynchrones

Les API JavaScript asynchrones peuvent être difficiles à utiliser. Ils sont grands. Ils sont complexes. Mais ce qui est le plus frustrant, c’est qu’ils offrent de nombreuses occasions de mal tourner. La dernière chose à gérer est la superposition d'une API asynchrone complexe (FileSystem) dans un monde déjà asynchrone (Workers). La bonne nouvelle est que l'API FileSystem définit une version synchrone pour soulager les nœuds de calcul Web.

Pour la plupart, l'API synchrone est exactement la même que sa cousin asynchrone. Les méthodes, propriétés et fonctionnalités vous seront familières. Les principaux écarts sont les suivants:

  • L'API synchrone ne peut être utilisée que dans un contexte de nœud de calcul Web, tandis que l'API asynchrone peut être utilisée à l'intérieur et à l'extérieur d'un nœud de calcul.
  • Les rappels sont sortants. Les méthodes d'API renvoient désormais des valeurs.
  • Les méthodes globales sur l'objet de fenêtre (requestFileSystem() et resolveLocalFileSystemURL()) deviennent requestFileSystemSync() et resolveLocalFileSystemSyncURL().

Hormis ces exceptions, les API sont identiques. C'est parti !

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 nœud de calcul Web. Le 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 réussite et d'erreur.

Comme pour l'API FileSystem normale, les méthodes sont actuellement précédées d'un préfixe:

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

Gérer les quotas

Il n'est actuellement pas possible de demander un quota PERSISTENT dans le contexte d'un worker. Je vous recommande de vous occuper des problèmes de quota en dehors des nœuds de calcul. Le processus peut se présenter comme suit:

  1. worker.js: encapsulez tout code de l'API FileSystem dans un try/catch afin de détecter les erreurs QUOTA_EXCEED_ERR.
  2. worker.js: si vous interceptez QUOTA_EXCEED_ERR, renvoyez un postMessage('get me more quota') à l'application principale.
  3. application principale: effectuer la danse window.webkitStorageInfo.requestQuota() lorsque la réponse n° 2 est reçue.
  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 assez complexe, mais elle devrait fonctionner. Consultez la section Demander un quota pour en savoir plus sur l'utilisation de l'espace de stockage PERSISTENT avec l'API FileSystem.

Utiliser des fichiers et des répertoires

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

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 de code Web Worker, je vous en veux ! Cela peut être une vraie douleur de comprendre ce qui ne va pas.

L'absence de rappels d'erreur dans le monde synchrone rend la gestion des problèmes plus complexe qu'elle ne devrait l'être. Si nous ajoutons la complexité générale du débogage du code de Web Worker, vous serez rapidement frustré. Une chose qui peut vous faciliter la vie est d'encapsuler tout le code de nœud de calcul pertinent dans une requête try/catch. Ensuite, en cas d'erreur, transférez-la à 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 objets Blob et des objets ArrayBuffer

Lorsque les Web Workers sont arrivés sur place, ils n'ont autorisé l'envoi de données de chaîne que dans postMessage(). Plus tard, les navigateurs ont commencé à accepter les données sérialisables, ce qui signifiait qu'il était possible de transmettre un objet JSON. Cependant, certains navigateurs comme Chrome ont récemment accepté de transmettre des types de données plus complexes via postMessage() à l'aide de l'algorithme de clone 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 de nœud de calcul. Les navigateurs qui acceptent le clonage structuré pour les nœuds de calcul vous permettent de transmettre des tableaux typés, des ArrayBuffer, des File ou des Blob aux nœuds de calcul. Bien que les données soient toujours une copie, la possibilité de transmettre un File se traduit par un avantage en termes de performances par rapport à l'ancienne approche, qui impliquait de convertir le fichier en base64 avant de le transmettre à postMessage().

L'exemple suivant transmet une liste de fichiers sélectionnée par l'utilisateur à un worker dédié. Le worker passe simplement par la liste de fichiers (il suffit de montrer que les données renvoyées sont en réalité 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 du Web Worker 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 worker

Il est parfaitement acceptable d'utiliser l'API FileReader asynchrone pour lire les fichiers dans un worker. Il existe cependant une meilleure solution. Dans Workers, 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 été supprimés avec l'FileReader synchrone. Cela simplifie l'imbrication des rappels lors de la lecture de fichiers. Au lieu de cela, 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. Il est utile d'avoir moins de rappels, ce qui rend les choses plus lisibles. Le véritable inconvénient de l'API synchrone réside dans les limites des workers.

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

Malheureusement, FileEntrySync et DirectoryEntrySync ne font actuellement pas partie des types acceptés. Comment faire pour récupérer les entrées de l'application appelante ? Pour contourner cette limitation, vous pouvez renvoyer une liste d'URL filesystem: URL au lieu d'une liste d'entrées. Les URL filesystem: ne sont que des chaînes et sont donc très faciles à transmettre. De plus, elles peuvent être résolues en entrées dans l'application principale à l'aide de resolveLocalFileSystemURL(). Cela vous ramène à 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 avec 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. Il s'agit d'une tâche idéale pour un thread de nœud de calcul.

L'exemple suivant ne récupère et n'écrit qu'un seul fichier, mais vous pouvez développer une image afin de 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é HTML5 sous-utilisée et sous-estimée. 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 aidé à changer d'avis. Le déchargement d'éléments tels que les opérations de disque (appels d'API Filesystem) ou les requêtes HTTP vers un nœud de calcul est un choix naturel et permet également de compartimenter votre code. Les API File HTML5 à l'intérieur de Workers offrent de nouvelles possibilités pour les applications Web que peu d'utilisateurs n'ont pas encore explorées.