Die synchrone FileSystem API für Worker

Einführung

Die HTML5 FileSystem API und die Web Worker sind für sich genommen sehr leistungsfähig. Die FileSystem API ermöglicht endlich den hierarchischen Speicher und Datei-E/A für Webanwendungen und Worker bieten echtes asynchrones Multithreading für JavaScript. Wenn Sie diese APIs jedoch zusammen verwenden, können Sie wirklich interessante Apps erstellen.

In diesem Tutorial finden Sie einen Leitfaden und Codebeispiele für die Verwendung des HTML5-Dateisystems in einem Webworker. Es werden Vorkenntnisse zu beiden APIs vorausgesetzt. Wenn Sie noch nicht ganz bereit sind, loszulegen, oder mehr über diese APIs erfahren möchten, lesen Sie die beiden Anleitungen zu den Grundlagen: Exploring the FileSystem APIs (Die FileSystem APIs kennenlernen) und Basics of Web Workers (Grundlagen von Webworkern).

Synchrone und asynchrone APIs

Die Verwendung von asynchronen JavaScript APIs kann eine Herausforderung darstellen. Sie sind groß. Sie sind komplex. Am frustrierendsten ist jedoch, dass sie viele Möglichkeiten bieten, dass etwas schiefgeht. Das Letzte, was Sie wollen, ist eine komplexe asynchrone API (FileSystem) in einer bereits asynchronen Umgebung (Worker) zu verwenden. Die gute Nachricht ist, dass die FileSystem API eine synchrone Version definiert, um die Probleme in Webworkern zu verringern.

Größtenteils entspricht die synchrone API genau dem asynchronen API. Die Methoden, Eigenschaften, Funktionen und Funktionen sind Ihnen bereits vertraut. Die wichtigsten Abweichungen sind:

  • Die synchrone API kann nur innerhalb eines Webworker-Kontexts verwendet werden, während die asynchrone API innerhalb und außerhalb eines Workers verwendet werden kann.
  • Callbacks sind nicht mehr verfügbar. API-Methoden geben jetzt Werte zurück.
  • Die globalen Methoden für das Fensterobjekt (requestFileSystem() und resolveLocalFileSystemURL()) werden zu requestFileSystemSync() und resolveLocalFileSystemSyncURL().

Abgesehen von diesen Ausnahmen sind die APIs identisch. Alles klar, wir können fortfahren.

Dateisystem anfordern

Eine Webanwendung erhält Zugriff auf das synchrone Dateisystem, indem sie ein LocalFileSystemSync-Objekt innerhalb eines Web-Workers anfordert. Die requestFileSystemSync() ist für den globalen Gültigkeitsbereich des Workers verfügbar:

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

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

Wie bei der normalen FileSystem API werden Methoden derzeit mit einem Präfix versehen:

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

Umgang mit Kontingenten

Derzeit ist es nicht möglich, ein PERSISTENT-Kontingent in einem Worker-Kontext anzufordern. Ich empfehle, Kontingentprobleme außerhalb von Workers zu beheben. Der Prozess könnte so aussehen:

  1. worker.js: Umschließen Sie FileSystem API-Code in try/catch, damit alle QUOTA_EXCEED_ERR-Fehler abgefangen werden.
  2. Worker.js: Wenn ein QUOTA_EXCEED_ERR erkannt wird, senden Sie eine postMessage('get me more quota') zurück an die Haupt-App.
  3. Haupt-App: Führe den window.webkitStorageInfo.requestQuota()-Tanz durch, wenn du die Nummer 2 erhältst.
  4. main app: Nachdem der Nutzer mehr Kontingent gewährt hat, sende postMessage('resume writes') zurück an den Worker, um ihn über den zusätzlichen Speicherplatz zu informieren.

Das ist eine ziemlich aufwendige Behelfslösung, sollte aber 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 jeweils FileEntrySync und 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 Stammverzeichnis erstellt.

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

Fehlerbehebung

Wenn Sie noch nie Web Worker-Code debuggen mussten, beneide ich Sie! Es kann echt mühsam sein, herauszufinden, was schief läuft.

