Bulkheads – דפוס עיצוב של חוסן (Resiliency Design Pattern)

בספינה, bulkheads הן מחיצות פיזיות בין חלקי גוף-הספינה. כאשר יש דליפה באזור מסוים בגוף הספינה, רק חלל אחד יתמלא בעוד ה bulkheds מונעים מחללים נוספים בספינה להתמלא במים בעקבות הפגיעה.
אם תוכננו בצורה נכונה, הצפה של חלל אחד – לא תטביע את הספינה. הנזק הוא יחסי לגודל החלל שהוצף.
זה לא מצב רצוי, אז זה מצב טוב הרבה יותר – מספינה טובעת.
[ב]

ירידה לעומקו של עניין

רעיון ה bulkhead[א] הוא רעיון עקרוני ליציבות של מערכות, שאותו ניתן לראות בשימוש גם בעולם התוכנה.

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

בניגוד ל redundancy (רעיון דומה), ב bulkhead כל החלקים הם בשימוש. כלומר: אין חלקים יתירים.
ב redundancy התסריט הנפוץ הוא פגיעה ללא הפרעה לשירות, אך ב bulkhead – ישנה פגיעה חלקית במקום מלאה (תמורת חסכון עלויות ה redundancy).

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

הנה שתי דוגמאות מוכרות ליישום של bulkhead שאנו מכירים מהיום-יום:

    • availability zones ב AWS (או המקבילה בעננים אחרים) – כשל של AZ יחיד יפגע בשירות (בטווח הקצר) – אך יאפשר לנו להמשיך את השירות כרגיל ב AZs האחרים.
      • לצורך כך מושקעים ב Amazon מאמצים רבים על מנת לוודא ש AZ אינם תלויים זה בזה, ושכשל באחד ה AZ (הצפה, נפילת מתח, בעיית תוכנה, וכו') – לא יגרור כשל של ה AZ האחרים.
      • כמובן שבתכנון מערכת המשתמשת ב AWS עלינו ליצור יתירות של שירותים חיוניים (למשל: NAT gateway או בסיס-נתונים) על מנת שנוכל להמשיך ולרוץ בזמן ש AZ אחד כשל.
    • תהליכים במערכת ההפעלה – מערכת ההפעלה יוצרת הפרדה בין תהליכים (processes) שונים כך שכשל בתהליך אחד לא ישפיע על תהליכים אחרים: תהליך אחד קורס – והשאר יכולים להמשיך לרוץ ללא הפרעה.
    • למען הדיוק הטכני שווה לציין שההפרדה הזו אינה bullet proof כאשר מדובר בגישה למשאבים משותפים.
      למשל: תהליך שגוזל 100% CPU עלול להיות מתוזמן (לחלופין) על כל ה cores של המכונה ולשתק בפועל את כולה. עלינו להצמיד את התהליך (בעזרת CPU binding / affinity) ל core מסוים – בכדי לקבל הגנה טובה בפני תסריט ה "CPU 100%". עניין דומה קיים לגבי זיכרון, גישה ל I/O, או כל משאב משותף אחר.

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

יישום בסיסי של bulkheads: להפריד את השרתים שלנו לשני clusters שונים (ומבודדים זה-מזה) ולנתב בקשות שונות ל cluster שונה. החלוקה יכולה להיות עבור microservice בודד, קבוצה של microservices, או אולי אפילו כל המערכת.

חלוקה גנרית וחסרת בידול היא לרוב רעיון רע ליישום של רעיון ה bulkhead.
אם נחלק טראפיק לפי סיומת כתובת ה IP (זוגי/אי-זוגי) הרי לא באמת עזרנו למערכת – אקראיות יכולה לגרום שקבוצה אחת תדרוש יותר משאבים, אך לא תקבל אותם: את המשאבים חייבנו להיות "חצי-חצי".

החכמה ביישום bulkhead מוצלח היא חלוקה סלקטיבית ע"פ שני קריטריונים:

  • מאפייני כשל (failure conditions) – כך שתעבורה מסוג I עלולה לכשול בעוד תעבורה מסוג II עשויה לעבוד כרגיל.
  • יתרון עסקי (financial benefit) – כאשר יש חשיבות עסקית מאחורי סוגי התעבורה השונים שעשויה להצדיק מצב בו תעבורה סוג I שורדת בעוד תעבורה סוג II כושלת.
Bulkhead מוצלח עשוי להיות על בסיס שני הקריטריונים, או רק אחד מהם.
הנה כמה דוגמאות ליישום של Bulkhead ברמה האפליקטיבית:
 
הדוגמה הקלאסית היא כנראה הפרדה בין לקוחות משלמים ללקוחות לא-משלמים. 
נניח: אתר שנותן שירות מוגבל בחינם, אך שירות משופר בתשלום (freemium).
שימו לב שהחלוקה היא עסקית.
וריאציה מקובלת: שני clusters:

  • Cluster A – ללקוחות משלמים
  • Cluster B – ללקוחות שאינם משלמים.
אם יש בעיה בפיצ'ר של לקוחות לא-משלמים שגורם לבעיה – לקוחות משלמים יכולים (ובצדק!) להמשיך ליהנות משירות תקין.
אפשר לשים יותר חומרה ומשאבים, קונפיגרציות יותר אמינות (גם אם עולות יותר) – ב cluster של הלקוחות המשלמים.החולשה של המודל היא במאפייני הכשל: דווקא הלקוחות המשלמים מקבלים כנראה יותר יכולות, ולכן יש סבירות גבוהה יותר שדווקא הטראפיק שלהם ייתקל בבאג כלשהו – שלא יקרה ללקוחות ה"חינמיים".
קצת פדיחה אם Cluster A נפל – בעוד cluster B עובד כרגיל…

תת וריאציה היא ש Cluster B יקבל תעבורה של שני סוגי הלקוחות: משלמים ולא-משלמים.
במקרה של תקלה – אפשר לדחות לקוחות לא-משלמים כליל מהמערכת. אם יש משהו שיציל את התעבורה של לקוחות משלמים (נניח: עוד חומרה) – אדרבא!
אם יש כשל שנובע מ"פיצ'ר חינמי" (נניח: פרסומות) – יש הגיון עסקי רב לבודד את הכשל מלקוחות משלמים.
הוריאציה הזו הגיונית ככל ש Cluster B גדול מ Cluster A (נניח: פי כמה מונים).

דוגמה: הפרדה בין לקוחות ישירים ללקוחות דרך שותפים עסקיים — בהנחה שה flow של שותפים עסקיים הוא שונה (למשל: חישוב מחיר שונה, מעקב שונה, ועדכון השותף העסקי בזמן אמת – וכו').

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

בדוגמה הזו יש ל bulkheads פוטנציאל גדול יותר להשיג שיפור ממשי מהדוגמה הקודמת.

דוגמה: הפרדה לפי שווקים

למשל:

  • Cluster ללקוחות ארה"ב
  • Cluster ללקוחות מערב אירופה
  • Cluster ללקוחות מזרח-אירופה
  • Cluster ללקוחות אנגליה
בהנחה שעבור כל מדינה יש חלקי קוד ייחודים המתאימים לרגולציה ו/או settings מעט שונים שהם מקובלים יותר (בגלל הבדלים בין השירות במדינות) – העלולים לגרום לתנאי כשל שונים.
ייתכן ויש בעצם 20 מדינות בהן עובדים, כאשר לכל מדינה יש תצורת עבודה מעט שונה. אבל – מאוד יקר לנהל 20 clusters, וגם אחוז המשאבים המבוזבז (כי לא משתפים אותם) – יגדל ויתעצם.
ניתוח של תנאי הכשל (אלו מדינות משתמשות בפיצ'רים שונים –> חשיפה לתנאי כשל פוטנציאלים שונים) והמשמעות העסקית מובילה אותנו לחלוקה ל-4 clusters.
במידה וכל השווקים (לאחר ה clustering) הם בעלי חשיבות עסקית דומה, הפוטנציאל של bulkheads המתואר זה תלוי בעצם בתנאי-כשל שונים משמעותית בין ה clusters. ככל שתנאי הכשל שונים בין ה clusters – כך ההצדקה להצבת bulkheads הולכת ועולה.

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

דוגמה אחרונה: מנגנון חדש מול מנגנון ישן ("canary release")
כאשר יש שכתוב של חלקים משמעותיים של המערכת, ובמיוחד כאשר המערכות הללו תלויות גם בשירותי צד-שלישי חדשים (= תנאי כשל נוספים) – ייתכן ויש הצדקה להפריד את התעבורה שעוברת במנגנון החדש והישן לזמן מסוים.
היום, בעידן הענן, לא קשה לעשות הפרדה כזו – אפילו אם היא תחיה, נאמר, לחודשים בודדים.
ה bulkheads יאפשרו שכשל מתגלגל במנגנון החדש, לא יפגע במאסה של הביזנס — שפועל עדיין על המנגנון הישן.

אמנם כל הדוגמאות שנתתי הן ברמת ה cluster האפליקטיבי, אבל הרעיון של Bulkhead הוא כללי ויכול להיות מיושם ברמות שונות. למשל: ברמת ה thread pool או רמת הסכמה בבסיס הנתונים.

אזהרת Patterns!!! (גנרית)

Bulkheads הוא סוג של דפוס עיצוב (Design Pattern) – ודפוסי עיצוב הם דבר "מדליק" המושכים אותנו ליישם אותם.
כני אדם, אנחנו נוטים לנגן שוב בראשנו את הסיפור כיצד השימוש ב Pattern "הציל את המצב" ומלבישים את הסיפור ההוא (שקרה במקום אחר, ואנחנו לא באמת מודעים לפרטים עד הסוף) עלינו, על המערכת שלנו, ועל הארגון שלנו.

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

מהנדסי תוכנה, נוטים סטטיסטית לאימוץ מופרז ולא מוצדק (Overuse) של דפוסי עיצוב.
לכן: ההמלצה היא לאמת דפוס-עיצוב רק לאחר שהוכחה הבעיה, ולא כהכנה מראש — (תנו לי, אני ״אוהב״ את הביטוי הזה:) כ ״הכנה למזגן״. (ביטוי מטעה מיסודו, ולכן בזוי בעיני).

אני רק מקווה שפוסט זה יצליח לייצר יותר תועלת (פתרון בעיות אמיתיות) מנזק (over-engineering).
זו דילמה שיש לי לפני כל פרסום פוסט שעוסק ב"דפוס-עיצוב".

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

[א] בעולם הספנות bulkheads נקראים גם partitions. המונח "partitions" בעולם התוכנה הוא מאוד מוכר ומתייחס בעולם לרעיון מעט אחר, ולכן בהקשר לתוכנה משתמשים רק במונח bulkheads על מנת לתאר … bulkheads.

[ב] התרשים הזה הוא חלק מהסבר כיצד למדו בעולם הספנות לבנות bulkheads נכונים יותר: על bulkheads שבקצוות האוניה (בעיקר בחרטום) עלול להיות מופעל לחץ גדול יותר ברגע שהוא דולף, ואז הספינה נוטה ומתחילה לשקוע – ועל כן חשוב לבנות אותם חזקים יותר, משאר ה bulkheads בספינה.
בהשלכה אלינו: bulkheads יש לבנות בחכמה: הם לא פתרון קסם לכל מצב.

מודל אנמי (Anemic Domain Model) – דפוס עיצוב שלילי

מודל אנמי (Anemic Domain Model, או בקיצור ADM) הוא תיאור שמתייחס למודל "מוחלש" בצורה שאתאר מיד.הצורה הנפוצה והפשוטה של מודל אנמי היא מצב בו קיים פער או מרחק בין ה state – הנשמר במחלקה אחת, לפעולות – הנשמרות במחלקה אחרת.

  • במבט ראשון המחלקות נראות הגיוניות: יש להן גודל הגיוני, תחום התעסקות הגיוני, מתודות הגיוניות וכו'.
  • במבט שני אנו שמים לב שבעצם רוב הפעולות של ה state מתבצעות במקום "מרוחק" – כל פעילות על Order מתבצעת ב OrderManager.

באופן טיפוסי המחלקה בעלת המתודות תיקרא בשמות כגון xxxManager, xxxService, xxxHandler או xxxUtils – שמות המעידים על קשר ברור, אך אחריות כללית שקשה להגדירה.

כאשר אנחנו מזהים מודל אנמי, הדבר הראשון שמציק לנו הוא ש "זוהי איננה גישת Object Oriented: הרי מחלקות צריכות להכיל גם את ה state וגם את הפעולות על ה state – באותו המקום".

בצורה יותר פורמאלית ניתן לומר שיש לנו שתי מחלקות בעלות high cohesion (קרבה רעיונית גדולה) אבל גם high coupling (תלות חזקה). מדוע אם כן אלו שתי מחלקות, ולא מחלקה אחת?!

מה הבעיה לחבר ביניהן?

ובכן – לכו ובדקו.

בד"כ זה לא עניין של קובץ גדול מדי (כמה גדול יכול להיות ה state?!). סיכויים טובים שתגלו ש:

  • אובייקט ה Order הוא Data Access Object (מכיל ידע על שמירה לבסיס הנתונים) או Data Transfer Object (מכיר ידע על serialization בכדי לעבור על גבי הרשת) – קוד שמרגיש לא נכון לערבב אותו עם "Business Logic", או
  • יש יותר ממחלקה אחת המבצעת פעולות על Order. למשל:
דפוס שכדאי לשים לב אליו הוא מתודות ב Service/Manager/Handler המקבלות את האובייקט כולו לצורך פעולות של שינוי state. משהו בנוסח:
updateItemPrices(Order o) // updates the order with up-to-date item prices

מתודה כאלו נקראות external methods.

מקור

"אחי, מה כואב לך?"

מה אכפת לנו, בעצם?

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

אז מה זה משנה שיש לנו Anemic Domain Model (בקיצור: ADM)?

נתחיל בדוגמה ממקום קצת אחר.

מכירים את המצב בו יש לנו מערכת חיצונית בה משתמשות מספר מחלקות במערכת, מערכת לשליחת מיילים – למשל?

האם נרשה לכל מחלקה במערכת לגשת אליה ישירות?

מה פתאום!!!

בסיטואציה כזו, הקונצנזוס הוא גורף ומיידי!

  • "ומה אם נרצה להחליף את מערכת צד השלישי לספק אחר?"
  • "ומה אם נרצה להוסיף פונקציונליות (למשל throttling או logging) גורפת לכל העולות מול אותו ספק?"

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

מדוע אם כן אנו שלווים, כאשר יש מספר מחלקות הניגשות ל state במערכת, שלא ע"י נקודת גישה אחת?

  • מה יקרה אם נרצה לשנות את הייצוג של ה state של Order / לבצע refactoring?
  • מה יקרה אם נרצה להוסיף פונקציונליות (אימות נתונים, או Logging) גורפת על ה state הזה?
הסיטואציות הן שקולות.
יתרה מכך: שינוי state באובייקט ליבה של הדומיין הוא נפוץ יותר מהחלפה של ספק חיצוני.
אני מניח שהסיבה שאיננו מוכנים לקבל את הסיטואציה בתצורה אחת, בעוד אנחנו מוכנים לקבל את אותה הסיטואציה (וחמורה יותר) בתוצרה שונה – היא פשוט עוד הטיה אנושית, לא רציונלית.
בטבע פותרים אנמיה בעזרת תזונה טובה יותר.
בתוכנה פותרים אנמיה בעזרת הכמסה ו/או Rich Domain Model.

מה הבעיה עם ADM? – ניתוח פורמאלי

הבעיה העיקרית של ADM היא המחסור בהכמסה (Encapsulation).

האובייקט Order לא יכול "לשלוט" במה שקורה ל properties שלו – כי "כל העולם נוגע ב properties בלי לשאול אותו". כלומר: אין מתודות שליטה בגישה ל properties – בהן אפשר לבצע validations או לקבוע התנהגויות.

כתוצאה מכך:

  • יווצר קוד כפול שבודק את תקינות ה properties של Order – באופן אקראי במחלקות A, B, C.
  • קוד הבדיקה, ותפעול (שינוי הערכים) של Order properties, במחלקות A, B, C – לא יהיה עקבי, וייתכן שיכיל חוסר-התאמות ואף סתירות.
  • מכאן יווצרו באגים, שיאטו את הפיתוח בשני אופנים:
    • הצורך לתקן את הבאגים – שקשה למנוע את היווצרותם.
    • הפחד מלבצע שינויים במערכת (המחשבה ש״שינוי״ יגרום ל״באג״) -> הקוד פחות מתחדש, פחות מתעדכן לצרכים העדכניים מהמערכת -> יותר עבודה להכניס שינויים.
  • הבעיה תלך ותחמיר ככל שהמערכת תגדל: יותר קוד (A עד F שעובדים עם Order, במקום A עד C), ויותר מפתחים שעובדים על ה codebase.
    • הבאגים שיווצרו יהיו גם הם – קשים יותר לאיתור.
  • עם הזמן הערכת הזמנים לשינויים בקוד הופכת להיות פחות אמינה: שיניתי ב A את הדברים, אבל רק לקראת סיום גיליתי שב C יש מנגנון שלם מסביב ל state שנגעתי בו – וצריך להמשיך בפיתוח בלתי-מתוכנן.
  • זו לא בעיה מסוג הבעיות ש״מתפוצצות״, אלא מסוג הבעיות שהן רגרגסיות הדרגתיות שלא מבחינים בהן בזמן שהן מתרחשות.
אני לא יודע כיצד להדגיש מספיק עד כמה הבעיה הזו הרסנית למערכת, במיוחד אם מדובר בקוד בליבת המערכת (core domain model), ובמיוחד כאשר מדובר במערכת בעלת חוקים עסקיים מורכבים.

מצב לא תקין שחלחל. גורם לקושי לאתר את מקור התקלה – ולצורך בתיקונים מורכבים יותר. הסתכלו על ה flow הבא:

כאשר אנחנו "מגלים" שיש לנו מצב לא תקין בשלב 4 – נעשו כבר נזקים, ויותר קשה לנו לאתר את שלב 1 – בו באמת קרתה התקלה.

הנטיה הטבעית, קצרת הטווח, היא להוסיף הגנות נוספות ב Class W, ולאחר שגילינו שדברים הסתבכו – עוד בדיקות ב Class Y. התוצאה: כפילות קוד, חוסר קונסיסטניות, ואי פתרון שורשי של הבעיה!

תופעת לוואי נוספות הן בדיקות פחות יעילות: מכיוון שקוד אימות ה state נמצא ״עמוק״ ב branching של הקוד – סביר יותר שהבדיקות יריצו תסריטים בעייתים מבלי שהמצב יתגלה:

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

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

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

אני רוצה לחדד פעם נוספת את הבעיה שביכולת לבצע שינויים ב state ממקומות שונים, ומדוע בעיות של data integrity  (להלן ״שלמות נתונים״) גורמות להאצה וסיבוך של באגים בקוד.

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

הנה דוגמה:

יש המון וריאציות של משולשים תקינים, אך לא כולם כאלו.

אם לדוגמה נשנה את ערכי אחת הזוויות כך שסך הזוויות יהיה 190 מעלות – חישוב השטח שלנו לא יהיה נכון.
האם יעוף exception באותו הרגע? לא.
יותר סביר שהלקוח יבוא אחרי חודש וישאל מדוע חייבנו אותו על שטח כזה וכזה…
חקירה כזו למציאת הבאג – תהיה ארוכה ומורכבת, וייתכן שהבאג כבר פגע בעוד אלפי לקוחות שצריך לתקן להם את הנתונים.
זו לא דוגמה ליעילות תפעולית.
זו גם דוגמה לבעיה שאפשר להניף בה הרבה אצבעות מאשימות, ולפספס בכלל שמדובר בבעיה ארכיטקטונית (הרי מישהו כתב קוד במחלקה C. למה הוא לא בדק מה מתרחש במחלקה B?!).

אפשר לטעון (בכמה מישורים) שהקוד שהצגתי הוא לא אופטימלי – כי הוא מאפשר ל state להיכנס למצב בלתי-תקין בקלות. למשל: אין בעיה להוסיף זווית רביעית למשולש.

הנה שיפור, עם יותר constraints, ויותר ערכים מחושבים (derived values):

כאן כבר לא נוכל לקבוע משלוש עם סך זוויות פנימיות של 190 מעלות, או ארבע זוויות פנימיות. זהו שיפור.

אבל עדיין נוכל לקבוע:

  • זווית של 0 מעלות
  • זווית של 200 מעלות, ולגרור מכך זוויות עם ערכים שליליים של זוויות
  • 3 זוויות שמסתכמות ל 180 מעלות, ושלוש צלעות סבירות לכאורה – שהן עדיין לא משולש הגיוני.
שימוש ב derived values הוא כלי רב-עוצמה לאימות נתונים פשוט ויעיל, אבל לא תמיד פשוט ו/או נכון לגזור נתון מנתונים אחרים – זה יכול לסבך את הקוד יתר על המידה.
המגבלה שאיני יכול לקבוע את הזווית השלישית בצורה ישירה – יכולה בקלות לסרבל את הקוד שמתשמש ב Triangle.
המשולש הוא מטאפורה, לאובייקט עסקי מורכב.
ואובייקט עסקי מורכב הוא כזה שקל לייצר ב state שלו מצבים בלתי-נכונים.
מכיוון שאין כוונת זדון, לא סביר באמת שמישהו יקבע זווית פנימית של 3600000-.
מצד שני יש אינספור דוגמאות, שקשה מאוד לצפות, למצבים לא תקינים, אך אפשריים.
הנה דוגמה לאובייקט משולש עם אימות נתונים קפדני יותר:

הקוד לא נבדק. זהו pseudo code שנכתב בזריזות
עדכון: תודה לעידו שהעיר שהקוד הזה לא ישים. יש לאפשר מתודה set המקבלת את שלושת הזווית / צלעות – כי כמעט כל שינוי במשולש כך שיישר תקין מצריך לפחות זוג של שינויים. אני מניח שעדיין אפשר להבין את העיקרון.
הערה נוספת: שווה להוסיף בדיקה ל value גדול מ 0.

אימות הנתונים הקפדני שווה רק כל עוד יש הכמסה על ה state.

אם מסירים את ה private מהשדות הפנימיים – כל האימות הופך לפרוץ עד חסר-משמעות.

מודל אנמי – שלב 2

עוד בעיה שמתרחשת עם מודל אנמי היא טרנזיטיביות של התלויות.

נניח שיש לנו מודל מורכב יותר (composite) של הזמנה – ופריט בהזמנה (להלן LineItem). למשל: ההזמנה היא הזמנה מהסופר, וה LineItems הם קורנפלקס, חמאת בוטנים, ולחם אחיד.

האם נכון שה Service Classes (או אובייקטים אחרים) המכירים את Order, יכירו גם את LineItem ואם כן – באיזו מידה?

הערה חשובה: מדובר על מצב בו בין Order ו LineItem יש קשר של composition (ב UML: מעוין שחור / "כתף מלאה"), כלומר: לאובייקט LineItem אין קיום או משמעות ללא אובייקט Order.

אם מחלקת שירות יכולה לשנות ערכים מ LineItem מבלי שה Order יידע או יוכל להגיב – מדובר בהפרת ההכמסה.
אם, גרוע יותר, מחלקת שירות יכולה לכתוב ולקרוא LineItem ישירות מה DB מבלי שה Order יהיה מעורב – מדובר בהפרת הכמסה.

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

הסכנה אם כן היא שמחלקת שירות תבצע שינויים על LineItem כלשהו, ותיצור:

  • סתירה / חוסר-התאמה עם תכונה של אובייקט ה Order. 
    • למשל אובייקט ה Order צריך לדעת אם צריך משלוח עם קירור, אבל הוספה של קוטג' למשלוח תשנה את המצב מבלי שהוא יהיה מודע לו.
  • סתירה / חוסר-התאמה עם ה state של LineItem אחר.
    • למשל: יש כלל שאסור להכניס לאותה הזמנה בשר וחלב. אם יש פריטים מסוג "חלבי" אין לאפשר הוספהש של פריטים מסוג "בשרי" להזמנה (ולהיפך).
הערה: אם אף אחד מהתנאים הללו לא מתרחש, ולא הגיוני שיתרחש עם התפתחות המערכת – אז כנראה שאין בעיה לאפשר למחלקות אחרות גישה לאובייקט הכפוף (LineItem) ללא מתן שליטה לאובייקט המרכז (Order).
אם כן אפשרנו גישה ללא-הכמסה, התוצאות הן שוב:
  • שכפול לוגיקת אימות-נתונים בין הצרכנים של Order.
  • ריבוי תלויות במערכת, הגברת ה fragility (הסבירות ליצירת באגים תוך כדי שינויי קוד – עולה) ול rigidity (שינוי אחד במערכת, מאלץ אותנו לשינויים לא-צפויים בחלקים אחרים של המערכת).
  • באגים קשים לאיתור, ובדיקות פחות יעילות.
שום "פיצוץ" – רק דעיכה מתמשכת.
אני מזכיר את זה בשל קיומם של מנהלים שרגישים לשבירות ("באג בפרודקשיין! חמור מאוד!"), אבל אטומים בפני הסדקים שגורמים לשבירות הללו ("קוד לא יפה? קוד שלכם, באחריותכם. לא מעניין אותי.").

מה עושים?
בד"כ בעזרת עבודה עם Rich Domain Model (להן RDM).
אובייקט שהוא Composite (נקרא גם Aggregate) הוא בעל האחריות לגישה לאובייקטים הכפופים שלו.

פתרון מקובל הוא לחשוף interface המכיל גישה read-only (ומגובלת מעבר לכך ע"פ הצורך) לאובייקט הבן.
אובייקט ה Order יחזיר LineItemInfo ע"פ הצורך במתודות שלו.
בכדי לבצע שינוי ב LineItem, יש לקרוא ל Order, עם אובייקט ה LineItemInfo כפרמטר: "זה? תן לו 2 בכמות", או לסירוגין "כל האובייקטים שנוצרו אתמול – בטל אותם".

אופי האינטרקציה הזה מאפשר ל Order לוודא ש "2 בכמות" הוא מצב תקין, שלא מפר אף כלל עסקי.

כלל אצבע שימושי הוא שאסור לאפשר למחלקת השירות – לגשת ל id (מזהה ייחודי) של אובייקט ה LineItem.

ברגע שיש אחיזה ב id – מתחילה ההידרדרות הפוטנציאלית לכיוון שבירת ההכמסה: בהתחלה שמירת ids אצל מחלקת השירות, שעכשיו מסוגלת לזהות את המופעים השונים. בהמשך, אח"כ אולי גישה אליהם דרך flow אחר, או (חס וחלילה) ישירות ב DB.

אם מחלקת השירות לא יכולה להגיע ל Id – אז היא לא יכולה להתדרדר לעוולות הללו.

הבחנה: הכמסה לפעמים נראית כאילו יש "אויב" שיש להתגונן מפניו.
זה נכון.

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

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

קונטרה

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

Rich Domain Model הוא Overhead מיותר. כתיבה של קוד מיותר.

זה נכון: Anemic Model היא גישה חסכונית יותר בכתיבת קוד. אפשר לפתח איתה מהר יותר – עד גודל מסוים של קוד. גישה זו נקראת גם transaction script.

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

גם ניהול ה state במשתנים גלובאליים יקצר את זמני הפיתוח.
לחלוקת המשתנים ל scopes, יש תקורה – אך גם תועלת.

אם הקדמתם סטארט-אפ, ולאחר שנה אתם מסתכלים על הקוד והוא נראה טוב – כנראה שעשיתם over-engineering.
בכל זאת, בשלב מסוים – חשוב לעשות את המעבר ולכתוב קוד robust יותר.

אם המערכת היא מערכת CRUD, שעיקר עיסוקה הוא לשלוף נתונים, לקבץ אותם ולהעביר לצד הלקוח – יש מצב ש RDM אינו מצדיק את התקורה הנלווית.

בואו נעשה RDM בצורה עצלה: נתחיל לכתוב קוד כ ADM, וע"פ הצורך – נעבור ל RDM.

לכאורה זהו טיעון הגיוני מאוד. מאוד אג'ייל.
התשובה שלי היא כזו:

  • ככל שהאובייקט גדל, עלות ההמרה ADM->RDM תגדל גם כן. זה יכול להגיע לשעות, ואולי אפילו ימים.
  • עלות שכזו, באופן מעשי, היא חסם ממשי לביצוע המעבר. למשל:
    • אני עובד על פיצ'ר ואז מבין שהוספתי שדה שיכול להיות חלק מ state לא תקין.
    • מה יותר טבעי? להעלים עין ולתת למפתח הבא לבצע מעבר ל RDM, או להשקיע עוד 3 שעות בלתי צפויות לקראת סיום כתיבת הפיצ'ר?
  • בקיצור: לאנשים מאוד ממושמעים, זה יכול לעבוד. כישראלים – עדיף להשקיע את עלות ה RDM בעוד האובייקט קטן, כל עוד זוהי "מדרגה" קטנה ולא כ"כ מורגשת. נוסיף בכך עלות קטנה לפיתוח – אך עלות שתשתלם ככל שהמערכת תגדל ותסתבך.
העיקרון הפתוח סגור (OCP) מחזק את גישת ה ADM. נראה לי ש ADM הוא יותר SOLID מ RDM, לא?
למשל: למה לחזור ולשנות את Order שוב ושוב ולהסתכן בשבירת קוד? יותר אמין לכתוב קוד חדש ב Class A, ולאחר מכן ב Class B, וכו' – וכך להיות "open for extension, but closed for modification"!על זה אני עונה:

  • ה OCP הוא עקרון שנוי-במחלוקת. הוא מבוסס על אמיתה נכונה, אך יש בו חור גדול: חסרה בו הנחיה מתי יישום OCP הוא טוב, ומתי הוא גורם לנזק.
    • כן – הוא בהחלט עלול לגרום לנזק.
  • core domain model הוא בדיוק המקום שאתם רוצים לשנות קוד שוב ושוב, תחת הסיכון ליצירת רגרסיה בהתנהגות.
    • אחרת מה? הקוד שלכם יתפזר לכל עבר?
    • איך באמת נראה לכם שזה יהיה יותר SOLID?!
ADM הוא בעצם תכנות פונקציונלי (Functional Programming). תכנות פונקציונלי הוא בטוח דבר טוב, והפרדה בין נתונים לפעולה – היא בעצם Best Practice.
  • יש פיל שעיר, מדיף ריחות עזים, ורועש במרכז החדר – שמשנה את התמונה מקצה לקצה. בואו לא נתעלם ממנו.
    אם לא הבחנתם בה – זוהי תכונת ה Immutability.
  • בתכנות פונקציונלי ה Service Classes לא מסוגלים להגיע ל state לא תקין, כי הם לא יכולים לשנות state של אובייקט שהוא immutable.
  • בנוסף, גם בתכנות פונקציונלי טוב שאובייקט המתאר state יבצע בעצמו את כל האימותים. מקום אחד + אחריות מלאה.
  • להזכיר: כל רעיון טוב ניתן לממש בצורה שגויה. Functional Programming – הוא לא יוצא דופן.

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

סיכום

Anemic Domain Model, או בקיצור ADM, הוא דפוס שלילי (Anti-Pattern) של מבנה שעלול לגרום לנזק מתמשך והרסני למערכת שלכם, בצד הארכיטקטורה.

כמו כל כלל, יש גם לו יוצאים מן הכלל. כשהמערכת בחיתוליה ו/או כאשר יש אפליקציית CRUD [א] – RDM עשויה ליצור עלות נוספת ולא-משתלמת.

בסה"כ, אם אתם בעולם של Business Software, תוכנה המבטאת ומיישמת כללים עסקיים מורכבים – Rich Domain Model הוא כנראה הבסיס לכתיבת קוד יעיל לצמיחה ולתחזוקה.  No way around it.

נ.ב – ברשת ניתן למצוא רפרנסים ל RDM שגם אחראים על גישה לבסיס הנתונים / Persistence.

זה לא באמת RDM, אלא דפוס עיצוב אחר בשם Active Record. יש לו יתרונות, וחסרונות – אבל זה כבר דיון אחר.

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

לינקים רלוונטיים

כמובן שגם ב Bliki יש רשומה על ADM. איך לא?

Anemic vs. Rich Domain Objects—Finding the Balance
כתבה שביסודה שגיאהThe Anaemic Domain Model is no Anti-Pattern, it’s a SOLID design, אך שגיאה נפוצה.

[א] למי שלא מכיר crud = "קקי" באמריקאית. אני משוכנע שראשי התיבות לא יצא סדר האותיות הזה במקרה.

מיקרו-שירותים: API Facade

בפוסט זה אני רוצה לדבר על דפוס עיצוב נפוץ למדי בעולם ה micro-services – ה API Gateway.
בדפוס העיצוב הזה נתקלתי מכמה מקורות, כאשר כולם מפנים בסוף לבלוג של נטפליקס – משם נראה שדפוס העיצוב התפרסם.

מקור: microservices.io

לפני שנתחיל – אני רוצה להצביע על אלמנט מבלבל במינוח של תבנית העיצוב:

Facade הוא הוא אובייקט המספק תמונה (View) פשוטה של רכיבים פנימיים מורכבים (״complex internals״ – מתוך ההגדרה של GoF) – למשל כמו ב Layered Architecture.
Gateway הוא אובייקט שמספק הכמסה לגישה למערכות או משאבים חיצוניים – למשל כמו רכיב הרשת Proxy שנקרא לפעמים גם Gateway.
Proxy הוא אובייקט שמספק אותו ממשק כמו אובייקט אחר, אך מספק ערך מוסף בגישה לאובייקט (למשל Laziness או Caching).

יש משהו לא מדויק ומבלבל בשם ״API Gateway״. כפי שנראה, דפוס העיצוב הוא בעצם שילוב של שלושת הדפוסים הנ״ל.

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

להזכיר: דפוס עיצוב, ככלל, הוא מקור השראה יותר משהוא \"אמת מוחלטת\" שכדאי להיצמד אליה בלהבלהבלה…..

דפוס העיצוב

API Facade החל בשל צורך של נטפליקס לארגון ה APIs שלהם: המערכת המונוליטית הפכה לעוד ועוד שירותים – עוד שירותים שעל ה clients היה להכיר. הצורך בהיכרות עם כל השירותים יצר dependency ב deployment שהפך את משימת ה deployment לקשה יותר. למשל: פיצול שירות לשני שירותים חדשים דרש עדכון של כל ה clients ו deploy של גרסה חדשה – גרסה שמודעת לכך ש API X עכשיו שייך לשירות B ולא לשירות A (מה אכפת ל Clients בכלל מהחלוקה הפנימית לשירותים?!).

ה API Facade הוא רכיב שיושב בין ה Client לשירותים, ומסתיר את פרטיהם הלא מעניינים. הוא מציג ל Client סדרת API פשוטים, כאשר מאחורי כל אחד מהם יש flow = סדרת קריאות ל APIs של שירותים שונים.

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

עוד בעיה שצצה היא עניין ה Latency:
כאשר ה Client ביצע יותר ויותר קריאות (כי יש יותר ויותר שירותים) עלויות ה latency של הרשת גברו:

מקור: הבלוג של נטפליקס

Latency לאמזון יכול בקלות להגיע ל 100-200ms. במקום לשלם על latency של קריאה אחת – שילמו על latency של הרבה קריאות. חלק מהקריאות הן טוריות (4 בתרשים לעיל) – ואז ה Latency האפקטיבי הוא פי 4 מ latency של קריאה יחידה.

בעזרת הצבת API Facade שמקבל קריאה יחידה, ואז מבצע סדרה של קריאות בתוך הרשת הפנימית (שהן זולות בהרבה: פחות מ 10ms באמזון, ופחות מ 1ms ב Data Center רגיל) – ניתן לקצר באמת את ה Latency של הרשת שעל ה Client \"לשלם\":

מקור: הבלוג של נטפליקס

חשוב לממש את ה API Facade כך, שהוא לא יגרע מהמקביליות שהייתה קיימת קודם לכן ב Client – אחרת הוא יכול לגרום ליותר נזק מתועלת. לצורך כך ממשים את ה API Facade בעזרת שפה/כלים שקל לבצע בהם מקביליות וסנכרון שיהיו גם קלים לתחזוקה, וגם יעילים מבחינת ביצועים. נטפליקס בחרה למשימה ב Closure (תכנות פונקציונלי) או Groovy עם מודל ראקטיבי (RX observables). אחרים בחרו בשפת Go או סתם #C או ג\'אווה.

תפקיד זה, של ניהול קישוריות בצורה יעילה, היא תפקיד קלאסי של Gateway.
וריאציה אחרת של תפקיד Gateway היא כאשר ה API Facade יודע לתרגם את פרוטוקול ה Client האחיד (נאמר HTTP) לפרוטוקולים שונים של השירותים השונים (נאמר SOAP, ODATA, ו RPC) – וכך לסייע בקישוריות.

עניין אחרון שבו ה API Facade מסייע הוא עניין של העשרת התקשורת. למשל: אתם רוצים שכל ה clients יבצעו SSO (קיצור של Single Sign-On, דוגמת SAML 2.0) או Audit על הגישה לשירותים שלכם. מה יותר נוח מלעשות זאת במקום אחד מרכזי (מאשר בכל אחד מהשירותים, ואז להתמודד עם פערי-גרסאות)?

כנקודת גישה מרכזית, ה API Facade יכול לרכז פעולות אלו ואחרות. זהו תפקיד קלאסי של Proxy.

סכנה!

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

עצה חוזרת ונשנה היא להשאיר את ה API Facade רזה ומינימלי ככל האפשר – כדי שלא יסתבך.
מדוע?
משום שה API Facade הוא ריכוז של תלויות:

  • הוא תלוי בכל ה Services אותם הוא מסתיר
  • הוא תלוי בכל ה Clients אותם הוא משרת

את כל התלויות מהן ניסינו להיפטר – העמסנו עליו, מה שאומר שהוא הופך להיות ל \"Single Point of Deployment\". הרבה שינויים במערכת (שינוי API של שירות, או שינוי API של Client) – יחייבו לעשות גם לו Deploy.
אם עושים למישהו Deploy תכוף מאוד, כדאי מאוד שהוא יהיה פשוט, אמין, ולא \"ייתפס באמצע פיתוח\" שיעכב את ה deploy. מצד כזה בדיוק יכול להיות לרכיב שמטפל גם בתרגום פרוטוקולים (Gateway), וגם בסיפוק שירותי אבטחה (Proxy).

לכן, ה Best Practice הוא לבצע extraction של כל לוגיקה אפשרית מה API Facade לשירותי-עזר שיספקו שירותים שכאלה (תרגום, SSO, וכו\'), בעוד ה API Facade הוא רק נקודת החיבור של שירותי העזר האלו ל API של ה Client.
התוצאה: ה API Facade נשאר \"easily deployable\" – הוא מתמחה רק בהחזקת תלויות רבות ובקריאה לרצפים (flows) של APIs של שירותים עסקיים, בעוד שירותי העזר שלו הם בעלי הלוגיקה, ולהם ניתן לבצע עדכון / deploy – רק ע\"פ הצורך ובקצב שנוח לעשות כן.

כך בערך זה נראה:

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

נכון! ע\"י כך שתקראו לו \"API Facade\", ולא \"API Gateway\" או \"API Proxy\".
מכיוון ש \"Facade\", בהגדרה, הוא \"שלד ועצמות\", רזה ומינימליסטי, בעוד \"Gateway\" או \"Proxy\" – הם לא.

הנה עוד המלצה לאופן שיצמצם את התלויות שה-API Facade נושא עליו:
לבצע \"partitioning\" של ה API Facade לכך שיהיו כמה API Facades במערכת – אחד לכל Client.
נטפליקס גילו שהקמת צוות ייעודי לתחזוקת ה API Facade יצרה צוואר-בקבוק וחוסר תקשורת מול צוותי ה client – הלקוח העיקרי של התוצר. הם העבירו את האחריות לקוד ה API Facade לצוות ה Client – כאשר יש API Facade לכל Client שקיים. התוצאות היו חיוביות – וגם חברות אחרות אימצו גישה זו.
מאוחר יותר, נטפליקס איחדו את התשתית של כל ה API Facades לתשתית אחידה – עבור יעילות ואחידות גבוהות יותר. עדיין כל צוות אחראי להגדרת ה flows שלו (flow = קריאה נכנסת מה Client, שמתתרגמת לסדרת קריאות מול השירותים השונים).

סיכום

בתבנית ה API Gateway Facade נתקלתי כבר מספר פעמים. בקרוב, אנחנו ננסה גם ליישם אותה אצלנו ב GetTaxi.
התבנית נראית כמו חלק טבעי במחזור החיים של ארכיטקטורה: ארכיטקטורת Micro-Services הופכת לפופולרית, ואיתה צצות כמה בעיות חדשות. מה עושים? מתארים Best Practices, או דפוסים – שמציעים פתרונות לבעיות הללו, ומתקשרים אותם כ\"דפוסי עיצוב\".
עם הזמן, הפתרונות המוצלחים שביניהם יהפכו לחלק מהגדרת הארכיטקטורה של MSA, ויפנו את מקומם ל Best Practices חדשים…

API Facade הוא דפוס שעוזר להתמודד עם בעיה ספציפית שנוצרת בעקבות ריבוי השירותים של Micro-Services Architecture, והצורך של ה Client לתקשר עם כל \"הסבך הזה\" (יש כאלו שהחלו לצייר את השירותים כ\"ענן שירותים\"). API-Facade מקל על הבעיה, אך גם יוצר פיתוי מסוים ליצור \"גוש מונוליטי של פונקציונליות, מלא תלויות\" ממנו כדאי להיזהר…

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

—-

מקורות:

להבין Dependency Injection [מעודכן]

עדכון: ספטמבר 2014 – הפוסט חודש כליל. הפוסט המקורי פורסם בספטמבר 2012.

לשימוש ב Dependency Injection (בקיצור: DI) יש שני מניעים עיקריים:

  • הקפדה על ה Single Responsibility Principle[א] (בקיצור: SRP).
  • ביצוע בדיקות יחידה / בדיקות אינטגרציה.
לעתים, השימוש ב DI הופך מוטמע כ\"כ עמוק בפרויקט / בתהליכים (״התשתית שלנו היא Spring״) – עד שניתן לשכוח מהיכן הכל התחיל. כאשר מקפידים מאוד על שימוש ב DI, אך מחלקות בפרויקט עושות מטלות שונות – זה נראה סוג של \"פספוס\": זה כמו להאכיל את הילדים בקינואה וברוקולי בצהריים, אבל צ\'יפס וקרמבו בשאר הארוחות. מותר… אבל לא מומלץ.
DI הוא גם פתרון ממשי ומעשי לבעיות שצצות בביצוע בדיקות יחידה, ואפילו יותר: בביצוע בדיקות אינטגרציה. אני עבדתי עם קבוצות פיתוח שהוסיפו DI בכדי לבצע בדיקות יחידה, וללא קשר ל SRP (ל SRP היה לנו כלי אחר).
כיום, נראה שביצוע בדיקות יחידה/אינטגרציה הוא יותר נפוץ מהקפדה מלאה על SRP. רוב הארגונים עושים היום בדיקות יחידה, אבל חלק קטן מהם (מניסיוני האישי) באמת מקפיד באמת על SRP. ויש עוד אחוז מסוים של ארגונים שמשתמשים בכלי DI – \"כדי שיעשה סדר\", אך ללא יכולת להסביר מהו בדיוק הסדר הזה שהם מצפים לו וכיצד הוא יקרה. לצפות ל\"פחות באגים\" בעקבות שימוש בכלי DI זה נחמד, אבל ניתן באותה מידה לצפות לכזו תוצאה משימוש ב Log4J… ללא שימוש נכון ויעיל – הציפיה היא תקוות שווא.

בפוסט זה אני מנסה גישה חדשה: פרסמתי את דוגמאות הקוד (העובדות) מהפוסט בגיטהאב: https://github.com/baronlior/di-post
בואו נראה איך זה עובד…

DI בהיבט של SRP

בואו נתבונן על הקוד הבא:

הממשק

ספק השירות

צרכן השירות

הנה השקענו, בהגדרת Interface (ע\"פ עקרון ה Program to an interface), בהפרדה בין נותן שירות לצרכן השירות. הפרדה זו ניתן לקטלג כ\"ניהול תלויות\" / \"Low Coupling\" – ערכים בסיסיים בהנדסת תוכנה.

צרכן השירות משתמש בשירות דרך ממשק, וכך הוא מוגבל לשימוש בממשק המצומצם (ובשאיפה: היציב) שהוגדר.
אמהמה: מישהו, מישהו צריך ליצור מופע (instance) של האובייקט Michelangelo ולתת רפרס שלו לצרכן השירות, והמישהו הזה – הוא צרכן השירות עצמו (!).

אמנם המשתנה המקומי artist הוא מסוג הממשק Artist – אך השימוש ב new מחייב שיהיה import ותלות למחלקה הקונקרטית. זרעי הפרת-התלויות – נזרעו.

מדוע הפסימיות? המתכנת ישתמש בממשק מבלי קשר ל imports הזמינים…

אמשיל את העניין למשהו יותר יומיומי:
דמיינו שאתם מטיילים ונתקלים בגדר עץ פשוטה ולא גבוהה. גדר שכזו היא כמעט אף פעם איננו מכשול שקשה לדלג מעליו – אבל היא עדיין מכשול פסיכולוגי אפקטיבי.
אם חלק מהגדר חסר (=\"פרצה\") אזי כוחו של המכשול הפסיכולוגי פוחת, וסביר יותר שתרשו לעצמכם לעבור את הגדר. להזכיר: הגדר מעולם לא הייתה מחסום פיסי מאתגר.

באופן דומה חשבו על המתכנת: הממשק הוא כמו גדר – זהו מחסום פיסי לא מאתגר. מה הבעיה ללכת לקוד של ה Interface ולשנות אותו? או לשנות את ההגדרה בקוד של המשתנה מטיפוס Artist (הממשק) לטיפוס Michelangelo (המחלקה הקונקרטית)? ממש כלום.

אבל יש פה מחסום פסיכולוגי: המתכנת יודע ש Interface מייצג קודים חברתיים מסוימים – לא עוקפים ממשק סתם כך. אם יש לו גישה זמינה למחלקה הקונקרטית (import) זוהי פרצה בגדר: עדיין הקוד החברתי קיים, אך קל יותר לבטל אותו / להתעלם ממנו. ויש את ה Auto-complete המסיתן הזה, שמציע למתכנת להפר, \"רק לרגע\", את הכללים.

מלבד החשש לטשטוש הגבולות שהוגדרו, יש פה עניין של \"קוד מיותר\".
יצירת הקשרים בין המלקות הוא לא חלק מה \"Single Responsibility\" של המחלקה – ופשוט עדיף שלא יהיה שם. עדיף ליצור מחלקה מיוחדת (למשל: Factory) שזהו ייעודה בחיים.

איך אפשר לעשות זאת?
– פשוט \"נעיף\" את ה import של המחלקה הקונקרטית מצרכן השירות ונוסיף מחלקה שהכרת המחלקות הקונקרטיות הוא ייעודה. Repository בו מחלקות יכולות להירשם, ולמצוא אחת את השנייה ע\"פ \"סימנים\" מסוימים (שם, שם התפקיד, ממשק, וכו\'). נקרא לו Service Locator:

דוגמא לשימוש ב Service Locator

הנה, כרגע אין לנו תלויות במימוש הקונקרטי – סגרנו את \"הפרצה בגדר\"!

עם השנים התפתחו ספריות של Service Locator שמקלות על המפתח, ועם הזמן החליטו להחליף את סדר האחריויות: במקום שמחלקה תגדיר למה היא זקוקה (\"the artist\"), מחלקת ה Service Locator תתפוס פיקוד והיא \"תזריק\" לצרכן השירות את מה שהוא צריך. מימושים אלו נקראו בהתחלה \"Inverse of Control\" (בקיצור: IoC), עד שמרטין פאוולר טבע את המונח \"Dependency Injection\" (בקיצור: DI) אותו אנו מכירים היום.

הנה דוגמה לשימוש ב JSR-330 (הסטנדרט של ג\'אווה ל DI):

ה Annotation של Inject@ מסמנת שלבנאי של Michelangelo \"יוזרק\" המימוש הקונקרטי של המחלקה מסוג ה Painter לה הוא זקוק – מה שמותיר את הקוד של Michelangelo נקי למדי מכל עניין של ניהול הקשרים בין המחלקות.

היכן מוגדר הקשר בין המחלקות? במחלקה אחרת, ייעודית לעניין:

מחלקה זו אינה מוגבלת בתלויות שלה – התפקיד שלה הוא \"לשאת בנטל\" התלויות של האפליקציה.

הקישור בין Inject@ והפונקציה ()bind נעשית בספריית ה DI בשם Google Guice – ה Reference Implementation הרשמי של JSR-330.

אי אפשר לא להזכיר את Spring – הספרייה שבעצם הפכה את DI לכ\"כ נפוץ, והופיעה זמן מה לפני JSR-330 או Guice. ספרינג היא עדיין כנראה ספריית ה DI הנפוצה ביותר בעולם הג\'אווה.

ההיסטוריה של ה DI/IoC. מקור

הערה: בעוד המינוח \"Dependency Injection\" היה ברור יותר מ \"IoC\" המעורפל-משהו, בד בדד הוא כנראה הותיר גם לאנשים מספיק מרחב לדמיין מהי \"הזרקה\". הזרקה, למשל, יכולה להיות גם השמה ב Reflection של Private member של מחלקה, לא? למשל כך:

זהו תחביר מקוצר לדוגמה האחרונה. במקום לקבל את הערך כפרמטר לבנאי המחלקה, ושם להציב אותו כ private member – אנו פשוט מציינים את ה Inject@ ישירות על ה private member של המחלקה.

מצד אחד חסכנו שורת קוד משעממת, מצד שני \"עקפנו\" את ה encapsulation של ג\'אווה והתרנו לגשת ל private member של המחלקה.

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

  1. טשטוש הגבולות של מושג ה private בג\'אווה.
  2. בעוד בנאי עם הרבה (נאמר 10) פרמטרים יציק לנו, וידרבן אותנו לנהל תלויות בצורה קפדנית / לחלק את המחלקה לכמה מחלקות קטנות יותר ועם התמחות מוגדרת יותר – (מניסיון) לא כ\"כ מפריע לנו שיש מחלקה עם 10 פרמטרים עליהם יש Inject@.
  3. שימוש מוגבר ב class members ולא משתנים עם scope מצומצם יותר – כי כ\"כ קל להגדיר בדרך זו משתנים.

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

ריבוי צורות (Polymorphism)

אם שמתם לב, הדרך להגדיר קשר בין הממשק למחלקה הקונקרטית (פקודת ()bind) היא על בסיס הטיפוס (class) בג\'אווה. בתחילת הדרך נהוג היה דווקא לרשום מחלקה תחת שם (מחרוזת מסוימת) ואז לבקש אותה בחזרה ב Service Lookup / בהזרקה. עם הזמן הגיעו ל 2 תובנות:

  • שם המחלקה הוא כנראה התיאור הברור והצפוי ביותר שלה.
  • השימוש ב class object בשילוב עם Generics מאפשר ליצור ספריית DI / Service Lookup ללא הצורך המרגיז בביצוע downcasting.
מה קורה כאשר רוצים להשתמש בריבוי צורות?

ברור לכם שברגע שהמיפוי הוא לא חד-חד ערכי, ה framework לא יצליח לבצע resolving נכון ב runtime. יש frameworks שמתירים מצבים שהם לא חד-חד ערכיים, בהנחה שהם יצליחו לנחש נכון, ויש כאלו שפשוט זורקים שגיאה בזמן ריצה, למשל: \"binding xxx was already configured\".

מה עושים? יש לכך כמה פתרונות (ל Spring Framework, למשל, יש כמה דרכים משלה), אך הפתרון המקובל ב JSR-330 הוא להוסיף Qualifiers (מעין תגיות) על ה binding ועל הבקשה (ועל צרכן השירות – בהתאמה), וכך לבצע את ההתאמה: התאמה ע\"פ תיאור הכוונה.

רישום של 2 מימושים לממשק Painter, תחת שמות שונים

הנה המימוש המחלקה Michelangelo שמקבלת שני מימושים שונים של Painter:

הדוגמה היא רק בכדי להמחיש את נושא ה DI

DI בהיבט של בדיקות יחידה/אינטגרציה

זווית אישית: כפי שכתבתי בפוסט קודם על בדיקות יחידה, אני מעדיף בבירור את הבניית הקוד כך שלא יידרשו Mock Objects עבור בדיקות יחידה – זו פשוט תקורה מיותרת. השימוש ב Mock Objects כן שימושי בעת ביצוע בדיקות אינטגרציה. יש כל-מיני פרשנויות ל \"בדיקות אינטגרציה\", אני מתכוון לבדיקות שרצות offline (למשל בתוך ה IDE) אך בודקות את השילוב של כמה מחלקות לידי תסריט גדול יותר.

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

ראינו את גישת ה Qualifiers של DI, שהתאימה לנו עבור מקרים של Polymorphism אך לא תתאים למקרים של בדיקות. צרכן השירות לא יכול לבקש משהו אחד בזמן הרצה רגילה, ומשהו אחר בזמן בדיקות. הוא לא אמור להיות מודע לסוג ההרצה שהוא עובד (אבוי אם כן…).

מה שעושים הוא פשוט להוסיף קונפיגורציות שונות לבדיקות, באופן הבא:

אזור ירוק – הכנסת הסביבה הבדיקה; אזור הכתום – הרצת הבדיקה.

  1. אנו בודקים את מחלקת ה Artist ולכן צריכים מופע שלה. כדי שה DI Framework יוכל לעבוד, אנו צריכים ליצור את Michelangelo בעצמו בעזרת ה Framework.
    יש פה הזרקה ל member. מה שאני לא מרשה לעצמי לעשות בקוד production, אני מרשה לעצמי לעשות בקוד של בדיקות – כדי לכתוב קוד קצר יותר.
  2. אני יוצר mock ל Painter. לא רציתי להתעסק עם ספריית mocking (כמו Mockito או PowerMock) ולכן יצרתי mock כ Anonymous Class.
  3. פונקציה זו היא בעצם inline של הקונפיגורציה. בעוד שבקוד production אנו רוצים להפריד את ה wiring מהקוד, דווקא בבדיקה ה wiring הוא חשוב להבנת הבדיקה, ולכן אני מעדיף שיהיה באותו הקובץ. שוב השתמשתי ב Anonymous Class.
  4. זו הבדיקה עצמה, הפעלה של המתודה ()createArt מול התוצאה הצפויה (הבדיקה עוברת).

סיכום

בפוסט זה סקרנו את נושא ה Dependency Injection ושורשיו, וניסינו להפוך \"שימוש אוטומטי\" בכלי DI, לשימוש מושכל יותר.

מה השתנה בשנתיים האחרונות שגרם לי לחדש את הפוסט?
אני חושב שבאופן אישי יצא לי להיחשף בעבר ל DI בעיקר ככלי לביצוע בדיקות יחידה – את ניהול התלויות העיקרי עשינו במערכת (הישנה) על גבי JNDI של JEE (סוג של Service Locator). את תמונת עולם זו שיקפתי בפוסט בצורה דיי נחרצת – כפי שהגיב והעיר ויטלי.

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

השמטתי מהפוסט המקורי פסקה שלמה בשם \"רגע של ספקנות\". אני אזכיר כמה רעיונות מרכזיים בקצרה:

  • אין צורך ב Framework בכדי לבצע DI. בסה\"כ, DI הוא רעיון, וה Frameworks השונים רק יוצרים אוטומציה של התהליך.
  • באוטומציה (או Framework) יש יתרון שהוא גם בעיה: היא חוסכת מאיתנו את הצורך בהבנה עמוקה. ללא הבנה עמוקה ניתן לשכוח מדוע אנו עושים מה שאנו עושים (במקרה שלנו – DI) – ולפתע למצוא את עצמנו עושים משהו אחר מהכוונה המקורית.
    אני נוטה בשנים האחרונות להטמיע רעיונות ראשית ללא Framework, בכדי להבין לעומק את הנושא – ורק בסיבוב השני להוסיף את ה Framework שמקל על העבודה. זה כמו ללמוד \"חשבון\" (מתמטיקה בסיסית) עם מחשבון ביד מהרגע הראשון: האם לא עדיף ללמוד כמה חודשים ללא מחשבון – ורק אז להוסיף אותו?
  • DI הוא לרוב רק חלק קטן במערך המודולריזציה של המערכת. שימוש ב DI לא מבטיח איכות או מודולריות, בפני עצמו. הוא רק כלי (נוסף) להשיג מטרות אלו.
  • אני לא שולל את הרעיון של אי-שימוש ב DI. למשל: להפסיק ליצור interface לכל מחלקה (מלבד מקרים של ריבוי צורות), להסתמך על public vs. private להגדרת הממשק, ולצמצם את השימוש ב DI למינימום האפשרי (למשל: עבור בדיקות אינטגרציה, או ניהול תלויות ברמת השירותים בלבד). בסופו של דבר אנו משלמים לא מעט עבור ההגנה שמספקת גישת ה Program to Interface (בצורה הדקדקנית שלה) וצוותים מסוימים / פרוייקטים מסוימים – יכולים להסתדר מספיק טוב עם פחות הגנות, ועדיף לתת להם להתקדם מהר יותר. 
את דוגמאות הקוד כתבתי ב Guice ולא בספריה הנפוצה יותר Spring Framework בעיקר בגלל ש Guice מצומצם ופשוט יותר. ספרינג מספקת הרבה מאוד יכולות – שיכולות מאוד שימושיות למערכת גדולה ומסובכת, אך לא היו תורמות דבר להבנת הרעיונות הבסיסיים.
שיהיה בהצלחה!

—-

[א] עקרון זה מוכר גם כ Separation of Concerns או Segregation of Duties. הכוונה היא שכל יחידת קוד (פונקציה, מחלקה, מודול) יעשו דבר אחד, ודבר אחד בלבד. צד שני של אותו המטבע הוא שכל אחריות תהיה בידיים של יחידת קוד אחת גם כן. כלל זה עוזר ליצור סדר במערכת, ולוודא שמי שכותב את הקוד יראה לנגד עיניו את התמונה המלאה ולא חלקים ממנה (כי כל האחריות נמצאת באותו המקום).

קישורים:

DI קליל ברובי
הפסיכולוגיה של בדיקות תוכנה – מישקו הארווי (היוצר של AngularJS ו jsTestDriver).

דפוסי ארכיטקטורה (Architectural Patterns): דפוסי עיצוב, בגדול

בפוסט זה אנסה לשפוך אור על נושא שיש לגביו בלבול רב – דפוסי ארכיטקטורה. בפשט: דפוסי ארכיטקטורה (כלומר: Architectural Patterns) הם דפוסים חוזרים בארכיטקטורות של מוצרים.

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

יותר גרוע: יש ארכיטקטים שנכנסים לחדר (ואולי זו בכלל דלת עץ זולה שלא נסגרת היטב), וגם הם מאמינים באגדת \"הארכיטקטורה האחת\" שאותה מוצאים הארכיטקטים מתוך המאגר הסודי של דפוסי הארכיטקטורה. הם מקדישים כמה דקות בכדי לסגוד לדפוס הארכיטקטורה החביב עליהם ואז מותחים את מהות המוצר להיות בדמותו של דפוס הארכיטקטורה שנבחר. אאוץ\'!

אתן לכם מטאפורה לדפוסי ארכיטקטורה:

דפוסי ארכיטקטורה הם כמו מתכון למרק

דפוסי ארכיטקטורה הם פתרון מהיר לבעיה שדורשת מיומנות לפתור – אבל המתכון הוא לא בהכרח הפתרון האופטימלי.
אם אתם לא ממש מומחים בבישול – שימוש בדפוס קיים יכול מאוד לעזור. ניסיון \"להמציא מתכון מהראש\" יכול להסתיים בחוסר נעימות.
מצד שני, המתכון הוא לא האידאל, ושף מוכשר יוכל להרכיב מתכון טוב בהרבה בהתחשב בחומרי הגלם הזמינים, הסועדים, או מצב הרוח. למתכון מוכן אין את ההקשר למצב הייחודי שלכם [א]. המתכון גם פעמים רבות הופשט מהאידאל, בכדי שהיה אפשר להעביר אותו לרבים, ועל גבי תקשורת מוגבלת (זה לא שאתם עוברים Mentoring של כמה ימים על המתכון).

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

מצד שני נסו לדמיין מצב בו ידע שנצבר בבישול לא היה מועבר בין אנשים? כל אחד היה אוכל רק מה מה שהצליח להמציא בעצמו. איך היו נראות הארוחות שלנו ללא מתכנונים כלשהם?
– מצבנו הקולינרי היה עגום כמו זה של תושבי אנגליה!! (חס וחלילה)

הדפוסים עליהם נעבור בפוסט

  • כדור גדול של בוץ (Big Ball of Mud)
  • \"הדר המסודר\" (Hadar Amesudar)
  • \"שכבות עוגה\" (Layered Architecture)
  • MVC (קיצור של Model-View-Controller)
  • \"דודות מרכלות\" (Event-Driven Architecture)
  •  Command-Query Separation (בקיצור: CQRS או CQS) 
  • \"גרעין קטן\" (MicroKernel) ודרכו נזכיר גם Plug-in Architecture
  • \"מיקרו-שירותים\" (Micro-Services Architecture, בקיצור: MSA)

דפוסים נפוצים אחרים שלא נעבור עליהם הם, אך שווה להזכיר:

  • צינורות ומסננים (Pipes & Filters) – אותו כיסיתי בפירוט בפוסט משלו.
  • BlackBoard (רלוונטי בעולם של בינה מלאכותית)
  • MVP, MVVM או Presentation-Abstraction-Control (דומים למדי ל MVC)
  • Broker (רלוונטי במערכות מבוזרות)
  • Model-Driven Architecture (מן \"רעיון גדול\" שלא ממש \"תפס\") או Domain-Specific Languages (בקיצור DSL – רעיון שהצליח חלקית).
  • וכו\'

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

בגדול אזכיר ש:

  • לדפוסים יש ערך רב כשפה משותפת וכמקור השראה.
  • הצמדות לדפוסים \"כפי שהוגדרו\" – יכולה להיות הרסנית.
  • כדאי ליצור וריאציות שלכם של הדפוסים שיתאימו לכם יותר, או לשלב רעיונות (Mix and Match) מדפוסים שונים.

בואו נצא לדרך!

כדור גדול של בוץ (Big Ball of Mud)

השם ה(לא) מחמיא הזה כמובן ניתן ל Anti-Pattern. כדור גדול של בוץ הוא מצב בו כל המחלקות במערכת שלנו נמצאות ב Pool אחד משותף, ללא חלוקה ברורה ומסודרת.
מצב זה אולי נוח כאשר יש 10 מחלקות, אך מאוד לא נוח כאשר יש כ 1000 מחלקות במערכת.
אפשר בהחלט להתחיל במערכת עם \"כדור גדול של בוץ\", לגלות את התכונות והמבנה והרצוי של המערכת – ואז לחלק. ככלל אצבע הייתי מצביע על כמה עשרות מחלקות, לכל היותר, כנקודה בה כדאי מאוד שהיה כבר מבנה מוגדר.

מדוע כדאי לנו לחלק את המערכת לחלקים? בגלל:

  • בעיית ההתמצאות (Orientation)
  • בעיית ה Development Scalability
  • יצירת מסגרת מנטלית של סדר וחוקיות במערכת. זו לא בדיחה: כמה כללים פשוטים מה \"נכון\" ומה \"לא נכון\" עוזרים לאנשים בפרויקט להרגיש בטוחים יותר במה שהם עושים. אי קיום הכללים הפשוטים הללו / חוסר סדר בסיסי כלשהו בפרויקט – משפיע על אנשים רבים (לא כולם) בצורה שלילית וברורה
    קשה להבין את ההשפעה עד שלא רואים את המעבר של פרויקט ממצב של \"אי-סדר\", לאפילו מצב של \"סדר בסיסי\".
את 2 הבעיות הראשונות תיארתי בפירוט בפוסט מדוע אנו זקוקים ל Software Design?

\"הדר המסודר\"

אוקיי: הבנו שיש חשיבות לסדר בסיסי במערכת. בואו ניקח בחור מסודר, שיקבע סדר.
האם משנה בדיוק מהו הסדר?

הדר בוחר בסדר פשוט:
נשרטט קווים, ונחלק את המערכת ל3 אזורים:

  • מחלקות עד 1000 שורות קוד
  • מחלקות מתחת ל 1000 שורות קוד
  • קבועים, enums, או data objects (שהם בד\"כ מאוד קטנים)

כל מפתח יודע היכן לשים את המחלקה שהוא כותב – הכללים ברורים.
אולי שיפרנו טיפה את בעיית ההתמצאות (\"חישוב האלגוריתם הוא מסובך מאוד, אחפש באזור המחלקות הגדולות\") ויצרנו תחושה (דיי מפוברקת) של מסגרת – אך לא טיפלנו בכלל ב Development Scalability.

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

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

  • דואר נכנס
  • דואר יוצא
  • אנשי קשר
  • קונפיגורציה
  • אחר

החלוקה הזו נשמעת יותר הגיונית, אבל גם בה יש בעיות עקרוניות. למשל: הקבוצה \"אחר\" יכולה להכיל לא-מעט מחלקות שלא מתקשרות ישירות לפונקציה \"עסקית\" (persistence, helpers, פקדי UI שבשימוש חוזר, וכו\'). יצרנו שם \"כדור בינוני של בוץ\" בפני עצמו. ההתמצאות עצמה היא בעיקר ברמת האזורים (\"שכונות בעיר\") אך לא מכסה רזולוציה קטנה יותר (\"רחובות\").
דבר נוסף הוא שכדי להשיג Developer Scalability (היכולת של הרבה אנשים לעבוד על המערכת במקביל) אנו נרצה להטיל מגבלות על היכולת של החלקים השונים של המערכת לתקשר זה עם זה.

מדוע אנו מעוניינים בסדר וניהול תלויות?

נמשיל את העניין לבעיה פשוטה מאוד: הפרדה של קוד בין 2 מחלקות:

את קוד ה Parser אנו מחלקים ל2 חלקים. מדוע?

  • כי יותר קל לנהל קוד בחתיכות קטנות ופשוטות, מחתיכה גדולה ומורכבת יותר.
  • כי אז ניתן לבצע שינוי באזור אחד, עם סבירות גבוהה* שלא יהיה צורך לשנות אזור אחר. פחות בדיקות רגרסיה, פחות \"פחד\" לשבור דברים, והיכולת של 2 מפתחים לעבוד במקביל בצורה יותר יעילה.
* כל עוד החלוקה היא אפקטיבית
לא מספיק שחילקנו את ה Parser ל 2 מחלקות, אנו רוצים להטיל מגבלות על התלויות ביניהן:
  • הכמסה (encapsulation) – מגבלה מאוד מקובלת בעולם ה OO, שמפרידה בין חלקים ציבוריים (בהם ניתן ליצור תלות) ופרטיים (בהם לא ניתן ליצור תלות). עצם כך שצמצמנו את התלויות – הגברנו את הסיכוי ששינוי בקוד (באזור הפרטי) לא ידרוש שינויים או ישפיע על מחלקות שתלויות במחלקה זו – שוב: Developer Scalability, תחזוקה קל היותר, ויכולת לבצע שינויים בצורה פשוטה יחסית (באזורים הפרטיים).
  • ניהול תלויות (dependency management) – מגבלה שאומרת שמחלקת ה Document Parser רשאית לקרוא ל Paragraph Parse – אך לא להיפך. צמצמנו כך את התלויות עוד יותר.

אציין שבניהול תלויות המטרה היא לצמצם תלויות, לא רק \"לנהל/לעקוב\" אחריהן.

כמו כן החכמה בניהול תלויות היא לא רק לקבוע כלל מגביל, אלא להגדיר מראש מבנה בו יהיה קל ליישם את הכללים  הללו. לדוגמה, אם היינו מחלקים את ה Parser בצורה הבאה:

לא היה סביר שהתלות החד-כיוונית שהוגדרה בין המחלקות, תשמר לאורך זמן (בהנחה שיש תלויות קונספטואליות בין פענוח אלמנטים מסוג A ו B). צוות עם עצבי ברזל אולי יגיע לשימוש בפונקציות עם הרבה פרמטרים והרבה Callbacks – אך זה גם לא מצב רצוי. סה\"כ זו דוגמה לחלוקה לא מוצלחת.

כללים מגבילים בין מחלקות / צמצום התלויות גם עוזר לשמור על הסדר שהוגדר: ברגע שהממשקים הם מצומצמים יש בד\"כ פחות \"דו-משמעות\" היכן קוד צריך להיות: מחלקה A או מחלקה B. כך נשמר התפקיד הייחודי של כל מחלקה, ומכאן יכולת התמצאות בקוד, יכולת לעשות שינויים מבודדים וכו\'.

הכללים המגבילים, מציבים בפנינו שקלול-תמורות מסוים:

  • פחות מדי מגבלות: כולם מדברים עם כולם במערכת – ונפגעת מאוד תכונת ה Development Scalability.
  • יותר מדי מגבלות: מקשות על ה productivity של המפתחים.
ככל אצבע, ארכיטקטורה טובה לא כוללת כמה שיותר מגבלות: היא מתאימה את כמות המגבלות לגודל הפרויקט, לשלב ההתפתחות שלו (יותר מגבלות ככל שמתקדמים + כמה עולה לפיתוח להוסיף מגבלות בשלב זה?) ולארגון הספציפי (יש מתכנתים שיסתדרו טוב יותר עם מעט מגבלות, יש כאלו שלא).

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

Layered Architecture

דפוס השכבות הוא הדפוס הארכיטקטוני בשימוש הנרחב ביותר.
הוא נפוץ במיוחד במערכות מידע / Web Applications / Enterprise Applications (מבחינה ארכיטקטונית, הן בערך אותו הדבר) – והוא כנראה גם הדפוס השולט גם ב domains רבים אחרים. חשבו: כמה מקובל להציג ארכיטקטורה כקוביות בזו מעל זו, ומה צורה זו מתאר.
באופן לא מפתיע, עם הפופולריות מגיעה גם כמות יפה של חוזר-הבנה, ושימוש לא נכון.

כללי החלוקה של דפוס השכבות הוא כדלהלן:

  • נפריד את מאגר המחלקות במערכת שלנו לכמה שכבות: שכבות עליונות לטיפול בעניינים ברמת הפשטה גבוהה, כאשר רמת ההפשטה יורדת ככל שיורדים בשכבות.
  • לוגיקה בשכבה גבוהה (n) תהיה מורכבת מהפעלה של לוגיקות (או פונקציות) בשכבה שמתחתיה (n-1). כמו כן, לוגיקה בשכבה גבוהה (n) תעזור להרכיב את הפונקציות בשכבה הגבוהה ממנה (n+1).
  • בד\"כ השכבה הגבוהה ביותר היא זו שמתקשרת עם העולם החיצון: משתמש או מערכות אחרות.
  • החלוקה של המחלקות לשכבות היא שלב אחד, ובד\"כ יש חלוקות נוספות בתוך השכבה למודולים / תתי-מערכות (sub-systems). 
  • בד\"כ בכל שכבה יש תת-שכבת API (או \"Facade\") מנוהלת – בה, ורק בה השכבה העליונה יכולה להשתמש. לא נרצה שכל ה APIs שזמינים בתוך השכבה יהיו זמינים לשכבה מעל – אחרת, איזו משמעות יש להפרדה שהגדרנו?!

בהצצה לתוך השכבה הבודדת, המבנה עשוי להראות דומה למבנה הבא:

פרשנות נפוצה של מודל השכבות 

במערכות ווב, מערכות מידע, או Enterprise Systems יש מן מתכון מקובל של לחלוקה המערכת לשכבות.
אין לכם כח לקבוע רמות הפשטה שונות ושימושיות של המערכת? – השתמשו במתכון מוכן (מתכון של מתכון):

  • UI – ממשק המשתמש
  • Business Logic – הפונקציה העיקרית שהמערכת ממלאה (ניהול ספקים, רכש, מלאי, וכו\')
  • Data Access – בשכבה זו פעם היה הרבה קוד מתוחכם, אך היא הוחלפה חלקית ע\"י מערכות ORM – ומה שנשאר בה הם האובייקטים של המערכת שמתארים את הנתונים (Entities, Collections, וכו\').
  • Persistency – שכבה זו מתייחסת לבסיס הנתונים (בהנחה שיש כזה, בד\"כ רלציוני) – סקריפטים ליצירת הטבלאות (DDL) ולעתים גם קוד שרץ בתוך בסיס הנתונים (stored procedures) [ב].

לחלוקה זו יש וריאציות שונות (הוספת שכבת Service מעל ה Business Logic, או שכבת Presentation Logic מתחת ל UI, וכו\') – אבל סה\"כ היא מאוד מקובלת.

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

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

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

דפוס ארכיטקטוני שעשוי להתאים במקרים בהם כל פיצ\'ר / פיתוח קטן נוגע בכל (או רוב) השכבות הוא דפוס ה microservices – שזוכה לפופולריות רבה לאחרונה. האם גם אותו אפשר \"לפספס\"? – כמובן!

אציין, שבמערכות Enterprise כדוגמת ERP של סאפ – יש ערך ממשי בחלוקה הנ\"ך לשכבות: מערכות אלו מבוססות במידה רבה על מידול (modeling) של נתונים, ואז באמת יש פיתוחים ב Business Layer שלא צריכים לעדכן את ה UI (כי הוא נבנה מתוך מודל הנתונים שלא השתנה), ולא צריכים לעדכן כמעט את הטבלאות בבסיס הנתונים. שכבת ה Business Layer היא משמעותית ומשתנה תדיר – ויש ערך רב בהפרדה זו. כמו כן ניתן לשנות את ה UI (כיצד מרנדרים את המודל) – מבלי לשנות את שכבת ה Business Logic בכלל, וכו\'.

עוד בעיה נפוצה היא פרשנות של המודל ע\"פ פונקציה אותה ממלאת המחלקה / מודול ולא ע\"פ רמת הפשטה, כך שלמשל, כל פעולת גישה לדיסק נכנסת לשכבת ה Persistence באופן אוטומטי – \"כי דיסק הוא Persistence\" ואף אחד לא שוקל מהי רמת ההפשטה של הפעולה. למשל: קריאה של קובץ מהדיסק ששקול ל Input מהמשתמש – נכון יותר, כנראה, שהיה בשכבת ה UI. זו אותה רמה הפשטה. שכבת ה Persistence הוגדרה מכיוון שיש הרבה מאוד פעולות דיסק שהם ברמה נמוכה – אך לא כולן הן כאלה.
התעלמות מהעיקרון של רמות ההפשטה השונות – גורם ל\"פספוס\" ביישום של מודל השכבות.

\"הב לי עלים ירוקים…\"

Model-View-Controller

בתיאוריה, MVC הוא דפוס נהדר לחלוקה של שכבת ה UI.
תאוריה בצד. בפועל: יש לו בעיה מהותית – שמונעת ממנו לעתים \"להמריא\".

דפוס MVC בא לחלק אפליקציית UI לחלקים מוגדרים היטב – ולהגדיר תלויות ביניהן.
המקור של MVC הוא ב framework של שפת Smalltalk-80 משנת 1980 בשם \"Model-View-Controller Editor\".

ה Framework הזה היה מיועד לאפליקציות command line (שיא ה UI באותה תקופה), והחלוקה הייתה בין:

  • Model – הנתונים של האפליקציה / מבני נתונים
  • View – תצוגת הפלט, מה לרשום על המסך
  • Control – קבל ה Input מהמשתמש וטיפול בקלט הזה – בעצם ה Business Logic.

MVC \"קלאסי\" / \"המקורי\"

כל מחלקה (או פונקציה) נופלת באחד מהאזורים – וחייבת לציית לכליל הנראות. בת\'כלס ה Controller רק יוצר את ה View ולא משתמש בו אח\"כ, ושניהם מכירים את המודל. בין מחלקה של View למחלקה של Controller יש בד\"כ קשר של 1:1 – והם מתארים \"מסך\" או \"תפריט\" באפליקציה.

הרעיון העיקרי מאחורי דפוס ארכיטקטוני זה היה הפרדה בין המודל (ה state של האפליקציה) לקלט והפלט של התפריטים השונים. הדפוס הפך להצלחה (אולי כאחד הדפוסים היחידים שמנסה לפתור בעיות של UI) – ועד היום אנו משתמשים בו… או שלא.

אם כולם היו מכירים את MVC ומשתמשים בו באותו האופן – לדפוס זה היה ערך רב יותר. אולם, יש 2 בעיות:

  • MVC המקורי לא כ\"כ מתאים לאפליקציות Desktop, Web או כל מה שאינו command line.
  • יש המון וריאציות של MVC: חלקן קיבלו שמות נבדלים (יחסית) כמו MVP, MVVM או PAC – אבל רוב הווריאציות פשוט משתמש בשם \"MVC\", תוך כדי ביצוע שינויים במודל \"המקורי\".
התוצאה המידית היא בלבול וחוסר תקשורת –  FUD. אם דיברנו על כך שדפוסים משמשים כשפה משותפת, אזי MVC משמש לרוב כאנטי-שפה-משותפת: מתכנת אחד אומר \"Controller\" והשני שומע \"Kotroller\" (כלומר: מה שהוא מכיר כ Controller). שני המתכנתים רבים כמה ימים, אולי שבועות – עד שהם מצליחים להסכים שבעצם השני לא אידיוט/מפגר/לא מבין – שניהם פשוט מכירים שתי וריאציות שונות של MVC.
בפועל, אוותר על הניסיון לתאר ולאפיין את כל הווריאציות התאורטיות של MVC (להזכיר: יש כאלו שהם Client-Side, כאלו שהם MVC צד שרת, כאלו שהם מערבים צד-לקוח וצד-שרת, וכאלו שמתאימים לאפליקציות Desktop או Native Mobile).
אני ממליץ לוותר על ההגדרות, ופשוט להיצמד לספרייה הרלוונטית שבה אתם משתמשים – והכללים שהיא מכתיבה: בסוף, התועלת העיקרית היא מכללים ברורים שכולם מצייתים להם – קצת פחות אם כלל מסוים מנוסח בווריאציה 2א\' או בווריאציה 3ג\'.
יש ספריות יש רבות הממשות (וריאציה של) דפוס זה: Struts, Play!, Grails, Ruby on Rails, Django, ASP.NET MVC , Angular.js, Backbone.js ועוד….
מבנה של MVC צד-לקוח מודרני טיפוסי אפשרי
העיקרון המשמעותי ביותר, והמוסכם ביותר בקרב ספריות \"MVC\" מודרניות הוא ההפרדה בין UI (כיצד משהו נראה על המסך) ל Presentation Logic (כיצד להחליט מה יש להציג על המסך). למשל (דוגמה סופר-פשוטה):
במקום לקבוע כלל ש:
  • אדם עם +500 חברים מקבל icon מיוחד.
מחלקים את המימוש ל2 אזורים:
  • Presentation Logic – אדם עם 500+ מתויג כ \"אדם פופולרי במיוחד\" (What)
  • UI – אדם המתויג כ \"אדם פופולרי במיוחד\" מקבל icon מיוחד.
הפרדה זו יוצרת מעט overhead על הפיתוח, אך היא מאפשרת להחליף את ה presentation (כלומר: אלמנטים ב UI / כל ה UI) בצורה קלה יחסית, מבלי \"לאבד\" או לשנות את ה Presentation Logic שהוא יותר יציב, או לחלופין להוסיף presentation נוסף (נאמר: mobile version), מבלי לשכפל את הקוד לגמרי.
החלפת / רענון שכבת ה UI – היא פעולה תדירה מאוד במערכות ווב. ארגונים גדולים עושים זאת כל 2-3 שנים, ואתרים קטנים מבצעים שינויים לפעמים על בסיס חודשי.

סיכום

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

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

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

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

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

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

Stored Procedures הם דוגמה מצוינת ליכולת שכזו, שכמעט תמיד משתמשים בה בצורה שסותרת בצורה בוטה את העקרונות של מודל השכבות: לוגיקה עסקית (רמת הפשטה גבוהה) מנוהלת בתוך בסיס הנתונים (שכבת הפשטה הכי נמוכה) – ויוצרת תלות קשה בתחזוקה, ביכולת להחליף מימוש של שכבות, וכו\'.

לינקים מעניינים

הדוד בוב בביקורת עוקצנית על MVC (וידאו): \"MVC is not an Architecture\"