Die synchrone FileSystem API für Worker

Einleitung

Die FileSystem API von HTML5 und Web Worker sind in ihrer Hinsicht schon enorm leistungsstark. Die FileSystem API bietet endlich hierarchischen Speicher und Datei-E/A für Webanwendungen und Worker ermöglicht echtes asynchrones Multithreading für JavaScript. Wenn Sie diese APIs jedoch zusammen verwenden, können Sie einige wirklich interessante Anwendungen erstellen.

Diese Anleitung enthält eine Anleitung und Codebeispiele für die Nutzung des HTML5-Dateisystems in einem Web Worker. Dabei wird vorausgesetzt, dass Sie mit beiden APIs vertraut sind. Wenn Sie sich noch nicht mit diesen APIs befassen möchten oder mehr darüber erfahren möchten, lesen Sie zwei nützliche Anleitungen, in denen die Grundlagen erläutert werden: Explore the FileSystem APIs und Basics of Web Workers.

Synchrone vs. asynchrone APIs

Asynchrone JavaScript-APIs können schwierig zu verwenden sein. Sie sind sehr groß. Sie sind komplex. Am frustrierendsten ist jedoch, dass sie viele Möglichkeiten bieten, bei denen Fehler auftreten können. Als Letztes sollten Sie sich damit befassen, wie eine komplexe asynchrone API (FileSystem) in einer bereits asynchronen Welt (Worker) überlagert wird. Die gute Nachricht ist, dass die FileSystem API eine synchrone Version definiert, um die Probleme in Web Workern zu verringern.

Die synchrone API entspricht größtenteils genau der asynchronen API. Die Methoden, Eigenschaften, Merkmale und Funktionalitäten sind Ihnen vertraut. Die wichtigsten Abweichungen sind:

  • Die synchrone API kann nur in einem Web Worker-Kontext verwendet werden, die asynchrone API hingegen in einem und außerhalb eines Workers.
  • Callbacks werden ausgegeben. API-Methoden geben jetzt Werte zurück.
  • Die globalen Methoden des Fensterobjekts (requestFileSystem() und resolveLocalFileSystemURL()) werden zu requestFileSystemSync() und resolveLocalFileSystemSyncURL().

Abgesehen von diesen Ausnahmen sind die APIs identisch. Okay, wir können loslegen!

Dateisystem anfordern

Eine Webanwendung erhält Zugriff auf das synchrone Dateisystem, indem sie ein LocalFileSystemSync-Objekt aus einem Web Worker anfordert. Der requestFileSystemSync() ist für den globalen Bereich des Workers freigegeben:

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

Beachten Sie den neuen Rückgabewert, da wir jetzt die synchrone API verwenden, sowie keine Erfolgs- und Fehler-Callbacks.

Wie bei der normalen FileSystem API sind Methoden zurzeit vorangestellt:

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

Mit Kontingenten umgehen

Derzeit ist es in einem Worker-Kontext nicht möglich, ein PERSISTENT-Kontingent anzufordern. Ich empfehle Ihnen, sich um Kontingentprobleme außerhalb der Mitarbeiter zu kümmern. Der Prozess könnte in etwa so aussehen:

  1. Worker.js: Verpacken Sie jeden FileSystem API-Code in try/catch, damit QUOTA_EXCEED_ERR-Fehler abgefangen werden.
  2. Worker.js: Wenn Sie ein QUOTA_EXCEED_ERR-Objekt erfassen, senden Sie ein postMessage('get me more quota')-Objekt an die Haupt-App zurück.
  3. Haupt-App: Zeige den window.webkitStorageInfo.requestQuota()-Tanz an, wenn Nr. 2 eingeht.
  4. Main-App: Nachdem der Nutzer ein höheres Kontingent gewährt hat, senden Sie postMessage('resume writes') zurück an den Worker, um ihm zusätzlichen Speicherplatz mitzuteilen.

Das ist eine recht aufwendige Problemumgehung, aber es sollte funktionieren. Weitere Informationen zur Verwendung von PERSISTENT-Speicher mit der FileSystem API finden Sie unter Kontingent anfordern.

Mit Dateien und Verzeichnissen arbeiten

Die synchrone Version von getFile() und getDirectory() gibt entsprechend FileEntrySync bzw. DirectoryEntrySync zurück.

Mit dem folgenden Code wird beispielsweise eine leere Datei namens „log.txt“ im Stammverzeichnis erstellt.

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

Mit dem folgenden Befehl wird ein neues Verzeichnis im Stammordner erstellt.

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

Fehlerbehebung

Wenn Sie noch nie Fehler im Web Worker-Code beheben mussten, beneide ich Sie! Es kann echt mühsam sein, herauszufinden, was schiefgeht.

Das Fehlen von Fehler-Callbacks in der synchronen Welt macht den Umgang mit Problemen schwieriger, als sie sein sollten. Wenn wir die allgemeine Komplexität der Fehlerbehebung für Web Worker-Code hinzufügen, werden Sie im Handumdrehen frustriert sein. Eine Sache, die das Leben einfacher machen kann, besteht darin, den gesamten relevanten Worker-Code mit einem Versuch zu versehen. Wenn Fehler auftreten, leiten Sie den Fehler mithilfe von postMessage() an die Hauptanwendung weiter:

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

