יום שישי, 20 ביולי 2012

מקביליות עם jQuery (ובכלל)

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

ה"מקושרים" שבכם, אלו שקוראים הרבה חדשות טכנולוגיות, בוודאי שמעו כבר על כמה שינויים שהוכרזו ע"י jQuery לאחרונה:
  • היכולת לארוז רק חלקים מהספרייה (אולי בגלל התחרות מ Zepto.js).
  • הפסקת התמיכה בגרסאות לא-חדשות של Internet Explorer.
  • איחוד מנגנוני הרישום לאירועים: bind, live ו delegate למנגנון ה on (חדשות ישנות יותר).

האמת שמדובר בשינוי שהוצג ב jQuery גרסה 1.5 (עם כמה תוספות ב 1.6 ו 1.7) - ואישית, אני לא זוכר שהוא עשה חצי מהמהומה של השינויים הנ"ל. בכל זאת זהו שינוי משמעותי וחשוב.

השינוי נוגע באזור ה Ajax ומאפשר גמישות שלא הייתה קיימת קודם לכן.

בואו נדבר עליו.


שייך לסדרה מבוא מואץ ל JavaScript ו jQuery




שינוי בפקודת ה Ajax
למי שלא מכיר, jQuery עוטף את ה XMLHttpRequest (או בקיצור XHR) של הדפדפן בצורה נוחה ומאפשר לקרוא בפשטות ajax.$ על מנת לבצע קריאת ajax. יש גם גרסאות מקוצרות בשם get.$ ו post.$.

עד גרסה 1.4 הדרך לבצע קריאת ajax הייתה כזו:
$.get('http://server.com/myurl', {
  success: onSuccess, // a callback function to be triggered on success
  failure: onFailure, // a callback function to be triggered on failure
  always: onAlways // a callback function that is triggered on either failure of success. Like "finally".
});

success, failure ו always הם callbacks שנקראים כאשר הקריאה מסתיימת, הצלחה או כישלון.

מגרסה 1.5 אפשר לכתוב את הקוד בדרך הבאה:
var request = $.get('http://server.com/myurl');
...
request.done(onSuccess);
request.fail(onFailure);
request.always(onAlways);

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

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

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


Making Promises
אפתח בכמה מילים גדולות: מה ש jQuery עשו, הוא לממש Design Pattern (יו... וואהו!) שידוע בשמות הבאים: Deferred Object או Promise או Future. יש אפילו הגדרה פורמלית בשם Promises/A שמגדירה כיצד תבנית זו אמורה להתנהג בג'אווהסקריפט.
לאכזבתם של לא-מעטים, jQuery לא נצמדה ל"הגדרה הפורמלית" ומימשה וריאציה קצת שונה של תבנית העיצוב, מימוש שהושפע כנראה מספריית Dojo שסיפקה יכולת דומה. אם אתם אורתודוקסים ל Promises/A, תוכלו למצוא מימושים "תקניים" בספריות כגון Q.js או Async.js שניתן להשתמש שהם. לרובנו, כל מה ש jQuery יעשו - מהווה את התקן בפועל.

על מנת להבין את המשמעות של הפעולה של ה Promise, נפרק אותה (Deconstruct) למרכיבים יותר בסיסים ונבחן אותם. הביטו על קוד ה JavaScript/jQuery הבא:
var deferred = new $.Deferred();
deferred.done(function(){ console.log('done'); }); 
deferred.fail(function(){ console.log('fail'); }); 
...
deferred.done(function(){ console.log('donedone'); }); 

האובייקט Deferred הוא לב העניין, אם כי אינו קשור ל ajax במאומה. הוא מעין handle-עתידי.
בעת קריאה ל ()deferred.resolve - יופעלו הפונקציות (ניתן לרשום מספר לא מוגבל של פונקציות, לכל אחד מהאירועים) שנרשמו ל done. במקרה זה - כתיבה של done ו donedone ללוג.
בעת קריאה ל ()deferred.reject - יופעלו הפונקציות שנרשמו ל fail. במקרה זה כתיבת fail ללוג.

