קטגוריה: כללי

שימוש בעקרונות ה-SOLID

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

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

חמשת העקרונות של הדוד בוב

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

חמשת העקרונות אמנם יוצרים שם קליט, אך כשלעצמם, הם קליטים פחות… אלו הם העקרונות:

SRP: Single Responsibility Principle – S (עקרון האחריות היחידה)
OCP: Open/Closed Principle – O (עקרון ה"פתוח/סגור")
LSP: Liskov Substitution Principle – L (עקרון ההחלפה של ליסקוב)
ISP: Interface Segregation Principle – I (עקרון הפרדת הממשקים)
DIP: Dependency Inversion Principle – D (עקרון היפוך התלות)

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

עקרון האחריות היחידה

עקרון ה-Single Responsibility אומר שמחלקה אחת צריכה לטפל ב"בעיה" אחת. אם אנחנו מתכננים מחלקה שצריכה לבצע מספר פעולות – אנחנו מתכננים לא נכון. בהסתכלות המקורית – לכל מחלקה תיתכן "סיבה אחת" לשינוי בהמשך החיים של המערכת.

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

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

עקרון ה"פתוח/סגור"

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

עקרון ההחלפה של ליסקוב

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

הדרישה של ליסקוב היא מחמירה יותר מכפי שרוב המפתחים מבינים אותה, והיא דורשת – מלבד הימנעות משינוי התנהגות כמו בדוגמאות הקלאסיות של מלבן/ריבוע או אליפסה/עיגול, גם שתי דרישות חשובות נוספות:

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

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

עקרון הפרדת הממשקים

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

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

עקרון היפוך התלות

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

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

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

מה, בעצם, היה לנו פה?

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

לכל מחלקה ישנם חמישה אספקטים חשובים:

  1. הבסיסי ביותר: מה תפקידה?
  2. מה היא מגדירה עבור מחלקות יורשות עתידיות, אם בכלל?
  3. אילו התחייבויות יש לה כלפי המחלקות מהן ירשה, אם יש כאלו?
  4. איזה ממשק היא מספקת ל-clients שלה?
  5. בעזרת איזה ממשק היא פונה למחלקות שהיא ה-client שלהן?

מבחינה גרפית, ניתן להסתכל על זה כך:

solid_1a

The 5 Aspects of a Class

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

solid_2a

The 5 Principles of SOLID SW Design

 

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

 

מודעות פרסומת

הטרוגניות

בעולם של היום, מחפשים במקומות רבים "Full Stack Developers", ובדרך כלל, מלווים את הכותרת ברשימה של אינספור טכנולוגיות שחשוב שהמועמד יכיר. ובכל זאת, בצוותים רבים מחפשים מפתחים שיהיו, עד כמה שאפשר, כפילים של המפתחים הנוכחיים: אם הצוות עובד על ג'אווה, מחפשים אנשים שהתמקדו בתחום הזה; אם מדובר בצוות שמתעסק בבסיסי נתונים רלציוניים, זה סוג האנשים שאותם ינסו למשוך.

לבנות צוות =! לאסוף מומחים

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

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

דרושים מפתחי ג'אווה

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

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

מדענים .vs מהנדסים

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

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

אני, כשלעצמי, התרגשתי פחות. כותב המאמר – באופן לא מפתיע במיוחד – היה מהנדס בנאס"א. למה לא הופתעתי? מפני – ובכן – שזה מה שמהנדסים עושים. רקטה לא מאויישת אינה יכולה למלא את תפקידה ב-80% מהמקרים, רק כדי להיות נאמנה לאג'נדה מדעית כלשהי. הרקטה חייבת להצליח ב-100% מהאתגרים העומדים בפניה, ואם זה אומר שצריך לאסוף פתרונות שונים ולהתאים אותם לבעיות שונות – זה מה שהיא תעשה. ברור לי לחלוטין שכל מהנדס עם מעט נסיון מעשי ויכולת לקרוא מאמרים מדעיים היה מגיע לפתרון מהסוג ה"היברידי" במקרה הזה, גם אם שונה מעט ביישומו. ובכל זאת, עבור מי שהתייחס לעניין מהזווית המדעית בלבד – זו היתה גישה פורצת דרך.

זמן אמיתי

באחת החברות בהן עבדתי, בנינו ארכיטקטורה חדשה ומבוזרת למנוע BackEnd מורכב, שכלל משימות רבות. חלק מן המשימות הוטרגו על ידי אירועים שונים, ואחרות התעוררו לחיים כל פרק זמן נתון, באמצעות Cron או מנגנוני תזמון אחרים. הרקע שלי היה קצת שונה משל מרבית הצוות; לפני שהגעתי לשם, התעסקתי פחות במערכות מהסוג הזה. בין היתר, ביליתי זמן רב בעבודה על מערכת אלקטרו-מכנית שדרשה עבודה בסביבת Hard Real Time.

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

מלחמת הכוכבים

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

השלם הוא יותר מסך חלקיו

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

טיפול בחריגות – Exceptions

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

טיפול בחריגות (Exception Handling) הוא אחד מאבני היסוד של כל מערכת תוכנה כמעט; אף על פי כן, התפיסות לגבי טיפול בשגיאות שונות בצורה מהותית ועקרונית בין שפות שונות, טכנולוגיות שונות, ופעמים רבות – גם אנשים שונים. בעיני, זריקת Exceptions היוותה שינוי תפיסתי גדול אפילו יותר מאשר המעבר מתכנות פרוצדורלי לתכנות מונחה עצמים או משפות שהן strictly typed לשפות חופשיות. הכרתי לא מעט מפתחים מעולים שהצליחו להסתגל לשינויים רבים, אבל התקשו להיפרד מההרגל של בדיקת ערך החזרה של הפונקציה כדי לוודא שהיא התנהלה כשורה. אפילו כואב מזה – אני מכיר מפתחים רבים שדווקא משתמשים במנגנונים האלו, אבל עושים את זה בדרך כזו שמוטב היה לו היו בודקים את ערך הפונקציה במקום זה…

exceptions

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

ברומא, התנהג כרומאי

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

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

שמרו על הלוגיקה ברורה

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

ודאו שהחריגות תורמות ליציבות המערכת, ולא להיפך

המטרה של ניהול החריגות היא לוודא שהמערכת מתנהגת כראוי גם במצבים לא צפויים, שאינם מאפשרים לה לפעול בדרך המתוכננת. שימוש לא נכון בזריקת exceptions עלול לגרום להיפך הגמור. קוד שבו חריגות פשוט עוצרות את הפעולה הנוכחית ו"עפות" חזרה במעלה מחסנית הפונקציות עלולות להשאיר את המערכת עם פורטים פתוחים, תהליכים יתומים, handles ללא שימוש, מבני נתונים "שבורים" ולא קוהרנטיים ובעצם במצב הרבה יותר גרוע מאשר סתם לא להצליח לבצע קריאה כלשהי. לכל שפה ומערכת יש את הכלים שלה כדי להתמודד עם הקושי הזה – אם זה כחלק אינהרנטי בהגדרת מנגנון החריגות (כמו משפט finally), באמצעות שימוש ב-smart objects או בכל דרך אחרת. ללא הבנה ברורה של המנגנונים האלו – אין טעם לטפל בחריגות.

בספר "Effective C++", סקוט מאיירס מגדיר שני כללים בסיסיים שלהם חייב לציית קוד במקרה של חריגות:

  1. קוד שהוא exception-safe, לעולם לא יאפשר דליפת משאבים;
  2. קוד שהוא exception-safe, לעולם לא ישאיר מבנה נתונים "שבור".

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

