2020-02-03

זמן ותאריכים בתוכנה (ועל ה JVM בפרט)

זמן עשוי היה להיות דבר פשוט.

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




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

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

זו באמת כזו בעיה?



זמן הוא דבר מורכב


בפועל הסינים לא ממש הסכימו לאכול בשעה 20:00 בצהריים. מסתבר שבני האדם, בכל העולם, מעדיפים לאכול צהריים ולתלות פושעים - בשעה 12:00.

למה דווקא שבאנגליה יהיה 12:00 בצהרי היום? (כי הם היו האימפריה הגדולה והחזקה בעולם בעת קביעת השעון הגלובאלי?)

המפלגה הקומוניסטית הסינית, למשל, לא רק רצתה שצהרי היום יהיו בשעה 12:00, אלא גם רצו להדגיש את איחוד סין וקבעו אזור זמן אחד - על אף שזו מדינה ענקית, המשתרעת על פני 5 אזורי זמן ״טבעיים״.

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

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

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

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

אפילו לא נתנו ליבשת הקפואה והשוממת הזו להיות פשוטה...
דוגמה משעשעת אחרונה היא שבזמן הקיץ, גם ב Greenwich הזמן הוא GMT+1:00, כלומר שעה אחרי... זמן Greenwich. זה בגלל מנגנון בשם Daylight saving - שאסביר בהמשך הפוסט. מנגנון עם וריאציות משלו.

אזרוק פה כמה קישורים למי שרוצה להכיר יותר:


זמן ותאריכים על ה JVM


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


בגרסה 1.0 ג'אווה יצאה עם אובייקט Date שבעצם לא מנהל תאריך, אלא ספירה של שניות החל מתחילת 1970 - כלומר: זמן יוניקס (קרי Unix Epoch, נקרא גם Epoch Time).

כמובן שגם Epoch Time אינו כ"כ פשוט, אם אתם מתעניינים במעט פיקנטריה:
  • Epoch Time החל במקור כספירה של cycles של מעבד (שהניחו שהם 60 הרץ) החל משנת 1971. רק מעט מאוחר יותר - הוצגה הגרסה שאנחנו מכירים.
  • כשבני האדם הגדירו זמן, הם גזרו את היממה מסיבוב של כדור הארץ סביב עצמו, ואז חלקו אותו ליחידות קטנות יותר (שעה, דקה,...) - ביניהן השנייה. הם לא ידעו שכדור הארץ לא מסתובב בקצב אחיד לגמרי, מה שדורש לבצע תיקונים בזמן מדי פעם, להלן דקה מעוברת (או leap second). זמן יוניקס כיום מתעלם מהסטייה הזו.
  • כאשר סופרים שניות מ 1970 במשתנה של 32 ביט, המשתנה מגיע לערך המרבי שלו בשנת 2038 - לא מאוד רחוק, מה שנקרא גם באג 2038.


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


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

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


כיום, הספרייה הזו נחשבת ל Case Study איך לא בונים ספריה. אני לא אאריך בדברים, אך מכיוון שעדיין אתם עשויים להיתקל (ולהשתמש?!) בספריה הזו, אציין כמה בעיות עיקריות:
  • גם אחרי שנים, מפתחים מתבלבלים מתי להשתמש ב Date ומתי ב Calendar. ההסבר הפשוט ביותר שמצאתי הוא ש Calendar נועד לבצע שינויים / חישובים בתאריך, ו Date הוא ה Data Structure ששומר את המידע לאורך זמן. סוג של תכנות פרוצדורלי קלאסי. גם ההגדרה הזו לא מדויקת - אך אין טעם להרחיב.
  • הטיפול במורכבויות של תאריכים לוקה בחסר. הטיפול ב Timezones ו DST הוא קשה ו error-prone למדי. הדרך היחידה להתעדכן בחוקי אזורי-הזמן (שמתעדכנים כמה פעמים בשנה) היה לעדכן גרסה של ה JDK. בתקופת ה On-Premises זה היה פתרון לא מספיק טוב, ומערכות רבות רצו על גבי חוקים לא-עדכניים.
  • לספריה יש כמה Defaults מסוכנים מאוד. לא מזהה שם של Timezone? אין בעיה, נניח שמדובר ב GMT. נראה שהצבת 14 כמספר חודש? אין בעיה - נוסיף שנה לתאריך ונחזור לפברואר, בלי להודיע שדבר כזה קרה. יש אפשרות להחמיר את הבדיקות, אך כנראה בשל ה backward compatibility הנוקשה של ג'אווה, ברירת המחדל היא עדיין הגישה המקלה.
  • המחלקות בספריה הן Mutable, מה שאומר ששימוש חוזר בהן יוביל לבאגים. זה לא ברור מאליו שעלי לייצר Calendar חדש או Formatter חדש, בכל טיפול בתאריך שאני מבצע.

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

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

גרסה 8 - ספריית java.time החליפה את Calendar ו Date, ובעצם הביאה את ה JVM למקום טוב הרבה יותר בכל הנוגע טיפול בזמנים ותאריכים. קפיצת מדרגה משמעותית.

הספרייה מציגה 2 מערכות זמנים שונות, בהתבסס על העבודה שנעשתה ב Joda-Time.



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


שני הרעיונות המרכזיים של java.time


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

