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 יש לבנות בחכמה: הם לא פתרון קסם לכל מצב.

קוטלין (Kotlin) למפתחי ג'אווה ותיקים – חלק ח': קוטלין וג'אווה (interoperability)

הפעם אני רוצה לדבר על Interoperability בין קוטלין וג'אווה.

 

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

מתישהו… במקרי הקצה – זה יגיע.
משהו בג'אווה לא יאהב משהו בקוטלין (או אולי ההיפך – אבל זה פחות נפוץ).

כיצד null יכול לצוץ לו בקוטלין ללא הודעה מוקדמת?

 

כבר כמה פעמים נשאלתי את השאלה: "האם אפשר לרשת מחלקת ג'אווה בקוטלין? קוטלין בג'אווה?"

בוודאי שאפשר! אחרת לא הייתי אומר ש interoperability ביניהן כ"כ מוצלח.

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

ערבוב של קוד קוטלין וג'אווה לצורך רצף הקריאות. במציאות כמובן שהקוד ישב בקבצים נפרדים.
  1. יצרנו מחלקה מופשטת A בשפת ג'אווה.
  2. הרחבנו את המחלקה בג'אווה A – בעזרת מחלקה בקוטלין B.
    1. מכיוון שברירת המחדל בקוטלין היא מחלקה final – עלינו להגדיר אותה כ open ע"מ שקוד הג'אווה יוכל לרשת את המחלקה C.
  3. ואכן הרחבנו את המחלקה בקוטלין B בג'אווה, ללא בעיה. כל שפה שומרת על הקונבנציות שלה (במידת האפשר)
  4. הממ… ה IDE מעיר לי פה משהו: 
Not annotated method overrides method annotated with @NotNull 

מה זה?
אני לא רואה Annotation בשם NotNull@ בקוד.

מה? java.lang.NullPointerException? – אבל אני כותב בקוטלין!?

 

בכדי להבין מה קורה, נחזור שלב אחר אחורה – למחלקה KotlinB.

במחלקה הזו דרסנו את המתודה ()getHelloMessage שהוגדרה בג'אווה.
ערך ההחזרה של המתודה שהוגדרה בג'אווה הוא String, אבל מה זה אומר עבור קוטלין: String או ?String, אולי?

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

והנה השימוש שלה בקוטלין:

ה IDE מסמן לי שערך ההחזרה של המתודה הזו הוא !String.

אין טיפוס כזה בקוטלין, וטעות נפוצה היא להניח ש !String הוא ההיפך מ ?String – כלומר: String שהוא בהכרח לא null.

מה שבאמת ה IDE מנסה לומר לנו הוא שהוא לא יודע אם ה String הוא null או לא. אני חושב שתחביר כמו [?]String היה יכול להיות יותר אינטואיטיבי.

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

other?.java?.object?.always?.might?.be?.null()
 

מכיוון שברוב המקרים מתודות יחזירו ערך שלא יכול להיות null – הניחו שזה המקרה והורו לקומפיילר "להיות אופטימי" לגבי ג'אווה.

זה נוח, אבל גם יכול לגרום לשגיאות בלתי צפויות.

הנה דוגמה מהחיים:

jdbi הוא פריימוק רזה (וחביב) הכתוב בג'אווה, ומאפשר גישה לבסיס הנתונים.
אופן השימוש בו הוא להגדיר interface או abstract class עם מתודות ומוסיף להן annotation עם השאילתה שיש לממש.
jdbi, בעזרת reflection, מג'נרט (בזמן ריצה) אובייקט DAO שמממש את הממשק שהגדרתי. התוצאה היא אובייקט שמבצע את השאילות שהגדרתי ב annotations. קוד עובד.

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

…עד הרגע שאני מפעיל את השאילתה עם job_id שלא קיים – וחוזר לי null.
מתישהו אני "חוטף" NullPointerException.

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

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

מה שצריך לעשות הוא להגדיר בצורה נכונה את ערך ההחזרה של הפונקציה. קוד הפונקציה, להזכיר, ממומש בג'אווה.
במקרה הזה הוא גם מג'ונרט בזמן ריצה – היכן שהקופיילר כבר סיים את עבודתו – אבל עצם הבעיה הוא חוסר ההבחנה של ג'אווה בין nullable string ל not-null String.

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

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

JebBrains (החברה מאוחרי קוטלין ו IntelliJ) סיפקה annotations לג'אווה שיכולים להנחות את ה IDE, מתי צפוי null ומתי לא ייתכן null. הנה דוגמה:

השימוש ב annotation מסיר מה IDE את הספק:

ואז הוא יכול להגן עלי.