"התחייבות בסיסית" (The Basic Guarantee)

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

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

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

"התחייבות חזקה" (The Strong Guarantee)

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

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

"התחייבות לאי-זריקה" (The Nothrow Guarantee)

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

Meyers_0321334876_mech.qxd

במקרה של בליעה, פנו מייד לרופא!

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

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

הרבה פעמים תפיסה גנרית של exceptions היא בכלל שאריות של שלב הפיתוח או הדיבאג, שבו רצינו להיפטר מדברים שלא עניינו אותנו באותו רגע, ולאחר מכן הקוד נשאר כמו שהוא. לכן, כסוג של כלל אצבע – במהלך כל code review, או לפני כל commit, חשוב מאוד לזהות מקרים של אי-טיפול ולוודא שהם אכן אמורים להיות שם (ואם הקוד אמור להיות שם – זה כנראה מסוג המקומות שדורשים הערות בתוך הקוד, גם אם זה נראה Self Explained Code).

הייררכיה של טיפול בחריגות

איך ומה?

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

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

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

כללי אצבע

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

הכלל השני הוא הכלל אותו הגדרנו בהתחלה – ברומא, התנהג כרומאי. אם המערכת בנויה כך ששגיאות מטופלות מקומית – טפל בהן כך. אם המערכת מצפה שכל שגיאה תוצף עד לרמה חיצונית כלשהי שתבצע בה טיפול "אחיד" – אז זוהי הדרך. אי שמירה על הכלל הזה תוביל לכך שתקלות פשוטות למדי תגרומנה לקריסה, או לחילופין, שלא ניתן יהיה לנתח ולהגיב לתקלות ב-scale מערכתי. זה נכון לא רק ל"טיפול" אלא גם ל"טיפוס". אל תזרוק שגיאה מסוג string ב-C++, למרות שאתה יכול – זרוק שגיאה ששייכת למשפחת הטיפוסים של exceptions. אל תזרוק שגיאת RunTime בג'אווה אם מדובר בשגיאה שניתן היה לצפות מראש… כל שפה וסביבה והכללים שלה לטיפוסים הרלוונטיים.

ובכל זאת?

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

טיפוסי חריגות מתאימים לטיפוסי הקוד

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

למה, בעצם?

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

אבל – אם אנחנו יוצאים לקונטקסט רחב יותר, המשמעות של החריגה כבר אינה ידועה. אם מתקבלת IO-Exception מפונקציה מורכבת, שאחראית לביצוע מספר רב של משימות, לא נוכל להבין ממנה איזו תקלה בדיוק אירעה, מה התבצע ומה לא, ולפיכך גם לא נוכל לתכנן טיפול יעיל במקרה שכזה. לעומת זאת, אם באותה הפונקציה מתקבלת שגיאה שמקורה ידוע – למשל, מפני שהיא מטיפוס PerformanceLogIOException ולא מטיפוס אחר, נניח UserDataIOException או StockTradeIOException, נדע לקבל החלטה מתאימה. למשל, שכל עוד מדובר רק בבעיה כלשהי שנוצרה בספריית רישום הביצועים, לא מדובר בתקלה קריטית במערכת, ולכן אפשר פשוט להפנות את המשימה מחדש לשרת אחר, לשנות קונפיגורציה, או לפעול בכל דרך מתאימה אחרת. כמובן, לא בהכרח יעניין אותנו הטיפוס הספציפי של השגיאה; ייתכן שיעניין אותנו רק המקור שלה. נניח, למשל, שכל טיפוסי החריגות במודול PerformanceLog יורשים טיפוס בסיסי של PerformanceLogException. במצב כזה, חלקים באיזורים "גבוהים יותר" בקוד יכולים להסתפק בלתפוס את טיפוס החריגה הזה, ולבצע טיפול גנרי לשירות רישום הביצועים.

על פי אותו העיקרון, נוכל לבצע הפשטה של כמה רמות. למשל, יצירת PerformanceException, שתחתיו תוגדרנה כל החריגות הרלוונטיות למודול הביצועים; ממנו, נוכל לרשת טיפוסי-אב נוספים, כמו למשל PerformanceLogException, שיהיה אחראי לכל תת-המודול העוסק ברישום ללוג של הביצועים, או PerformanceTweakException, שיהיה אחראי לכל תת-המודול העוסק בביצוע שינויים בביצועים. ותחתם, נוכל לרשת רמות נוספות, כמו למשל PerformanceLogIOException. כך, בכל רמה בהייררכיות המערכת, נוכל להחליט אם לטפל בשגיאה הספציפית או שנכיר אותה רק בצורה כללית יותר.


לדוגמא:

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

השימוש ברמות שונות של ירושה מאפשר רמות שונות של היכרות והחלטה.


 

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

שמרו על המשמעות

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


לדוגמא:

נניח שיצרנו מבנה נתונים חדש, שמתנהג כמו Map או Dictionary – מחזיק אוסף של מפתחות ולכל מפתח ערך מתאים. אם ננסה לגשת למפתח שלא קיים, אנחנו אמורים לזרוק חריגה מסוג של NoSuchKeyException, או משהו בדומה לזה. אם ננסה לקבל את המפתח לערך כלשהו שאינו קיים, נקבל חריגה מסוג שונה – משהו בסגנון NoSuchValException. עד כאן הכל הגיוני… אבל מה אם יצרנו מבנה נתונים A שמסיבות כלשהן (נניח, זמן גישה) שומר את הערכים שלו כמפתחות ב-Dictionary פנימי B? במקרה כזה, גישה לערך שאינו קיים ב-A תגרום לזריקת NoSuchKeyException כאשר יתבצע המימוש שלה באמצעות קריאה ל-B. אם נחליט פשוט שאנחנו זורקים כל חריגה לרמה שמעליה, נזרוק למעשה (ברמת המבנה A) חריגה שאינה נכונה ואינה מובנת. המערכת פנתה לקבל ערך, וקיבלה חריגה על מפתח שאינו קיים – זו אינה התנהגות צפוייה, והיא יכולה להיות אפילו מסוכנת, כי מי שקרא לערך ינסה לתפוס אך ורק חריגות רלוונטיות, ולא חריגות מטיפוסים שלא אמורים להיזרק. לכן, במקרה כזה, הדבר הנכון יהיה לתפוס את החריגה מ-B, ולזרוק במקומה חריגה מטיפוס NoSuchValException. בחלק מהשפות ניתן לכלול את החריגה המקורית כחלק מהמידע שנושאת איתה החריגה החדשה (לא בהכרח נרצה לעשות את זה – תלוי האם אנחנו בכלל מעוניינים לחשוף את המימוש הפנימי).


 

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

הגדירו בדיוק אילו חריגות עשויה כל קריאה לזרוק

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

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

תפוס וזרוק

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

פולימורפיזם

