קובצי שירות (service worker) בסביבת הייצור

צילום מסך לאורך

סיכום

איך השתמשנו בספריות של שירותי עובדים כדי להפוך את אפליקציית האינטרנט של Google I/O 2015 למהירה ולמותאמת לעבודה אופליין?

סקירה כללית

אפליקציית האינטרנט של Google I/O 2015 נכתבה השנה על ידי צוות קשרי המפתחים של Google, על סמך עיצובים של החברים שלנו ב-Instrument, שכתבו את הניסוי האודיו-ויזואלי. המשימה של הצוות שלנו הייתה להבטיח שאפליקציית האינטרנט של I/O (שנקראת בשם הקוד IOWA) תציג את כל מה שאפשר לעשות באינטרנט המודרני. חוויית שימוש מלאה שמתמקדת בעבודה אופליין הייתה בראש רשימת התכונות שחייבות להופיע באפליקציה.

אם קראתם מאמרים אחרים באתר הזה לאחרונה, סביר להניח שנתקלתם בשירותי עובדים, ולא תהיה לכם הפתעה לשמוע שהתמיכה של IOWA במצב אופליין מסתמכת עליהם במידה רבה. בהתאם לצרכים של IOWA בעולם האמיתי, פיתחנו שתי ספריות לטיפול בשני תרחישים שונים לשימוש אופליין: sw-precache לצורך אוטומציה של אחסון מראש של משאבים סטטיים, ו-sw-toolbox לצורך טיפול באחסון במהלך זמן הריצה ובאסטרטגיות חלופיות.

הספריות משלימים זו את זו בצורה יפה, והן אפשרו לנו להטמיע אסטרטגיה יעילה שבה 'הקליפה' של התוכן הסטטי של IOWA תמיד הוצגה ישירות מהמטמון, ומשאבים דינמיים או מרוחקים הוצגו מהרשת, עם חלופות לתשובות ששמורות במטמון או לתשובות סטטיות במקרה הצורך.

אחסון במטמון מראש באמצעות sw-precache

המשאבים הסטטיים של IOWA – קובצי ה-HTML, ה-JavaScript, ה-CSS והתמונות – מספקים את המעטפת המרכזית של אפליקציית האינטרנט. היו לנו שתי דרישות ספציפיות שחשובות כשמדברים על שמירת משאבים כאלה במטמון: רצינו לוודא שרוב המשאבים הסטטיים נשמרים במטמון ושהם מעודכנים. sw-precache נוצר מתוך מחשבה על הדרישות האלה.

שילוב בזמן ה-build

sw-precache בתהליך ה-build שמבוסס על gulp של IOWA, אנחנו מסתמכים על סדרה של דפוסי glob כדי להבטיח שאנחנו יוצרים רשימה מלאה של כל המשאבים הסטטיים שבהם IOWA משתמשת.

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

גישות חלופיות, כמו קידוד של רשימת שמות קבצים במערך, וזיכרון לשנות את מספר הגרסה של המטמון בכל פעם שאחד מהקבצים האלה משתנה, היו חשופות לשגיאות, במיוחד מכיוון שמספר חברי צוות בדקו את הקוד. אף אחד לא רוצה לגרום לשיבושים בתמיכה אופליין בגלל ששכח להוסיף קובץ חדש למערך שמנוהל באופן ידני. שילוב בזמן ה-build אפשר לנו לבצע שינויים בקבצים קיימים ולהוסיף קבצים חדשים בלי לדאוג לבעיות כאלה.

עדכון משאבים שנשמרו במטמון

הפונקציה sw-precache יוצרת סקריפט של שירות עובד שכולל גיבוב MD5 ייחודי לכל משאב שמאוחסן במטמון מראש. בכל פעם שמשתנה משאב קיים או שמתווסף משאב חדש, סקריפט ה-service worker נוצר מחדש. הפעולה הזו מפעילה באופן אוטומטי את תהליך העדכון של ה-service worker, שבו המשאבים החדשים מאוחסנים במטמון והמשאבים הלא מעודכנים נמחקים. משאבים קיימים עם גיבובי MD5 זהים נשארים כפי שהם. כלומר, משתמשים שביקרו באתר בעבר מורידים רק את הקבוצה המינימלית של המשאבים שהשתנו, וכתוצאה מכך חוויית השימוש יעילה הרבה יותר מאשר אם התוקף של כל המטמון יפוג בבת אחת.

