אני עומד לעסוק בנושא בסיסי מאוד: 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 שגוי של משתמש הוא גם בעייתי בהיבטי האבטחה של המערכת.
אם מפתחים שונים בוחרים חריגות מטיפוסים שונים לאותו המצב, אז:
- הקוד שלנו לא ממש אחיד => מה שמפחית במעט את קריאות הקוד.
- אם אני רוצה לתפוס את המקרה כ Exception - אני לא באמת יודע לאיזה Exception לצפות, ויותר גרוע: אני יכול לתפוס את סוג ה Exception - שנזרק ע"י קטע קוד אחר שהמתכנת שלו חשב שזה טיפוס ה Exception הנכון ביותר לתאר אותו.
אם חשבתם אולי לשלוח "שק" של Exceptions שיתאר בצורה רחבה יותר את מה שקרה - אנא חשבו על זה שוב. זה רעיון לא מוצלח....
>> אז מה עושים?
כיצד אתם בוחרים באיזה סוג Exception להשתמש?
איך קבוצת הפיתוח בה אתם חברים, כקבוצה, מקבלת כזו החלטה? (החלטות הן לא תמיד מסונכרנות)
בחזרה למהות
בואו נזכר בתוצאה של זריקת Exception. מדוע בעצם אנחנו זורקים אותן? מה אנחנו רוצים שיקרה?
אנו זורקים Exceptions כיוון שמשהו השתבש, ואנחנו לא יכולים, או לא נכון לנו להמשיך את ה flow. למשל: אם אנו מגלים שאנו עומדים ליצור חוסר-עקביות (inconsistency) בבסיס-הנתונים, כנראה שעדיף לנו לעצור עכשיו מאשר ליצור בעיה עתידית, קשה יותר לזיהוי ולתיקון.
אם אנחנו יודעים להתאושש מהתקלה (למשל להשתמש ב default הגיוני לפרמטר שחסר) - אז אנחנו פשוט ממשיכים. אין צורך לזרוק Exception.
מה התוצאה של זריקת ה Exception, מצד הלקוח שקרא לפונקציה שלנו? נסכם בפשטות את האפשרויות:
- Rollback של טרנזקציה / פעולה, או פעולות cleanup - זה קורה לפעמים.
- כתיבת הודעת לוג (שגיאה / אזהרה) - בכדי ללמוד עליה ולטפל ידנית במצב ו/או לשפר את המערכת שמצב זה לא יקרה בשנית. את זה עושים כמעט תמיד.
- הצגת אינדיקציה למשתמש-הקצה ב UI, ברמת פירוט כזו או אחרת, שמשהו השתבש - אם קיים משתמש כזה.
זהו. זו בגדול התוצאה.
ברוב המקרים - הטיפול האמיתי ב Exception יהיה מעבר ל Flow בקוד:
- המשתמש יראה את הודעת השגיאה וינסה שוב (אולי מדובר ב timeout?). אולי ינסה שוב עם קלט מעט אחר.
- המתכנת יחקור את התקלה. אולי יש לבצע טיפול ידני? אולי נשפר את הקוד כך שתקלה זו לא תקרה בעתיד. זה לרוב קורה שעות אחרי שה Exception נזרק.
הנה קוד לדוגמה שתופס Exception:
למה בעצם משנה איזה טיפוס של 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 - ואז הכל בסדר.
- במקרים בהם המימוש הוא אחר, למשל: כתובת האובייקט בזיכרון - איבדנו את ההזדמנות לאסוף מידע שימושי לגבי השגיאה.
בואו נסתכל בדוגמת קוד אחרת, יותר טובה (אך עדיין בעייתית) ומאוד נפוצה:
קרתה שגיאה אחת, אבל בלוג יכתבו 3 stack trances שעשויים להראות כמו 3 שגיאות. ה stack traces הם כמעט חופפים, כאשר בכל פעם נוספת רק עוד שורה אחת.
אפשר בקלות לבזבז זמן בחקירה של הלוג שנכתב ע"י דודי (כלומר: doD) או הלוג שנכתב ע"י דובי (doB) - בעוד חסר לנו ההקשר החשוב שהתחלנו לפעול בעצם מ doA.
בגדול:
- ב catch clause בחרו: או שאתם מדפיסים הודעה ללוג - או שאתם זורקים שגיאה הלאה.
- כל Framework / WebServer שמכבד את עצמו יתפוס את כל השגיאות שהתרחשו - ויכתוב אותן ללוג.
- כתיבת ה Stack trace ללוג ברמה הגבוהה ביותר - תספק את מירב המידע, ולכן זו השגיאה שאנו רוצים לחקור. חבל לכתוב הודעות כפולות ללוג.
- הערת משנה: אם יש stack trace אז יהיה כתוב איזה פונקציה נקראה. עדיין המון אנשים אוהבים להוסיף בעצמם את שם המתודה (ולפעמים גם את שם המחלקה) להודעת השגיאה - וזה דיי מיותר. נחשו מה קורה כאשר עושים rename לשם המתודה?
- בהודעת השגיאה חשוב לציין מידע חדש ומעניין. למשל: פרמטרים מסוימים של הריצה - שלא תהיה לנו גישה אליהם מאוחר יותר.
הנה דוגמת הקוד הזו בגרסה הטובה יותר שלה:
ה 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:
כאשר קוד מתייחס לטיפוס הזה, ומריץ איתו 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 (הרבה לקחים נלמדו, ונלמדים בעולם הג'אווה לאורך השנים). קוד הג'אווה הבא:
יזרוק חריגה "סטנדרטית" (כי הקוד הוא של ג'אווה [א]) מסוג UnsupportedTemporalTypeException.
למה? בכדי להדגיש ש Instant (המייצג של epoch ב Java Time APIs) נועד לשימוש ע"י מכונה, ולא בכדי לייצג מידע על תאריך שיגיע למשתמשים בני-אדם.
האם זה נכון, לזרוק שגיאה בשל "שימוש שנראה שגוי בספריה"?
זה דיון ארוך שלא ארצה להיכנס אליו עכשיו, אבל אקצר בכך שזו לא התנהגות ממש צפויה - ושחשוב מאוד לכתוב קוד צפוי שלא יפתיע אותנו ב Production ביום שישי בערב...
אני רצה לסיים ולציין תנאים מומלצים לשימוש ב Standard Exception:
- החריגות הסטנדרטיות הללו הוגדרו במערכת שלכם, ולא יכולות להיזרק בטעות ע"י ספריית צד-שלישי שנכתבה ע"י מישהו שלא מכיר את הכללים שקבעתם לשימוש בחריגה.
- ישנם כללים ברורים וידועים - מה אומר השימוש בחריגה הזו.
- לחריגה הספציפית הזו, יש משמעויות ברמת המכונה / קוד במערכת - והן לא נועדו בכדי להסיר אחריות מהודעת השגיאה שתדווח.
דוגמה אחת היא javax.ws.rs.WebApplicationException:
- היא אמנם "סטנדרטית בתעשייה" ולא ייחודית לכם. לכן, נכון לזרוק אותה - אבל כדאי להימנע ולהסתמך עליה בלכידת Exception לצרכים אפליקטיביים. היא עשויה להיזרק ע"י כל אחד.
- יש לה סמנטיקה משמעותית וברורה ברמת המכונה: Web Applications Servers יתפסו אותה ברמתם - אבל יעבירו את הודעת השגיאה שהוגדרה בה, כמו שהיא, ללקוח. אפשר גם לציין במחלקה הזו גם HTTP Status קוד שיעבור גם הוא ללקוח. למשל: HTTP 403 - הוא קוד בעל משמעות חשובה.
- מי שזורק את WebApplicationException, חשוב שיבין את הסמנטיקה שלה - ולא יכתוב בה הודעת שגיאה עם מידע פנימי שלא אמור להגיע ללקוח שביצע את הקריאה.
בדוגמה אחרת, הגדרנו ב Next-Insurance טיפוס Exception פנימי בשם RobinUserInputException.
- רובין היא מערכת פנימית שלנו, המשמשת רק משתמשים בארגון. אם משתמש במערכת הזין קלט שנראה שגוי - אנו רוצים להעביר את ההודעה (באנגלית של "מתכנתים", שלא עברה הגהה) ישר מלב הקוד במערכת - עד למשתמש הקצה. אין פה בעיות אבטחה.
- ספציפית ל RobinUserInputException יש שתי הודעות שגיאה: כזו שמיועדת ללקוח הקצה (הפנימי), ושניה (שרק תודפס ללוג, ולא תגיע למשתמש) עבור המתכנת שירצה אח"כ לחקור את הבעיה.
- כללי-השימוש, מתועדים ומוסברים היטב.
זה שימוש סמנטי (בעל משמעות ברמת הקוד), ופנימי. יש יתרון ממשי בהגדרה ושימוש בסוג כזה של חריגה.
סיכום
אני מקווה שהצלחנו להאיר ולהדגים כמה עקרונות חשובים לגבי Exception Handling.
לצערי, הרבה חשיבה מסביב ל Exception Handling מוקדשת ל Tooling (מחלקות שונות של Exceptions) - ולא למהות השגיאה, וכיצד לטפל בה.
חדי העין אולי שמו לב שההמלצה שסתרתי בפוסט, "Favor the use of standard exceptions" - מגיעה מספר מכובד (של מחבר מכובד) בשם Effective Java. הנה סיכום הדברים:
גם אני, גדלתי על הספר הזה - שפתח א
אבל, עם הזמן יצא לי לתהות שוב לגבי חלק מההמלצות שלו - ולהגיע למסקנה שכמה מההמלצות שלו - אינן טובות.
הייתי רוצה להאמין שגם Joshua Block, וקוראים אחרים - הגיעו / יגיעו לתובנות דומות (בהנחה שהן נכונות).
הספר נכתב במקור בזמן שאני עוד הייתי סטודנט, והרבה הבנה התפתחה מאז על כתיבת קוד, ועל ג'אווה בכלל [ב].
שיהיה בהצלחה!
-----
[א] אני קצת צוחק, כי זו באמת חריגה ממוקדת, שלא משתמשים בה מחוץ ל Java Time API - אבל יש כאלו שרואים כל חריגה של הספריות של ג'אווה כחריגות "סטנדרטיות".
[ב] כן, אני יודע ש Item 60 מופיע גם במהדורה השלישית של הספר שיצאה רק לפני שנתיים. הייתי שמח לתפוס את המחבר לשיחה על האייטם הזה ועוד כמה אייטמים שנראים לי שגויים - ולברר איתו את העניינים. לא סביר שזה יקרה.