אפשרות אפילו טובה יותר, אם כי לא מתאימה לכל סיטואציה, תהיה להשתמש בעקרון הפולימורפיזם. אם הטיפוס DatabaseAuthenticationError יורש את הטיפוס DatabaseConnectionError, הבעיה אינה צריכה פתרון – היא פשוט אינה קיימת. כל המנגנונים הקיימים יודעים שעליהם לטפל ב-DatabaseConnectionError. כמובן, כעת נוכל לשפר אותם ולהוסיף טיפול ייחודי עבור המקרה של בעיית אותנטיקציה, אבל לא שברנו את הקוד בשום מקום. ירושה כזו, כמובן, אינה תמיד אפשרית; אולם זהו אחד היתרונות בבנייה של מערכת חריגות הייררכית כפי שהצעתי קודם לכן – היא מאפשרת פתרונות מהסוג הזה ושומרת על המערכת יציבה יותר. כצעד מקדים, ניתן לוודא שפונקציות זורקות טיפוסים "כלליים" לצד טיפוסים ספציפיים יותר. למשל, גם אם בזמן כתיבת הפונקציה אנחנו יודעים שהיא עלולה לזרוק DatabaseNotExistsError (שיורש מהטיפוס הכללי של חריגת DatabaseConnectionError), לא נגדיר את החתימה שלה רק עם DatabaseNotExistsError, אלא גם עם הטיפוס הכללי יותר, DatabaseConnectionError. כך נוכל לוודא שאם בעתיד תהיינה חריגות DB אחרות – כמו, למשל, DatabaseAuthenticationError – הן תטופלנה ולא תיגרם שבירת קוד.

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

טפלו בכל חריגה צפויה. אל תטפלו בחריגות בלתי צפויות

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

סיכום

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

  • שמירה על "חוזה" חריגות תקף
  • טיפול / זריקה במודע של כל חריגה לגיטימית
  • אי טיפול בחריגות שאינן צפויות
  • שמירה על ה"ההתחייבויות", בעדיפות ל"התחייבות החזקה"

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

 

כשלים

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

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

VF01

סליחה, תקלה. (מקור: freevector.com, רשיון: CCA4.0)

כשלים: איך לא?

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

  • מניעת כשלים
  • צמצום כשלים
  • זיהוי כשלים
  • התאוששות מכשלים
  • התרעה על כשלים

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

מניעת כשלים

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

תכנות

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

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

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


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


 

תכנון

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

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

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

צמצום כשלים

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

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

זיהוי כשלים

מטרה

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

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

אמצעי

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

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

התאוששות

ישנם מנגנוני התאוששות שונים, אשר ניתן, בצורה גסה, לחלק לשלוש גישות. פתרון, שמתגבר על הבעיה המקורית; מעקף, שמאפשר המשך עבודה גם ללא פתרון ה-Root Cause, ותכנון טולרנטי: בניית ארכיטקטורה אשר מתמודדת באופן טבעי עם הכשל. ישנה גם גישה רביעית, הנדרשת בעיקר בסוג מסויים של מערכות: השבתה — או, למעשה, אי-התאוששות.

רוטינת פתרון

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

  • הפיכת אחד משרתי ה-Slave של בסיס נתונים לשרת ה-Master
  • הפניית התעבורה דרך מסלול ראוטינג שונה
  • שימוש באמצעי אחסון מידע חליפי
  • תעדוף שונה של תעבורה
  • חסימת שירותים לא קריטיים

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

מעקף בעיות

לא את כל הכשלים נוכל לצפות מראש. כשלים רבים עלולים להיות חמקמקים ולא ברורים לחלוטין. לעתים, גם אם בעיות במערכת תמשכנה להופיע ברציפות, יהיה קשה לזהות מה בדיוק מקורן ולגזור מכך את הפתרון המתאים. ובכל זאת, גם לבעיות מסוג זה, ניתן פעמים רבות לייצר "מעקף" (Workaround) אשר ידאג שהמערכת תמשיך לתפקד גם ללא הבנה של מקור הבעיה — ה-Root Cause. במקרים כאלו, ניתן לנסות ולזהות מאפיינים אשר מתקשרים בסבירות גבוהה לכשל, גם אם לא מדובר בקשר ודאי או בכזה שיש לנו הסבר לגביו. אם נזהה מצב כזה, נוכל לנסות ולהפעיל "רוטינת פתרון" (אוטומטית או אנושית, בדיוק כמו בסעיף הקודם), כך שהמערכת תמשיך לתפקד כראוי. הנה כמה דוגמאות למצבים כאלה:

  • עומס יתר. מצב שבו משאבים — כמו זכרון, שטח דיסק, כוח עיבוד, ערוץ תקשורת — נמצאים בעומס יחסי, עלול לגרום לבעיות. לא תמיד נוכל לזהות בוודאות את הבעיה הקונקרטית, אך ייתכן שנוכל בכל זאת לראות כי כשלים מסויימים, זהים או שונים, מתרחשים תחת עומס. במקרה כזה, ניתן לפתור את הבעיה בכך שנוריד את רמת העומס במערכת. בין אם באמצעות ניטור העומס והגדלת המשאבים ברגע שעלינו מעל לסף כלשהו (למשל, הקמה של עוד שרתים וירטואליים או הפניה של משימות למשאבים אחרים) ובין אם באמצעות הקטנת נפח העבודה (למשל, הורדת קצב הביצוע של מנועים עצמאיים או אי קבלת בקשות חדשות של שרתי SaaS).
  • ריסטארט. זהו הפתרון האוטומטי שלנו לבעיות רבות בחיי היום-יום, ובכל זאת אפשר לקבל צמרמורת רק מהמחשבה שזה יהיה פתרון במערכת שבנינו. המציאות היא אי שם באמצע: מערכת שתבצע ריסטארט אוטומטי כדי לחזור לתפקוד תקין היא לא בהכרח פאר היצירה הארכיטקטוני, אבל מצד שני – אם זה עושה את העבודה, אז צמרמורת היא לא טיעון-שכנגד חזק מספיק. תארו לעצמכם, למשל, מערכת מבוזרת בת כמה שנים, שעברה אבולוציה משמעותית מאוד מאז שהוגדרה ונכתבה לראשונה, ובה כל שרת הופך לפחות אפקטיבי לאחר שבוע של עבודה רציפה. האפשרויות הן לבצע ריסטארט לכל שרת לאחר שבוע, תוך וידוא ששאר השרתים ממשיכים לעבוד באותו הזמן ולספק את השירות, או – להקים כח משימה שיחקור לעומק את מהות הבעיות, יזהה אותן במלואן ובוודאות, וישכתב את הקוד כך שהכשל ייפתר בצורה מהותית. עם כל אי הנוחות שב"פתרון" מסוג זה, כנראה שבמקרים כאלו זהו הפתרון הנכון.
  • ביצוע מחדש. במערכת בה כל משימה עוברת Workflow מורכב, ייתכן שבחלק מהמקרים ישנן משימות שאינן מגיעות לשלב הסיום. דרך אחת היא לעקוב אחר המשימות לאורך השלבים השונים, ולזהות בדיוק את הכשל – באיזה רכיב, באיזה סוג משימות וכן הלאה. פתרון אחר, פשוט יותר, יכול לקבוע לכל משימה זמן סביר לביצוע, ואם עבר ה-Timeout מרגע ייצור המשימה, והיא עדיין לא הגיעה לכדי סיום – נבטל אותה ונייצר משימה אחרת במקומה.

דיון מעניין בשאלה מתי נכון להתמקד בזיהוי ופתרון אמיתי של ה-root cause ומתי להעדיף פתרון שהוא workaround מופיע כאן: "ארכיטקטורה: האם לנסות שוב?" בבלוג של ליאור בר-און.


 

ארכיטקטורה טולרנטית