כל קובץ שתואמת לאחד מהדפוסים של glob מורידים ומאוחסנים במטמון בפעם הראשונה שמשתמש מבקר ב-IOWA. השקענו מאמצים כדי לוודא שרק משאבים קריטיים שנדרשים לעיבוד הדף נשמרו במטמון מראש. תוכן משני, כמו המדיה ששימשה בניסוי האודיו/הוויזואלי או תמונות הפרופיל של הדוברים בסשנים, לא נשמר במטמון מראש בכוונה, ובמקום זאת השתמשנו בספרייה sw-toolbox כדי לטפל בבקשות אופליין למשאבים האלה.

sw-toolbox, לכל הצרכים הדינמיים שלנו

כפי שצוין, לא ניתן לאחסן מראש כל משאב שדרוש לאתר כדי לפעול במצב אופליין. חלק מהמשאבים גדולים מדי או שנעשה בהם שימוש לעיתים רחוקות, ולכן לא כדאי לשמור אותם בזיכרון. משאבים אחרים הם דינמיים, כמו התשובות מ-API או משירות מרחוק. עם זאת, גם אם בקשה לא מאוחסנת מראש במטמון, לא בטוח שהיא תגרום ל-NetworkError. sw-toolbox העניק לנו את הגמישות להטמיע מפעילי בקשות שמטפלים במטמון בסביבת זמן הריצה של משאבים מסוימים, ובחלופות מותאמות אישית של משאבים אחרים. בנוסף, השתמשנו בו כדי לעדכן את המשאבים ששמורים במטמון בתגובה להתראות.

ריכזנו כאן כמה דוגמאות למטפלי בקשות מותאמים אישית שיצרנו על סמך sw-toolbox. היה קל לשלב אותם עם סקריפט הבסיס של ה-service worker באמצעות importScripts parameter של sw-precache, שמציב קובצי JavaScript עצמאיים בהיקף של ה-service worker.

ניסוי אודיו-ויזואלי

בניסוי האודיו/הווידאו, השתמשנו בשיטת האחסון במטמון של sw-toolbox,‏ networkFirst. כל בקשות ה-HTTP שתואמות לדפוס כתובת ה-URL של הניסוי יישלחו קודם לרשת, ואם תתקבל תשובה מוצלחת, התשובה הזו תישמר באמצעות Cache Storage API. אם תתבצע בקשה נוספת כשהרשת לא זמינה, המערכת תשתמש בתגובה שנשמרה במטמון.

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

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

תמונות בפרופיל של הדוברים

לגבי תמונות הפרופיל של הדוברים, המטרה שלנו הייתה להציג גרסה של תמונה של דובר מסוים שנשמרה במטמון, אם היא הייתה זמינה, ואם לא, להשתמש ברשת כדי לאחזר את התמונה. אם הבקשה לרשת נכשלה, כחלופה אחרונה, השתמשנו בתמונה גנרית של placeholder ששמורה במטמון מראש (ולכן תמיד תהיה זמינה). זוהי אסטרטגיה נפוצה לשימוש כשעובדים עם תמונות שאפשר להחליף ב-placeholder כללי, וקל להטמיע אותה על ידי קישור של הטיפולים cacheFirst ו-cacheOnly של sw-toolbox.

var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';

function profileImageRequest(request) {
    return toolbox.cacheFirst(request).catch(function() {
    return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
    });
}

toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
                    profileImageRequest,
                    {origin: /.*\.googleapis\.com/});
תמונות פרופיל מדף סשן
תמונות פרופיל מדף סשן.

עדכונים בלוחות הזמנים של המשתמשים

אחת מהתכונות המרכזיות של IOWA הייתה האפשרות של משתמשים מחוברים ליצור ולנהל לוח זמנים של סשנים שהם תכננו להשתתף בהם. כצפוי, עדכוני הסשנים בוצעו באמצעות בקשות HTTP POST לשרת קצה עורפי, והקדשנו זמן כדי למצוא את הדרך הטובה ביותר לטפל בבקשות האלה לשינוי המצב כשהמשתמש אופליין. הגענו לשילוב של תור לבקשות שנכשלו ב-IndexedDB, בשילוב עם לוגיקה בדף האינטרנט הראשי שבדקה ב-IndexedDB אם יש בקשות בתור וניסו שוב את כל מה שמצאו.

