ממשק API הסינכרוני של FileSystem לעובדים

אריק בידלמן

מבוא

ל-FileSystem API ול-Web Workers יש עוצמה אדירה ביחס אליהם. ה-API של מערכת FileSystem סוף סוף מספק אחסון היררכי וקלט/פלט של קבצים ביישומי אינטרנט, והעובדים מביאים ל-JavaScript עם 'ריבוי שרשורים' אסינכרוני אמיתי. עם זאת, כשמשתמשים בממשקי ה-API ביחד, אפשר ליצור אפליקציות מעניינות מאוד.

מדריך זה מספק מדריך ודוגמאות קוד לשימוש ב-HTML5 FileSystem בתוך Web Worker. הוא מתבסס על ידע בעבודה של שני ממשקי ה-API. אם אתם עדיין לא מוכנים להתעמק בנושא, או אם אתם מעוניינים ללמוד עוד על ממשקי ה-API האלה, קראו שני מדריכים מצוינים שעוסקים ביסודות: היכרות עם ממשקי ה-API של מערכת הקבצים והיסודות של Web Workers.

ממשקי API סינכרוניים לעומת ממשקי API אסינכרוניים

השימוש בממשקי JavaScript אסינכרוניים של JavaScript עלול להיות קשה. הם גדולים. הם מורכבים. אבל מה שהכי מתסכל הוא שהם מציעים שפע של הזדמנויות לדברים שישתבשו. הדבר האחרון שאתם צריכים להתמודד איתו הוא לבצע שכבות על ממשק API אסינכרוני (FileSystem) מורכב בעולם שכבר אסינכרוני (Workers)! החדשות הטובות הן ש-FileSystem API מגדיר גרסה סינכרונית כדי להקל על 'עובדי אינטרנט'.

לרוב, ה-API הסינכרוני זהה לחלוטין למקור האסינכרוני שלו. השיטות, המאפיינים, התכונות והפונקציונליות יהיו מוכרים. הסטיות העיקריות הן:

  • ניתן להשתמש ב-API הסינכרוני רק בהקשר של Web Worker, ואילו ב-Worker אפשר להשתמש בממשק ה-API האסינכרוני.
  • אי אפשר להתקשר חזרה. שיטות ה-API מחזירות עכשיו ערכים.
  • השיטות הגלובליות באובייקט החלון (requestFileSystem() ו-resolveLocalFileSystemURL()) הופכות ל-requestFileSystemSync() ול-resolveLocalFileSystemSyncURL().

מלבד החריגים האלה, ממשקי ה-API הם זהים. אוקיי, אנחנו מתחילים.

שליחת בקשה למערכת קבצים

אפליקציית אינטרנט מקבלת גישה למערכת הקבצים הסינכרונית באמצעות בקשה לאובייקט LocalFileSystemSync מתוך Web Worker. השדה requestFileSystemSync() חשוף להיקף הגלובלי של ה-Worker:

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

שימו לב לערך המוחזר החדש, כשאנחנו משתמשים ב-API הסינכרוני, וגם במקרה שבו אין פונקציית קריאה חוזרת (callback) מצליחה או של שגיאות.

כמו בממשק ה-API הרגיל של FileSystem, יש כרגע תחילית של השיטות:

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

טיפול במכסות

בשלב הזה, לא ניתן לבקש מכסה של PERSISTENT בהקשר של עובד. מומלץ לטפל בבעיות מכסות מחוץ לעובדים. התהליך עשוי להיראות כך:

  1. worker.js: צריך להקיף כל קוד של FileSystem API בתוך try/catch כדי לאתר שגיאות QUOTA_EXCEED_ERR.
  2. worker.js: אם זיהיתם QUOTA_EXCEED_ERR, שלחו postMessage('get me more quota') בחזרה לאפליקציה הראשית.
  3. האפליקציה הראשית: לבצע את הריקוד של window.webkitStorageInfo.requestQuota() כשמספר 2 מתקבל.
  4. באפליקציה הראשית: אחרי שהמשתמש יקצה עוד מכסה, שולחים את postMessage('resume writes') בחזרה לעובד כדי להודיע לו על נפח אחסון נוסף.

זה פתרון די מעורב, אבל זה אמור לעבוד. למידע נוסף על השימוש בנפח אחסון בנפח PERSISTENT באמצעות FileSystem API, ראו בקשת מכסה.

עבודה עם קבצים וספריות

הגרסה הסינכרונית של getFile() ו-getDirectory() מחזירה FileEntrySync ו-DirectoryEntrySync, בהתאמה.

לדוגמה, הקוד הבא יוצר קובץ ריק בשם 'log.txt' בספריית השורש.

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

עכשיו תיווצר ספרייה חדשה בתיקיית השורש.

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

טיפול בשגיאות

אם מעולם לא הייתם צריכים לנפות באגים בקוד Web Worker, אני מקנא בכם! יכול להיות קשה מאוד לזהות מה השתבש.

היעדר קריאות חוזרות (callback) של שגיאות בעולם הסינכרוני מקשה על ההתמודדות עם בעיות. אם נוסיף את המורכבות הכללית של ניפוי באגים בקוד של Web Worker, תהיו מתוסכלים ולא תוכלו. אחד הדברים שיכולים להקל עליכם הוא לעטוף את כל קוד ה-Worker הרלוונטי בהליך 'ניסיון'. לאחר מכן, אם מופיעות שגיאות, מעבירים את השגיאה לאפליקציה הראשית באמצעות 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);
}