אין כנראה פתרון טוב יותר: ואישהו בתפר בין ג'אווה וקוטלין עלולים "לזלוג" nulls מג'אווה לקוטלין.
סימון ה Nullability בעזרת annotations הוא לא תמיד אפשרי (למשל: ספריית צד-שלישי) וגם אותו אפשר לשכוח.

באופן דומה, אגב:

(Mutable)List

הוא סימן שה IDE מספק שמשמעו: רשימה שייתכן שהיא mutable, וייתכן immutable. הקומפיילר לא מסוגל להגיע למסקנה בעצמו.

הנה דוגמה לביטוי מורכב:

  • הרשימה ו/או האיברים בה עלולים להכיל ערך null.
  • הרשימה עשויה להיות mutable או לא.
קוד הג'אווה מאחורי המתודה ()getStrings הוא זה:
 
 
מה שמוביל אותנו לעניין נוסף שכדאי להכיר:
כאשר מתודה בג'אווה נקראת ב naming של JavaBeans, כלומר: ()getXxxx או ()setXxxx – קוטלין מתייחסת אליהם כתכונה בשם xxxx.
 
הנה הקוד בקוטלין שקורא לקוד הג'אווה הנ"ל:
 
 
אתם רואים שהשלפנים (getters) שכתובים בג'אווה נראים בקוטלין כמו תכונות לכל דבר.
מכיוון ש true היא מילה שמורה בקוטלין, יש לעטוף (escaping) אותה בגרש מוטה.
 
באופן סימטרי, תכונות (properties) שהוגדרו בקוטלין כ yyyy יופיעו בקוד הג'אווה כמתודות ()getYyyy ו/או ()setYyyy.
 
 
כדרך אגב, יכולת ה escaping של שמות בקוטלין – מאפשר לתת שמות קריאים יותר לפונקציות של בדיקות:
 
 

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

 

חשיפה מתוכננת

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

אם יש לי תכונה בשם yyyy בקוטלין (מה שטבעי בקוטלין), הגישה אליה תהיה בעזרת getYyyy ו setYyyy – מה שטבעי בג'אווה.

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

אאוץ. אאוץ!!

הנה רשימת בעיות:

  • כאשר אני קורא לתכונה now מג'אווה – שם הפונקציה מופיע כ ()getNow, ומסיבה כזו או אחרת אני רוצה להשתמש בשם now כ field.
  • המילה transient היא מילה שמורה בג'אווה – אך לא בקוטלין. אי אפשר לקרוא לפונקציה הזו מתוך ג'אווה, ואין escaping בג'אווה המאפשר להשתמש בשמות שאינם תקינים בשפה.
  • אני לא יכול ליהנות מהערך ברירת המחדל של המתודה repeat. אין קונספט של default value בג'אווה – ולכן אני נדרש לשלוח את שני הפרמטרים בכל קריאה. בריבוי קריאות – זה יכול להיות מעצבן!
  • יצרתי companion object על מנת "לחקות" מתודות סטטיות בג'אווה – אבל הדרך לקרוא ל foo היא באופן: ()KotlinProducer.Companion.foo. מסורבל!

מה עושים?

הנה הפתרון, מבוסס annotations – אך עובד:

 

JvmOverloads היא הנחיה להשתמש ב default values על מנת לג'נרט מופעים שונים של פונקציות בקומבינציות השונות, מה שנקרא בג'אווה Method Overloading. אני מניח ששאר ה annotations הן self-explanatory.

הוספתי גם דוגמה לשימוש ב extension function. איך משתמשים ב extension functions מתוך ג'אווה?!

הנה קוד הג'אווה שמשתמש בקוד הקוטלין, "בהנאה":

 

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

לא מקסים, אבל בג'אווה יש מבנים של הספריות הסטנדרטיות שהם לא פחות "כבדים".

 

סיכום

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

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

ה interoperability בין ג'אווה וקוטלין פשוט עובד!

יצא לראות לא מעט קוד קוטלין (צד-שרת) שעובד:

  • בצורה אינטנסיבית עם ספריות של ג'אווה.
  • ספריות ותיקות, שנכתבו לג'אווה – עוד לפני שקוטלין הייתה מעניינת.
  • ספריות שמבצעות reflection והורשה לקוד הקוטלין שנכתב (למשל: JDBI, Guice, או Jackson שמקודד עשרות רבות של מחלקות ל json ובחזרה לקוטלין)
  • והעבודה הייתה בסה"כ חלקה מאוד!
    • במקרים מעטים היה צורך / או היה יפה יותר להשתמש בכלים שסיפקתי בפוסט הזה.
    • במקרים מעטים נאלצנו לכתוב קוד "java-like" בקוטלין, על מנת שדברים יעבדו. עם הזמן צצו wrappers לקוטלין שהקלו על הדברים, ואפשרו להשתמש בסגנון "קוטליני" בחופשיות.

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