var DB_NAME = 'shed-offline-session-updates';

function queueFailedSessionUpdateRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, request.method);
    });
}

function handleSessionUpdateRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedSessionUpdateRequest(request);
    });
}

toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
                    handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
                        handleSessionUpdateRequest);

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

simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
    var replayPromises = [];
    return db.forEach(function(url, method) {
    var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
        return db.delete(url).then(function() {
        return true;
        });
    });
    replayPromises.push(promise);
    }).then(function() {
    if (replayPromises.length) {
        return Promise.all(replayPromises).then(function() {
        IOWA.Elements.Toast.showMessage(
            'My Schedule was updated with offline changes.');
        });
    }
    });
}).catch(function() {
    IOWA.Elements.Toast.showMessage(
    'Offline changes could not be applied to My Schedule.');
});

Google Analytics אופליין

באופן דומה, הטמענו טיפולן כדי להוסיף לתור בקשות שנכשלו ב-Google Analytics ולנסות להפעיל אותן מחדש מאוחר יותר, כשהרשת תהיה זמינה. הגישה הזו מאפשרת לכם להמשיך לקבל את התובנות של Google Analytics גם כשאתם אופליין. הוספנו את הפרמטר qt לכל בקשה בתור, והגדרתנו אותו לפי משך הזמן שחלף מאז הניסיון הראשון לשלוח את הבקשה, כדי לוודא שזמן השיוך הנכון של האירוע יגיע לקצה העורפי של Google Analytics. מערכת Google Analytics תומכת באופן רשמי בערכים של qt של עד 4 שעות בלבד, לכן עשינו כמיטב יכולתנו כדי להפעיל מחדש את הבקשות האלה בהקדם האפשרי, בכל פעם ש-service worker הופעל.

var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;

function replayQueuedAnalyticsRequests() {
    simpleDB.open(DB_NAME).then(function(db) {
    db.forEach(function(url, originalTimestamp) {
        var timeDelta = Date.now() - originalTimestamp;
        var replayUrl = url + '&qt=' + timeDelta;
        fetch(replayUrl).then(function(response) {
        if (response.status >= 500) {
            return Response.error();
        }
        db.delete(url);
        }).catch(function(error) {
        if (timeDelta > EXPIRATION_TIME_DELTA) {
            db.delete(url);
        }
        });
    });
    });
}

function queueFailedAnalyticsRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, Date.now());
    });
}

function handleAnalyticsCollectionRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedAnalyticsRequest(request);
    });
}

toolbox.router.get('/collect',
                    handleAnalyticsCollectionRequest,
                    {origin: ORIGIN});
toolbox.router.get('/analytics.js',
                    toolbox.networkFirst,
                    {origin: ORIGIN});

replayQueuedAnalyticsRequests();

דפי נחיתה של התראות בדחיפה

שירותי העבודה לא רק טיפלו בפונקציונליות של IOWA במצב אופליין, אלא גם הפעילו את התראות ה-push שדרכן הודענו למשתמשים על עדכונים לפגישות שסימנו. בדף הנחיתה שמשויך להתראות האלה הוצגו פרטי הסשן המעודכנים. דפי הנחיתה האלה כבר שמורים במטמון כחלק מהאתר הכולל, ולכן הם כבר עבדו במצב אופליין, אבל היינו צריכים לוודא שפרטי הסשן בדף הזה מעודכנים, גם כשמציגים אותו במצב אופליין. כדי לעשות זאת, שינינו את המטא-נתונים של הסשן שנשמרו במטמון עם העדכונים שהפעילו את ההתראה, ושמרנו את התוצאה במטמון. המידע המעודכן הזה ישמש בפעם הבאה שדף פרטי הסשן ייפתח, בין אם זה יקרה אופליין ובין אם אונליין.