למי שלא מכיר הכלל הראשון של עבודה בתאריכים הוא כזה: אם אתם יכולים להימנע בעיסוק ב Timezones - עשו זאת! הסיבוכיות הנלווה ל Timezones ומקרי הקצה האפשריים - הם רבים. java.time מאפשרת פרדיגמה נוחה, ליישום ה best practice הזה.

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

  • המחלקה LocalDate מייצגת תאריך, כמו 28/02/2019.
  • המחלקה LocalTime מייצגת רק זמן, כמו 16:09:00.
  • המחלקה LocalDateTime היא הרכב של שניהם (ירוק + אדום = חום?!)
  • המחלקה ZoneId מייצגת את אזור הזמן, כמו "Asia/Jerusalem"
  • המחלקה ZonedLocalDateTime - היא הרכב של כל הרכיבים: LocalDateTime + ZoneId (ירוק, אדום, וכחול = מרכיבים את הצבע הלבן).
באיזה אובייקט הכי כדאי להשתמש לרוב השימושים?

מחשבה הגיונית ונאיבית, תצביע על השלם - על ZonedLocalDateTime. אם זה "חינם", למה לא לקבל "הכל"?

דווקא ההמלצה היא להיצמד לאובייקט ה LocalDate או LocalDateTime (אם נדרש). מדוע? בכדי לפשט את העבודה עם Timezones.


Zoned vs. Local

כפי שאמרנו Timezones סבוכים, וקל להתבלבל בהם. ברוב התוכנות, רוב הקוד - צריך להשתמש באותה הפעלה ב Timezone בודד, אז למה להסתבך?



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

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

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



2 מערכות זמנים, זו לצד זו

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

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

ב java.time עשו את הפעולה ההפוכה המתבקשת: לקחו את עניין התאריכים והזמן הסבוך (La Vasa) ופרקו אותו ל-2 מערכות פשוטות יותר: נושאת גייסות וספינת מלחמה, כך ששתי הספינות הללו יוכלו לשוט בבטחה.


מתי נכון להשתמש בכל מערכת?


מערכת ה Epoch

מערכת זו כוללת את ה Instant ואת ה Duration. היא פשוטה למדי ונצמדת ל Java Epoch. בג'אווה, מאז ומעולם, ספרו את הזמן מתחילת 1970 - אך ברזולוציה של מילישניות. הערך נשמר בשדה 64-ביט, ויכול לתאר תאריכים עד שנת מיליארד.

כל המרה בין Unix Epoch ו Java Epoch כוללת הכפלה / חילוק פשוט ב 1,000.
בכדי לתאום ל Unix Epoch, גם Java Epoch מתעלמת מ Leap Seconds.

מחלקת ה Instant מחזיקה את ה Java Epoch ומאפשרת עליה פעולות. המחלקה Duration מתארת מרחק בין שני Instants, ומשמשת לפעולות חישוב של הפרשי-זמנים.

טבלת השוואה בין שתי מערכות הזמנים של java.time

Instant הוא זמן אבסולוטי ואוניברסלי. אין לו פרשנויות - ובכל מקום בעולם הוא אותו דבר (זוכרים את התמונה הראשונה בפוסט?)

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

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

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


מהם הסימנים לכך שאנחנו משתמשים לא נכון ב Instant?

  • אם זמן שבן אדם מדווח עליו נשמר כ Instant - זה רמז לשימוש בסבירות גבוהה איננו נכון. טעות נפוצה.
  • עוד כלל אצבע שאני אוהב, הוא שאל לנו לבצע פעולת השוואה בין epoch. 
    • תאורטית, epoch הוא ברזולוציה אינסופית ולכן ההשוואות הנכונות הן רק: האם זמן נתון הוא לפני או אחרי ה Instant. לכאורה, נדיר מאוד שיהיו שני Epochs עם ערך זהה בדיוק - ולא נכון להסתמך על זה.
    • המחלקה Instant עובדת כברירת מחדל במילי-שניות, אך ניתן גם לעבוד ברזולוציה של ננו-שניות.
  • אם אנו עסוקים בהמרות של Timezones ל Instants - אז כנראה שאנו עושים משהו לא נכון. Instant אמור לתעד אירועים שלא תלויים ב Timezone, וריבוי המרות שכזה מעיד שאנו עושים בו abuse.
הנה דוגמה קטנה שתעזור להמחיש את הנקודה. יכולים לנחש מה הקוד הבא עושה?

instant.minus(10, ChronoUnit.YEARS)

נכון! הוא זורק Exception. יש משהו לא נכון, בביצוע פעולות ברזולוציה של שנה על Instant. זו לא הייתה כוונת המשורר.

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

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



מערכת ה Date/Time


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

מה הסימנים שאנחנו משתמשים לא נכון במערכת ה Dates?

  • דיסקליימר: נתקלתי בהרבה יותר שימוש לא נכון ב Instant (הטכני, הדומה לאופן הטיפול של שפות תכנות ישנות יותר) - אז קצת קשה לי יותר לומר.
  • עבודה מופרזת עם Timezones וקוד מסובך - הוא סימן שאולי אנו עושים שימוש יתר ב ZonedDateTime.




סיכום


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


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




2019-12-15

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


אני עומד לעסוק בנושא בסיסי מאוד: 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 - ואז הכל בסדר.
    • במקרים בהם המימוש הוא אחר, למשל: כתובת האובייקט בזיכרון - איבדנו את ההזדמנות לאסוף מידע שימושי לגבי השגיאה.


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

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

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

fun doD() {
  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 לשם המתודה?
    • בהודעת השגיאה חשוב לציין מידע חדש ומעניין. למשל: פרמטרים מסוימים של הריצה - שלא תהיה לנו גישה אליהם מאוחר יותר.

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

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

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

fun doD() {
  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 Block, וקוראים אחרים - הגיעו / יגיעו לתובנות דומות (בהנחה שהן נכונות).

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

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


-----

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

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