Dateien, Blobs und ArrayBuffers übergeben

Als Web Worker zum ersten Mal auftraten, haben sie nur das Senden von Stringdaten in postMessage() zugelassen. Später begannen Browser damit, serielle Daten zu akzeptieren, wodurch ein JSON-Objekt übergeben werden konnte. In letzter Zeit akzeptieren einige Browser wie Chrome jedoch komplexere Datentypen, die mithilfe des Algorithmus für strukturierte Klonen über postMessage() weitergegeben werden.

Was bedeutet das genau? Das bedeutet, dass es viel einfacher ist, Binärdaten zwischen der Hauptanwendung und dem Worker-Thread zu übergeben. In Browsern, die strukturiertes Klonen für Worker unterstützen, können Sie Typed Arrays, ArrayBuffer, Files oder Blobs an Worker übergeben. Obwohl es sich bei den Daten immer noch um eine Kopie handelt, bedeutet die Möglichkeit, ein File zu übergeben, einen Leistungsvorteil gegenüber dem vorherigen Ansatz, bei dem die Datei vor der Übergabe an postMessage() in base64 umgewandelt wurde.

Im folgenden Beispiel wird eine vom Nutzer ausgewählte Liste von Dateien an einen dedizierten Worker übergeben. Der Worker durchläuft einfach die Dateiliste (einfach zu zeigen, dass die zurückgegebenen Daten ein FileList sind) und die Hauptanwendung liest jede Datei als ArrayBuffer.

Im Beispiel wird auch eine verbesserte Version des Inline-Web Worker-Verfahrens verwendet, das unter Grundlagen zu Web Workers beschrieben wird.

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

Dateien in einem Worker lesen

Die Verwendung der asynchronen FileReader API zum Lesen von Dateien in einem Worker ist völlig in Ordnung. Es gibt jedoch eine bessere Lösung. In Workers gibt es eine synchrone API (FileReaderSync), die das Lesen von Dateien optimiert:

YouTube App:

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

Wie erwartet, werden Callbacks mit dem synchronen FileReader entfernt. Dies vereinfacht die Callback-Verschachtelung beim Lesen von Dateien. Stattdessen wird mit den readAs*-Methoden die Lesedatei zurückgegeben.

Beispiel: Alle Einträge abrufen

In einigen Fällen ist die synchrone API für bestimmte Aufgaben viel übersichtlicher. Weniger Callbacks sind schön und besser lesbar. Der eigentliche Nachteil der synchronen API ist auf die Einschränkungen von Workern zurückzuführen.

Aus Sicherheitsgründen werden Daten zwischen der aufrufenden Anwendung und einem Web Worker-Thread nicht freigegeben. Beim Aufruf von postMessage() werden Daten immer zum und vom Worker kopiert. Daher kann nicht jeder Datentyp übergeben werden.

Leider gehören FileEntrySync und DirectoryEntrySync derzeit nicht zu den akzeptierten Typen. Wie bekommen Sie Einträge an die Anruf-App zurück? Eine Möglichkeit, die Beschränkung zu umgehen, besteht darin, statt einer Liste von Einträgen eine Liste mit filesystem: URLs zurückzugeben. filesystem:-URLs sind nur Strings, also können sie ganz einfach weitergegeben werden. Außerdem können sie mit resolveLocalFileSystemURL() in Einträge in der Hauptanwendung aufgelöst werden. Dadurch können Sie zu einem FileEntrySync/DirectoryEntrySync-Objekt zurückkehren.

YouTube App:

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

Beispiel: Herunterladen von Dateien mit XHR2

Ein häufiger Anwendungsfall für Worker ist das Herunterladen mehrerer Dateien mit XHR2 und das Schreiben dieser Dateien in das HTML5-Dateisystem. Dies ist eine perfekte Aufgabe für einen Worker-Thread.

Im folgenden Beispiel wird nur eine Datei abgerufen und geschrieben. Sie können aber auch das Bild erweitern, um eine Reihe von Dateien herunterzuladen.

YouTube App:

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

Fazit

Web Worker sind eine nicht ausgelastete HTML5-Funktion, die nur wenig geschätzt wird. Die meisten Entwickler, mit denen ich spreche, brauchen diese zusätzlichen Vorteile nicht, aber sie können für mehr als nur reine Berechnungen verwendet werden. Wenn Sie wie ich skeptisch sind, hoffe ich, dass dieser Artikel Ihre Meinung geändert hat. Das Auslagern von Datenträgervorgängen (Filesystem API-Aufrufe) oder HTTP-Anfragen an einen Worker passt natürlich und hilft auch, Ihren Code zu gliedern. Die HTML5 File APIs in Workers bieten völlig neue Möglichkeiten für Web-Apps, die bisher noch nicht erkundet wurden.