caches.open(toolbox.options.cacheName).then(function(cache) {
    cache.match('api/v1/schedule').then(function(response) {
    if (response) {
        parseResponseJSON(response).then(function(schedule) {
        sessions.forEach(function(session) {
            schedule.sessions[session.id] = session;
        });
        cache.put('api/v1/schedule',
                    new Response(JSON.stringify(schedule)));
        });
    } else {
        toolbox.cache('api/v1/schedule');
    }
    });
});

דברים שחשוב לדעת

כמובן, אף אחד לא עובד על פרויקט בסדר גודל של IOWA בלי להיתקל בכמה מלכודות. ריכזנו כאן כמה מהבעיות שבהן נתקלת, ואת הדרכים שבהן טיפלנו בהן.

תוכן ישן

כשמתכננים אסטרטגיית אחסון במטמון, בין אם היא מיושמת באמצעות שירותי עובדים או באמצעות המטמון הרגיל של הדפדפן, צריך להחליט אם להעביר את המשאבים מהר ככל האפשר או את המשאבים העדכניים ביותר. באמצעות sw-precache, הטמענו אסטרטגיה אגרסיבית של שמירה במטמון קודם (cache-first) בקונכיית האפליקציה שלנו. כלומר, ה-service worker לא יבדוק את הרשת לעדכונים לפני שהוא יחזיר את ה-HTML, ה-JavaScript וה-CSS בדף.

למזלנו, הצלחנו להשתמש באירועים במחזור החיים של שירות העבודה כדי לזהות מתי התוכן החדש זמין אחרי שהדף כבר נטען. כשאנחנו מזהים גרסת service worker מעודכנת, אנחנו מציגים למשתמש הודעה קופצת עם המלצה לטעון מחדש את הדף כדי לראות את התוכן העדכני ביותר.

if (navigator.serviceWorker && navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.onstatechange = function(e) {
    if (e.target.state === 'redundant') {
        var tapHandler = function() {
        window.location.reload();
        };
        IOWA.Elements.Toast.showMessage(
        'Tap here or refresh the page for the latest content.',
        tapHandler);
    }
    };
}
ההודעה על התוכן החדש ביותר
הכרזה על 'התוכן העדכני ביותר'

חשוב לוודא שהתוכן הסטטי הוא סטטי!

sw-precache משתמש בגיבוב MD5 של תוכן הקבצים המקומיים, ומאחזר רק משאבים שהגיבוב שלהם השתנה. המשמעות היא שהמשאבים יהיו זמינים בדף כמעט באופן מיידי, אבל המשמעות היא גם שברגע שתוכן מסוים נשמר במטמון, הוא יישאר במטמון עד שיוקצה לו גיבוב חדש בסקריפט מעודכן של שירות העבודה.

נתקלנו בבעיה עם ההתנהגות הזו במהלך I/O, כי הקצה העורפי שלנו צריך לעדכן באופן דינמי את מזהי הסרטונים ב-YouTube של השידור החי בכל יום של הפגישה. מכיוון שקובץ התבנית הבסיסי היה סטטי ולא השתנה, תהליך העדכון של ה-service worker לא הופעל, והתגובה שתוכננה להיות דינמית מהשרת עם עדכון סרטוני YouTube הפכה לתגובה שנשמרה במטמון עבור מספר משתמשים.

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

ביטול האחסון במטמון של בקשות האחסון המקדים

כש-sw-precache שולח בקשות לאחסון מראש של משאבים, הוא משתמש בתשובות האלה ללא הגבלת זמן, כל עוד הוא חושב שה-hash של ה-MD5 של הקובץ לא השתנה. כלומר, חשוב במיוחד לוודא שהתגובה לבקשה של האחסון המוקדם היא תגובה עדכנית, ולא תגובה שחוזרת מהמטמון של ה-HTTP בדפדפן. (כן, בקשות fetch() שנשלחות על ידי עובד שירות יכולות להשיב עם נתונים מהמטמון של HTTP בדפדפן).

כדי לוודא שהתשובות שאנחנו מאחסנים מראש במטמון מגיעות ישירות מהרשת ולא מהמטמון של ה-HTTP בדפדפן, sw-precache מוסיפה באופן אוטומטי פרמטר שאילתה שמבטל את המטמון לכל כתובת URL שהיא מבקשת. אם אתם לא משתמשים ב-sw-precache ומשתמשים בשיטת תגובה מסוג 'אחסון במטמון קודם', חשוב לבצע פעולה דומה בקוד שלכם.