Da es in der synchronen Welt keine Fehler-Callbacks gibt, ist die Fehlerbehebung schwieriger, als sie sein sollte. Wenn wir die allgemeine Komplexität des Debuggens von Webworker-Code hinzufügen, sind Sie schnell frustriert. Eine Sache, die das Leben einfacher macht, ist, Ihren gesamten relevanten Worker-Code in einem Versuch/Fang zusammenzupacken. Wenn Fehler auftreten, leite den Fehler mit postMessage() an die Haupt-App 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 Webworker eingeführt wurden, war es nur möglich, Stringdaten in postMessage() zu senden. Später akzeptierten Browser serialisierbare Daten, sodass ein JSON-Objekt übergeben werden konnte. In letzter Zeit akzeptieren einige Browser wie Chrome jedoch komplexere Datentypen, die über postMessage() mithilfe des Algorithmus zum Strukturklonen übergeben werden.

Was bedeutet das genau? Das bedeutet, dass es viel einfacher ist, binäre Daten zwischen der Haupt-App und dem Worker-Thread zu übergeben. In Browsern, die das strukturierte Klonen für Worker unterstützen, können Sie typisierte Arrays, ArrayBuffer, File oder Blob an Worker übergeben. Obwohl es sich bei den Daten immer noch um eine Kopie handelt, bedeutet die Übergabe eines File-Objekts gegenüber dem vorherigen Ansatz einen Leistungsvorteil, bei dem die Datei zuerst mit Base64 erstellt werden musste, bevor sie an postMessage() übergeben wurde.

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

Im Beispiel wird außerdem eine verbesserte Version der Inline-Webworker-Methode verwendet, die im Artikel Grundlagen von Webworkern 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

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

Haupt-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 sind Callbacks bei der synchronen FileReader nicht mehr verfügbar. Dadurch wird die Anzahl der Callback-Verschachtelungen beim Lesen von Dateien vereinfacht. Stattdessen geben die readAs*-Methoden die gelesene Datei zurück.

Beispiel: Alle Einträge abrufen

In einigen Fällen ist die synchrone API für bestimmte Aufgaben viel übersichtlicher. Weniger Rückrufe sind schön und sorgen sicherlich für mehr Lesbarkeit. Der eigentliche Nachteil der synchronen API ergibt sich aus den Einschränkungen von Workern.

Aus Sicherheitsgründen werden Daten zwischen der aufrufenden App und einem Webworker-Thread nie weitergegeben. Daten werden immer vom Worker in den Worker kopiert, wenn postMessage() aufgerufen wird. Daher kann nicht jeder Datentyp übergeben werden.

Leider gehören FileEntrySync und DirectoryEntrySync derzeit nicht zu den zulässigen Typen. Wie können Sie also Einträge zurück in die Anruf-App erhalten? Eine Möglichkeit, diese Einschränkung zu umgehen, besteht darin, anstelle einer Liste von Einträgen eine Liste von filesystem: URLs zurückzugeben. filesystem: URLs sind nur Strings und lassen sich daher ganz einfach weitergeben. Außerdem können sie mit resolveLocalFileSystemURL() in Einträge in der Haupt-App aufgelöst werden. Dadurch gelangen Sie zu einem FileEntrySync-/DirectoryEntrySync-Objekt.

Haupt-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: Dateien mit XHR2 herunterladen

Ein gängiger Anwendungsfall für Worker besteht darin, mehrere Dateien mit XHR2 herunterzuladen und in das HTML5-Dateisystem zu schreiben. Das ist eine perfekte Aufgabe für einen Worker-Thread.

Im folgenden Beispiel wird nur eine Datei abgerufen und geschrieben. Sie können es jedoch erweitern, um mehrere Dateien herunterzuladen.

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

Webworker sind eine unterausgeschöpfte und unterschätzte Funktion von HTML5. Die meisten Entwickler, mit denen ich spreche, benötigen diese zusätzlichen Rechenressourcen nicht. Sie können aber nicht nur für reine Berechnungen verwendet werden. Falls Sie (wie ich) skeptisch sind, hoffe ich, dass Ihnen dieser Artikel geholfen hat, Ihre Meinung zu ändern. Das Auslagern von Daten wie Datenträgervorgängen (Filesystem API-Aufrufen) oder HTTP-Anfragen an einen Worker ist eine natürliche Lösung und hilft auch bei der Aufgliederung Ihres Codes. Die HTML5-Datei-APIs in Workern eröffnen Webanwendungen ganz neue Möglichkeiten, die viele noch nicht entdeckt haben.