יסודות: גרסאות בתוכנה

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

גִּרְסָאוּת (Versioning) היא טכניקה שנועדה לצמצם מורכבות של "העולם" הנובעת משינויים, בלתי-תלויים, של אלמנטים שונים המשפיעים על המערכת.

כשאנחנו מתחזקים מערכת-תוכנה לאורך זמן, החלקים השונים שלה עוברים שינויים מגוונים: שינויים בקוד המערכת, בספריות צד/שלישי (היום: בעיקר Open Srouce), או שינויים בחומרה ובסביבת הריצה עליה המערכת רצה (מערכת הפעלה, Virtual Machine, ענן, וכו').

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

החוקים הלא כתובים של הגרסאות

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

יש כמה כללים מקובלים לגבי גרסאות:

  • Immutability – שינוי ברכיב בעל הגרסה (להלן: הרכיב), יוביל לשינוי הגרסה. זה לא תמיד נכון, אבל זו הציפיה המקובלת.
    • Git Hash (סוג של גרסה), למשל, נובע מתוך התוכן, כך שכל שינוי קוד, אפילו לא משמעותי (למשל: הוספת שורת רווח) – יתבטא בשינוי ב Hash.
    • אין דבר שמונע מכותב ספריית צד-שלישי לשנות את הקוד, מבלי לעדכן את הגרסה. זו לא הציפיה – אך זה יכול לקרות (אולי יש היום כלי שמתריעים בפני כזה מצב, אני לא מכיר).
  • Orderability – יש דרך מובחנת להבחין איזו גרסה חדשה יותר מגרסה אחרת. לרוב הגרסה ממוספרת כמספר, אז מספר גבוה יותר – גרסה מאוחרת יותר.
    • פעם היה מקובל שמספר גרסה ביטא את גודל ההתקדמות. למשל: אחרי Windows NT 3.1 הגיעה גרסאת 3.5 בכדי לבטא שזה שינוי משמעותי. כנ"ל ב MacOS.
    • כמה הפצות של לינוקס משתמשות בתאריך ההפצה בפורמט YY-MM כגרסה. אז 16.10 אכן מאוחרת יותר מ 16.04, אבל לפעמים מפתיע שזו גרסה גדולה, ולא עדכון קטנטן, שיכול להשתמע מהמספרים העשרוניים. כמו כן, אין גרסאות אמצע. אין גרסה 16.05.
    • GNU בחרו בגרסאות במספרים מאוד גדולים. למשל: 5001, 5002. לא ברור לי למה, או מדוע אני נטפל דווקא למערכות הפעלה…
    • אנשי שיווק לא תמיד מקפידים על כללי ה Orderability בגרסאות. נסו לסדר את הגרסאות השונות ע"פ הסדר הנכון: Xbox One, Xbox 360, Xbox X, Xbox S, Xbox one S, ו Xbox one X.
נשמע כמו טקסט של ד"ר סוס…

Semantic Versioning

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

  • שינויי Patch – מבטיחים תאימות לפנים (forward-compatibility) ולאחור (backward-compatibility). אתם יכולים לשנות לעבור מגרסה 1.4.1 ל 1.4.3 של ספרייה הלוך ושוב – ולצפות ששוב התנהגות לא "תשבר" לכם.
    • כמובן שגרסה מבטאת שינוי ברכיב. ההתנהגות הצפויה אמורה לא להשתנות, אבל אולי "באג" נוסף בגרסה 1.4.1 ושינה את ההתנהגות הצפויה. התיקון שלו בגרסה 1.4.3 החזיר את ההתנהגות בפועל להתנהגות הצפויה. כלומר: גרסת Patch לא תשנה את ההתנהגות הצפויה, אך היא עלולה לשנות את ההתנהגות בפועל (בעקבות באגים).
  • שינויים Minor מבטיחים תאימות לאחור, אך לא תאימות לפנים. כלומר: אם הקוד שלי עבד עם גרסה 1.2.1, הוא צפוי לעבוד גם עם גרסה 1.3.5 – אך לא להיפך. אני לא יכול לצפות שקוד שעבד עם גרסה 1.3.5 יעבוד עם גרסה 1.2.1.
    • הסיבה העיקרית לזה היא שגרסאת Minor משמשת לתוספות התנהגות, למשל: APIs חדשים.
    • זו גם הסיבה מדוע כלי ניהול-תלויות הנתקלים בקונפליקט בגרסאות minor – בוחרים את הגרסה המאוחרת יותר בצורה אוטומטית. זו הציפיה מ"חוזה" הגרסה. נדבר על זה בהמשך.
  • שינויים Major לא מבטיחים שום סוג של תאימות. במעבר מגרסה 1.3.5 לגרסה 2 כלשהי (למשל 2.0.1) – מצופה ממני לקרוא בקפדנות את ה release notes, ולבצע את כל הבדיקות הנחוצות אם ההתנהגות העדכנית מתאימה לי. גרסאות Major שמורות לשינויי התנהגות, שכל מערכת "חייה" צריכה לעשות מדי-פעם.
    • כמובן שאם אני רוצה לשמור על קהל הלקוחות שלי אנסה לצמצם שינויים כלשהם, גם בגרסאות Major – למינימום. לפעמים השינויים בגרסאת Major הם גדולים מדי, והלקוחות שלי פשוט ייעצרו מלעדכן גרסאות. למשל: שינויים שוברי-התנהגות בפייטון 3 עיכבו את הקהילה כעשור, בממוצע, באימוץ החידושים.

ל Semantic Versioning יש כמה הרחבות, שקצת יותר פתוחות לפשרנות:

  • מקף אחרי מספר הגרסה – הוא מקום לתאר Pre-Release
    • ההגדרה המסורתית היא ש Alpha הוא שלב בתוכנה שעדיין מוסיפים / חסרים פיצ'רים עיקריים, בעוד ב Beta כל הפיצ'רים שם – רק לא עובדים עד הסוף.
      • כיום ההגדרה הרבה פחות מדויקת, ולא פעם נטען שמוצרים משוחררים (GA = General Availability), כשבעצם ברמת האיכות הם מתאימים יותר לשלב בטא. בחודשים אחרי השחרור הרשמי – יתבצעו התיקונים. הגישה הזו מאוד מתיישרת עם רעיונות ה Lean Startup.
    • אין גאמה😃, אבל אחרי שלב הבטא נראה לא פעם RC – קיצור של Release Candidate. המשמעות: המוצר כמעט מוכן, ורוצים לבדוק אותו ממש לפני שחרור. לרוב יהיו כמה RCs שימוספרו: RC1, RC2, וכו'.
    • לפעמים משתמשים במספר 0 (אפס) כ Major version לתאר pre-release. למשל: 0.3.1.
    • "SNAPSHOT" כ pre-release הוא סימן מקובל בעולם ה JVM שאנחנו מעדכנים את הרכיב, בלי לעדכן את מספר הגרסה. כלומר: תמיד הגרסה תישאר 1.0.0-SNAPSHOT, למרות שהקוד השתנה. שימוש זה הוא רק בזמן פיתוח – ולא לגרסאות ששוחררו "לעולם".
  • סימן + אחרי ה pre-release הוא מקום סטנדרטי להוספת Build metadata.
    • זה יכול להיות מספר סידורי של הבילד (עולה בכל פעם שעושים Build למוצר השלם). למשל: 3601. המספר גבוה כי בונים כמה פעמים ביום.
    • זה יכול להיות ערבוב גם של מספרים ואותיות, כל מידע אינפורמטיבי שיעזור למפתחים שמדבגים, בעיקר.
    • לא פעם משתמשים ב build number כב Patch version. למשל: 2.13.3601, כאשר 3601 הוא מספר ה build.

גרסאות של ספריות

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

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

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

כאשר שינוי הגרסה הוא minor או patch – אנחנו לא צופים אי-תאימות, אבל היא יכולה לקרות, בשל באג או בגלל שכותב הספרייה לא צפה שתהיה אי-תאימות.

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

בעיה שהייתה נפוצה בעבר, הוא כאשר ספריות הותקנו במערכת ההפעלה לשימוש כל האפליקציות (aka DLL Hell or Jar Hell). אפליקציה א' השתמשה בספרייה X בגרסה 1.0.0, אבל אז הותקנה אפליקציה ב' שהתקינה את ספרייה X בגרסה 2.0.0 (דרסה את 1.0.0) – וכך גרמה לאפליקציה א' להפסיק לעבוד.

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

בעיה שנותרה רלוונטית, היא בעיית "תלויות היהלום" (Diamond dependency) בין ספריות בהן משתמשת אותה האפליקציה/מערכת:

למשל ב Case 1 המערכת שלנו משתמשת, טרנזיטיבית, בשתי גרסאות שונות של Library C: גרסה 3.1.0 וגרסה 3.2.0.

יש שתי דרכים עיקריות לארוז את הקוד של המערכת שלנו:

  • Shared Libraries – כל המערכת / אפליקציה תשתמש בגרסה יחידה לכל ספרייה (להלן: ספרייה C).
    • יתרון אחד הוא Deployable קטן יותר של המערכת. במערכות גדולות יש לעתים מאות תלויות טרנזיטיביות, ולא נדיר למצוא אותה ספרייה ב 10 גרסאות שונות ויותר. לעתים ההבדל בין Shared Libraries ל Isolated Libraries יכול להגיע ל Deployable גדול פי 2-3 כאשר אנחנו משתמשים ב Isolated Libraries.
    • Deployable קטן יותר – משמע פחות זיכרון (RAM) שנדרש. פחות הכפלה של Singleton classes (למשל: או State שיושב על הגדרות המחלקות, ה Classes). אם ישנו בספרייה Cache – יהיה מאגר אחד לכל האפליקציה, ולא כמה Caches כפולים, ע"פ גרסאת הספרייה המדויקת.
    • בשפות JVM ה Class Loader יסרב לטעון שתי מחלקות עם אותו השם (נניח: מגרסאות שונות של ספרייה C). בשפות Strongly Typed אחרות, ייתכן ויהיה אפשר – אך בזמן ריצה מבני נתונים לא תואמים (עם אותו השם, אבל למשל טיפוסים שונים המגיעים מגרסאות שונות של הספרייה) – יגרמו לשגיאות בזמן ריצה, שגיאות שלעתים מאוד קשה לשחזר ולתקן.
    • גם בשפות שאינן Strongly typed צפויות בעיות, הם יצוצו מאוחר יותר – ויהיו קשות יותר למציאה. כאשר המערכת משתמשת לסירוגין פעם אחת באויבקט של גרסה 3.1.0 ולעיתים של גרסה 3.2.0 (ואין type safety לשים לב להבדל) – יכולים לצוץ באגים קשים ומבלבלים.
  • Isolated Libraries – כל ספרייה נארזת עם ספריות המשנה שלה – בגרסה שהיא ביקשה (ובדקה). אמנם ה Deployable שלנו יהיה גדול יותר, ויצרוך יותר זיכרון – אך לא נצטרף לפתור קונפליקטים של גרסאות, כמו: "באיזו גרסה של Library C עלינו להשתמש". ספרייה B תשתמש בגרסה 3.1.0 וספרייה A תשתמש בגרסה 3.2.0.
    • אמנם חסכנו התמודדות עם קונפליקטים בטווח הקצר, אבל עדיין עם אובייקטים בזיכרון, של ספרייה C בגרסאותיה השונות יעברו לסירוגין בקוד של ספריות A ו B – צפויים באגים מוזרים וקשים לאיתור במערכת.

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

על ה JVM למשל, נראה:

  • Isolated Libraries כ Fat Jar (נקרא גם Uber Jar) – כלומר אריזה של כל הספריות התלויות כ jars מקוננים בתוך jar יחיד (להלן: כל אפליקציה מספקת את כל הספריות שהיא זקוקה להן, ולא מניחה שהן מותקנות כבר במערכת ההפעלה).
  • Shared Libraries כ Shadow Jar – כאשר אנחנו אורזים לתוך jar גדול (ולכן מתבלבלים לעתים כאן עם השם Uber Jar) את כל הספריות שנדרשות – אבל עותק אחד מכל אחת. בתהליך יצירת ה Shadow Jar ייתכן וישונה ה bytecode של הספריות בכדי לתאום ל package name יחיד.

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

נחזור לתרשים למעלה: איך פותרים את הקונפליקט?

ב Case 1 – לרוב כלי ה build יפתור את הקונפליקט אוטומטית (למשל בעולם ה JVM: מייבן וגריידל יפתרו אוטומטית, Ivy – לפי דעתי יזרוק שגיאה וידרוש התערבות ידנית). ההנחה כאן היא שחוקי הגרסאות הסמנטית נשמרים – ולכן שינוי גרסה שהוא minor, הוא backward compatible – ולכן בטוח לקחת את הגרסה המאוחרת יותר. * ההנחה הזו לרוב עובדת, אבל לא תמיד.

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

שווה לציין שמנהלי תלויות שונים עשויים להתנהג בצורה שונה. למשל:

  • במייבן הכלל המוביל הוא "Nearest first", אז ב Case 2 תבחר גרסה 3.1.0 של ספרייה C כי היא רק "קשת אחת" מהאפליקציה.
  • בגריידל הכלל המוביל הוא "Latest first" ולכן תמיד תיבחר גרסה 3.2.0 של ספרייה C (כלל יותר הגיוני ועקבי).
    • במייבן אגב, אם המרחק שווה (כמו ב Case 1), תבחר הגרסה של התלות שהופיעה ראשונה בקובץ ה POM.XML. כלומר: אם כתבנו את התלות ב Library A קודם – אז תבחר גרסה 3.2.0 של ספרייה C, ואם כתבנו קודם את התלות של Library B קודם – אז תבחר גרסה 3.1.0 של ספרייה C. כלומר: סידור קובץ ה POM.XML (למשל: לפי א"ב) – עלול לשבור לכם את האפליקציה. מאוד מבלבל.

בואו נבחן מקרה קשה מעט יותר. מה נעשה כאשר הקונפליקט בין הגרסאות הוא בין Major versions?

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

בכל מקרה, יהיו מקרים בהם הגרסה נבחרה אוטומטית (ואז הבילד נכשל בקומפיליציה/בדיקות) או שהמפתח התערב ידנית – אבל הקוד לא עובד: ספרייה B דורשת את גרסה 4.1.0 וספרייה A לא מסוגלת לעבוד עם גרסה 4.1.0. מה עושים אז? האם אנחנו חייבים להחליף את השימוש בספרייה A או ספרייה B (מקרה הקיצון 😱) לספריות חלופיות – או שיש לנו ברירה אחרת?

  • ברירה נפוצה אחת היא לעשות downgrade לספרייה B. הרי לפני העדכון האחרון שלה, היא עבדה עם גרסה 3 כלשהי, והמערכת עבדה. כלומר: נדחה את העדכון של B עד שספרייה A תתמוך בגרסה 4 של ספרייה C. לעתים זה יכול לקחת שנה ויותר – וזו בהחלט פשרה.
  • עוד ברירה נפוצה, וזו שלפעמים בה פותחים, הוא לנסות ולהכריח את ספרייה B לעבוד עם גרסה ישנה יותר של Library C. אולי 3.2.0, ואולי גרסה מאוחרת יותר 3.4.3 (האחרונה בגרסה 3, למשל).
    • קיים סיכון שהקומפילציה תצליח, אבל רק לאחר שבועות נגלה בעיות ב Production. אם יש לנו סט בדיקות מקיף, וניטור טוב של פרודקשיין – האופציה הזו הופכת ליותר רלוונטית. ניסוי וטעייה.
דוגמה לנעילת ("force") גרסה ספציפית של תלות ב Gradle. במקרה הזה נעלנו טווח בין 3.9 עד 4.0 (לא כולל).
בגרדייל יש אפילו הגדרת because כתיעוד להסביר מדוע הנעילה נעשתה.
שווה לציין ש Gradle סט יכולות רחב, ויש מספר דרכים שונות לבצע נעילות של גרסה של ספרייה.
  • אפשרות קיצונית יותר אך אפשרית הוא לעבור להשתמש בספרייה אחרת (במקום ספרייה A או ספרייה B). האפשרות הזו סבירה יותר ככל שהספרייה קטנה יותר, והתלות שלנו בה קלה יותר.

סיכום

סקרנו את הבסיס של ניהול גרסאות, בדגש על גרסאות של ספריות שהמערכת שלנו תלויה בהן. גִּרְסָאוּת רלוונטית גם לפרוטוקולים, APIs, נתונים גולמיים, ועוד.

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

לפעמים תיקון באג, יקלקל לנו את המערכת. טעות נפוצה היא לחשוב שהוספת Validations בקוד הם backward compatible changes – אבל קריאות שעד לפני השינוי עברו – יתחילו להיכשל. כלומר: Validation נוסף הוא דוגמה חמקמקה לשינוי שסמנטית אינו Backward compatible בהכרח.

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

שווה להזכיר לרגע את האירוע בו מתכנת JavaScript הסיר את הספרייה הפצפונת left-pad ממנהל החבילות NPM – ושבר את האינטרנט, בתור תזכורת לכמה תלויות קיימות בין ספריות שאנחנו לא מודעים אליהן. כל פעם שאני נכנס בפרויקט לתיקיית ה cache של ה package manager ורואה בכמה ספריות המערכת תלויה בפועל – אני מתפלא.

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

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