פתרון נקי יותר לביטול האחסון במטמון הוא להגדיר את מצב המטמון של כל Request שמשמש לאחסון מראש ל-reload. כך תוכלו לוודא שהתגובה תגיע מהרשת. עם זאת, נכון למועד כתיבת המאמר, האפשרות של מצב מטמון לא נתמכת ב-Chrome.

תמיכה בכניסה לחשבון וביציאה ממנו

IOWA אפשרה למשתמשים להתחבר באמצעות חשבונות Google שלהם ולעדכן את לוחות הזמנים המותאמים אישית של האירועים, אבל המשמעות היא גם שהמשתמשים עשויים לצאת מהחשבון מאוחר יותר. ברור ששמירת נתוני תשובות מותאמות אישית במטמון היא נושא מורכב, ולא תמיד יש גישה נכונה אחת.

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

    self.addEventListener('message', function(event) {
      if (event.data === 'clear-cached-user-data') {
        caches.open(toolbox.options.cacheName).then(function(cache) {
          cache.keys().then(function(requests) {
            return requests.filter(function(request) {
              return request.url.indexOf('api/v1/user/') !== -1;
            });
          }).then(function(userDataRequests) {
            userDataRequests.forEach(function(userDataRequest) {
              cache.delete(userDataRequest);
            });
          });
        });
      }
    });

שימו לב לפרמטרים נוספים של שאילתה

כשעובד שירות בודק אם יש תגובה ששמורה במטמון, הוא משתמש בכתובת ה-URL של הבקשה בתור המפתח. כברירת מחדל, כתובת ה-URL של הבקשה חייבת להתאים בדיוק לכתובת ה-URL ששימשה לאחסון התשובה ששמורה במטמון, כולל כל פרמטר של שאילתה בחלק search של כתובת ה-URL.

בסופו של דבר, הבעיה הזו גרמה לנו בעיה במהלך הפיתוח, כשהתחלנו להשתמש בפרמטרים של כתובת URL כדי לעקוב אחרי המקור של התנועה. לדוגמה, הוספנו את הפרמטר utm_source=notification לכתובות URL שנפתחו כשלחצנו על אחת מההתראות שלנו, והשתמשנו ב-utm_source=web_app_manifest ב-start_url של מניפסט של אפליקציית האינטרנט. כתובות URL שתואמות בעבר לתשובות ששמורות במטמון הופיעו כ'חסר' כשהפרמטרים האלה נוספו.

הבעיה הזו נפתרת באופן חלקי באמצעות האפשרות ignoreSearch, שאפשר להשתמש בה כשקוראים ל-Cache.match(). לצערנו, עדיין אין תמיכה ב-ignoreSearch ב-Chrome, וגם אם הייתה, ההתנהגות היא'הכול או כלום'. מה שדרוש לנו הוא דרך להתעלם מחלק מהפרמטרים של שאילתות בכתובות URL, תוך התחשבות בפרמטרים אחרים שיש להם משמעות.

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

function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
    var url = new URL(originalUrl);

    url.search = url.search.slice(1)
    .split('&')
    .map(function(kv) {
        return kv.split('=');
    })
    .filter(function(kv) {
        return ignoredRegexes.every(function(ignoredRegex) {
        return !ignoredRegex.test(kv[0]);
        });
    })
    .map(function(kv) {
        return kv.join('=');
    })
    .join('&');

    return url.toString();
}

מה המשמעות של זה מבחינתכם

השילוב של שירותי העבודה באפליקציית האינטרנט של Google I/O הוא כנראה השימוש המורכב ביותר בעולם האמיתי שפרוס עד כה. אנחנו מצפים שמפתחי האינטרנט ישתמשו בכלים שיצרנו sw-precache ו-sw-toolbox, וגם בשיטות שאנחנו מתארים כדי לפתח אפליקציות אינטרנט משלהם. קובצי שירות הם שיפור מתקדם שאפשר להתחיל להשתמש בו כבר היום. כשמשתמשים בהם כחלק מאפליקציית אינטרנט עם מבנה תקין, המהירות והיתרונות במצב אופליין הם משמעותיים למשתמשים.