ההתנהגות העצלה (deferred) נובעת, כפי שאתם בוודאי מבינים, מעצם רישום והפעלת פונקציות בג'אווהסקריפט. האובייקט Deferred הוא פשוט למדי ואינו כולל התנהגויות אסינכרוניות מיוחדות.

ה Promise הוא אובייקט (יחידון = singleton) שחוזר מקריאה ל ()deferred.promise וכל מטרתו היא לאפשר גישה מוגבלת: לאפשר לרשום פונקציות ל done, fail ו always - אבל לא לבצע triggering ל reject או resolve.

Read/Write Pattern - שליטה ב"הרשאות" ע"י הפרדת הפעולות ל interfaces שונים. אפשר להחליף את user ב promise ואת userMaint ב Deferred על מנת לראות את תבנית העיצוב בהקשר לדיון שלנו.

הערה: ל reject או resolve ניתן לקרוא פעם אחת בלבד בחיי אובייקט ה Deferred. מאותו הרגע, כל פונקציית callback שתנסה להירשם - תופעל במקום.

כעת, לאחר שהבנו את ההתנהגות של Promise, בואו נראה אלו בעיות מבנה זה יכול לפתור לנו.


Concurrent Pattern: Monitor
השימוש ב Deferred יכול לסייע לנו לבנות מבנה של concurrent programming בשם בקר (monitor). בקר הוא כלי לטיפול באסינכרוניות, הגבוה ברמת ההפשטה שלו מ mutex או semaphore, אך נמוך מ "synchronized" של ג'אווה / #C. אנו עשויים למצוא אותו דיי שימושי בשפת ג'אווהסקריפט / שימושי ב ajax בהם אסינכרוניות היא נפוצה [ב].

Monitor - מחכה שמספר פעולות אסינכרוניות יגיעו למצב מסוים, ואז יוזם פעולה. סוג של "יישור קו".

נאמר שאני מחכה בג'אוובסקריפט ל2 פעולות שיסתיימו: טעינה של נתונים מהשרת ואנימציית מעבר. רק ש2 אלו יסתיימו אני רוצה להציג dialog עם שאלה למשתמש.
הדרך הנאיבית לעשות זאת היא ש callback א' ישנה ערך גלובאלי כלשהו, ו callback ב' ימתין עד שהערך הגלובאלי השתנה. כאשר callback ב' מצא את הערך לו הוא ציפה - אני יודע ש2 הפעולות הסתיימו.
הבעיה?
הקוד קשה לקריאה: יהיה צורך לעקוב אחרי הלוגיקה של ה callbacks להבין מה הם עושים.
קרוב לוודאי שייוצר קוד מסורבל ומאוד לא נוח לתחזוקה.
הקוד לא יעיל מבחינת ביצועים.

הרבה יותר אלנגנטי, יעיל ונוח יהיה פשוט מאוד לכתוב:

var promiseA = $.get(...);
var deferredAnimation = $.Deferred(); // call  deferredAnimation.resolve() when it is done
var promiseB = deferredAnimation.promise();


$.when(promiseA, promiseB).
done(function(promiseAargs, promiseBargs) {
   ... // what happens when both are done 
});

תענוג!


מודולריות
השימוש העיקרי, והנפוץ יותר ל Promises הוא עבור callbacks פשוטים.
כפי שציינו קודם לכן, הקוד

$.get('http://server.com/myurl', {
  success: onSuccess, // a callback function to be triggered on success
  failure: onFailure, // a callback function to be triggered on failure
  always: onAlways // a callback function that is triggered on either failure of success. Like "finally".
});


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