בסעיף הראשון הצענו לפתור בעיות אותן אנו מזהים; בסעיף השני, הצענו דרך לפתור בעיות אותן איננו מזהים. דרך שלישית היא לבנות מערכת שבה הבעיות אינן דורשות "פתרון", כיוון שהן כלל לא תהפוכנה לכשל מערכתי. נניח, למשל, מערכת הפרושה על פני מספר איזורים, מתנהלת מול בסיס נתונים, ומעבירה משימות בין תהליכים שונים בעזרת Message Queue. הנה מספר כשלים שייתכנו במערכת כזו:

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

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

  • במקום לנהל Message Queue במבנה נתונים היושב בזכרון של תהליך (כלומר, ב-RAM), נשתמש בטבלה או Collection של בסיס נתונים כדי לממש את התור. כעת, גם אם המערכת נפלה – כל המשימות (מסרים) שהיו בה נשמרו, וברגע שהמערכת תעלה מחדש, היא תמשיך בדיוק מהנקודה בה היא הפסיקה.
  • האם כל האיזורים זקוקים בדיוק לאותו המידע? ייתכן שכל איזור זקוק אך ורק (או: בעיקר) למידע מקומי, שמקורו בתהליכים אחרים של המערכת באיזור זה. בניה נכונה של בסיס נתונים (למשל: חלוקה למספר בסיסי נתונים איזוריים, או שימוש בטכנולוגיות של Sharding/Partitioning) יכולה להפוך "בעיה" מסוג זה ל"לא בעיה". למשל: מערכת לניהול בתי ספר בארה"ב יכולה להחזיק את כל המידע בבסיס נתונים אחד, אך היא יכולה גם להחזיק את המידע, פיסית, באיזורים גיאוגרפיים שונים. כך, גם אם תהיה תקלת תקשורת בין אזורים שונים, המערכת שבניו-יורק תמשיך לגשת לנתוני בתי הספר בניו-יורק ללא הפרעה, בעוד שהמערכת שבטקסס תעשה את אותו הדבר מול בסיס הנתונים המקומי שלה.
  • במערכות מסויימות ייתכן ש"משימה" היא עניין קריטי שיש לבצע בדיוק פעם אחת. במערכות אחרות, "משימה" עשויה להיות בנויה כך שאם היא לא התבצעה תוך זמן מסויים, לא קרה כלום, כיוון שהמנגנון ייצר בכל מקרה משימה נוספת על פי הצורך. למשל: תהליך של תרמוסטט יכול ליצור משימה לתהליך אחר לצורך הנמכת הטמפרטורה או הגברה שלה. כל עוד הטמפרטורה אינה בטווח הנכון, הוא ייצר משימות מתאימות. גם אם משימה הלכה לאיבוד, או לחילופין התבצעה פעמיים – לא קרה כלום. המערכת בנויה כך שזה פשוט לא ישפיע על הפעולה שלה כל עוד לא מדובר באיבוד מסיבי (עיקרון דומה לזה מופעל בפרוטוקולי תקשורת של מדיה שהם unreliable).

השבתה

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

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

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

במערכות שאינן קריטיות כמו מנועי מטוס או קוצבי לב, כדאי לעיתים להשתמש במנגנון ההשבתה בכל מקרה של באג מהסוג של "Can't Happen". למשל, אם נתון ש"חייב" להיות בבסיס הנתונים אינו נמצא, או אם קיבלנו ערך מטיפוס לא מתאים למרות שאין במערכת דרך לגיטימית להכניס אותו כאינפוט. במקרים כאלו, משהו מאוד לא טוב קרה. ייתכן שמישהו תקף את המערכת שלנו, או שיש בה כשל מסוג שכלל לא העלינו על דעתנו (זאת, בניגוד לבעיות "לגיטימיות", כמו בעיות תקשורת או אלמנטים של מערכת ההפעלה). אם נסתפק ב-"Alert", לא בטוח שיהיה מי שיטפל בזה, לעומת השבתה, שתקפיץ מיד את כל המערכת. הדברים נכונים עוד יותר בשלבים מוקדמים של מערכת, כאשר היא טרם הבשילה, ונמצאת בשלבי בדיקות או POC.

התרעה על כשלים

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

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

  • למי ממוענת ההתרעה? התרעה יכולה להיות רלוונטית למשתמש, למנהל, לאיש התמיכה, לאיש הפיתוח או לנמענים אחרים. יש דברים שלא נרצה להראות ללקוח, ויש דברים שסתם יטרידו את איש התמיכה. חשוב למען כל התרעה למקום הנכון.
  • איזה מידע נושאת ההתרעה? מנגנונים פשוטים מסתפקים לעיתים בהודעה טקסטואלית. מנגנונים קצת יותר מתוחכמים, מוסיפים "קוד שגיאה". אבל מנגנונים מפותחים יותר יכולים לשאת מידע רלוונטי, כמו כתובות רשת, שמות תהליכים, שם קובץ ומספר שורה, שם משתמש, ערכים מספריים הקשורים לגורם הבעיה, Exception רלוונטי, מצב המערכת וכדומה. מנגנונים כאלו הם מסובכים יותר למימוש, ועוד יותר לתחזוקה – ככל שהמערכת מתקדמת יותר, גם המידע הרלוונטי הופך להיות מורכב יותר. מצד שני, הם נותנים הרבה יותר מידע לאנשי התמיכה או אבטחת האיכות; הם מאפשרים שמירה יעילה לבסיסי נתונים בדרך שתאפשר לאחר מכן ניתוח מעמיק; ויותר מכך, הם מאפשרים למערכות תוכנה אחרות להגיב להם או לפעול על פיהם ביתר קלות.
  • "עוצמת" ההתרעה – האם מדובר במידע, באזהרה, בשגיאה, בכשל? הגדרה לא נכונה של רכיב זה יכולה להפוך לחלוטין את יעילות מנגנון ההתרעה.
  • "מצב" ההתרעה – האם מדובר בהודעה שהיתה נכונה לאותו רגע (למשל: נסיון תקשורת נכשל; תהליך נפל) או בהודעה שעדיין תקפה, ויש צורך לטפל בה (למשל: לא קיימת תקשורת – יש לוודא חיבור פיסי; תהליך אינו רץ – יש להריץ אותו מחדש).
  • תדירות ההתרעה – מערכת נאיבית תשלח בכל פעם את אותה ההתרעה עבור אותו כשל. אולם, לעתים רבות המשמעות של הצטברות כשלים היא שיש בעיה חמורה בהרבה. למשל, נסיון גישה לבסיס נתונים שנכשל, הוא מקרה לגיטימי שמערכת סבירה תתמודד איתו בקלות. אולם, אם נסיונות רבים נכשלים, מדובר כנראה בבעיה אקוטית יותר. האם מדובר בכשל של כל נסיון, או אולי רק במחצית מהנסיונות? האם מדובר בכשל גישה לכל רשומה בבסיס הנתונים, או אולי אך ורק לטבלה מסויימת? ניתוח נכון של הכשלים יכול לייצר סט של "התרעות-על", שהן מורכבות יותר לזיהוי, אך עשויות להיות חשובות לאין ערוך מהתרעות "פשוטות". התרעות-על אלו ניתן לייצר ברמת המערכת עצמה, אך ניתן גם לייצר אותן באופן נפרד לחלוטין — למשל, באמצעות תהליך הסורק את רשימת ההתרעות בשעות האחרונות ומחפש בהן חזרות ותבניות.

סיכום

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

