פשוט קוד: טיפול בשגיאות

אני עומד לעסוק בנושא בסיסי מאוד: Exception Handling. בסיסי – אך שיש מה לעסוק בו.

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

הערה: אני מניח שלא מדובר במערכת Embedded  או Realtime שם נמנעים מ Exception בשל העלות של stack unwinding. בשפות / מערכות אחרות (כמו Golang) – החליטו לא להשתמש ב Exceptions על מנת להיות צפויים ומבוקרים יותר. למשל: כאשר Exception נזרק ממקבץ של 4 שורות קוד – אני לא יודע בוודאות איזו שורה זרקה את ה Exception, ואולי אף איני לצפות את ה Exception שעלול להיזרק מכל שורה – מה שיוביל אותי לטיפול כללי ופחות מדויק בחריגה.

בדוגמה אני אכתוב מעל ה JVM, בשפת קוטלין, ולכן ארצה להזכיר לרגע את מבנה השגיאות ה JVM:

  • שפת ג'אווה מתייחסת בשונה ל Checked Exceptions (היורשות מהמחלקה Exception) – אשר יש חובה קוד להתייחס אליהן, ו Unchecked Exceptions (היורשות מהמחלקה RuntimeException) המתנהגות כמו Exceptions ברוב שפות התכנות, ואפשר להתייחס אליהן – או להתעלם ואז הן יעברו לטיפול מי שקרא לי.
    • הרעיון החדשני והמעניין של Checked Exceptions (המושפע מרעיון בשם checkers בשפת OCaml) הוכר בפועל כהחמצה, ולא הועתק (למיטב ידיעתי) לשום שפת תכנות נפוצה אחרת.
    • גם בשפת קוטלין "ירדו" מהרעיון, ואין צורך להתייחס אחרת לחריגות היורשות מהמחלקה Exception.
  • Error הוא ענף אחר בהיררכיית ההורשה, ושגיאה (Error) שתיזרק לא תיתפס כחריגה (Exception) – כי היא מחלקה מטיפוס אחר. ה Error נשמר למצבים חמורים שהאפליקציה לא אמורה לטפל בהם – ובעיקר נזרקים ע"י ה JVM (כגון OutOfMemoryError). לא זכור לי שאי פעם נתקלתי ב Error בפרודקשיין, בטח לא כזה שקוד היה יכול למנוע / להתמודד איתו.
    • IntellJ (ה IDE) מימש משפטי TODO (כאלו שלא נכתבים כהערה) ככאלו שיזרקו Error. אם שכחתם לכתוב את הקוד שהתכוונתם אליו – ואתם מריצים את הקוד, אנחנו לא רוצים שהפרט הזה ייתפס כ Exception ויכתב לשורה ללוג אשר קל לפספס. הנה שימוש נכון ל Error.
 
חזרה לסיפור שלנו: אז איזה סוג של Exception כדאי לזרוק?
  • ברור לי שזה לא Error. (למרות שנתקלתי במקרים בהם אנשים זרקו Errors ב flows אפליקטיביים).
  • אני לא מתעסק בהבחנה בין Checked ל Unchecked Exception – וטוב שכך.
  • יש מן עצה כזו שאומרת "Favor the use of standard exceptions", אך עדיין נותר לי מבחר גדול של Exceptions רק מתוך הספריות הסטנדרטיות של ג'אווה:
הרשימה באמת, היא אפילו יותר ארוכה
 
אני רוצה לכתוב קוד טוב יותר וקריא יותר – אבל איך בוחרים?
 
צעד הגיוני ונפוץ יחסית, הוא ללכת ולהתייעץ בכל מיני "מילונים" לסוגי החריגות השונות, ומתי להשתמש בהן:

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

  • יש כאלו שיבחרו ב IllegalArgumentException – כי הפעילו את המתודה עם ארגומנט לא חוקי.
  • יש כאלו שיבחרו ב IllegalStateException – כי בכלל ארגומנט לא חוקי לא אמור להגיע כך ללב המערכת. זו תקלה במצב המערכת. "אם הגענו עד לכאן – זו כבר תקלה ב state".
  • יש כאלו שיבחרו ב NotFoundException – באמת לא מצאתי את הלקוח הזה.
  • יש כאלו שיבחרו ב UnsupportedOperationException – זה לא חוקי לשלוף פרטי לקוח עם מזהה לא נכון (?!).
  • יש כאלו שפחות מתאמצים ופשוט זורקים RuntimeException (החריגה ארעה בזמן ריצה) או פשוט סתם Exception.
  • יש כאלו שיותר מתאמצים ומגדירים טיפוס חדש של חריגה למצב הספציפי, למשל: CustomerNotFoundByIdException. 
  • אפילו ראיתי מקרה בו בתסריט כזה נזרק SecurityException. אני מניח שטיפול ב id שגוי של משתמש הוא גם בעייתי בהיבטי האבטחה של המערכת.

אם מפתחים שונים בוחרים חריגות מטיפוסים שונים לאותו המצב, אז:

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

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

בחזרה למהות

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

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

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

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

  1. Rollback של טרנזקציה / פעולה, או פעולות cleanup – זה קורה לפעמים.
  2. כתיבת הודעת לוג (שגיאה / אזהרה) – בכדי ללמוד עליה ולטפל ידנית במצב ו/או לשפר את המערכת שמצב זה לא יקרה בשנית. את זה עושים כמעט תמיד.
  3. הצגת אינדיקציה למשתמש-הקצה ב UI, ברמת פירוט כזו או אחרת, שמשהו השתבש – אם קיים משתמש כזה.

זהו. זו בגדול התוצאה.

ברוב המקרים – הטיפול האמיתי ב Exception יהיה מעבר ל Flow בקוד:

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

הנה קוד לדוגמה שתופס Exception:

try {
...
} catch (e: Exception) {
  logger.error("something went wrong $e")
}

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

היי, רגע!

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

  • לא שלחנו את ה exception עצמו ל logger. זה אומר שלא יודפס ה stack-trace – וזו בעיה חמורה, כי יהיה קשה מאוד להבין מה השתבש בחקירה שתגיע מאוחר יותר.
    • אני מבקש בזאת מכותבי ה logging framework הבא לג'אווה שהחתימה למתודה (…)logger.error תחייב לספק ארגומנט מסוג ?Exception. השכחה לשלוח את ה Exception ללוגר היא מספיק מזיקה. אם אנו רוצים לכתוב ללוג error שלא קשור ל Exception – אני מעדיף שנשלח ערך null.
  • בלענו את ה Exception. האם זו הייתה הכוונה?
    • לפעמים כן – וזה בסדר. מספיק שכתבנו ללוג (אבל נוסח ההודעה יהיה יותר כמו "failed to do…") ומישהו יטפל בזה אח"כ.
    • לפעמים לא – וזו יכולה להיות ממש תקלה. 
      • גם במקרה כזה הייתי שמח אם ברירת המחדל של השפה הייתה להמשיך ולזרוק את השגיאה, עם אפשרות להוסיף משפט break אולי swallow או משהו – במקרים בהם לא רוצים במודע להמשיך לזרוק את השגיאה.
  • הדפסנו את האובייקט של ה Exception – ולא את הודעת השגיאה (e.message).
    • ברוב הפעמים, מימוש ברירת-המחדל ל ()toString של מחלקת ה Exception יהיה message – ואז הכל בסדר.
    • במקרים בהם המימוש הוא אחר, למשל: כתובת האובייקט בזיכרון – איבדנו את ההזדמנות לאסוף מידע שימושי לגבי השגיאה.

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

fundoA() {
try {
  ...
  doB()
  ...
  } catch (e: Exception) {
    logger.error("something went wrong: $e.message", e)
    throw e
  }
}

fundoB() {
try {
  ...
  doD()
  ...
  } catch (e: Exception) {
    logger.error("Dubi: something din't work as expected: $e.message", e)
    throw e
  }
}

fundoD() {
try {
  ...
  } catch (e: Exception) {
    logger.error("doD: WTF?! $e.message", e)
    throw e
  }
}

קרתה שגיאה אחת, אבל בלוג יכתבו 3 stack trances שעשויים להראות כמו 3 שגיאות. ה stack traces הם כמעט חופפים, כאשר בכל פעם נוספת רק עוד שורה אחת.
אפשר בקלות לבזבז זמן בחקירה של הלוג שנכתב ע"י דודי (כלומר: doD) או הלוג שנכתב ע"י דובי (doB) – בעוד חסר לנו ההקשר החשוב שהתחלנו לפעול בעצם מ doA.