העברה של קבצים, blobs ו-ArrayBuffers

כשעובדי Web Workers הופיעו לראשונה, הם התירו שליחה של נתוני מחרוזות רק ב-postMessage(). מאוחר יותר, דפדפנים התחילו לקבל נתונים שניתן לערוך בסדרה, וכתוצאה מכך הייתה אפשרות להעביר אובייקט JSON. עם זאת, לאחרונה, דפדפנים מסוימים כמו Chrome מקבלים סוגי נתונים מורכבים יותר שמועברים דרך postMessage() באמצעות אלגוריתם השכפול המובנה.

מה זה באמת אומר? המשמעות היא שהרבה יותר קל להעביר נתונים בינאריים בין האפליקציה הראשית ל-thread של ה-Worker. דפדפנים שתומכים בשכפול מובנה ל-Workers מאפשרים להעביר מערכים מוקלדים, ArrayBuffer, File או Blob אל Workers. הנתונים הם עדיין עותק, אבל היכולת להעביר File מעניקה יתרון בביצועים על פני הגישה הקודמת, שכללה הגדרת base64 של הקובץ לפני ההעברה אל postMessage().

הדוגמה הבאה מעבירה רשימת קבצים שנבחרה על ידי המשתמש ל-Worker ייעודי. ה-Worker פשוט מעביר את רשימת הקבצים (כדי להראות שהנתונים שמוחזרים הם למעשה FileList), והאפליקציה הראשית קוראת כל קובץ בתור ArrayBuffer.

הדוגמה גם כוללת גרסה משופרת של השיטה 'Work Web Worker' שמתוארת במאמר היסודות של 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>

קריאת קבצים ב-Worker

מותר להשתמש ב-API אסינכרוני FileReader API כדי לקרוא קבצים ב-Worker. עם זאת, יש דרך טובה יותר. ב-Workers יש API סינכרוני (FileReaderSync) שמייעל את קריאת הקבצים:

האפליקציה הראשית:

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

כמו כן, הקריאות החוזרות (callback) נעלמות עם ה-FileReader הסינכרוני. כך קל יותר את כמות הקינון של קריאה חוזרת (callback) כשקוראים קבצים. במקום זאת, שיטות ReadAs* מחזירות את הקובץ שנקרא.

דוגמה: אחזור כל הרשומות

במקרים מסוימים, ה-API הסינכרוני הרבה יותר נקי בשביל משימות מסוימות. פחות קריאות חוזרות (callback) הן דבר נחמד ובהחלט הופך את הדברים לקריאים יותר. החיסרון האמיתי של ה-API הסינכרוני נובע מהמגבלות של Workers.

מטעמי אבטחה, הנתונים בין אפליקציית ההתקשרות לבין ה-thread של Web Worker אף פעם לא משותפים. הנתונים תמיד מועתקים אל ה-Worker וממנו כשמתבצעת קריאה לפונקציה postMessage(). כתוצאה מכך, אי אפשר להעביר כל סוג נתונים.

לצערנו, FileEntrySync ו-DirectoryEntrySync לא שייכים כרגע לסוגים הנתמכים. איך אפשר להחזיר את הבקשות לאפליקציית השיחות? דרך אחת לעקוף את ההגבלה היא להחזיר רשימה של מערכת קבצים: כתובות URL במקום רשימה של רשומות. כתובות URL מסוג filesystem: הן פשוט מחרוזות, כך שקל מאוד להעביר אותן. בנוסף, אפשר להמיר אותן לרשומות באפליקציה הראשית באמצעות resolveLocalFileSystemURL(). כך חוזרים לאובייקט FileEntrySync/DirectoryEntrySync.

האפליקציה הראשית:

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

דוגמה: הורדת קבצים באמצעות XHR2

אחד מהתרחישים הנפוצים של Workers הוא להוריד כמה קבצים באמצעות XHR2 ולכתוב את הקבצים האלה ב-HTML5 FileSystem. זו משימה מושלמת לשרשור של עובד!

בדוגמה הבאה אפשר לאחזר ולכתוב רק קובץ אחד, אבל אפשר להרחיב אותו באמצעות תמונה כדי להוריד קבוצת קבצים.

האפליקציה הראשית:

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

סיכום

Web Workers היא תכונה של HTML5 שלא משתמשים בה הרבה, ופחות מוערך בה. רוב המפתחים שאני מדבר איתם לא צריכים יתרונות חישוביים נוספים, אבל אפשר להשתמש בהם ליותר מחישוב טהור. אם יש לך ספקות (כמו שאני הייתי), אני מקווה שהמאמר הזה עזר לך לשנות את דעתך. הסרה של דברים כמו פעולות דיסק (קריאות ל-API של מערכת הקבצים) או בקשות HTTP ל-Worker היא פעולה טבעית הגיונית ועוזרת להפריד את הקוד. ממשקי ה-API של HTML5 File בתוך Workers פותחים אפשרויות חדשות ומדהימות לאפליקציות אינטרנט שאנשים רבים עדיין לא חקרו.