failures

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

  • ברמת הקידוד:
    • נהלי קידוד אחידים וברורים ותהליכי Code Review שיוודאו את מימושם
  • ברמת הארכיטקטורה:
    • ביזור משימות ואלמנטים
    • מנגנוני Watchdog
    • תכנון טולרנטי
  • ברמת המשאבים (כוח עיבוד, נפח זכרון ושמירה, ערוצי תקשורת, הגדרות מערכת ההפעלה):
    • הקצאה
    • הגדרת ספים קריטיים
    • ניטור
  • ברמת ההתרעות:
    • זיהוי מדוייק ומיידי של כשלים ומצבים קריטיים
    • ניתוח של כשלים והתרעות לכדי "התרעות-על"
  • ברמת המערך:
    • מנגנוני התאוששות אוטומטיים במקומות בהם זה אפשרי
    • מערך תמיכה אנושי לטיפול בשאר התחומים

מספור גרסאות (SW Versioning)

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

 

Branches on a rainy day

 

גרסאות: מה חשוב לדעת?

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

קהל היעד

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

תכולת הגרסא

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

גרסא מונוליטית vs. גרסא מבוזרת

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

  • באיזו רזולוציה לחלק את הרכיבים השונים?
  • האם כל הרכיבים מנוהלים בדרך דומה, או שיש הפרדה בין "תשתית" לבין "אפליקציות"?
  • מה היחס בין רכיבים בגרסאות שונות – איזה צירוף של גרסאות רכיבים הוא "חוקי" ואיזה לא יאפשר מערכת עובדת?
  • כיצד תיאכף התצורה של הגרסאות השונות לבדיקת החוקיות שלה?
  • כיצד ינוהלו שלבי המוצר השונים ובאילו תצורות של גרסאות רכיבים (פיתוח, בדיקות, הטמעה, PreSale, PostSale וכן הלאה)?

גירסאות סביבת הבניה

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

ארכוב תוצרים

