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

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

סיכום

למדו איך השתמשנו בספריות של קובצי שירות (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-time

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

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

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

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

בהמשך מפורטות כמה דוגמאות לרכיבי handler בהתאמה אישית של בקשות שבנינו מעל תיבת הכלים של WW. היה קל לשלב אותם עם הסקריפט של קובץ השירות הבסיסי (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 גנרי, וקל ליישם אותה באמצעות שרשור של ה-handlers של sw-toolbox cacheFirst ו-cacheOnly.

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 במצב אופליין

באותו אופן, יישמנו handler כדי להוסיף לתור בקשות שנכשלו מ-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();

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

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

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 בלי להיתקל בכמה משימות. הנה כמה בעיות שבהן נתקלנו, והאופן שבו עבדנו עליהן.

תוכן לא פעיל

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

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

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

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

עקיפת מטמון (cache busting) שלך בקשות מוגדרות מראש

כש-sw-precache שולח בקשות למשאבים לשמירה מראש, הוא משתמש בתגובות האלה ללא הגבלת זמן, כל עוד הוא סבור שגיבוב ה-MD5 של הקובץ לא השתנה. כלומר, חשוב במיוחד לוודא שהתגובה לבקשת השמירה במטמון היא חדשה, ולא מוחזרת ממטמון ה-HTTP של הדפדפן. (כן, בקשות של fetch() שנשלחות ב-Service Worker יכולות להגיב עם נתונים ממטמון ה-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);
            });
          });
        });
      }
    });

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

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

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

מטופל באופן חלקי באמצעות האפשרות ignoreSearch, שניתן להשתמש בה כשמבצעים קריאה ל-Cache.match(). לצערנו, Chrome עדיין לא תומך ב-ignoreSearch, וגם אם כן, מדובר בהתנהגות של "הכול או כלום". היינו צריכים דרך להתעלם מחלק מהפרמטרים של שאילתות בכתובות 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();
}

המשמעות עבורכם

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