ביצוע קריאות אסינכרוניות בצורה סינכרונית, למה ואיך.
כידוע לכל, JavaScript יועדה לרוץ ללא מקביליות בקוד (ב-nodejs/browser) כך שבכל זמן נתון רק שורה אחת של הקוד רצה, ובקריאה אסינכרונית ל-IO שרצה "ברקע" לכשהפעולה תסתיים יופעל callback מלולאת האירועים רק כששאר הקוד סיים לרוץ.
בכל מקרה אין מצב בו שורות קוד JS של הפרויקט שלנו רצות במקביל. (כיום יש Worker Threads אבל זה כבר נושא לפוסט אחר).
נניח ואנחנו מעוניינים לבצע אוסף פעולות אסינכרוניות (שלא "עוצרות" את הקוד שלנו) אבל להריץ אותן סינכרונית. איך עושים כזה דבר ב-JS?
אני אתן דוגמה מהעולם האמיתי. בפרויקט הבית החכם שלי ה-casanet עלתה בעיה כזו, הפרויקט יועד לנהל בממשק אחד את כלל המכשירים החכמים בבית ללא תלות בחברה המייצרת ובפרוטוקול התקשורת הספציפי של המכשיר. לכן יצרתי ממשק גנרי של קבלת סטטוס ע"י callback ועבור כל סוג מכשיר מומש ה-API של החברה הספציפית בעטיפה של הממשק הגנרי. קל ופשוט.
התסריט הקלאסי לפרויקט כזה הוא לעבור לפי בקשה על כלל המכשירים בבית ולבדוק מה הסטטוס שלהם (כבוי, דלוק וכדו').
כך אמור להראות הקוד:
(בדוגמה יצרתי פונקציה שממתינה זמן רנדומלי ומחזירה סטטוס רנדומלי)
מה שעשינו פה בעצם זה לעבור על כל המכשירים בבית, עבור כל אחד מהם לפנות ל-API ולבדוק מה הסטטוס, ברגע שכלל המכשירים יחזירו תשובה (כמות האיברים ברשימת המכשירים שווה לכמות המכשירים), נדפיס את רשימת הסטטוסים של המכשירים.
בשלב זה אין לנו שליטה על סדר הזנת הסטטוסים ברשימה.
מה שאפשר לעשות זה לבצע קריאה אסינכרונית, שלכשתסתיים היא תפעיל setTimeout של כמה שניות שמקבלת פונקציה שתפעיל את הקריאה האסינכרונית הבאה, וכך עד למכשיר האחרון. כך גם סדר הקריאות סינכרוני, ואין קריאה שתתנגש באחרת, וגם בניית מערך הסטטוסים יהיה מסודר וקריא.
וכך נראה הקוד:
מבחינת הקוד אכן זו רקורסיה קלאסית, עם תנאי עצירה קלאסי. אבל אין פה באמת StackTrace בין הקריאות. (בהינתן רשימת המכשירים אין סופית, הקוד ירוץ לנצח ולא נקבל StackOverflow). בגלל מבנה השפה כל קריאה לפונקציה אסינכרונית משחררת את ה- Stack וה-callback יופעל מלולאת האירועים אחרי שחזרה התשובה, והלוגיקה שוב תפעיל את הקריאה הבאה ותשחרר את ה-Stack וחוזר חלילה.
בשורה התחתונה זאת לא באמת רקורסיה, אבל עדיין מבחינת הקוד שלנו זאת קריאה עצמית מקוננת.
זהו שזה לא באמת רע, והפתרון הזה בהחלט עובד וגם יחסית קריא. אממה אנחנו משתמשים בפתרון מורכב עבור בעיה שהייתה אמורה להיות סופר פשוטה. כל מה שרצינו היה לעבור בלולאה הכי מטופשת בעולם על המכשירים לבקש עבור כל אחד את הסטטוס, לשמור את התוצאה לרשימה ולהמתין כמה שניות עד שממשיכים את הלולאה,
וכך זה היה אמור להראות ב-Pseudo קוד:
for (device of devices) {
currentStatus = readStatus(device)
devicesStatuses.push(currentStatus)
sleep(x)
}
ואיכשהו מצאנו את עצמנו כותבים קוד הרבה יותר מורכב, לא נעים.
למזלנו נוספה תמיכה מובנית ב-Promises, ולא רק אלא גם תחביר של async-await ובעזרתם נראה שממש קל ליצור קוד שגם עומד בדרישות כמו הקוד הרקורסיבי וגם נראה יפהפה בדיוק כמו ה-Pseudo קוד.
ראשית אנחנו צריכים פונקציה אסינכרונית שממתינה x שניות ונראית כך:
/** Simple pattern to sleep by promise */
const sleep = (seconds) => {
return new Promise((resolve, reject) => {
setTimeout(resolve, seconds * 1000)
})
}
(כמובן כמו כל דבר טוב קיימת חבילה כזו ב-NPM)
שנית, את ה-API של הממשק הגנרי לקבלת סטטוס מכשירים צריך לשנות ל-Promise במקום Callback.
(בסופו של דבר גם Promise זו סה"כ עטיפה נוחה למנגנון ה-Callbacks)
ועכשיו הקוד ייראה כך:
מבחינה לוגית לא השתנה כלום, הלוגיקה אותה לוגיקה. אבל עכשיו זה נראה קוד לגיטימי כמו שציפינו. קשה לדמיין שזה בדיוק אבל בדיוק הרקורסיה שעשינו מקודם.
Photo by Sarah Pflug from Burst