באופן עקרוני, שימוש בגרסת קוד מקור נתונה, יחד עם הקפדה על סביבת בנייה זהה (בכל דרך שנבחר), אמורה לספק לנו תוצר זהה ב-100% (כמעט. למעט Timestamps, חישובים רנדומליים וכדומה. בכפוף לתקנון. ט.ל.ח.) למוצר המקורי. לכן, גישת קצה אחת מציעה לשמור אך ורק את הקוד, כדי להימנע מבעיות קלאסיות של שכפול מידע, כמו נפח גבוה ומיותר או, גרוע יותר, חוסר קונסיסטנטיות של הרכיבים השונים. עם זאת, לעתים נבחר לשמור גם תוצרים (כמו: תוצרי קומפילציה; תוצרי לינקייג'; קבצי מניפסט שונים; קבצי קוד הנוצרים באמצעות סקריפטים, …). יכולות להיות מספר סיבות לגישה זו, למשל:

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

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

קונפיגורציה

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

קבצים בינאריים

איך נשמור קבצים בינאריים? האם הם יהיו ממש חלק מהגרסא, או שאולי נשמור רק path אליהם, או חתימת md5? לא מדובר כאן בהכרח על קבצי תוצר, עליהם שוחחנו קודם, אלא על "resources", כמו קבצי תמונה, וידאו או סאונד.

ליניאריות הגרסא

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

ניהול Branches

לאילו גרסאות "רצות", אנו מצפים בכל רגע נתון?

גרסאות Product

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

גרסאות פנימיות

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

פעפוע שינויים

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

צורת הפיתוח וההכנסה ל-repository

כאן מדובר לא רק על branching נכון, אלא על כל ניהול הרויזיות והשינויים.

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

  • אילו branches קיימות במערכת, לצורך הפיתוח
  • איך ומתי נקבע tag על גרסא או branch נתון
  • מהי תכולת "שינוי" ברויזיות השונות (קובץ אחד? סט קבצים? סט קבצים שמכיל פיצ'ר מושלם?…)

אכיפת שימוש בגרסאות "נקיות"

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

שמות הגרסאות

זה אולי נשמע ברור לחלוטין, אבל במו עיני ראיתי לא מעט חברות שנופלות בדבר הדי-בסיסי הזה. קראנו לגרסא "אלפא" – האם זה אומר שלקוחות יקבלו אותה או שהיא פנימית לחלוטין? ואם זו "ביתא"? או "Pre-Beta", או "Final Alpha Clean"? לפעמים שם גרסא מוחלט ללא מחשבה עמוקה, או מסיבות פוליטיות ("התחייבנו ללקוח לספק גרסת ביתא עד אפריל", "הפרודאקט דורש מהפיתוח שבדיקות על גרסת 3.0 יתחילו כבר בשבוע הבא". כאלה…) והוא יוצר בהמשך בלבול רב. או, יותר מוצלח מזה – מקרים בהם תוך כדי תנועה משנים שם של גרסא מסיבות כאלו. יצא לי להיות בחברה שבה אחת לכמה שבועות נשלח מייל לכל אנשי הפיתוח, האומר דברים בסגנון "הגרסא שנקראה עד כה 'אלפא' תיקרא מעתה 'ביתא פנימית'. במקום גרסא 'ביתא' נשתמש מעכשיו בשם 'ביתא חיצונית'. כל מפתח העובד על פיצ'ר שמוגדר להכנסה לגרסת 'אלפא' או 'ביתא' יעדכן את הפיצ'ר או הבאג בהתאמה. תודה!"

סיכום

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

 

מעבר לארכיטקטורה חדשה: אבולוציה של מערכת קיימת ("המחלף והצומת", חלק 5)

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

אבולוציה של מודולים

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

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

  • כתיבת שלד ארכיטקטורה יארך זמן רב ואנו נדרשים לפתור חלק מהבעיות השורשיות באופן מיידי. במקרה כזה, ייתכן שאפילו מספר עקרונות בודדים (לעתים: אחד בלבד) מהארכיטקטורה החדשה יפתרו בעיות קשות, ויש חשיבות עליונה לאמץ אותם מהר ככל האפשר, ללא תלות בשאר הארכיטקטורה.
  • חלקים בארכיטקטורה החדשה חייבים להיבחן לאורך זמן לפני שנחליט לאמץ אותם. אם שאר חלקי הארכיטקטורה תלויים בחלקים אלו, לא נרצה לבצע עבודה מלאה של בניית שלד שייתכן ובסופו של דבר לא יתאים לנו כלל. במקום זאת, נעדיף לממש חלקים מסויימים בשלב ראשון, לתת להם לעבוד זמן מה, ורק אז להמשיך בבניית שאר המערכת.
  • הקצאת כח אדם לבניית ארכיטקטורה מלאה אינה אפשרית. לכל גוף תוכנה ישנם אילוצים רבים, והפניית מספר רב של מפתחים לטובת ארכיטקטורה חדשה לאורך זמן, עלולה לפגוע בשאר משימות הקבוצה. לכן, ייתכן וניתוב המשאבים יחולק באופן שונה, למשל, מעט מפתחים לאורך זמן ארוך, או מפתחים מדיסציפלינות שונות בכל פעם, בהתאם לשאר המשימות בכל רגע נתון. במקרה כזה יהיה קל יותר להתקדם בשלבים קטנים. בעיית כח האדם איננה חייבת להיות בקרב המפתחים בלבד. ביצוע של בדיקות סיסטם לשתי מערכות במקביל עלול לדרוש משאבי QA גבוהים, בעוד בדיקת מערכת אחת בכל פעם, גם אם חלים בה שינויים משמעותיים, מאפשר המשך בדיקות במשאבים דומים. אותו הדבר נכון גם להטמעה, ל-PreSale, ל-PostSale ולכל תחום אחר המושפע מכך.

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

גמישות מוצרית

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

התקדמות בטוחה

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

סיכונים אפשריים

למה לדחות למחר מה שאפשר לדחות למחרתיים?

למרות שכאמור, מדובר בגישה כמעט טריוויאלית – בגישה זו סיכונים וקשיים רבים מאוד. בראש וראשונה, הסיכון העיקרי: דחיית הארכיטקטורה בשלמותה לגרסא "9.99". כל עוד אנו נוקטים בגישה של שינויים מינוריים יחסית לאורך זמן, ניתן לומר בבטחון שמשימות דחופות אחרות ייכנסו בתעדוף גבוה יותר בכל שלב. לכן, קיים סיכוי גבוה שלעולם לא נשלים את בניית הארכיטקטורה במלואה, ונישאר רק עם חלקים ממנה. כמו שאני נוהג לומר בכל נושא: לא מדובר בהכרח בדבר רע. ייתכן בהחלט שההחלטה הנכונה תהיה לאמץ רק 50% מתכניות השינוי המוצעות, ולפתור בכך 95% מהבעיות הקיימות. אולם אם זו ההחלטה – יש לקבל אותה כהחלטה, ולא לגלות בדיעבד שנגררנו לשם בגלל הנסיבות.

כשיש יותר מעקפים מכבישים

סיכון גבוה אפילו יותר הוא, שנקבל מוצר שהוא שעטנז בין הארכיטקטורה הקודמת, הארכיטקטורה החדשה, ושאריות של טלאים שנוספו כדי לאפשר את השינוי הזה בשלבים קטנים. ככל שהארכיטקטורה הנוכחית בנויה נכון יותר ומורכבת מחלקים שהם Loosely Coupled, כך יהיה קל יותר לבצע בה החלפות מקומיות ללא תקורה מיותרת. אם, למשל, יש לנו מערכת שרצה על מחשב אחד אבל מבוזרת לתהליכים רבים – יהיה קל יחסית להוסיף שכבת תקשורת שמאפשרת ביזור של התהליכים על פני מחשבים שונים. אם היא לא בנויה מתהליכים אבל כל רכיב בנוי במודול משלו עם API סטנדרטי מול שאר המערכת, יהיה קשה יותר, אך עדיין אפשרי להפוך כל מודול לתהליך. אם כל הרכיבים יושבים באותו מודול תחת איזשהו Switch-Case, זה יהיה כבר "ניתוח" מסובך בהרבה. ואם הלוגיקה של כל רכיב כלל לא יושבת באותה יחידה אלא פזורה בין מודולים יחד עם קוד של עוד רכיבים… זה כנראה יהפוך למבצע מסובך כל כך, שספק רב אם זו הגישה הנכונה עבורו. במקרה כזה, כל שינוי ברכיב נתון ידרוש הרבה מאוד תקורה עבור אדפטציה.

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

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

  1. שינוי כל קריאה ל-DB הישן שאינה תואמת את החדש, כך שהיא תחזיר אותם ערכים
  2. שינוי כל קריאה מסוג "X" לקריאה מסוג "Y"
  3. שינוי כל קריאה מסוג "A" לרצף קריאות מסוג "B, C, D" ולוגיקה עליהן

וכך נמשיך וניצור עוד ועוד דורות של שינויים. בעצם, הארכיטקטורה המקורית שאליה שאפנו בכלל הגדירה הפרדה מוחלטת בין הלוגיקה לבין ה-DB! התכנית שאליה שאפנו היתה ליצור API סגור, שעובד מול ORM כלשהו, וכך יוצר הפרדה מוחלטת בין הלוגיקה לבין ה-Data. אבל הצורך לבצע שינוי מינורי בכל פעם, כך שהמערכת תמשיך לעבוד – השאירה אותנו עם המוני תיקונים, מעקפים וטלאים, כך שכאשר הגענו לשלב ה-API, כבר הוחלט לוותר על חלק מהשינויים כיוון שהם יקרים מאוד ופחות קריטיים למוצר. זוכרים את תמונת המחלפים מהפוסט המקורי? כן. על זה בדיוק אני מדבר. זה מה שקיבלנו.

High Five

 

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

שלב אחרי שלב

גם הפעם נדגים את הגישה באמצעות ה"מערכת לזיהוי שירים" שהצגנו בעבר. במערכת זו, ייתכן למשל רצף השינויים הבא:

  1. המערכת המקורית מכילה את בסיס הנתונים הישן ואת מנגנון ה-Analyzer יחד עם שלושת הפיצ'רים לשירים.
  2. נחליף DB, ויחד איתו נבצע את השינויים המתחייבים ברכיב ה-Analyzer ובכל אחד משלושת הפיצ'רים של השירים כך שיתאים ל-DB החדש.
  3. נוסיף את פיצ'ר #1 לסרטים. הוא צריך להיות תואם ל-DB החדש, אבל עדיין עובד במנגנון הישן של ה-Analyzer שמריץ ישירות את הפיצ'ר בהתאם לקריאת ה-API.
  4. נשכתב את פיצ'ר השירים #1 בצורה כזו: הלוגיקה העיקרית שלו תרוץ באופן עצמאי כמיקרו-שירות. כיוון שהמערכת עדיין אינה מכילה Dispatcher המנהל את הקריאות למיקרו-שירותים, נבנה שכבת לוגיקה דקה עבור פיצ'ר זה, שמופעלת ישירות בידי המנגנון הקיים (Analyzer) ואחראית לתפעל את הלוגיקה של הפיצ'ר כמיקרו-שירות.
  5. נבצע את אותו התהליך עבור פיצ'ר השירים #2.
  6. כעת נרצה להוסיף את פיצ'ר הסרטים השני. נכתוב גם אותו בדרך דומה, כמיקרו-שירות עצמאי עם שכבת הפעלה הנמצאת ב-Analyzer.
  7. נשכתב את הפיצ'רים שנותרו (פיצ'ר שירים #3, ופיצ'ר סרטים #1) כמיקרו שירותים.
  8. כעת, כאשר כל הפיצ'רים במערכת רצים כמיקרו שירותים, נבצע את השינוי המשמעותי ביותר בארכיטקטורה: נחליף את ה-Analyzer במנגנון ה-Dispatcher, שיתפעל מעכשיו באופן ישיר את חמשת המיקרו-שירותים במערכת.
  9. אם נרצה להוסיף כעת עוד פיצ'ר (פיצ'ר סרטים #3), נוסיף אותו פשוט כעוד מיקרו-שירות.

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

arch-evolution

סכמה המתארת מעבר רב-שלבי בין הארכיטקטורה המקורית לארכיטקטורת היעד

סיכום

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

יתרונות

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

חסרונות

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

מתי זה יתאים

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

בהצלחה!

מעבר לארכיטקטורה חדשה: מערכת חדשה, עם פרוקסי לישנה ("המחלף והצומת", חלק 4)

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

המערכת החדשה תספק פרוקסי למערכת הישנה

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

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

כיצד זה עובד, שלב אחר שלב

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

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

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

צעד אחד קדימה: שיפור המערכת הישנה בעזרת הפרוקסי

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

פתרון בעיות תזמון. נחשוב על מקרים בהם המערכת הישנה היתה פגיעה בשל תזמוני I/O (למשל, מצבי race condition שלא טופלו היטב). ייתכן ומערכת תזמונים פשוטה בפרוקסי יכולה למנוע את התקלות. למשל, סידור של פניות ממספר רב של clients דרך message-queue או מערכת דומה. מבחינת המערכת הישנה, לא היה כל שינוי. ובכל זאת, כבר בשלב הראשון, ועוד לפני שכתבנו פיצ'ר אחד חדש על גבי המערכת החדשה, פתרנו בעיה רצינית.

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

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

ואולי אפילו – שני צעדים קדימה?

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

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

איך זה עובד? דוגמת זיהוי השירים

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

  1. בניית שלד הארכיטקטורה החדשה
  2. הוספת פרוקסי למערכת הישנה, כך שכל קריאת API תחת "/System/Api/Songs" תופנה למערכת הישנה
  3. הוספת רכיב "Count Checker" לפרוקסי, המוודא שאין יותר ממספר מסויים של קריאות בכל רגע נתון, ואם הגיעה בקשה נוספת, הוא מחזיר הודעת שגיאה
  4. הוספת הפיצ'רים החדשים (פיצ'רים לסרטים, תחת "/System/Api/Movies") במערכת החדשה
  5. העברה של הפיצ'רים הישנים למערכת החדשה
  6. הורדה של המערכת הישנה

 

arch_new_as_proxy

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

 

לסיכום –

יתרונות:

  • ניתן להעלות את המערכת החדשה עוד לפני שכל הפיצ'רים הישנים שוכתבו עבורה
  • אין צורך לבודד כל מודול/פיצ'ר מהמערכת הישנה עם adapter משלו
  • מבחינת המשתמש, ישנו ממשק אחד בלבד
  • מערכות צד שלישי המסתמכות על ממשקי המערכת הישנים (למשל לוגים, התראות וכדומה) יכולים להמשיך לעבוד
  • מאפשר העברה הדרגתית של פיצ'רים למערכת החדשה
  • מאפשר פתרון של בעיות מערכתיות בארכיטקטורה הישנה ללא שינויה

חסרונות:

  • עשוי להיות הרבה יותר מסובך מכתיבת adapters לפיצ'רים ברמת המודול – תלוי במערכת
  • תיתכן ירידה ביציבות הכוללת, שעכשיו תלויה ביציבות שתי המערכות ובממשקים ביניהן
  • דורש משאבים להרצת שתי המערכות במקביל
  • ייתכן שיהיה קשה להעביר בהמשך מודולים/פיצ'רים בודדים למערכת החדשה

מתי זה יתאים:

  • כאשר כתיבת adapters ברמת הפיצ'ר הבודד היא משימה מסובכת (למשל, תלויות רבות בין פיצ'רים שונים)
  • כאשר כתיבת הפרוקסי למערכת הקודמת היא פשוטה יחסית (למשל, ניתוב כל הקריאות והנתונים דרך פרוטוקול פשוט)
  • כאשר היציבות של המערכת הקודמת אינה מסכנת את המוצר כולו
  • כאשר פתרונות ברמת הפרוקסי יכולים לשפר את המערכת הישנה
  • כאשר ניתן לכתוב פיצ'רים חדשים ללא תלות חזקה בפיצ'רים הישנים
  • בסביבה המאפשרת הרצה של שתי המערכות במקביל

