Two Way Data Binding is Code Smell

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

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

"...you can set the directionality of it to be 2-Way Data Binding. That actually seems to be a good idea until you have a large scale application and then it turns out you have no idea whats going on... and turns out to be an anti-pattern for large apps."

ואם בארזים נפלה שלהבת, מה יעשו אזובי קיר?

1. הבעייה עם קשירת מידע דו-כיוונית

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

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

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

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

רמת הצימוד הנמוכה ביותר (כלומר הטובה ביותר) היא כאשר שני רכיבים מתקשרים ביניהם באמצעות ממשק ציבורי בלבד.

2. קשירת מידע דו כיוונית בהכרח מובילה לצימוד פתולוגי

קחו שני Controllers שונים הרוצים לשתף מידע ביישום אנגולר. חיפוש פשוט בגוגל יספר לכם שכדי לשתף מידע באופן זה יהיה עליכם להשתמש ב Service חיצוני. התשובה המומלצת ב Stack Overflow אף כוללת קישור לדוגמת קוד פשוטה המיישמת את הרעיון. כך זה נראה:

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

המעבר המומלץ ל API ציבורי באנגולר מתבצע דרך הגדרת Properties במודל, למשל באמצעות הקוד הבא:

app.service('App', function ($rootScope) {
  this._data = {status:'Good'};  			
  
  Object.defineProperty(this, 'status', {
    get: function() {
      return this._data.status;
    },
    set: function(value) {
      this._data.status = value;
    }
  });

וכך זה נראה בקוד חי:

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

בשביל לנתק את הקשר הישיר בין התבנית ל Service, ולעבור דרך ה Controller היינו אולי רוצים להגדיר ב Scope עותק מקומי של הערך הנדרש להצגה (נשים לב שזה עשוי להיות שונה מהערך כפי שנשמר ב Service). אבל זה לא עובד:

עדכון ה Service בחזרה לאחר שינוי כבר לא מבוצע באופן אוטומטי, ועברנו למצב בו הסטטוס נשמר ב-3 מקומות נפרדים (לכל Controller ערך סטטוס משלו, והשלישי נשמר ב Service). שינוי הערך במקום אחד לא מוביל לשינוי במקומות האחרים.

3. הפתרון: עדכון מפורש בכל שינוי

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

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

4. סיכום וקריאת המשך

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

את השימוש ב ng-change מחליפים לעתים ב $watch. יש לזה את היתרון שעדכון הערך ב Scope מכל מקום בפקד יגרום לשינוי הערך ב Service. דרך זו יכולה גם להחליף את מנגנון האירועים (במקום לחכות לאירוע מה Service אנו עוקבים אחר שינויים בערכו). החסרון הוא בהוספת עוד $watch, מה שעלול להצטבר ולגרום להאטה ביישום.

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

צימוד בתכנות מונחה עצמים (מתוך ויקיפדיה):
http://en.wikipedia.org/wiki/Coupling_%28computer_programming%29

שילוב ארכיטקטורת Flux לניהול המידע ביישום עם אנגולר:
https://github.com/christianalfoni/flux-angular

הרצאת מבוא לאנגולר 2 (כולל התיחסות לנושא Two Way Data Binding):
https://www.youtube.com/watch?v=uD6Okha_Yj0#t=1785

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