בגדול:

  • ב catch clause בחרו: או שאתם מדפיסים הודעה ללוג – או שאתם זורקים שגיאה הלאה.
    • כל Framework / WebServer שמכבד את עצמו יתפוס את כל השגיאות שהתרחשו – ויכתוב אותן ללוג.
    • כתיבת ה Stack trace ללוג ברמה הגבוהה ביותר – תספק את מירב המידע, ולכן זו השגיאה שאנו רוצים לחקור. חבל לכתוב הודעות כפולות ללוג.
  • הערת משנה: אם יש stack trace אז יהיה כתוב איזה פונקציה נקראה. עדיין המון אנשים אוהבים להוסיף בעצמם את שם המתודה (ולפעמים גם את שם המחלקה) להודעת השגיאה – וזה דיי מיותר. נחשו מה קורה כאשר עושים rename לשם המתודה?
    • בהודעת השגיאה חשוב לציין מידע חדש ומעניין. למשל: פרמטרים מסוימים של הריצה – שלא תהיה לנו גישה אליהם מאוחר יותר.

הנה דוגמת הקוד הזו בגרסה הטובה יותר שלה:

fundoA() {
try {
  ...
  doB()
  ...
  } catch (e: Exception) {
    throw Exception("failed ... id=$id", e) // = java's "new Exception"
  }
}

fundoB() {
  ...
  doD()
  ...
  // if you have nothing smart/new to say - spare our time.
}

fundoD() {
try {
  ...
  } catch (e: Exception) {
    throw Exception("Failed DoDing: x=$x, y=$y", e)
  }
}

ה Framework הוא זה שיתפוס את ה Exception ויכתוב אותה, ואת ה stack trace המלא – ללוג. זה ה Best Practice שנקרא global exception handling.

שווה לציין, שב Web Frameworks ההתנהגות המקובלת היא שה Framework תופס את ה Exception ומוציא החוצה הודעה לקונית בנוסח "HTTP 500 internal server error". אנחנו לא רוצים לשלוח ברשת payload גדול של כל ה stacktrace, ומבחינת אבטחה אנחנו לא רוצים לחשוף החוצה את ה internal של המערכת שלנו וללמד את התוקף הפוטנציאלי מה קורה. לכן, כברירת-מחדל, הודעות השגיאה ששקדתם עליהן יגיעו ללוג – ולא ללקוח.

בואו נחזור לשאלה האחרונה ששאלנו:

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

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

התשובה

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

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

זו שאלת המפתח, שלצערי כמעט ולא נשאלת.

אם מי שעומד לתפוס את ה Exception הוא ה Framework בכדי לכתוב אותו לוג – אין כל טעם לשגר Exception מטיפוס מיוחד.
אם מי שעומד לתפוס את ה Exception הוא קוד, בכדי לבצע באופן גנרי rollback / resource cleanup – אין כל טעם לשגר Exception מסוג מיוחד.

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

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

אני אצטט לרגע אנשים שחקרו את הנושא עמוק ממני:

"Examination of small programs leads to the conclusion that requiring exception specifications could both enhance developer productivity and enhance code quality, but experience with large software projects suggests a different result – decreased productivity and little or no increase in code quality." — Joseph R. Kiniry

ובכן זה התסריט המשמעותי היחיד לשימוש בטיפוס ספציפי של Exception:

try {
...
} catch (e: CustomException) {
  // do some cleanup / revert logic
} catch (e: Exception) {
  throw Exception("Failed DoDing: x=$x, y=$y", e)
}

כאשר קוד מתייחס לטיפוס הזה, ומריץ איתו branching אחר של הקוד.

חשוב לציין ש cleanup logic צריך ברוב הפעמים לשבת ב finally clause – ולרוץ ללא קשר לסוג התקלה שהתרחשה.

התקדמנו.

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

נחזור רגע להמלצה המוכרת: "Favor the use of standard exceptions".
שימו לב שאם CustomException יהיה Exception סטנדרטי כמו IllegalArgumentException אזי הוא יוכל להיזרק לא רק מהקוד שלנו – אלא מכל קוד אחר במערכת שבחר "להשתמש ב Standard Exceptions". אולי אלו 3rd Party, אולי גם הספריות הסטנדרטיות של ג'אווה.

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

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

מתי, אם כן, אפשר להשתמש ב Standard Exception?

  • כאשר אתם רוצים לייצר הבחנה בין שגיאות שונות של הפונקציה שלכם. אם יש רק מצב-שגיאה אחד משמעותי – מספיק להשתמש פשוט ב Runtime)Exception).
    • חשוב מאוד לתעד מה המשמעות של כל Exception ספציפי שנזרק. בלי תיעוד – זה תרגיל ב fuzzy human communication ביניכם למי שעומד לכתוב את הקוד שיטפל בשגיאה.
  • כאשר קהל היעד שלכם לא ידוע (נניח: אתם כותבים ספריה סטנדרטית), ואתם לא יודעים איך המשתמשים עומדים להשתמש ב Exception שזרקתם , או שיש מגוון רחב של אפשרויות תגובה.
  • כאשר אתם משוכנעים שאתם הם אלו שעומדים לזרוק את השגיאה הזו, ולא פונקציה אחרת שאתם קוראים לה!

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

השאלה היותר חשובה, היא מתי לתפוס Standard Exceptions?

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

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

int day = Instant.now().get(ChronoField.DAY_OF_MONTH);