בהצלחה!

תאימות לאחור – Backward Compatibility

כל מערכת שכתבתם תמיד שמרה על תאימות לאחור. ברור!
זו היתה דרישה מספר 1 (או 0, אם אתם אנשי C) במסמך הדרישות שקיבלתם: Backward Compatibility.

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

 

lego

לגו. תאימות לאחור, כבר חמישים שנה (מקור: quickmeme.com)

 

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

דוגמא אחת קטנה

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

באחת החברות בהן עבדתי, בתחום התקשורת, לקוח יכל לקנפג את המוצר שלו בדרכים שונות. אחד הפרמטרים קבע כללים להפניית תעבורת רשת, כך שלכל כלל הוצמד מספר שקבע את קדימותו ביחס לאחרים. כלל מספר 10 – ייבדק לפני כלל מספר 20, כלל מספר 20 – לפני כלל מספר 30, וכך הלאה. קונפיגורצית ברירת המחדל התבססה על שלושה או ארבעה כללים, הממוספרים 10, 20, 30, 40. הלקוח, כמובן, יכול להוסיף כללים משלו, ולמספר אותם כרצונו: 5, 17, 52…

בשלב מסויים, מסיבות כלשהן, הוחלט כי יש למספר את הכללים מ-1 ועד 16, ולא יותר. הכללים של ברירת המחדל הפכו, לפיכך, להיות ממוספרים כ-2, 4, 6, 8. כאשר הפיתוח של הגרסא החדשה הסתיים וביקשתי להבין את מהות השינוי, הסבירו לי בדיוק את זה. "ומה עם תאימות לאחור?", שאלתי. התשובה היתה "אין שום בעיה. כאשר הלקוח יעלה את הגרסא החדשה, אם יש לו כללים שמספרם גבוה מ-16, המערכת פשוט מחלקת אותם בעשר. מספר 20 יהפוך ל-2, 50 יהפוך ל-5, וכך אנו מוודאים שאין שום כלל הממוספר יותר מ-16".

מי שסיפק לי את ההסבר הזה היה אחד המפתחים הטובים ביותר שיצא לי להכיר. הסתכלתי עליו קצת המום, ואמרתי לו שאין כאן תאימות לאחור. הוא התעקש שדווקא יש: המערכת תהפוך את כל הערכים הישנים לחוקיים על פי הכללים החדשים, ולכן תמשיך לעבוד ללא שגיאות. ניסיתי להבין ממנו מה יקרה אם הלקוח קינפג במערכת הישנה כללים הממוספרים, נניח, כ-24, 26, 28. כעת שלושתם יהפכו לבעלי אותה רמת קדימות בדיוק – 2 – ולא ניתן לדעת בוודאות מי מהם יתבצע ראשון. כך ייתכן שלאחר עדכון המערכת, המוצר יפעל פתאום אחרת מאשר בגרסא הקודמת! אבל מבחינתו זו היא כבר "בעיה של הלקוח". הדבר היחיד שהיה חשוב מבחינתו הוא שלא יהיו שגיאות ריצה, וזה, מבחינתו, היווה "תאימות" מספקת.

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

הפתרון הנכון למקרה הזה יכול להיות:

  • כל עוד המערכת מקונפגת עם 16 כללים או פחות – מספור מחדש שלהם בצורה התואמת את סדר המספרים המקורי (למשל – 10, 20, 22, 24, 26, 30 – יהפוך ל-1, 2, 3, 4, 5, 6, ולא ל-1, 2, 2, 2, 2, 3).
  • אם המערכת מקונפגת עם יותר מ-16 כללים – לא להתחיל שדרוג עד שהמפעיל מסדר אותם מחדש.

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

נקודה למחשבה: מוכנות הלקוח

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

  • אפליקציה מציעה למשתמש שיתקין גרסא חדשה יותר מזו שבה הוא משתמש כרגע.
  • לקוח קונה טלפון מדגם דומה לדגם הקודם שלו, אבל בעל עיצוב חדשני יותר, ומשתמש בפונקציה של היצרן להעברת כל נתוני הטלפון הישן.
  • לקוח רכש רשיון שימוש במערכת SaaS גרסא 2.3 תמורת $30,000 לשנה, לצורך התממשקות עם המערכות הארגוניות שלו, ורכיבים בשרתי ה-SaaS מוחלפים ללא ידיעתו.
  • אפליקציה אינטרנטית בנויה מרכיב שרת ורכיב לקוח:
    • רכיב הלקוח (FrontEnd) נשאר זהה, אולם השרתים (Backend) הוחלפו לחלוטין.
    • רכיב הלקוח שינה עיצוב בצורה משמעותית, אולם השרתים נשארו ללא שינוי.
    • שני הרכיבים, גם צד השרת וגם צד הלקוח, עברו שינוי במקביל.
  • מכונת כביסה חכמה הורידה באופן אוטומטי גרסה חדשה שאמורה לשנות את תאוצת המנועים לצורך שמירה על חייהם.

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

