הייררכיה של טיפול בחריגות
בפוסט הקודם דיברתי על הדרך הנכונה לנהל טיפול בחריגות (Exceptions). כעת, אפשר לדון בשאלה אילו חריגות לזרוק.
איך ומה?
טיפוסים: איזה טיפוס exception צריך לזרוק? האם להסתפק בסתם IOException, או אולי להשתמש בטיפוס ספציפי יותר המוגדר בשפה? ואולי, בכלל, ליצור טיפוס מיוחד עבור המקרה הזה?
טיפולים: מי אמור לטפל בחריגה? האם ברגע שכתבתי את הפונקציה, אני זה שאחראי לטפל במצב, לוודא שהכל בסדר, ולתת לפונקציה שקראה לי להמשיך כרגיל, בלי לדעת בכלל שאירעה תקלה וכבר טופלה? או אולי עלי לא לעשות דבר מלבד למסור את ה-exception הלאה במעלה המחסנית, ולתת למודול שמכיר את התמונה הרחבה יותר להחליט מה לעשות איתה? ואולי עלי לבצע פעולות כלשהן, ובכל זאת להעביר את החריגה הלאה לפונקציה שמעלי?
שתי השאלות הללו נשאלות בכל מערכת, וגוררות בעקבותיהן לא מעט "מלחמות דת". בדרך כלל, הן קשורות זו לזו: בניית הייררכיה של טיפוסי שגיאות וחריגות יכולה להיות קשורה קשר הדוק לשאלה באיזו רמה הן מטופלות. כמו בכל דיזיין או תבשיל – מדובר בעניין של טעם, אבל ברור שאפשר להגדיר מה אינו ראוי לאכילה.
כללי אצבע
הכלל הראשון הוא שיש לשמור על ה"התחייבויות" שהגדרנו קודם. אם הקצינו משאב כלשהו בתוך הפונקציה ועכשיו נזרקה exception – אנחנו חייבים לוודא שהמשאב לא ידלוף. זה לא משנה אם נבצע טיפול ממשי ברמת הפונקציה שלנו, או שנזרוק אותה הלאה לרמה שמעלינו – יש דברים שאפשר לטפל בהם רק ברמה המקומית.
הכלל השני הוא הכלל אותו הגדרנו בהתחלה – ברומא, התנהג כרומאי. אם המערכת בנויה כך ששגיאות מטופלות מקומית – טפל בהן כך. אם המערכת מצפה שכל שגיאה תוצף עד לרמה חיצונית כלשהי שתבצע בה טיפול "אחיד" – אז זוהי הדרך. אי שמירה על הכלל הזה תוביל לכך שתקלות פשוטות למדי תגרומנה לקריסה, או לחילופין, שלא ניתן יהיה לנתח ולהגיב לתקלות ב-scale מערכתי. זה נכון לא רק ל"טיפול" אלא גם ל"טיפוס". אל תזרוק שגיאה מסוג string ב-C++, למרות שאתה יכול – זרוק שגיאה ששייכת למשפחת הטיפוסים של exceptions. אל תזרוק שגיאת RunTime בג'אווה אם מדובר בשגיאה שניתן היה לצפות מראש… כל שפה וסביבה והכללים שלה לטיפוסים הרלוונטיים.
ובכל זאת?
ישנן מספר גישות שבעיניי מאפשרות טיפול נכון יותר בחריגות. לכל גישה תמיד יהיו יתרונות וחסרונות – ולכן חשוב לבחון את המערכת הספציפית איתה אתם מתמודדים ועד כמה הגישות אותן אני מציע מתאימות עבורה.
טיפוסי חריגות מתאימים לטיפוסי הקוד
כאשר אנו כותבים מודול חדש, אנו מגדירים בו טיפוסים ומחלקות חדשים, ייחודיים עבורו. אין סיבה שלא לכתוב באותה הדרך גם טיפוסים עבור exceptions רלוונטיים. בדרך כלל, הדבר הנכון הוא ליצור exception אב, שיורש את טיפוס השגיאה/חריגה הסטנדרטי בשפה, וממנו יורשים כל שאר טיפוסי ה-exception שבמודול הזה. במרבית המקרים, השימוש העיקרי במחלקות החדשות שהגדרנו במודול מסויים יהיה בתוך המודול עצמו. במקרה של חריגות – המצב מעט פרדוקסלי לעתים: דווקא בתוך המודול פחות חשוב לנו להשתמש במחלקות החדשות שהגדרנו; הצורך בהן הוא יותר חיצוני.
למה, בעצם?
כל עוד אנחנו נמצאים בקונטקסט מקומי יחסית, אנחנו מבינים בדיוק את הבעיה ויודעים איך להגיב לה. למשל – אם אנחנו מנסים ליצור תקשורת עם שרת HTTP מרוחק ללא הצלחה, נקבל IO-Exception כלשהו. כיוון שאנחנו יודעים מה רצינו לעשות, אנחנו יודעים איך לנסות להתגבר על זה. בין אם זה לבצע Retry או לנסות להתחבר לשרת חליפי. גם אם אנחנו לא ממש בתוך הקונטקסט המיידי אלא מעט מסביבו, זה עדיין בסדר; אם החריגה נזרקה לא ממש מפונקצית השליחה של ה-HTTP אלא מפונקציה שתפקידה לייצר את הבקשה ולשלוח אותה – גם אז, המשמעות של IO-Exception והתגובות הרלוונטיות תהיינה די ברורות.
אבל – אם אנחנו יוצאים לקונטקסט רחב יותר, המשמעות של החריגה כבר אינה ידועה. אם מתקבלת IO-Exception מפונקציה מורכבת, שאחראית לביצוע מספר רב של משימות, לא נוכל להבין ממנה איזו תקלה בדיוק אירעה, מה התבצע ומה לא, ולפיכך גם לא נוכל לתכנן טיפול יעיל במקרה שכזה. לעומת זאת, אם באותה הפונקציה מתקבלת שגיאה שמקורה ידוע – למשל, מפני שהיא מטיפוס PerformanceLogIOException ולא מטיפוס אחר, נניח UserDataIOException או StockTradeIOException, נדע לקבל החלטה מתאימה. למשל, שכל עוד מדובר רק בבעיה כלשהי שנוצרה בספריית רישום הביצועים, לא מדובר בתקלה קריטית במערכת, ולכן אפשר פשוט להפנות את המשימה מחדש לשרת אחר, לשנות קונפיגורציה, או לפעול בכל דרך מתאימה אחרת. כמובן, לא בהכרח יעניין אותנו הטיפוס הספציפי של השגיאה; ייתכן שיעניין אותנו רק המקור שלה. נניח, למשל, שכל טיפוסי החריגות במודול PerformanceLog יורשים טיפוס בסיסי של PerformanceLogException. במצב כזה, חלקים באיזורים "גבוהים יותר" בקוד יכולים להסתפק בלתפוס את טיפוס החריגה הזה, ולבצע טיפול גנרי לשירות רישום הביצועים.
על פי אותו העיקרון, נוכל לבצע הפשטה של כמה רמות. למשל, יצירת PerformanceException, שתחתיו תוגדרנה כל החריגות הרלוונטיות למודול הביצועים; ממנו, נוכל לרשת טיפוסי-אב נוספים, כמו למשל PerformanceLogException, שיהיה אחראי לכל תת-המודול העוסק ברישום ללוג של הביצועים, או PerformanceTweakException, שיהיה אחראי לכל תת-המודול העוסק בביצוע שינויים בביצועים. ותחתם, נוכל לרשת רמות נוספות, כמו למשל PerformanceLogIOException. כך, בכל רמה בהייררכיות המערכת, נוכל להחליט אם לטפל בשגיאה הספציפית או שנכיר אותה רק בצורה כללית יותר.
לדוגמא:
הפונקציה שמנסה ליצור תקשורת עם שרת הלוג של הביצועים נכשלת וזורקת PerformanceLogIOException. הפונקציה שקראה לה מכירה את השגיאה הזו, ויודעת באיזה קונטקסט היא מתבצעת. היא יכולה, למשל, לנסות ולקרוא שוב לפונקצית התקשורת, בתקווה שזו היתה בעיה מקומית. היא יכולה גם, אם היא אינה מתגברת על הבעיה, לזרוק אותה הלאה. שם, באיזור גבוה יותר בקוד, לא בהכרח ברור מה בדיוק נכשל. לעומת זאת, ניתן לראות בוודאות שמדובר בתקלה הקשורה ברישום הלוג של הביצועים, ולכן היא לא בהכרח פוגעת במודול הביצועים עצמו. המערכת יכולה להחליט שזה בסדר גמור להמשיך ככה, ורק לרשום סוג של Alert לאנשי התחזוקה. היא יכולה גם להחליט שהיא לא מסוגלת למצוא פתרון לבעיה, ולזרוק את החריגה הלאה במעלה המחסנית. שם היא תגיע ללב המערכת, שלא בהכרח יודעת מה המשמעות הספציפית של התקלה, אלא רק שהיא הגיעה ממודול הביצועים. ושוב, המערכת תוכל לבצע החלטה בקונטקסט הספציפי הזה – האם תקלה במודול הביצועים מחייבת רק הוצאת הודעת שגיאה, או אולי מעבר לרוטינה של איתחול כל מערכת ניטור הביצועים בתוכנה.
השימוש ברמות שונות של ירושה מאפשר רמות שונות של היכרות והחלטה.
שימו לב, כמובן, מה אתם חושפים החוצה. אם אתם לא מעוניינים שצד שלישי המשתמש בספריות או בשירותים שלכם יידע שאתם משתמשים באלגוריתם של חברת BestAlgComp, מוטב שלא לתת לחריגה מטיפוס BestAlgCompException לחלחל החוצה. וכמובן, לא תרצו לזרוק למעלה והלאה חריגה הנושאת מידע לפיו לא הצליחה להתחבר לשרת עם שם משתמש XXX וסיסמא YYY…
שמרו על המשמעות
כמו תמיד, הגישה שהצעתי קודם היא רק המלצה – והיא גם לא תתאים בהכרח לכל מערכת. לפעמים הדבר הנכון יהיה דווקא לוותר על יצירה של טיפוסים חדשים, ופשוט לנצל את סט החריגות הקיים. כמעט לכל מקרה שהוא, אחד הטיפוסים הקיימים יתאר את החריגה בצורה סבירה (אם כי לא בהכרח יבהיר את הקונטקסט). לפעמים זו אותה חריגה עצמה שעלתה מפונקציית שירות לה קראנו. לפעמים מדובר דווקא בחריגה אחרת – במקרים מסויימים זה אפילו קצת טריקי.
לדוגמא:
נניח שיצרנו מבנה נתונים חדש, שמתנהג כמו Map או Dictionary – מחזיק אוסף של מפתחות ולכל מפתח ערך מתאים. אם ננסה לגשת למפתח שלא קיים, אנחנו אמורים לזרוק חריגה מסוג של NoSuchKeyException, או משהו בדומה לזה. אם ננסה לקבל את המפתח לערך כלשהו שאינו קיים, נקבל חריגה מסוג שונה – משהו בסגנון NoSuchValException. עד כאן הכל הגיוני… אבל מה אם יצרנו מבנה נתונים A שמסיבות כלשהן (נניח, זמן גישה) שומר את הערכים שלו כמפתחות ב-Dictionary פנימי B? במקרה כזה, גישה לערך שאינו קיים ב-A תגרום לזריקת NoSuchKeyException כאשר יתבצע המימוש שלה באמצעות קריאה ל-B. אם נחליט פשוט שאנחנו זורקים כל חריגה לרמה שמעליה, נזרוק למעשה (ברמת המבנה A) חריגה שאינה נכונה ואינה מובנת. המערכת פנתה לקבל ערך, וקיבלה חריגה על מפתח שאינו קיים – זו אינה התנהגות צפוייה, והיא יכולה להיות אפילו מסוכנת, כי מי שקרא לערך ינסה לתפוס אך ורק חריגות רלוונטיות, ולא חריגות מטיפוסים שלא אמורים להיזרק. לכן, במקרה כזה, הדבר הנכון יהיה לתפוס את החריגה מ-B, ולזרוק במקומה חריגה מטיפוס NoSuchValException. בחלק מהשפות ניתן לכלול את החריגה המקורית כחלק מהמידע שנושאת איתה החריגה החדשה (לא בהכרח נרצה לעשות את זה – תלוי האם אנחנו בכלל מעוניינים לחשוף את המימוש הפנימי).
לא משנה אם אנחנו משתמשים בסט של טיפוסי חריגות שהוגדרו במיוחד למודול מסויים או בחריגות סטנדרטיות – המשמעות שלהן חייבת להיות רלוונטית כדי שניתן יהיה לפעול נכון כשתופסים אותן.
הגדירו בדיוק אילו חריגות עשויה כל קריאה לזרוק
מבין כל הכללים וההצעות לקביעת רמות הטיפול בחריגות – זהו הכלל החשוב ביותר. הגדרת ה-API של פונקציה כוללת גם את החריגות שהיא עשויה לזרוק. ישנן שפות בהן השפה מחייבת הגדרה ברורה ומלאה של חריגות אפשריות כחלק מחתימת הפונקציה. בשפות אחרות, ובמקרים שונים, הקומפיילר/אינטרפרטר אינו אוכף זאת. אף על פי כן, זוהי חובתו של כל מפתח לוודא שברור לחלוטין אילו חריגות הפונקציות שלו עלולות לזרוק (אם בדרך של רישום בחתימת הפונקציה גם כאשר אין אכיפה, אם בדרך של תיעוד בעזרת הערות או בכלים אחרים). כאשר קוראים לפונקציה הזו, חובה לקבל החלטה ברורה לגבי הפעולה שתתבצע עבור כל חריגה לגיטימית שתיזרק ממנה. אפשר להחליט לטפל, ואפשר להחליט לזרוק אותה הלאה, אבל ההחלטה חייבת להיעשות בצורה מודעת.
אם מתבצע שינוי בקוד של פונקציה, אשר עשוי לגרום לה לזרוק חריגה שקודם לכן לא הוגדרה עבורה, צריך לעשות זאת בזהירות רבה. שינוי כזה יכול לשבור קוד קיים ולגרום לבעיות בלתי צפויות באיזורי קוד אחרים או אפילו בקוד של צד שלישי המשתמש בספריות או השירותים שלנו.
תפוס וזרוק
הדרך הטובה ביותר תהיה פשוט להימנע מזה. יכול מאוד להיות שניתן להשתמש באחד מסוגי החריגה שכבר הוגדרו. נניח שיש לנו פונקציה שניגשת לרשת, ואז לבסיס נתונים, וידוע שהיא יכולה לזרוק אחת משתי החריגות NetworkConnectionError או DatabaseConnectionError. כעת הכנסנו מנגנון הרשאות חדש לבסיס הנתונים, ולכן ייתכן שנצליח להתחבר אליו אולם לא נקבל את ההרשאה המתאימה. המצב הזה עלול לגרום לכך שאותה הפונקציה תזרוק חריגה מסוג DatabaseAuthenticationError. אבל – זו תהיה עבירה חמורה על הכלל שהגדרנו כרגע. במקרה שחריגה מסוג זה תיזרק, אין לנו כל יכולת לנבא מה תהיה התנהגות המערכת, כיוון שהיא אינה מצפה לה. פתרון אפשרי אחד, אם כן, יהיה לתפוס אותה בתוך הפונקציה, ולזרוק במקומה אחת מהחריגות הלגיטימיות – למשל DatabaseConnectionError. פעולה זו אפשרית, כמובן, אך ורק אם קיימת חריגה מתאימה – כיוון שאחרת, אנחנו עוברים על הכלל החשוב לפיו יש חשיבות למשמעות החריגה. זריקה של חריגה לא רלוונטית, כמו המרה של השגיאה לשגיאה מסוג NetworkConnectionError, אמנם תשמור על ה"חוזה" הקיים של הפונקציה, אבל תהיה חסרת טעם לחלוטין – המערכת שתתפוס אותה תנסה לבצע רוטינות הפותרות בעיות תקשורת, לא בעיות בגישה ל-DB.
פולימורפיזם
אפשרות אפילו טובה יותר, אם כי לא מתאימה לכל סיטואציה, תהיה להשתמש בעקרון הפולימורפיזם. אם הטיפוס DatabaseAuthenticationError יורש את הטיפוס DatabaseConnectionError, הבעיה אינה צריכה פתרון – היא פשוט אינה קיימת. כל המנגנונים הקיימים יודעים שעליהם לטפל ב-DatabaseConnectionError. כמובן, כעת נוכל לשפר אותם ולהוסיף טיפול ייחודי עבור המקרה של בעיית אותנטיקציה, אבל לא שברנו את הקוד בשום מקום. ירושה כזו, כמובן, אינה תמיד אפשרית; אולם זהו אחד היתרונות בבנייה של מערכת חריגות הייררכית כפי שהצעתי קודם לכן – היא מאפשרת פתרונות מהסוג הזה ושומרת על המערכת יציבה יותר. כצעד מקדים, ניתן לוודא שפונקציות זורקות טיפוסים "כלליים" לצד טיפוסים ספציפיים יותר. למשל, גם אם בזמן כתיבת הפונקציה אנחנו יודעים שהיא עלולה לזרוק DatabaseNotExistsError (שיורש מהטיפוס הכללי של חריגת DatabaseConnectionError), לא נגדיר את החתימה שלה רק עם DatabaseNotExistsError, אלא גם עם הטיפוס הכללי יותר, DatabaseConnectionError. כך נוכל לוודא שאם בעתיד תהיינה חריגות DB אחרות – כמו, למשל, DatabaseAuthenticationError – הן תטופלנה ולא תיגרם שבירת קוד.
כמובן, לא תמיד הפתרונות האלו יהיו אפשריים. במקרים אלו לא תהיה ברירה אלא לוודא שאנחנו מגיעים לכל הנקודות בקוד הקוראות לפונקציה, ומעדכנים אותן בהתאם; אם הקוד נמסר לצדדים שלישיים כלשהם, יש צורך לוודא ששינוי כזה מתבצע רק בזמן עדכון גרסא רציני ומסודר ומובהר היטב ללקוח; זהו מהלך מסוכן שעלול לפגוע אנושות באמינות מול לקוחות (זמן מצויין להיזכר כמה חשובה התאימות לאחור). אם זה אפשרי מבחינת המערכת, טוב יותר במקרה כזה להשאיר את הפונקציה כמו שהיא (ורק להגדיר אותה obsolete), ולכתוב במקומה פונקציה חדשה.
טפלו בכל חריגה צפויה. אל תטפלו בחריגות בלתי צפויות
את העובדה שיש לטפל (או במפורש או בגלגול הלאה) בכל חריגה "לגיטימית" הבהרנו כבר; אבל לא פחות מזה, חשוב שלא לטפל בחריגות שאינן צפויות. לכאורה זוהי גישה לא רעה – לוודא שהכל מכוסה. אבל (כמו שהוסבר בפוסט על כשלים) – טיפול בבעיה שלא אמורה לקרות יגרום לכך שלא נדע שהיא קרתה. למעשה, זוהי בדיוק "בליעת שגיאות", שעליה דיברנו בתחילת הפוסט. כל עוד המערכת שומרת על כללי ההתחייבות לקוד שהוא Exception Safe, עדיף שבעיה כזו תצוף לרמת טיפול גבוהה יותר, ולא תטופל מקומית ותיעלם. זה לא אומר בהכרח שחייבים להשבית את כל המערכת – אפשר גם לתפוס אותה בשלב כלשהו ולהרים דגל במערכת ההתרעות.
סיכום
בפוסט הזה עברתי על פרקטיקות מומלצות להגדרה וטיפול בחריגות. חלק מהכללים ראוי לשמור בדבקות, אחרים הם בגדר המלצות, ויש לבחון את התאמתם למערכת ספציפית. על כל פנים, הנקודות שעליהן אסור להתפשר על מנת לשמור על יציבות המערכת הן –
- שמירה על "חוזה" חריגות תקף
- טיפול / זריקה במודע של כל חריגה לגיטימית
- אי טיפול בחריגות שאינן צפויות
- שמירה על ה"ההתחייבויות", בעדיפות ל"התחייבות החזקה"
וכלל חשוב נוסף שכדאי ליישם הוא, לבחון את מנגנוני הטיפול בחריגות בקפידה בכל code review ולפני כל commit – אלו בדיוק המקומות המועדים לפורענות, ועלולים להישאר בלתי שלמים לאחר שהלוגיקה הבסיסית מתקמפלת ורצה בהצלחה.