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.




סיכום


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


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