יום שבת, 28 באוקטובר 2017

Plan/Execution Segregation

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

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

כל זה מוכר לרוב הקוראים, אני מקווה. אני חוזר על התובנה (החשובה) הזו שוב ושוב.

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


איך זה מתחיל?


ההתחלה המודעת של הסיפור היא, בד"כ - כבר לאחר כברת-דרך.

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

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

מידלנו את ההזמנה כאובייקט Order, המכיל רשימה של אובייקטי ListItem.

ListItem הוא "Pattern" נפוץ ושימושי למקרים כאלו.

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

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

עד כאן - טוב ויפה!


הבעיה מתחילה (לכאורה) כמה חודשים לאחר ההשקה:

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

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

דרישות כמו הנ"ל מריחות כמו בקשות ל"פיצ'ר פירוס" (Pyrrhic Feature) - פיצ'ר שלא באמת יפתור את בעיה, אלא רק יקבע ויחמיר אותה. על פיצ'ר כזה נאמר: "עוד פיצ'ר כזה - ואבדנו".

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

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



איך זה באמת התחיל? (סיפור ה Prequel)


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

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

למה לתחזק נתונים כפולים ב Database? למה סתם לשכפל קוד? הרי אומנו "לחפש ולהשמיד" כל כפילות קוד אפשרית.

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

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


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

ככל שהדוגמה יותר מורכבת, יותר קשה להבחין בטעות. למשל:


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

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



בכלל, האם "הזמנה" ו"משלוח" הם באמת אותו הדבר?


הנה כמה דוגמאות מדוע הם לא אותו הדבר (וטיעוני-נגד נפוצים):

פריטים (lineitems) שקיימים בהזמנה עשויים לא להיות במלאי - ולכן לא להיכלל במשלוח.
  • "אין בעיה! נמחק אותם מהרשימה"

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

אולי המשלוח יתבצע למקום אחר מאשר מה שהוזמן במקור? ("כן, אדוני השליח! אני לא בבית. תביא את זה בבקשה לחמתי או לשכן").
  • "נעדכן את הכתובת. זה קל!".

התנהגות המערכת לגבי החזרת פריטים במשלוח, וביטול פריטים בהזמנה היא כנראה שונה.
  • "השאלה בקוד: (if(deliveryTimeStamp != null תספק את ההבחנה, מה הבעיה? טודו-בום, הכל בסדר!".

למשלוח יש סטטוס-מעקב (state) שונה מזה של הזמנה: "המשלוח יצא", "המשלוח נמסר", "המשלוח התקבל" מול: "הזמנה התקבלה" / "הזמנה בוטלה".
  • "אין בעיה לייצר לאובייקט state רחב, הכולל את כל המצבים. טודו-בם? טודו-בום!".


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



הכל בסדר שם?

שלוש בעיות



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


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

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

במקום לפצל את הקוד ליחידות קטנות יותר שלכל אחת אחריות קטנה ("divide and conquer") - יצרנו ערמת קוד גדולה יותר להתמודד איתה ("unite and endure").



בעיית הטווח הבינוני: חוסר בהיסטוריה

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

Smells נפוצים לבעיה הזו הם:
  • יש לנו כל מיני שדות שמנסים לחפות על חוסר בהיסטוריה AmountIfDelivered, או cancellationDate ביחד עם realCancellationDate.
  • יש לנו audit (לוג או טבלה) - דרכו אנו מנסים לדבג תקלות, ובעצם ליצור "היסטוריה חלופית".
    בני אדם (מפתחים, support) יכולים לקרוא את הלוג - אך קשה מאוד לקוד שרוצה לבצע פעולה להסתמך עליו (כי הוא לא well-structured והפורמט עלול להשתנות לאורך הזמן).
  • אנו מוסיפים טבלאות ייחודיות ל BI - כי הם זקוקים להבנת ההיסטוריה, שאנחנו לא מסוגלים לספק להם עם נתוני הליבה שלנו (core data, לא של אפל).
במקרים הללו הקוד גולש, תחת הלחצים לתקן עוד ועוד באגים ולטפל במקרי קצה שלא דמיינו בתחילת הפיתוח - ל"קוד Job Security", כזה שרק מי שכתב - יכול באמת להבין ולשנות. שינוי משמעותי - לרוב כרוך ב rewrite של כל המנגנון.

האם לארגון שלנו יש מספיק משאבים ל re-writes הללו?



בעיית הטווח הארוך

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

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

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




