L'API FileSystem sincrona per i worker

Introduzione

L'API FileSystem e i web worker HTML5 sono estremamente efficaci ai loro fini. L'API FileSystem consente infine l'archiviazione gerarchica e l'I/O dei file nelle applicazioni web, mentre i worker portano il vero "multi-threading" asincrono a JavaScript. Tuttavia, l'utilizzo combinato di queste API consente di creare app davvero interessanti.

Questo tutorial fornisce una guida ed esempi di codice per sfruttare il file system HTML5 in un web worker. presuppone una conoscenza pratica di entrambe le API. Se non vuoi approfondire l'argomento o ti interessa saperne di più su queste API, leggi due ottimi tutorial sulle nozioni di base: Esplorazione delle API FileSystem e Nozioni di base sui web worker.

API sincrone e asincrone

Le API JavaScript asincrone possono essere difficili da usare. Sono grandi. Sono complesse. La cosa più frustrante è che offrono molte opportunità di errore. L'ultima cosa da affrontare è l'integrazione su un'API asincrona (file system) complessa in un mondo già asincrono (Worker). La buona notizia è che l'API FileSystem definisce una versione sincrona per alleggerire gli aspetti critici dei web worker.

Per la maggior parte, l'API sincrona è esattamente uguale alla sua cugina asincrona. Metodi, proprietà, caratteristiche e funzionalità ti saranno familiari. Le deviazioni principali sono:

  • L'API sincrona può essere utilizzata solo all'interno di un contesto web worker, mentre l'API asincrona può essere utilizzata all'interno e all'esterno di un worker.
  • Sono disponibili i callback. I metodi API ora restituiscono valori.
  • I metodi globali sull'oggetto finestra (requestFileSystem() e resolveLocalFileSystemURL()) diventano requestFileSystemSync() e resolveLocalFileSystemSyncURL().

Fatta eccezione per queste eccezioni, le API sono le stesse. Ok, siamo pronti.

Richiesta di un file system

Un'applicazione web ottiene l'accesso al file system sincrono richiedendo un oggetto LocalFileSystemSync da un web worker. requestFileSystemSync() è esposto all'ambito globale del worker:

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

Nota il nuovo valore restituito ora che stiamo utilizzando l'API sincrona e l'assenza di callback di successo ed errore.

Come con la normale API FileSystem, al momento i metodi sono preceduti da un prefisso:

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

Gestione della quota

Attualmente, non è possibile richiedere la quota PERSISTENT in un contesto worker. Consiglio di occuparmi dei problemi di quota al di fuori dei worker. Il processo potrebbe essere simile al seguente:

  1. worker.js: esegui il wrapping di qualsiasi codice dell'API FileSystem in un try/catch in modo che vengano rilevati eventuali errori QUOTA_EXCEED_ERR.
  2. worker.js: se rilevi un QUOTA_EXCEED_ERR, invia un postMessage('get me more quota') all'app principale.
  3. app principale: ripeti il ballo window.webkitStorageInfo.requestQuota() quando ricevi il n. 2.
  4. app principale: dopo che l'utente concede una quota maggiore, invia postMessage('resume writes') al worker per informarlo della disponibilità di spazio di archiviazione aggiuntivo.

Questa è una soluzione alternativa abbastanza complessa, ma dovrebbe funzionare. Per ulteriori informazioni sull'utilizzo dello spazio di archiviazione PERSISTENT con l'API FileSystem, consulta l'articolo sulla richiesta di quota.

Lavorare con file e directory

La versione sincrona di getFile() e getDirectory() restituiscono rispettivamente FileEntrySync e DirectoryEntrySync.

Ad esempio, il codice seguente crea un file vuoto denominato "log.txt" nella directory principale.

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

Quanto segue crea una nuova directory nella cartella principale.

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

Gestione degli errori

Se non hai mai dovuto eseguire il debug del codice del web worker, ti invigo! Può essere molto difficile capire cosa sta andando storto.

L'assenza di callback di errore nel mondo sincrono rende la gestione dei problemi più complicata di quanto non debba essere. Se aggiungiamo la complessità generale del debug del codice web worker, non avrai tempo. Una cosa che può semplificare la vita è includere tutto il codice worker pertinente in un tentativo/catch. Quindi, se si verificano errori, inoltra l'errore all'app principale utilizzando 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);
}