במערכות שכאלו, מה שסביר שיקרה הוא אחד מהבאים:
  • ה onSuccess callback יפנה לאזורים שונים במערכת ויבצע שינויים. למשל: העלמת שעון חול, הפיכת כפתור ל enabled, הוספת פריט לרשימה. קרוב לוודאי שפעולות אלו ישברו את ההכמסה של המודולים במערכת.
  • אם ישנה מודעות מספקת להכמסה, קרוב לוודאי שכל אובייקט יכלול פונקציית callback שיודעת להגיב לאירוע ולשנות את הערכים המקומיים למודול. אבל... איך מעבירים את ה callback למקום בה נעשית הצורה האסינכרונית? לעיתים זה יהיה פשוט כי הגיוני שתהיה תלות בין המודולים - ולעיתים קרובות נאלץ להעביר את ה callback בין אובייקטים רבים ולהוסיף אולי תלויות על מנת לבצע את הקישור. מי שראה קוד שמעביר מספר callbacks בין אובייקטים, על מנת לטפל באירועים שונים או פשוט callbacks ממקורות שונים, יודע עד כמה הקוד עלול להיות מסורבל ו"ללכלך" את המערכת. התועלת בהכמסה עלולה לפחות - אם המחיר הוא קוד סבוך כל כך! 
פתרון שבמקרים רבים יהיה אלגנטי למדי הוא שימוש ב promise בעת יצירת הקריאה האסינכרונית ותעבירו את ה promise לאובייקטים אחרים. אובייקטים אלו יוכלו להירשם לאירועים שמעניינים אותם, אבל מעבר לזה - להעביר את ה promise הלאה לאובייקטים אחרים על שמודול המקור לא מכיר. מה שנקרא "הכמסה". - כך שאובייקטים אחרים שהם מכירים יירשמו עליו.

יש פה ייתרון במספר האובייקטים המועברים. אם 2 אובייקטים שונים היו רוצים לדעת על הצלחה או כישלון של אירוע שיצרתם - הייתם צריכים להעביר 4 callbacks. במקום זה אתם יכולים להעביר promise אחד בלבד.


סיכום 
Promise הוא לא Silver Bullet, אבל במקרים רבים הוא יכול להיות כלי רב-עצמה שיתיר סיבוכיות רבה מהמערכת. היכולת לנתק את השליטה (קריאות resolve ו reject של Deferred) מצד אחד ואת התגובה לסיום הפעולה (done, fail ו always של ה promise) מצד שני מאפשרות לשמור על מודולריות גבוהה של המערכת במחיר נמוך.

אם אתם מוצאים את תבנית העיצוב של Deferred ו Promise שימושית, יש עוד פונקציות שניתן לחקור:
Deferred ו Promise יכולים לתקשר בניהם גם אודות progress (שלא כיסיתי בפוסט). לפעולת when יש פעולה "אחות" בשם then. חשובה אולי מכולן היא פעולת ה pipe שמתירה לשרשר הצלחות / כישלונות של promise וכך לחסוך קוד.


שיהיה בהצלחה!


---

[א] בגרסה 1.6 שונו שמות הפונקציות - אני משתמש בשמות החדשים. בגרסה 1.8 השמות הישנים (complete, success ו error) יוכרזו כמיושנים (deprecated) ולכן אני נמנע משימוש בהם.

[ב] בג'אווהקריפט בדפדפן יש כמובן thread יחיד ואין מקביליות חישוב. מצד שני יש הרבה פעולות IO אסינכרוניות שקוראות במקביל ויש אתגר תכנותי לשלוט בהן.


4 תגובות:

  1. תמיד תענוג לקרוא פוסט בבלוג שלך! :)
    שבת שלום

    השבמחק
  2. חידשתי לי הרבה, תודה על הפוסט.

    השבמחק
  3. אני שמח לשמוע.
    שבת שלום לשניכם.

    השבמחק
  4. פוסט מעניין ומרענן, ובכלל כן ירבו בלוגים כאלו :)
    קיפ איט אפ!

    השבמחק