Plan Execution Segregation



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

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

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

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

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

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


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

כאשר מבצעים הפרדה בין Plan ל Execution חשוב שתהיה דרך לקשר בין האובייקטים. בד"כ יהיה על אובייקט הביצוע (Execution Object) רפרנס (id?) לאובייקט התוכנית (Plan Object).






עוד המלצות נלוות:



מה השלב שבו הופכים Plan ל Execution? האם זה מועד הביצוע? זמן קבוע לפניו (למשל: חצי שעה לפני)?
  • כלל נפוץ הוא לעשות זאת על בסיס Resource Allocation: לרוב execution לא יוכל להתבצע ללא הקצאה של משאבים. כאשר המשאב הוקצה (ונרשם) - יש סיכוי טוב שתרצו לעשות את המעבר. למשל:
    • Order של מונית הופך ל Ride כאשר הוקצה נהג. זה יכול לקרות מיד (ואז הלקוח ממתין עוד כמה דקות להגעת המונית) או אולי כמה שעות לפני הנסיעה - כאשר זו הזמנה לנסיעה עתידית.
    • כאשר אין משאב ברור, המעבר לרוב יקרה ברגע תחילת הביצוע. למשל: PaymentPlan הופך ל Payment , ברגע בו התחלתי לבצע את התשלום. 
      • תשלום אגב יכול לארוך שניות, דקות, ואף שבועות: קיבלנו reject מסולק אחד, ואנו פונים לסולק אחר, אין מספיק כסף בחשבון כרגע וננסה "לנדנד" עד שיהיה, כרטיס אשראי בוטל - אך עדיין רוצים "לרדוף" אחרי התשלום, וכו'.


    תיאור ה Execution כהצלחה (SUCCESS) או כשלון (FAIL) לעתים רבות אינו מספיק:
    כאשר הביצוע מורכב מכמה פעולות ו/או כמה שלבים:
    • יש פעולות שלא הורצו בכלל - כי הן תלויות בפעולה אחרת. האם הן הצלחה, או כישלון?
    • יש פעולות שמתמשכות לאורך זמן, ולמרות שהמשכנו מהן הלאה - עדיין לא ברור אם הצליחו. למשל: המשלוח הסתיים - אבל רק מחר בבוקר נקבל טלפון שחבילה לא הגיעה ללקוח.
    • אולי קרו כמה כישלונות, כמה לקוחות קיבלו פריטים לא נכונים. האם יש הבדל בין בעיה אחת לשלושה?
    • פתרון נפוץ הוא לחשב את ה state בצורה דינאמית:
      • הרבה פעמים נוח להחזיר רשימה של בעיות, במקום סיכומם כערך יחיד. יש צרכנים של הפונקציה שיספרו רק אם מספר הבעיות גדול מאפס (או ()errors.isEmpty), ויש כאלו שיכנסו יותר לפרטים.
      • לעתים שווה גם לציין פעולות pending או suspension - שעדיין לא התבצעו, ולכן לא ברור מה המצב שלהן. יש הבדל גדול בין "0 תקלות משלוח!" לבין "0 תקלות משלוח, אבל 6 חבילות עדיין לא סופקו". 
      • השם pending מרמז יותר על פעולות שמתבצעות כסדרן אך לא הסתיימו, ו suspension על פעולות שהתעכבו / הסתבכו - אך עדיין לא ברור אם יצליחו לתקן ולסיים אותן בהצלחה.
        • אם יש suspensions - לרוב כדאי לקבוע גם deadline שהופך אותן לכישלון. לא נרצה להיות ב state לא ברור לאורך זמן.

    לעתים יש טעם לשמור את ה Plan לשימוש חוזר. למשל: לקוח בונה רשימה של פריטים אותם הוא מזמין כל שבוע. 
    • האם זה שכפול פשוט של Plan או אובייקט אחר? תלוי במקרה - אבל אל תשללו את האופציה שמדובר באובייקט דומה אך אחר. אובייקט כזה נקרא גם Protocol (נוהל החוזר על עצמו, מונח מקובל בשירותי-בריאות). השם מעט מוזר לפריטים אהובים מהסופרמרקט, אך יותר הגיוני אם מדובר בצורת תשלום שחוזרת על עצמה, נניח PaymentPlan מול PaymentProtocol.



    סיכום


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


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



    3 תגובות:

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

      תודה רבה,
      ליאור

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

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

      השבמחק