יזרוק חריגה "סטנדרטית" (כי הקוד הוא של ג'אווה [א]) מסוג UnsupportedTemporalTypeException.

למה? בכדי להדגיש ש Instant (המייצג של epoch ב Java Time APIs) נועד לשימוש ע"י מכונה, ולא בכדי לייצג מידע על תאריך שיגיע למשתמשים בני-אדם.

האם זה נכון, לזרוק שגיאה בשל "שימוש שנראה שגוי בספריה"?

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

אני רצה לסיים ולציין תנאים מומלצים לשימוש ב Standard Exception:

  • החריגות הסטנדרטיות הללו הוגדרו במערכת שלכם, ולא יכולות להיזרק בטעות ע"י ספריית צד-שלישי שנכתבה ע"י מישהו שלא מכיר את הכללים שקבעתם לשימוש בחריגה.
  • ישנם כללים ברורים וידועים – מה אומר השימוש בחריגה הזו.
  • לחריגה הספציפית הזו, יש משמעויות ברמת המכונה / קוד במערכת – והן לא נועדו בכדי להסיר אחריות מהודעת השגיאה שתדווח.
דוגמה אחת היא javax.ws.rs.WebApplicationException
  1. היא אמנם "סטנדרטית בתעשייה" ולא ייחודית לכם. לכן, נכון לזרוק אותה – אבל כדאי להימנע ולהסתמך עליה בלכידת Exception לצרכים אפליקטיביים. היא עשויה להיזרק ע"י כל אחד.
  2. יש לה סמנטיקה משמעותית וברורה ברמת המכונה: Web Applications Servers יתפסו אותה ברמתם – אבל יעבירו את הודעת השגיאה שהוגדרה בה, כמו שהיא, ללקוח. אפשר גם לציין במחלקה הזו גם HTTP Status קוד שיעבור גם הוא ללקוח. למשל: HTTP 403 – הוא קוד בעל משמעות חשובה.
    1. מי שזורק את WebApplicationException, חשוב שיבין את הסמנטיקה שלה – ולא יכתוב בה הודעת שגיאה עם מידע פנימי שלא אמור להגיע ללקוח שביצע את הקריאה.

בדוגמה אחרת, הגדרנו ב Next-Insurance טיפוס Exception פנימי בשם RobinUserInputException.

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

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

סיכום

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

לצערי, הרבה חשיבה מסביב ל Exception Handling מוקדשת ל Tooling (מחלקות שונות של Exceptions) – ולא למהות השגיאה, וכיצד לטפל בה.

חדי העין אולי שמו לב שההמלצה שסתרתי בפוסט, "Favor the use of standard exceptions" – מגיעה מספר מכובד (של מחבר מכובד) בשם Effective Java. הנה סיכום הדברים:

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

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

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

—–

[א] אני קצת צוחק, כי זו באמת חריגה ממוקדת, שלא משתמשים בה מחוץ ל Java Time API – אבל יש כאלו שרואים כל חריגה של הספריות של ג'אווה כחריגות "סטנדרטיות".

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

ללמוד מהקוד

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

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

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

את קטע הקוד הבא מצאתי בתשובה של StackOverflow, תשובה שזכתה לכמה upvotes [א].

אז בואו נתחיל.

הקוד

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

fun balance(amount: Int, round: Boolean, rate: Double, year: Int) : Double {
  var yearlyInterestPaid: Double
  var totalAmount = amount.toDouble()
  for (i in 0..year) { // for (int i = 0; i <= year; i++ ){
      yearlyInterestPaid = totalAmount * rate
      if (round) yearlyInterestPaid = Math.floor(yearlyInterestPaid * 100) / 100 .
      totalAmount += yearlyInterestPaid
  }
  return totalAmount
}

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

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

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

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

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

מבט שני

נחזור ונתבונן בקוד, לאחר שהסרנו את הבעיה הבולטת:

fun calcInterest(amount: Int, round: Boolean, rate: Double, year: Int) : Double {
   var yearlyInterestPaid: Double
   var totalAmount = amount.toDouble()
   for (i in 0..year) {
       yearlyInterestPaid = totalAmount * rate
       if (round) yearlyInterestPaid = Math.floor(yearlyInterestPaid * 100) / 100 . 
       totalAmount += yearlyInterestPaid
   }
   return totalAmount
}
 

האם אתם מזהים עכשיו בעיות נוספות?

—-

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

הנה הקוד לאחר שני שיפורים נוספים:

 
fun calcInterest(amount: Double,
                 round: Boolean,
                 rate: Double,
                 year: Int) : Double {
 
  var totalAmount = amount
 
  for (i in 0..year) {
      var yearlyInterestPaid = totalAmount * rate
      if (round) yearlyInterestPaid = Math.floor(yearlyInterestPaid * 100) / 100 .
      totalAmount += yearlyInterestPaid
  }
 
  return totalAmount
}

אם לא שמתם לב, אז הנה התיקונים (הקטנים) שביצענו:

  • הכנסנו את yearlyInterestPaid ל scope מצומצם יותר. אני אזכיר את הכלל הידוע: ״הגדירו משתנים מאוחר ככל האפשר / קרוב ביותר למקום שמשתמשים בהם״.
  • מדוע אנו מבצעים המרת טיפוס ל totalAmount? יכול להיות שאין במערכת צורך מיידי בכך, אך הרבה יותר הגיוני שפעולה מתמטית תתבצע על אותו הטיפוס: אם ערך ההחזרה של ה amount לאחר הריבית הוא Double – אז גם שהקלט של ערך הבסיס יהיה מהטיפוס הזה.

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

calcInterest(1000.0, true, 0.04, 10);

מה ניתן להבין מהשורה הזו?   קצת קשה להבין בדיוק מה קורה כאן. מה true? מה בדיוק אומר כל מספר?
האם ייתכן והערך true נוסך בנו יותר בטחון בשימוש בפונקציה מהערך false?! 🙂   במקרה כזה כנראה שהצעד הבא בקריאת הקוד יהיה להיכנס לתוך הפונקציה ולקרוא אותה.   יש IDEs שיוספו כ hint את שמות המשתנים ליד כל ערך – אבל שימו לב ששמות המשתנים לא פותרים את הבעיה:

calcInterest(amount: 1000.0, round: true, rate: 0.04, year: 10);

אם בכדי להבין מה שורה עושה, עלינו להיכנס לקוד הפונקציה – זה לא מצב טוב. בזבזנו זמן, וזו אינדיקציה חזקה מאוד שה \"UX של הפונקציה״ (אפשר לקרוא לזה DX = Developer eXperience) – אינה טובה.   אם בכדי להבין שורת קוד יהיה עלינו לצלול למימוש הפונקציות שנקראות מתוך שורת הקוד – אזי ניתן לומר היפותטית שאנו עומדים, רקורסיבית, לקרוא את כל הקוד במערכת — בכדי להבין אותה. זה לא טוב וחשוב שפונקציות יהיו שכבת הפשטה של הבנה – מעל למימוש שלהן.

שיפור ה DX של הפונקציה שלנו

  בואו נהפוך את חתימת הפונקציה שלנו לקריאה יותר, וכך – למובנת יותר.

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

כלל אצבע מוכר, הוא לא לשלוח יותר מ 4 פרמטרים לפונקציה. לעתים אנשים חושבים שהבעיה היא חתימה ארוכה של הפונקציה – ואז מעבירים את 12 הפרמטרים ל Request Object אחר בן 12 פרמטרים.

בעצם – מה ההיגיון בזה?!

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

val totalSalary = managersSalary + employeeSalary

עכשיו הופך ל:

val totalSalary = request.managersSalary + request.employeeSalary

זהו עומס נוסף על הקוד, שלא הופך אותו קל יותר לקריאה.

במקום ליצור Request Object אחד, עדיף לייצר 3-4 Request Objects קטנים המקבצים פרמטרים דומים יחדיו.

כך הגישה ל request object היא לא מעמיסה – אלה גם נותנת משמעות לפרמטרים. למשל:

val totalSalary = salaries.managers + salaries.employee

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

כלומר: ב Request Objects טובים יעשה שימוש חוזר – שיפחית את העומס על הקוד.

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

סדר הגיוני של פרמטרים: 

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

למי שעובד ב IntelliJ, הקיצור CMD+F6 (במק) – הוא קיצור חשוב מאוד. כשאתם מזהים סדר לא נהיר של פרמטרים – הקדישו חצי-דקה וסדרו אותם. IDE מודרני באמת מאפשר לבצע שינוי כזה במהירות ובבטיחות.

הנה הקוד שלנו לאחר השיפור:

fun calcInterest(initialAmount: Double,
                 interestRate: Double,
                 years: Int,
                 roundInterestYearly: Boolean): Double {

   var totalAmount = initialAmount

   for (i in 0..years) {
       var yearlyInterestPaid = totalAmount * interestRate
       if (roundInterestYearly) yearlyInterestPaid = floor(yearlyInterestPaid * 100) / 100 
       totalAmount += yearlyInterestPaid
   }

   return totalAmount
}

שינוי פעוט בשם פרמטר, מ year ל years – הוא משמעותי.

round הוא שם נורא בעיני. הפכנו אותו לשם בעל משמעות.

ההעברה של round לסוף רשימת הפרמטרים מבליטה שהוא:

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

לא סיימנו!

הקוד, אם קיבלנו אותו ל Review לא כולל חלק חיוני – בדיקות יחידה.

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

שאלות מהותיות הן:

  • האם אתם מאשרים PR כאשר פונקציה עם Pure Business Logic מוצגת – ללא בדיקת יחידה?
  • האם אתם מאשרים PR אם קוד לוגי הוא קרוב להיות טהור, ובמעט עבודה אפשר לזקק אותו – ולבדוק אותו בבדיקות יחידה?

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

  • הפעלת הפונקציה עם סכום של 1000, בריבית הנקובה למעלה, לאורך עשר שנים – מחזירה תוצאה של 1539.4540563150777.
    • כאשר אנו מבצעים עיגול כל שנה – הערך הוא עגול (1539.41), אך בהרצה לשש שנים – הערך המתקבל עם הרצת עיגול הוא 1315.9099999999999.
  • סכום חיובי וסכום שלילי – לא מתעגלים בצורה סימטרית (כי ה floor הוא אבסולוטי לערך).

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

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

אם אתם מופתעים, ושואלים את עצמכם ״אבל… מדובר ב Stack Overflow – אתר עם ביקורת רבה וקשוחה, איך זה יכול להיות?!״ אני אתן את הציטוט הבא שקראתי בו לאחרונה:

The research echoes an academic paper from 2017 that found 1,161 insecure code snippets posted on Stack Overflow had been copied and pasted into 1.3m Android applications available on Google Play. — source

StackOverflow הוא מקור מעולה לקוד שעובד – אבל הוא לא תמיד טוב מבחינת הנדסת-תוכנה (לטווח ארוך). תמיד כדאי להיות ספקניים, גם עם כמה עשרות אנשים הצביעו לתשובה שמכילה את הקוד.
לא פעם יצא לי למצוא קוד מורכב / מאופטם בצורה חריגה לסביבה שלו – וחיפוש קצר בגוגל הראה שהוא הועתק, as-is, מ StackOverflow. אנחנו עושים את זה, ולא מעט. כולנו במידה כזו או אחרת ״Google Engineers״ (= כאלו שמחפשים בגוגל ואז מעתיקים את התשובה) – אבל חשוב לבדוק את הקוד ולא לסמוך עליו בעיניים עצומות.

סיכום

אשמח לדעת מה דעתכם.

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

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

—-

[א] ליתר דיוק: שילבתי שתי תשובות שונות שזכו ל upvotes – על מנת לחדד כמה נקודות לדיון.

Refactoring 2020

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

הספר Refactoring טבע כמה מושגי יסוד שהושרשו עמוק בתעשייה כמו Refactoring ,Code smells, או Testability של קוד. החלק החשוב בספר הוא לא קטלוג ה Refactoring (הטכני), אלא 4 הפרקים הראשונים העוסקים בכמה רעיונות מאוד חשובים בפיתוח תוכנה:

  • בדיקות-יחידה (Unit-Tests), היו אז עוד רעיון חדשני ולא כל-כך מוכר.
    • שווה לציין שיותר ממה שבדיקות יחידה פותרים באגים, הם מאפשרים לעשות Refactoring במהירות ובביטחון.
  • הרעיון שהשלב הראשון בפתירת באג, היא לכתוב בדיקה שתיכשל – ואז לגרום לבדיקה לעבור (מה שיוכיח שהבאג תוקן, ויוודא שאין רגרסיה והוא חוזר).
  • הרעיון שניתן לשנות/לשפר Design של מערכת קיימת אחרי שהקוד נכתב.
    • הרעיון הזה תומך ומחזק רעיונות כמו Minimum Viable Products (בקיצור: MVP) ו YAGNI – רעיונות סופר-חשובים ומשמעותיים.
    • הרעיון הזה, לצערי, לא חלחל לכל קצוות התעשייה עד היום.
  • הרעיון ש Refactoring צריך להיות חלק מובנה מתהליך פיתוח תוכנה, כמו ש Delivery הוא כזה. כמובן שבלי delivery אין פיצ׳רים ללקוחות, בעוד refactoring ניתן לדחות ולדחות עד ה Rewrite הבלתי נמנע.
    • בספר פאוולר מקדיש חלק ל״איך לשכנע את המנהל״ לבצע Refactoring. עצה אחת היא שהמנהל לא יאהב את רעיון ה Refactoring, אבל אם האלטרנטיבה היא Rewrite – אז הוא יעדיף אותה. עצה שניה היא פשוט לא לספר למנהל – אלא פשוט לעשות. הדילמה הזו רלוונטית גם היום.

האם רעיון ה Refactoring באמת הצליח?

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

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

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

למה? הרי כתיבת בדיקות היא פעולה שגרתית (לרובנו), והרעיון נשמע נכון – אז למה כתיבת הבדיקה היא לא הדבר הראשון שאנו עושים כשאנו מתחילים לעבוד על באג?
  • כדי לכתוב בדיקה לבאג, אנחנו צריכים לדעת לשחזר אותו. החקירה כיצד בדיוק היא משתחזר יכולה לארוך זמן רב, והיא מוציאה אותנו מה context של כתיבת בדיקה. עד שמצאנו את הבעיה, והתיקון קטן – הנטייה האנושית היא ״כבר לגמור עם זה״.
  • לא כל מצב במערכת ניתן לשחזר בעזרת בדיקת יחידה.
    • עדיין אנחנו כותבים יותר מדי בדיקות אינטגרציה לקוד שניתן להפריד ולכתוב לו בדיקות יחידה – וזה חבל.
    • בדיקות אינטגרציה הן לא קלות לכתיבה, במיוחד לא במצבי-קיצון, וכך פעמים רבות אנו מוותרים על המאמץ לנסות ולכתוב בדיקה שמשחזרת את הבאג.
Software Death Spiral
ביצוע Refactoring אמור להחזיר אותנו מעט לאחור – ולהאריך את קץ המערכת…
האם חשוב לעשות Refactoring?
האם חשוב לעירייה לפתח את העיר עליה היא אמונה? לסלול כבישים, לשנות ייעוד של מבנים, ולשנות חוקים בתוכנית הבניה העירונית (תב״ע)?
המציאות משתנה לאורך הזמן, ולכן גם הצרכים. אם לא נשנה את העיר שלנו – היא לא תתאים לצרכים המפתחים שלנו. אם אין תעסוקה, אין חניה, או אין תחבורה מספיקה – גם עיר מצוינת יכולה להתדרדר ולהפוך לעיר גרועה. קשה לי לחשוב על עיר גרועה שהפכה למצוינת, ללא השקעה משמעותית. זה כנראה לא קורה.
כנ״ל לגבי המערכת שלנו: הביזנס עובר שינויים, ההבנה שלנו על הביזנס מתפתחת – והמערכת חייבת להתעדכן בכדי להישאר רלוונטית. כאשר אנו מנסים לעשות שינויים במערכת, בתוך Design שלא תומך בשינויים הללו – השינויים הם קשים וארוכים לביצוע.
מכיוון שלא ניתן ליצור Design שיתאים לכל הצרכים, ה Design של המערכת חייב להתעדכן לאורך זמן. האלטרנטיבה השנייה היא לסחוב עוד ועוד סרבול ואטיות בשינויים – עד ה Rewrite הבלתי נמנע.
אין לי שום הוכחה מספרית, אך הקונצנזוס הרחב בקרב המומחים בעולם התוכנה, הוא שהרבה יותר יעיל לבצע Refactoring הדרגתי – מכתיבה מחדש של התוכנה. כמובן שבנקודה מסוימת בזמן – כל מערכת תצטרך לעבור Rewrite.
אפשר לומר, ככלל אצבע מאוד לא מדויק ש Rewrite אחרי שנתיים או פחות – הוא כישלון, ו Rewrite לאחר חמש שנים – הוא הצלחה.
כמובן שהעניין תלוי בגורמים שונים: עד כמה משמעותיים היו השינויים בביזנס? עד כמה השקענו ב Refactoring במהלך חיי המערכת? עד כמה המערכת מורכבת. לא ניתן לקבוע מדד אוניברסלי לאורך חיי מערכת.
בכל זאת Refactoring הוא תהליך של הארכת חיי המערכת – והוא תהליך חיוני לארגונים שרוצים להיות יעילים.

אז למה לא לעשות Refactoring?

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

למה?

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

״תנו לנו 3 שבועות, נבצע Refactoring באזור ההוא – ואז הכנסה של פיצ׳רים תהיה קלה בהרבה!״

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

  • ה Refactoring קצת הסתבך וארך בעצם כמעט 5 שבועות.
  • פיצ`רים לא תמיד יותר מהירים לאחר ה Refactoring, לפחות לא משמעותית: ״כן, ה Refactoring שיפר מהירות של פיצ`רים מסוג z, אבל זה פי׳צר מסוג y…״
  • ה Refactoring עלול לגרום לרגרסיות במערכת ו/או באגים נוספים.
באמת, ממעוף הציפור, ומבלי להכיר את הפרטים – Refactoring אכן עשוי להישמע כדבר שרצוי להימנע ממנו, ולדחות אותו.
מי צודק? המנהלים או אנשי-התוכנה?

למה Refactoring נכשלים?

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

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

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

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

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

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

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

יאללה, המלצות (סיכום)

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

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

הנה כמה המלצות קונקרטיות, ממיטב ניסיוני:

  • Refactoring גדול = סיכון גדול, Refactoring קטן = סיכון קטן. 
    • דוגמה קלאסית ל Refactoring עם ROI חיובי ברור הוא Rename לאובייקט מרכזי במערכת שהשם שלו מטעה / פשוט לא נכון. ההשקעה היא במסגרת הדקות – התמורה היא ודאית.
    • נסו ליצור תרבות של Refactoring קטנים עליהם לא צריך לספר למנהל (כפי שהציע פאוולר), ולא צריך לשנות בשבילם Schedule של פיתוח. סיכוי טוב שה ROIs יהיה מוצלח, וגם תהליכי ה Code Review יוכלו לספק בקרה ופידבק לגבי הנכונות ההנדסית של ה Refactoring הללו.
  • כאשר אתם עושים Refactoring גדול, התייחסו אליו כמו כל פיצ׳ר גדול ומסוכן אחר:
    • עשו בקרה לעיצוב המוצע. למשל: Design Review.
    • נסו ברצינות לפרק אותו לשלבים קטנים יותר / לבצע MVP שיספק פידבק מוקדם יותר עד כמה השינוי מוצלח/יעיל.
    • נסו לצמצם אותו ולחשוב: מה אפשר להוריד מהמודל היפה והאלגנטי הזה שמוצע. על אלו פרטים ניתן לוותר – בלי לאבד הרבה מהערך. מניסיון – זה עובד.
  • הכי מסוכן זה Refactoring גדול של מישהו שקיבל לאחרונה אחריות על קוד. למשל: חודש-חודשיים אחרי שצוות מקבל קוד מצוות אחר, הוא ״מיואש״ כבר מהקוד – ודורש לבצע Refactoring גדול.
    • יש בכך יתרון: תוך Refactoring כזה, הקוד בהדרגה "יהפוך לשל הצוות": הוא יהיה מוכר יותר והצוות ירגיש שלם יותר איתו. Refactoring היא דרך מעולה להבין קוד לעומק.
    • הסיכון הגדול הוא שהצוות לא מבין את הדומיין טוב מספיק עדיין, וסיכון גדול שהוא יבצע שינוי ל Design שאינו טוב יותר.
    • נסו למנוע / לצמצם עבודת Refactoring גדולה של מישהו שלא עבד עם הקוד חצי שנה לפחות. כן עודדו לעשות כמה שיותר Refactoring קטנים (כמה שעות כ״א, למשל). זה כלל האצבע שלי.
  • אל תוותרו על Refactoring, גם אם זה קצת מורכב:
    • Refactoring הוא אוויר לנשימה למערכת. מערכות דורשות חמצן והתחדשות – ותהליך ה Refactoring מספק להן את זה.
    • לא פחות חשוב: Refactoring הוא אויר לנשימה למהנדסים שעובדים על המערכת.
      • זה ממש רע לקבל, ולהתרגל לעיוותים וחוסרי-הגיון בקוד – זו דרך טובה להפוך למהנדסים פחות טובים.
      • Refactoring הוא גם רענון שכלי למפתח (שאנו לא רוצים ״שיתייבש״): עבודה הנדסית שקצת שונה לפעמים מבוספת פיצ'רים למערכת.
      • Refactoring עוזר לקבל רמת עומק גבוהה יותר עם הקוד שמתחזקים, במיוחד לאורך זמן (״הנה המשמעות של מצב A, מול המשמעות של מצב B״)
שיהיה בהצלחה!

שירותים באנגולר

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

 

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

  • היא משותפת לכמה רכיבים – והשירות מאפשר שימוש-חוזר של הקוד.
  • היא כוללת Business Logic (המעט שיש בצד-הלקוח) או Integration Logic (קריאה לצד-השרת) – שני סוגי לוגיקה שאנו לא רוצים להכליל ברכיב.
  • החזקה של State לאורך זמן באפליקציה. רכיבים הם קצרי-חיים (עד ה navigation הבא) ושירותים חיים יותר (עד ה refresh הבא / פתיחה חדשה של האפליקציה).
במבט מהיר וביקורתי, ניתן לטעון ש"דחפו" לשירותים מספר תפקידים שונים שהמשותף להם: שהם לא מתאימים ל Component. באמת יש כמה Patterns וספריות – המחלקים את שלושת האחריויות שלמעלה לסוגים שונים של שירותים / שחקנים חדשים ושונים.
בגרסה הפשוטה, משתמשים בקונבנציות בשמות השירותים על מנת להדגיש תפקידים ספציפיים:

 

  • שירות שמכיל Business Logic בלבד (כאלו שיכולים וצריכים להיבדק לגמרי ב Unit-Tests) – ייקרא Helper Service או Utility Service. 
  • שירות שתפקידו העיקרי הוא לטעון ולהחזיק בזיכרון נתונים לטווח ארוך מאורך החיים של רכיבים – ייקרא Data Service.
  • רכיב אחר – ייקרא פשוט Service.

שירות פשוט

נעבור עכשיו לראות קצת קוד. נשתמש בשירות מאוד בסיסי שטוען הגדרה של מקצועות (מה שנקרא cob או class of business) מהשרת. השירות מינימליסטי – מסוג של Data Service.

לפני שנראה את קוד השירות, נראה את קוד הרכיב שמשתמש בו:

 

 

  1. בשדה errorStatus נאחסן מחרוזת – אם הייתה שגיאה. תקשורת לשרת, בהגדרה, עלולה להסתיים בתקלות – ואל לנו להתעלם מהמצבים הללו.
  2. הבנאי שלנו מכיל שדה מסוג CobService – זה השירות שלנו..
    1. הגדרה של שדה בתוך חתימת הבנאי היא כתיבה מקוצרת ב TypeScript להגדרה של שדה, קבלת פרמטר בבנאי – והשמת ערך הפרמטר לשדה.
    2. מאיפה מגיע המופע של השירות? מי מפעיל את הבנאי ושולח את מופע השירות?
      אנגולר מרושתת ב Dependency Injection (בקיצור DI) שפועל כאן. כברירת-מחדל, המופע של השירות שאנחנו מקבלים הוא משותף לכל האפליקציה – כלומר Singleton.
  3. המתודה של השירות שבה אנו משתמשים נקראת ()$getCobMap . סימן ה$ בסוף שם של פונקציה או משתנה הוא קונבנציה לכך שהמשתנה הוא מטיפוס / או הפונקציה מחזירה טיפוס של Observable.
    1. לבינתיים נסתפק בידיעה ש Observable הוא כלי יסוד בספריה בשם RxJs (שאנגולר משתמשת בה) הדומה ל Promise, אך הוא יכול להחזיר תשובה כמה פעמים (בניגוד ל Promise שיעשה זאת רק פעם אחת). במקרה שלנו תהיה רק חזרה אחת – מכיוון שיש רק תשובה אחת לקריאת ה HTTP שהשירות מבצע.
  4. בכדי לשלוף נתונים מתוך Observable אנו משתמשים באופרטור subscribe. ה Observable מייצג Stream של ערכים (או תקלות) שיכולים להמשיך ולהגיע עם הזמן. כפרמטר אנו מעבירים אובייקט (להלן: ה Observer) עם כמה פונקציות:
    1. next -פונקציה שתטפל באיבר הבא.
    2. error – פונקציה שתטפל בשגיאה. כאן אנו מציבים ערך בשדה errorStatus. קיום של ערך בשדה מלמד שיש שגיאה בטעינת הנתונים – כפי שנראה מיד.
    3. את subscribe אפשר להפעיל בתחביר מקוצר יותר – שלא השתמשתי בו פה.
    4. הפעלה של Subscribe מחזירה אובייקט Subscription שאותו אנחנו שומרים – בכדי "לנקות".
  5. פונקציית עזר כללית על הרכיב – שמשמשת אותנו בתוך ה Template.
    1. הייתה לי דוגמה אחרת לפונקציה כללית, אך נתקלתי במקרה קצה מוזר במנגנון ה Change Detection של אנגולר, וכך בעצם הפונקציה הזו גם פותרת בעיה – שקצת מורכב להסביר במסגרת הפוסט. למי שרוצה לצלול ולהעמיק.
  6. בעת הריסת הרכיב (בעיקר: navigation ל"מסך" אחר) עלינו לבצע פעולת Unsubscribe אחרת יהיה לנו memory leak ואירועים ימשיכו להישלח ל Subscription שלנו.
    1. כאשר יש כמה subscriptions באותו הרכיב, אזי מומלץ להשתמש באובייקט מרכזי (כמו subSink) לביטול הרישום – כך שלא נשכח לבצע את הפעולה.
    2. הצורך לזכור ולבטל רישום הוא אכן מטריד ולא רצוי. בהמשך הפוסט נראה כיצד כלי בשם ה AsyncPipe עוזר לנו להימנע מהצורך הזה – ברוב המקרים.
    3. כמובן שאנחנו מהרכיב שלנו מממש את הממשק OnDestory בכדי לבדוק שאין שגיאה בהגדרת חתימת הפונקציה (ואז היא לא תיקרא).
 
מכאן נמשיך ונציג את ה Template של הרכיב:

 

 
  1. אם קיים ערך ב errorStatus – אנחנו נציג אותו למשתמש. יש דרכים אלגנטיות יותר לנהל הודעות שגיאה – אבל זה מספיק טוב לצורך הפוסט.
  2. אנו לא רוצים להציג טבלה ריקה עם עדיין לא הגיעו הנתונים מהשרת (בכדי למנוע flickering) או אם יש מצב שגיאה. כל מבנה ה table לא יתווסף ל DOM על עוד אין לנו נתונים.
  3. את הנתונים עצמם אנו מציגים בטבלה, ולכן משתמשים ב ngFor* בכדי לעשות איטרציה על המפתחות של המפה, ואז מציגים את הערכים.
    1. בניגוד ללולאת for ב ES6 בה ניתן להשתמש גם ב in וגם ב of, ה directive של אנגולר ngFor* תומך רק ב of – מעבר על איטרטור / רשימה.
 
ניתן לראות את התוצאה של רינדור הרכיב שלנו:
 
מדהים!
 

אבל איפה השירות?!

 
נכון. עדיין לא ראינו את השירות עצמו. הנה הוא:
 
  1. Injectable@ הוא decorator שמסמן מחלקה המיועדת להזרקה במנגנון ה DI של אנגולר. מכיוון שאין ממש סיבה להזריק אובייקטים שהם לא שירותים (services) – אזי Injectible@ הוא סימן הזיהוי של שירותים בפרויקטי אנגולר.
    1. באפליקציית אנגולר יש היררכיה של Injectors:
      1. ה Injector הראשי נקרא root והוא שייך ל AppModule. הוא האב של כל ה injectors האחרים.
      2. לכל רכיב (component) יש Injector משלו, שהוא האב של ה injectors של רכיבים בנים.
      3. לכל מודול יש Injector שהוא אב לכל ה Injectors לרכיבים במודול – אך בן ל root injector.
    2. אנו רושמים את השירות שלנו ל Injector הראשי. כאשר מנהלים טעינה דינאמית של מודולים – נכון לשקול לרשום שירות למודולים ספציפיים.
  2. בבנאי אנו מבקשים Injection ל HttpClient – שירות סטנדרטי של אנגולר המספק יכולת לבצע קריאות HTTP.
  3. אנו משתמשים ב HttpClient בכדי לבצע קריאת GET. התשובה של קריאות מה HttpClient הן Observable – שהוא ה hook לחזרה של שהתשובה.
    כאשר אנחנו רוצים "לשבת על ה Stream ולהתערב בתוכן" אנו משתמשים בפונקציה pipe של ספריית RxJs. ל pipe אנו מספקים שורה של פונקציות שיפעלו על האיברים שעוברים ב stream. במקרה שלנו:
    1. אופרטור ה map (אופרטור = השם ב RxJs של פונקציה היכולה לפעול על Stream) פועל על כל איבר ב Stream ומבצע המרה שלו.
      1. Object.entities מחזיר רשימת Pairs מה properties של האובייקט. 
      2. אנחנו ממירים את ה Pairs מ [string, any] ל [number, string].
        1. ה Pairs במפה של TypeScript מיוצגים כ tuples (מבנה נתונים ב TypeScript הנראה כמו מערך עם טיפוס מוגדר לכל מקום ברשימה). 
        2. התחביר k+ הוא תחביר מקוצר ומקובל ב TypeScript להמרה של מחרוזת למספר. ההשמה ל string מתבצעת אוטומטית מתוך הגדרת ערך ההחזרה.
    2. אופרטור ה catchError פועל על כל שגיאה (Error) שעוברת ב Stream. לרוב מגדירים אותו בסוף ה pipe בכדי שיתפוס תקלות גם מכל הפונקציות שהוגדרו לפניו.
  4. הפונקציה שנשלחת ל catchError, מקבלת error ומחזירה Observable. יש את האופציה להחזיר Observable חדש שיחזיר ערכים מכל סוג. במקרה שלנו אנו זורקים את השגיאה מחדש, ולכן לא מחזירים Observable. אנו מצהירים על הפונקציה כמחזירה Observable של never – שזו הדרך לומר שלא יכול לחזור שם ערך. קונספט מאוד דומה ל Nothing בשפת קוטלין, למשל.
    1. אנו עושים איזו הבחנה מהירה אם זו תקלת צד-שרת או צד-לקוח וכותבים ללוג.
    2. …ואז אנו זורקים שגיאה מחדש. throwError הוא אופרטור שיוצר Observable חדש ומיד זורק לתוכו את השגיאה שלי. אתם בוודאי יכולים לשים לב שזרקתי מחרוזת (string) כשגיאה ולא איזה "טיפוס Error גנרי". ב TypeScript אין טיפוס Error גנרי – ומי שמקבל את ה Error צריך להיות מודע לכך (או להסתכן בכשלון בזמן ריצה). האופרטור throwError מקבל any – שהוא הטיפוס הכללי ביותר TypeScript הרחב אפילו יותר מ object.
      ה Error הנפוץ ביותר באפליקציות אנגולר הוא כנראה ה HttpErrorResponse – בשל השימוש הרחב בקריאות HTTP. קל להתרגל למבנה שלו ולהניח שזה תמיד ה Error שיחזור – אבל זה לא תמיד נכון. 

שיפור העבודה הידנית

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

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

אנגולר מספקת מנגנון של "unsubscribe אוטומטי" בעזרת pipe בשם AsyncPipe. מכיוון שאנגולר שולטת במחזור החיים של הרכיב, היא יכולה לבצע unsubscribe אוטומטי כאשר הרכיב נהרס.
לצורך כך יש לשמור על הרכיב (כלומר: המחלקה ב TypeScript) מצביע ל Observable ולבצע את השליפה ("subscribe") בתוך ה Template בעזרת ה AysncPipe. בואו נראה איך זה קורה:

  1. כפי שציינתי, אנו שומרים מצביע ל Observable על הרכיב כשדה.
    1. כאשר אנו מציינים את הטיפוס של המשתנה (זו לא חובה), TypeScript תדע לדרוש מאיתנו את המקרה בו חוזרת מחרוזת (השגיאה שאנו שולחים) מה Stream – ולא רק מפה.
  2. אנו עדיין רוצים לבצע טיפול מיוחד כאשר מגיעה שגיאה ב stream. הפעם אנחנו לא "יחידת הקצה" (קרי: ה subscriber. ה Template עכשיו הוא יחידת הקצה) – אנחנו רק "יושבים על ה stream" ומתערבים בעזרת אופרטור ה pipe.

והנה ה Template:

 

  1. ה asyncPipe פועל על Observable (או Promise), עושה לו subscribe ושולף ממנו ערך. כל ערך נוסף שישלח ב stream – יגרום לעדכון של ה markup בדפדפן.
    1. מכיוון שאנחנו רוצים להשתמש בערך שנשלף מה Observable במקום נוסף ב template – אנו שומרים את הערך למשתנה בשם cobs בעזרת המילה השמורה as בשפת ה Template של אנגולר.
      השימוש ב as הופך את הקוד לקריא יותר, וגם יעיל יותר מבחינת ביצועים – כי נמנעים מריבוי subscription לאותו observable.
  2. הנה השימוש הנוסף במשתנה שהצבנו בשלב 1.
  3. keyValuePipe מפרק לנו את המפה (פועל גם על אובייקט) לרשימה של Pairs (אובייקט מסוים), עליהם אנו יכולים לקרוא לשדות key ו value.
 

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

 
הגם שהכתיבה של async עדיפה, חשוב להבין מה קורה מאחורי הקלעים – ולכן הלכתי בדרך הארוכה, ופתחתי בעבודה עם subscribe.

 

ארגון שירותים

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

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

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

הדפוס של Container Presenter הוא מרכזי וחשוב – אבל לפעמים הוא מדגיש את הסרבול במבנה שירותים שטוח.
"מסך" (קרי Container component) מורכב, עשוי להזדקק לנתונים מ 10 או 20 שירותים. אם הנתונים צריכים לעבור טרנספורמציה וחיבור (סוג של "join" אפליקטיבי) – אזי מדובר בלא מעט קוד באותו רכיב Container.

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

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

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

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

  •  Akita – פריימוורק פשוט יחסית לניהול State, המושפע מ Domain Modeling של OO (למשל: Model/Entity ו Repository), ויכול לעבוד באותה המידה עם אנגולר, Vue, או React.
  • NgRx – פרימוורק מקיף למדי לניהול state, ומאוד פופולארי בעולם של אנגולר. הוא מבוסס על עקרונות של תכנות פונקציונלי ומושפע מאוד מ Redux של React. לי אישית, הוא מזכיר קצת Enterprise Java בתפקידים הרבים שהוא מגדיר, וכללי-התקשורת המסודרים ביניהם. NgRx מכוון לאפליקציות סופר גדולות ומורכבות.
    • נראה שרוב המשתמשים ב NgRx הם followers של כותבי-אפליקציות גדולות ומורכבות, ועבור הרוב הגדול של המשתמשים, NgRx הוא פשוט  overkill. לא פעם יוצרי NgRx יוצאים בקריאה "אתם לא צריכים NgRx" – ולא סתם. 
  • Ngxs הוא "חיקוי" NgRx רזה יותר, המשתמש בכלים של TypeScript בכדי ליתר הרבה מה boilerplate code הנדרש ב NgRx (ויש הרבה כזה). Ngxs מגדישה את הדפוס של CQRS.
  • יוצרי NgRx יצאו בעצמם עם הפסטה פשוטה יותר של NgRx/Store בשם NgRx/data, המאפשרת לעבוד עם המנוע של NgRx/Store אך ללא רבים מהסיבוכים וה boilerplate code של NgRx/Store. ע"פ הגדרתם זו "כניסה רכה ל NgRx/Store – עם אופציה לצלול לעומק ברגע שיהיה צורך". ממבט מהיר – נראה שההפשטה שהם יצרו דומה למודל של Akita. יש עוצמה רבה בפשטות.

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

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

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

זה נכתב עוד על Flux, תשתית פשוטה בהרבה מ Redux. מקור

מתי בעצם צריך State Management?

זו שאלה ראשונית וסופר-חשובה למי שכותב אפליקציות אנגולר.

פייסבוק הציגו את Redux במידה רבה לפתור בעיה של race condition בעדכון נתונים באפליקציה. למשל: Server push מול עדכונים של המשתמש. הגישה הפונקציונלית מתאימה מאוד לשמירת עקביות ותמונה אחידה בנתונים (Immutable state משותף, שזוכה לעדכונים מנקודה יחידה במערכת). לא לכל האפליקציות יש את הבעיה הזו – ולכן לא כולן צריכות לאמץ Redux/NgRx – מכיוון שיש תקורה גבוהה באימוץ המודל הזה [א].

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

 
 

בגדול אפשר לסווג את ה State של האפליקציה לאחד משלושה סוגים עיקריים

  • Session State – פרטים על המשתמשים וה Login, פרטי מעקב (sessionId/contextId), ומצב האפליקציה (האם כבר בחרתי xyz או שעדיין לא, ולכן אזור שלם באפליקציה חסום בפני).
  • Entity State – אובייקטים עסקיים שהאפליקציה טענה, בכדי להציג / לעדכן / לשתף בלוגיקה כלשהי.
  • Local State – פרטים מקומיים ל "מסך" או "חלק מסך" (רכיב) מסוים – שאין להם משמעות מעבר לאותו ה"מסך". למשל: מה הבחירה שלי ב Dropdown מסוים. אם אעבור לחלק אחר באפליקציה ואחזור – כנראה שלא אצפה שהבחירה שלי ב dropdown תישמר (אלא תחזור לערך ברירת-המחדל).

דבר ראשון שחשוב לציין הוא ש Local State הוא חשוב ושימושי.
למשל: אני רוצה לשמור משתנה מקומי לרכיב כמו ה errorStatus בדוגמה למעלה, או אינדיקציה שעלי להציג אנימציה "…Loading".

אפשר לשמור את ה state הזה ב store מרכזי, לשתף ולפעפע אותו ברחבי האפליקציה – אבל חבל.
זה לא נחוץ, זו תקורת פיתוח, וזה בהחלט מסבך את האפליקציה.

כל State שיכול להיות Local – עדיף שיהיה כזה. מבחינת שימושיות זה יהיה לרוב יותר מבלבל אם תזכרו עבור המשתמש כל selection על כל תא במסך שעשה. זה טוב לשכוח, מדי פעם.

Session State – אין לכם הרבה ברירה לגביו. אתם צריכים לשמור אותו ולשתף אותו בכל רחבי האפליקציה.
ברוב הפעמים ה session state הוא דיי קטן, ואפשר ליצור SessionService שיתחזק וינהל אותו.

השאלה הגדולה היא בעיקר לגבי ה Entity State שעלול להיות:

  1. גדול – מחלקות (classes) רבות, ו/או מופעים (objects) רבים.
    • כאשר יש יותר אובייקטים ממה שסביר לשמור בו-זמנית בזיכרון האפליקציה – מבחינת צריכת-זיכרון. 
  2. משותף לכלל האפליקציה. אם מסך אחד אומר שללקוח יש יתרה של $400, ומסך $350 – זה באג. לא מעניינת אותי מדיניות ה caches ופעפוע הנתונים.
  3. להתעדכן בצורה תכופה. הנתונים בשרת משתנים בטווח של דקות או אולי המשתמש מבצע עדכונים רבים – כי זה אופן השימוש באפליקציה.

ככל ששלושת הצירים הללו מתקדמים יותר – כך סביר יותר שנזדקק לפתרון מורכב יותר מקבוצה של שירותים ו/או FacadeService / BehaviorSubjects.

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

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

הסכנות שיש לשים לב אליהן הן בעיקר:

  • האם אפשר להגיע למצב של אי-עקביות של הנתונים? מה הסיכוי שנחזיק את אותם הנתונים כפול במערכת – והם לא יהיו זהים?
    • לעתים, רק גודל של האפליקציה יכול לגרום למצב הזה, כי יד ימין #6 לא יכולה בדיוק לדעת מה יד שמאל #24 – עושה. למשל: נוצרים שני שירותים במערכת שמביאים מהשרת ומנהלים – את אותם הנתונים.
  • מה מפת הקשרים בין החלקים באפליקציה שלנו?
    • הרבה קשרים בין רכיבים לשירותים – זה לרוב בסדר, ומקסימום Facade Services יכולים לצמצם את כמות הקשרים. 
    • הרבה קשרים בין שירותים הם גם בסדר – אם יש להם היגיון ברור וסדור לאורך האפליקציה (למשל: ל FacadeServices יש הרבה קשרים לשירותים בסיסיים יותר).
    • כאשר הקשרים בין השירותים הופכים להיות דו-כיוניים / צורך במעגלים – זה המקום לחשוב על ניהול מורכב יותר של State.
  • Race conditions בעדכון הנתונים – כאשר התופעה רחבה, קשה מאוד לניהול בקשרים פשוטים בין שירותים.
    • גם: אפליקציות מובייל כאשר ייתכן latency גבוה / לא עקבי בתגובה לקריאות HTTP ו/או ניתוקים אפשריים.

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

שתי תכונות שנוטים לייחס לפתרונות לניהול State בצורה מוגזמת, וכנראה לא נכונה הן Testability טוב יותר (כנראה ההיפך הוא הנכון) וביצועים טובים יותר (Caches בהחלט עוזרים – אבל אפשר להשתמש ב caches גם ללא פתרון ניהול state).

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

סיכום

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

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

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

—-

קישורים שימושיים:

השוואה בין Akita, NgRx, ו Ngxs – פרימוורקים לניהול State באנגולר (או בכלל).

—–

[א] You Might Not Need Redux – פוסט מהיוצר של רידאקס.

TCR, או: מתי TDD כבר יוכרז כמיושן?

TCR היא טכניקת תכנות חדשה מבית היוצר של Kent Beck.

 

אם זה לא הרשים אתכם עד כה, אני אזכיר שקנט בק הוא הבחור שהיה שותף להמצאה של ה Wiki והבאת CRC Cards (שיטת Design) לקהל הרחב. הוא גם המציא את (Extreme Programming (XP וגרם למהפכה בעולם התוכנה. ביחד עם Erich Gamma (מה GOF) הוא כתב את JUnit ועוד גרסאות בשפות שונות. לבסוף הוא המציא את TDD – שגם השאירה חותם גדול על התעשייה.

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

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

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

 

רקע

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

למי שלא מבין את הקושי, כלל האצבע של התכנותיקה גורס שגדילה של ארגון הנדסי (נניח: גדול מ 25 עובדים), בקצב של מעל 50% בשנה – הוא הרסני:

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

המצב של גדילה כ"כ גדולה (יותר מ 50% בשנה) נקרא Hyper-growth או Blitzscaling, ואין לו פתרון פשוט.

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

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

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

התנגשות (merge או גרוע הרבה יותר: build שבור) – הוא נזק סביבתי חמור. כאשר ה build שבור:
א. מספר לא חסום של מפתחים – חסום לעבוד לפרק זמן.
ב. ייתכן וחלק מהמפתחים שכפלו ל branch שלהם את התקלה ואז:
     ב1 – היא עלולה לגזול להם זמן נוסף באיתור ותיקון התקלה בעצמם.
     ב 2 – היא יכולה, בצורה מסוימת, לחזור ל master מחדש כחלק מתהליך ה merge חזרה.

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

 

תרשים של קנט בק על ההבדלים בין TDD ל TCR

ל TDD – המתודולוגיה הקודמת של קנט, שעוזרת לשמור על קוד בשליטה וניתן-לשינוי בכל רגע, יש כשל עמוק כאשר מדברים על Scale: מתודולוגית ה TDD לא מחייבת אנשים ל commits קטנים. מתכנת יכול לכתוב 20 בדיקות, ואז לעבוד עוד יום או יומיים או שלושה עד שכולם יהיו ירוקים. הוא כבר לא יעמוד בקריטריון הרשמי של Continuous Integration [א] – אבל לכאורה הוא עדיין עושה TDD.

קנט בא לתקן את מתודולגיית ה TDD לעבוד ב Hyperscale וקרא לה Test && Commit:
הרזולוציה המחויבת מעכשיו היא של בדיקה בודדת:

  • בצע שינוי קטן של קוד.
  • כתוב בדיקה שמאמתת את השינוי.
  • הרץ את הבדיקה, ואם היא עברה – עשה merge ל master.

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

השם של השיטה מתוך הסקריפט הבא, שאמור לרוץ בסוף כל cycle קצר שכזה:

test && git commit -am working
 

הרעיון של "Living Integration" במקום "Continuous Integration" (שאולי עכשיו נכון יותר לקרוא לו Daily integration – הוא אטי הרבה יותר מדי מ"הנדרש") – נקרא ע"י קנט והצוות שלו בשם "Limbo", המצב האינסופי של העולם הבא.

יש קונצנזוס רחב בתעשייה, כבר כשני-עשורים לפחות, שעבודה ב batches קטנים ככל האפשר – היא דרך ליעל תהליכי עבודה ולהימנע מהתברברויות יקרות. הלימבו אמור להיות המצב התאורטי הגבוה ביותר של batches הקטנים ביותר והאינטגרציה הרציפה ביותר האפשרית. רעיונות שעלו בצוות היו "בואו נכתוב סקריפט שעושה push ל master כל פעם שקובץ נשמר" או "בואו נאסור על commit לכלול יותר מ 25 שורות שהשתנו" (שני רעיונות קיצוניים למדי, ותאורטיים – לפחות כיום).

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

קנט לא אהב את הרעיון, ולכן לפי דבריו "הוא היה חייב ללכת לנסות אותו!". בעצם נראה שקנט אהב את הרעיון – ומשם המתודולוגיה הפכה ל: (Test && (Commit || Revert – מכאן שמה הוא TCR.

מעכשיו המתודולוגיה עובדת כך:

  1. בצע שינוי קטן של קוד.
  2. כתוב בדיקה שמאמתת את השינוי.
  3. הבדיקות עברו? – בצע commit (ושלח את הקוד ל master)
    הבדיקות נכשלו? בצע revert –hard לקוד – והתחל מהתחלה.
 
כל הכנסה של קוד צריכה לעבור את כל הבדיקות, יש ב TCR איזו הנחה סמויה שהמערכת מכוסה לגמרי בבדיקות-יחידה, שיכולות לרוץ בדקות בודדות – לכל היותר.
 
 
הרעיון של TCR הוא די קיצוני: למחוק את הקוד אם לא הצלחנו בפעם הראשונה?
 
קנט הוא בית-חרושת לרעיונות קיצוניים, והדרך הזו באמת מחייבת לעבוד ב batches קטנים: מספיק שכמה פעמים ביום יהיה עליכם לעשות revert לקוד שכתבתם – בוודאי משם תתחילו לעבוד ב batches יותר ויותר קטנים, בכדי לאבד פחות עבודה. ההיגיון ברור, אני מניח.
סקריפט שימושי להריץ בזמן שאתם עובדים על ה master branch. כולם צריכים לעבוד על ה master branch – כמובן!

שימוש מעשי

אם אתם מפתחי תוכנה מנוסים, אתם עשויים לחשוב בתחילה שהרעיון של TCR, ומחיקת קוד פעם אחר פעם – הוא רעיון אבסורדי.
 
בספר "Extreme Programming Explained" קנט בק הסביר שב XP הוא עורך בעצם ניסוי. הוא לוקח כל מיני פרקטיקות טובות שכרגע נמצאות על רמה 2 או 3 – לרמה 10, לראות מה קורה.
 
"אם Code Review הוא טוב – עשו Code Review כל הזמן!" – כך נוצרה הפרקטיקה של Pair Programming, למשל – כחלק מ XP.
 
XP לא ממש הצליחה כשיטה, אבל הפרקטיקות שלה, שהחזירו אותן לרמה 3 או 4 – עבדו מצוין, והן מוטמעות היום עמוק בתעשייה. 
 
גם TDD – לא ממש התממש לפי החזון. אומרים שרוב מי שעושה TDD – לא באמת מבין את המתודולוגיה, וגם מי שמבין אותה – לא עושה אותה "By the Book".
 
אז מה? TDD גרם למהפכה, וגרם לאנשים לחשוב מחדש על בדיקות (גם אם חלקם הגיעו למסקנה מעט שונה מזו שקנט התכוון).
 
ברור שהרעיונות של TCR הם אבסורדים ולא מעשיים! זהו קנט בק, וטוב שיש מישהו כמוהו – שמעלה אותם.
 
מה שנשאר למצוא הוא כמה צעדים אחורה יש לקחת מנקודת הקיצון – על מנת להפוך את הפרקטיקה ליעילה.
כנראה ואין תשובה אחת, ולאנשים שונים / ארגונים שונים / זמנים שונים – יתאימו התאמות שונות של הפרקטיקה.
בואו נראה כמה יישומים מעשיים שמדוברים עכשיו:
 
 
לימוד / אימון
 

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

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

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

LDR הוא סוג של TCR. בחיי.
 

"30 דקות ל commit"

את הגרסה הזו אני ניסיתי בחודשים האחרונים:

  1. התחל לעבוד על קוד. 
    1. חתור למצב בו יש מצב יציב שעובר commit מהר ככלל האפשר: 5 עד 10 דקות.
    2. עדיף מאוד עם בדיקות – אבל לא כל commit שלי באמת נבדק בטסט חדש.
  2. אם הגעת ל 30 דקות בלי commit – בצע revert לקוד והתחל מחדש.

בגרסה הזו מתמקדים ב commit מהיר כל 5-10 דקות (מקסימום: 30 דקות) – ולא ב merge מהיר ל master, מה שעוזר "לדלג" מעל קושי גדול ביישום ריאלי של TCR. ה merge עדיין מכיל מספר commits, ועובר תהליך qualification ארוך ומורכב (יחסית לדרישות של TCR).

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

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

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

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

It’s 2019. Blocking, asynchronous code reviews are the dominant method for collaboratively developing software. If I draw one certain conclusion from my experiments with TCR and Limbo, it’s that blocking, asynchronous code reviews are not the only effective workflow for collaboration. While I don’t know if TCR and/or Limbo will be the future, I think that something different  is coming — Kent Beck

TCR /w Async Merge

תהליכי ה Integration בחברות לא מאפשרים כיום לעשות merge כל כמה דקות. מי שאינו קנט בק חי בדרך כלל עם Suite של בדיקות מסוגים שונים שלוקח להם לפחות 10 דקות לרוץ, ולעתים גם כחצי שעה או שעה (זה הקצה הקצת פחות טוב…).
ויש גם את ה Code Review שיכול להסתיים רק אחרי כמה שעות. גם היסטוריה ב Git תהיה בלאגן אם כל מפתח יכניס commit כל 10 דקות. המערכות שלנו לא מוכנות כרגע באמת ל commit ל master כל 10 דקות, ע"י מאסות של מפתחים.

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

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

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

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

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

  • יש משהו עוצמתי ב TCR שגורם לאנשים להמשיך ולנסות אותו. אני לא זוכר כזו דבקות מסביב ל Implementation Patterns.
  • הדרכים העיקריות ליישום שימושי של TCR עדיין לא נמצאו.

אני אישית חושב ש "30 דקות ל commit" היא האופציה המעשית ביותר. לי היא עובדת כשינוי גישה מרענן.  

סיכום

  • מה אתם חושבים על TCR?
  • האם ניסיתם? האם היו תוצאות טובות?

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

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

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

[א] ע"פ ההגדרה: כל מפתח עושה merge ל trunk/master כל יום.