ציר I: מחוייבות ללקוח

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

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

ציר II: משמעות השינוי

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

ציר III: הבנת הלקוח

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

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

קבלת החלטה

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

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

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

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

 

מעבר לארכיטקטורה חדשה: פלטפורמה חדשה, לוגיקה ישנה ("המחלף והצומת", חלק 3)

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

ארכיטקטורה חדשה עם שימוש ברכיבי לוגיקה קיימים

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

איך עושים את זה? שלב אחרי שלב

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

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

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

שינוי ממשקים בלבד

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

התאמת-ארכיטקטורה מינימלית נדרשת

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

התאמה פנימית מינימלית

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

שלבי המעבר

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

  1. כתיבת שלד הארכיטקטורה החדשה.
  2. כתיבת adapter המקבל קריאות שנכתבו ל-ORM הישן ומתרגם אותם לקריאות ל-ORM ולבסיס הנתונים החדש.
  3. כתיבת "מיקרו-שירות" גנרי, המאפשר ממשקי message-queue למודולים שנכתבו בצורה הפונקציונלית של המערכת הישנה.
  4. כתיבת מיקרו-שירות חדש, המשתמש ב-adapter, במיקרו-שירות הגנרי ובמודול הישן של "פיצ'ר שירים #1" כדי לקבל את הפיצ'ר בהתאם לארכיטקטורה החדשה.
  5. פירוק "פיצ'ר שירים #2" לשני חלקים, ושימוש ב-adapter ובמיקרו-שירות הגנרי כדי לאפשר לשני מיקרו-שירותים חדשים אלו לתת את אותו הפיצ'ר בהתאם לארכיטקטורה החדשה ובדרך שתמנע בעיות ביצועים.
  6. כתיבת מיקרו-שירות חדש, המשתמש ב-adapter, במיקרו-שירות הגנרי ובמודול הישן של "פיצ'ר שירים #3" לאחר ששוכתבו בו הקטעים שאין להם חלופה דומה ב-ORM החדש, כדי לקבל את הפיצ'ר בהתאם לארכיטקטורה החדשה.
  7. רק בשלב זה ניתן להעלות את המערכת, במקומה של המערכת הישנה, ולאפשר את אותם השירותים שניתנו בעבר – ייתכן שבצורה טובה יותר (למשל: עם פחות בעיות ביצועים בפיצ'ר #2) אך ייתכן שעם באגים חדשים.
  8. כתיבת שירותים חדשים (למשל: פיצ'רים הנוגעים לסרטים) בהתאם לפילוסופיה של הארכיטקטורה החדשה.
  9. לאורך זמן – החלפה של הפיצ'רים הישנים בפיצ'רים שנכתבו מלכתחילה בהתאמה לארכיטקטורה החדשה, למשל – מחולקים נכון למיקרו-שירותים.

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

arch_change_keep_old_modules

מעבר בין ארכיטקטורות תוך שימוש במודולים מהארכיטקטורה הישנה

מתי נכון להשתמש בגישה זו לשינוי ארכיטקטורה?

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

יתרונות:

  • אין צורך לתחזק שתי מערכות במקביל
  • המערכת החדשה תגיע בצורה מהירה יחסית לאותן יכולות של המערכת אותה היא מחליפה
  • הלוגיקה הפנימית של כל פיצ'ר נשארת בשלב ראשון כמעט ללא שינוי, למרות המעבר למערכת חדשה
  • תהליך השכתוב מחדש לפיצ'רים נעשה בצורה מסודרת, לאורך זמן ולפי תעדוף מתאים
  • פיצ'רים פשוטים ויציבים יחסית שאינם דורשים תחזוקה (תיקונים/שיפורים) יכולים להישאר מאחורי adapter ולחסוך לנו זמן פיתוח יקר

חסרונות:

  • המערכת החדשה מחליפה לחלוטין את הישנה; ייתכנו לפיכך בעיות יציבות ובאגים שכבר נפתרו בעבר
  • נבזבז זמן על כתיבת adapters זמניים שבסופו של דבר לא יהיה בהם צורך
  • זמן העליה הראשוני של המערכת יהיה רק לאחר המרת כל הפיצ'רים הישנים, בניגוד לגישה המקבילית למשל
  • "אין קבוע מן הזמני" – ללא תכנית מסודרת, עלולים חלק מהפיצ'רים להישאר במצב הביניים שלהם לזמן רב (זה לא בהכרח רע, ועשוי לחסוך זמן פיתוח; השאלה היא אם זה נעשה במתוכנן או מתכנון לקוי)
  • השימוש בקוד שנכתב בגישה מסויימת בתוך מערכת שנכתבה עם פילוסופיה אחרת עלול להיות לא יעיל – למשל במשאבים, בשכפול מידע/חישוב, ביכולות מופחתות (היכולות הן קבוצת החיתוך של יכולות המערכת החדשה והישנה…)

מתי זה יתאים:

  • כאשר לא ניתן להריץ שתי מערכות במקביל
  • כאשר חשוב שהמערכת החדשה תעלה במהירות יחסית עם הפיצ'רים הקודמים
  • כאשר הפיצ'רים הקיימים ניתנים לשימוש בצורה סגורה יחסית מאחורי adapters עצמאיים
  • כאשר ניתן להכיל ירידה מסויימת בביצועים/יציבות לטובת הקמה מהירה של המערכת החדשה

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

 

מעבר לארכיטקטורה חדשה: קיום מקבילי ("המחלף והצומת", חלק 2)

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

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

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

מערכת המלצות מבוססות דמיון

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

arch1

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

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

arch2

סכמה של ארכיטקטורת הדור החדש

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

  1. אנו מתחילים במצב בו קיימת המערכת הישנה
  2. כתיבת שלד לארכיטקטורה חדשה והזנת בסיס הנתונים החדש בסרטים
  3. כתיבת פיצ'ר סרטים ראשון: "מצא סרטים נוספים עם אותם שחקנים"
  4. כתיבת פיצ'ר סרטים שני: "מצא סרטים נוספים של אותו הבמאי"
  5. הזנת בסיס הנתונים החדש בשירים מהמערכת הישנה וכתיבה מחדש של אחד הפיצ'רים הישנים: "מצא שירים נוספים מאותו הז'אנר"
  6. כתיבת פיצ'ר סרטים/שירים: "מצא סרטים שפס הקול שלהם מכיל שיר מסויים"
  7. כתיבה מחדש של אחד הפיצ'רים הישנים: "מצא שירים נוספים של אותם הזמרים"
  8. כתיבה מחדש של פיצ'ר השירים השלישי: "מצא שירים נוספים מאותו מקום וזמן"
  9. …לאחר שכל השירותים הועברו למערכת החדשה: הורדה של המערכת הישנה
arch_change_parallel

תיאור סכמטי של המעבר מהמערכת הישנה לחדשה בצורה של עבודה מקבילית

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

יתרונות:

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

חסרונות:

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

מתי זה יתאים?

  • כאשר פיצ'רים שונים של המערכת אינם תלויים באופן הדוק זה בזה
  • כאשר יש לנו התחייבות שלא לשנות את היכולת/יציבות של מערכת קיימת
  • כאשר יש לנו יכולת (משאבים, כח אדם) לתחזק ולהריץ את שתי המערכות במקביל

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