Passaggio a file, BLOB e Arraybus

Quando i web worker sono apparsi per la prima volta, consentivano l'invio di dati stringa solo in postMessage(). In seguito, i browser hanno iniziato ad accettare dati serializzabili, il che significava che era possibile passare un oggetto JSON. Tuttavia, recentemente, alcuni browser come Chrome accettano di trasmettere tipi di dati più complessi attraverso postMessage() utilizzando l'algoritmo di clone strutturato.

Cosa significa davvero? Significa che trasferire dati binari tra l'app principale e il thread di lavoro è molto più semplice. I browser che supportano la clonazione strutturata per i worker consentono di passare array digitati, ArrayBuffer, File o Blob ai worker. Anche se i dati sono ancora una copia, la possibilità di trasmettere un File comporta un vantaggio in termini di prestazioni rispetto al precedente approccio, che prevedeva l'utilizzo del file base64 prima di trasmetterlo a postMessage().

L'esempio seguente passa un elenco di file selezionati dall'utente a un worker dedicato. Il worker passa semplicemente attraverso l'elenco dei file (in modo semplice per mostrare che i dati restituiti è in realtà un FileList) e l'app principale legge ogni file come ArrayBuffer.

L'esempio utilizza anche una versione migliorata della tecnica Web Worker integrata descritta in Nozioni di base sui web worker.

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

Lettura di file in un worker

È perfettamente accettabile utilizzare l'API FileReader asincrona per leggere i file in un worker. Tuttavia, c'è un modo migliore. In Workers è disponibile un'API sincrona (FileReaderSync) che semplifica la lettura dei file:

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

Come previsto, i callback non saranno più disponibili con il valore sincrono FileReader. Ciò semplifica la quantità di nidificazione dei callback durante la lettura dei file. Invece, i metodi readAs* restituiscono il file letto.

Esempio: recupero di tutte le voci

In alcuni casi, l'API sincrona è molto più pulita per determinate attività. Un minor numero di callback è utile e rende sicuramente più leggibile il testo. Il vero svantaggio dell'API sincrona deriva dalle limitazioni dei worker.

Per motivi di sicurezza, i dati tra l'app di chiamata e un thread di web worker non vengono mai condivisi. I dati vengono sempre copiati da e verso il worker quando viene chiamato postMessage(). Di conseguenza, non tutti i tipi di dati possono essere trasmessi.

Purtroppo FileEntrySync e DirectoryEntrySync al momento non rientrano nei tipi accettati. Quindi, come puoi recuperare le voci nell'app per le chiamate? Un modo per aggirare la limitazione è restituire un elenco di file system: URL anziché un elenco di voci. Gli URL filesystem: sono solo stringhe, pertanto sono molto facili da usare. Inoltre, possono essere risolti in voci nell'app principale utilizzando resolveLocalFileSystemURL(). In questo modo torni a un oggetto FileEntrySync/DirectoryEntrySync.

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

Esempio: download di file utilizzando XHR2

Un caso d'uso comune per i lavoratori è scaricare un gruppo di file utilizzando XHR2 e scrivere i file nel file system HTML5. Questa è un'attività perfetta per un thread di worker.

L'esempio seguente recupera e scrive solo un file, ma puoi espanderlo con le immagini per scaricare un insieme di file.

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

Conclusione

I web worker sono una funzionalità sottoutilizzata e sottovalutata di HTML5. La maggior parte degli sviluppatori con cui parlo non ha bisogno dei vantaggi di calcolo aggiuntivi, ma può essere usata per qualcosa di più di un semplice calcolo. Se sei scettico (come lo ero io), spero che questo articolo ti sia stato utile. L'offload di cose come le operazioni sul disco (chiamate API Filesystem) o le richieste HTTP a un worker è una soluzione naturale e consente anche di compartimentare il codice. Le API HTML5 dei file di lavoro all'interno di Workers offrono una meraviglia completamente nuova per le app web che molti non hanno ancora esplorato.