סיכום
איך השתמשנו בספריות של שירותי עבודה כדי להפוך את אפליקציית האינטרנט של 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.
ניסוי אודיו-ויזואלי
לצורך הניסוי האודיו/ויזואלי, השתמשנו באסטרטגיית המטמון networkFirst
של sw-toolbox
. כל בקשות ה-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
, הטמענו אסטרטגיה אגרסיבית של שמירה במטמון קודם לקליפת האפליקציה. כלומר, ה-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);
});
});
});
}
});
שימו לב לפרמטרים נוספים של שאילתות!
כש-Service Worker בודק אם יש תגובה שנשמרה במטמון, הוא משתמש בכתובת ה-URL של הבקשה בתור המפתח. כברירת מחדל, כתובת ה-URL של הבקשה חייבת להתאים בדיוק לכתובת ה-URL ששימשה לאחסון התשובה ששמורה במטמון, כולל כל פרמטר של שאילתה בחלק search של כתובת ה-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
, וגם על
השיטות שמתוארות כאן כדי להפעיל אפליקציות אינטרנט משלכם.
קובצי שירות הם שיפור מתקדם שאפשר להתחיל להשתמש בו כבר היום. כשמשתמשים בהם כחלק מאפליקציית אינטרנט עם מבנה תקין, המהירות והיתרונות במצב אופליין הם משמעותיים למשתמשים.