להכיר את גריידל (Gradle)

Gradle (מקור השם: עריסה, Cradle – כאשר האות הראשונה שונתה ל G עבור Groovy) הוא כלי Build נפוץ בעולם הג'אווה, וכלי ברירת-המחדל לפיתוח באנדרואיד.

Gradle הוא כלי Build מודרני, מלא פיצ'רים ואופטימיזציות – שנמצא דור או שניים לפנים מהחלופה העיקרית שלו: Maven. גריידל יודע לבצע Caching ו Incremental Build ברמות הפשטה שונות – ומסוגל לשפר מאוד את זמני ה Build של פרויקטים גדולים.
 
מצד שני, Gradle מורכב יותר לשימוש, וכנראה לפרויקטים פשוטים – מייבן הוא כנראה עדיין כלי עדיף, בשל הפשטות שלו.
 
בניגוד למייבן המשתמש ב XML להגדיר את ה buildscript – בגריידל משתמשים ב DSL מבוסס שפת-תכנות. גרדייל תומכת ב Groovy-DSL ו Kotlin-DSL, כאשר בשנתיים האחרונות היא עושה מעבר לכיוון קוטלין, מכיוון שזו הולכת והופכת לנפוצה יותר ויותר (בניגוד ל Groovy – שקצת "נתקעה" במקום).
 
לו היו נותנים שם היום לפרויקט, נראה ש Gradle היה נקרא Kradle.


לגריידל אין מערכת Repositories משלה, והיא משתמשת בזו של מייבן, של ivy, או פשוט בתיקיה של מערכת הקבצים המקומית. זה יתרון גדול, מכיוון שכך אין תחרות בין "Maven Repositories" ל "Gradle Repositories".
 
 

מה גריידל יכולה להציע מול מייבן?

 
הבדלי ביצועים בין מייבן, גריידל, ובאזל. מקור: האתר של גריידל.

 

הדבר הראשון, וכנראה החשוב ביותר הוא ביצועים:

  • בהרצה ראשונה, גריידל ומייבן יהיו דיי דומים. אולי גריידל תרוץ טיפה יותר מהר.
  • בהרצה השנייה והלאה, ייכנסו לשימוש מנגנונים ייחודיים ל Gradle:
    • Incremental Build – גריידל עוקבת אחר מערכת הקבצים ומקמפלת רק קבצים שהשתנו, או קבצים שתלויים בהם.
    • Config Cache ו Build Cache – שימוש חוזר בתוצרים (ברמות שונות) מהפעולות בילד קודמות.
      • תאורטית גם מייבן עושה זאת – אך בצורה משמעותית פחות יעילה.
    • Gradle Daemon – גרדייל מחזיקה תהליך (process) של מערכת ההפעלה שממשיך לחיות ברקע, להחזיק caches מיידיים בזכרון ולהנות מאופטימיזציות של Java JIT compilation.
גריידל מספקת יותר גמישות:

  • למייבן יש מחזור-חיים קבוע וקשיח, validate > compile > test > package > install, שלא ניתן לסטות ממנו, אלא ב"תרגילים". בגרדייל יש עץ של Tasks (כמו Compile או Test) שניתן לקנפג וקל הרבה יותר להתאים את שלבי הריצה לצרכים שלכם.
    • למשל: בכדי לדלג על שלב הבדיקות או הקומפילציה ב Maven יש לעשות "תרגילים" שלא תמיד מצליחים. בגרדייל דילוג על שלבים זו אופציה מובנית.
    • קל להגדיר Custom Tasks בגריידל – וזו גמישות רבת-עוצמה. המקבילה במייבן היא למצוא Plugin שמתאים בדיוק לצרכים, או לכתוב Plugin בעצמכם – שזה הרבה יותר מסובך
  • גריידל מספקת ניהול תלויות עשיר וחכם הרבה יותר ממייבן – מה שמשמעותי בפרויקטים גדולים ו/או מורכבים.
    • למשל: במייבן תלות במודול יגרור קבלת כל התלויות של אותה ספריה (propograted dependencies, שלעתים נקרא בטעות transitive dependencies). הדבר לא מאפשר לעקוב ולנהל במדויק תלויות של מודולים.
      בגריידל אפשר לבחור או בגישה זהה למייבן, או בגישה מדויקת ונשלטת יותר.
  • גריידל מממשת את העיקרון של Conventions over Configuration, ואין צורך באמת להשתמש בכל הכלים המתקדמים יותר שלה. קושי נפוץ של newcomers הוא להבחין אלו מנגנונים הם פשוטים ו straightforward, ואלו מנגנונים הם רבי-עוצמה, אך דורשים גם הבנה מעמיקה יותר.
גריידל היא מודרנית יותר:
  • גריידל זוכה לתמיכה טובה יותר, ועדכונים תכופים יותר. למשל: כבר שנה וחצי כשיש בעיה בהרצה של JUnit 5 ב Failsafe של מייבן בצורה מקבילית – אך בגריידל הכל עובד כשורה מהגרסה הראשונה של Junit 5.
  • לגריידל יש כלים תומכים משמעותיים וטובים יותר:
    • Build Scan עוזר לנתח ולהבין בדיוק מה קרה ב Build. האם הגדרה מסוימת שרצינו אכן פעלה? לא צריך לנחש מתוך הלוגים – אפשר לבדוק בצורה ישירה.
      • גריידל מספקת Build Scan רזה יותר גם למייבן – בעיקר בכדי לתמוך במעבר ולוודא שה Build בגריידל מכסה את כל מה שקרה במייבן.
    • Continuous Build – גרדייל יכול להאזין למערכת הקבצים ולקמפל את הקוד תוך כדי שאתם עובדים.
    • Gradle Profiler – שעוזר למצוא bottlenecks בבילד ולשפר אותם. ניתן לגשת לתוצאות שלו בתוך ה Build Scan.
    • Gradle Build Debugger – בגלל שה build Script הוא שפת תכנות – אפשר ממש לדבג את תהליך הבילד כדי לנתח תקלות קשות-להבנה.
    • Gradle Enterprise – הם סט של יכולות נוספות שחברת Gradle מספקת בתשלום – כמו Central build Cache (שימוש משותף בתוצרים: מפתח אחד קימפל מודול, כל השאר יכולים להשתמש בתוצר), או כלי Diagnostic ו Analytics משופרים לבילד ולהרצת הבדיקות.
  • נטפליקס, היא תורמת גדולה של Gradle Plugins. רבים מהם היו הבסיס לפיצ'רים שהיום הם סטנדרטיים בגריידל.
  • Gradle Wrapper (שמירת תאימות מדויקת של גרסת גריידל בין מפתחים) הוא כלי פופולארי בגריידל, אך שווה לציין שהוא קיים גם במייבן (אם כי שם הוא פחות נפוץ בשימוש).
התייחסות מהירה לבאזל
ב 2015 שחררה גוגל, את כלי ה Build שלה כפרויקט קוד פתוח, ולאחרונה הוא הפך ל GA.
  • Bazel ו Gradle דומות זו לזו, יותר משהן דומות ל Maven או Ant.
  • גריידל מספקת גמישות רבה יותר, באזל היא יותר מובנה.
  • באזל בנויה יותר מסביב לניהול מרכזי (למשל: ניהול גרסאות של תלויות) בעוד גריידל לא לוקחת הנחות כאלו, ותומכת טוב יותר במציאות מבוזרת של שליטה.
  • לגרדייל יש קהילה גדולה יותר, ובגרות גדולה יותר – במיוחד בעולם ה JVM. סיכוי טוב שקהילות כמו Go ו ++C יזכו לתמיכה טובה יותר בבאזל.
  • היכולת להשתמש ב Cache מרכזי ומשותף היא יכולת ליבה של Bazel (שעליה מתבססים במידה הביצועים הטובים של הספריה) בעוד ב Gradle זו יכולת פרמיום בתשלום (Gradle Enterprise).
  • בסה"כ באופן דיי עקבי (מסקירת מאמרים ומבחנים) Gradle מהירה יותר מ Bazel – גם כאשר משתמשים ב Build Cache מרכזי.
    • מבחני ביצועים לא מעטים משווים את שתי התצורות בגרסה החינמית שלהן – ואז ל Bazel יש יתרון משמעותי על יכולת שב Gradle היא בתשלום (Central Build Cache).
  • הנה מאמר מ 2015 של יוצרי גריידל שמנתחים את באזל.
אין ספק שבאזל מכניסה תחרות וגורמת לגריידל לעבוד קשה יותר. בעוד ההשוואה בין גריידל למייבן היא מאוד מחמיאה, ההשוואה של גריידל מול באזל נראית תחרותית הרבה יותר.
הפוסט שלי נכתב על גריידל, שנראה שתמשיך להיות בטווח הנראה לעין הסביבה המתקדמת והמקובלת בעולם ה JVM. בעולמות הללו לגריידל יש עדיין יתרון משמעותי – וקהילות לא מתחלפות כ"כ מהר. חשוב לציין שבאזל היא רענון חשוב בעולם כלי ה Build, ובוודאי היא מעניינת יותר בעולמות ה Go וה ++C כבר עכשיו.

תכל'ס

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

Maven

Gradle

Pom.xml,
Where:

  Properties section
  Modules section     

  DependencyManagement section
  Most other sections

build.gradle.kts
Mapping to:
  gradle.properties file
  settings.gradle.kts file
  dependencies.kts file (a convention)

  java-conventions.gradle.kts (file per language being compiled)

pom.xml file (what happens when we build)

build script (that comprised of multiple files)

Plugin

Task

–  (there is one fixed build flow)

Plugin (describes build flow)

Goal

Lifecycle Task

Module

(sub) project

Dependency scope

Dependency configuration

Profile

Custom Task or Custom Properties.gradle file (depends what the profile does).

“Install” (to maven repo)

“publication” (to whatever repo)

"package”

“assemble”

בואו נראה מבנה של קובץ בילד מינימלי (שם הקובץ הוא: build.gradle.kts. הסיומת kts מציינת שזה kotlin script, ולא Groovy – שמגיע ללא סיומת):
  1. אנו מייבאים את הפלאגין של Java.
    1. בגריידל, Plugin מביא איתו שורה של Tasks ו Lifecycle Tasks (ועוד הגדרות רבות) – ובעצם קובע את השלבים השונים ב build והסדר ביניהם. במייבן ניסו לבנות תהליך אחד גנרי לכל שפות-התכנות, למרות שלשפות שונות (JavaScript, ++C) – יש צרכים שונים. בגריידל לרוב יהיה לנו בפרויקט Plugin אחד שיגדיר את ליבת התהליך ויספק גם את ה Tasks הנדרשים (Compile, Test, Jar, וכו' – המתאימים לשפה / תהליך)
      בנוסף אולי נרצה להשתמש ב Plugins נוספים כמו: למשל War, PMD, או Reports – ולהרחיב את התהליך.
  2. ה default scope שלנו ברמה הגבוהה ביותר היא אובייקט הפרויקט, וכך אנו קובעים properties מסוימים על הפרויקט.
  3. כאן אנו קובעים באלו Repositories לחפש את ה dependencies שלנו. כמו במייבן, אפשר ומומלץ לקבוע כמה repositories בכדי שיהיה גיבוי (לא עשינו את זה בדוגמה) – והחיפוש יעשה ע"פ הסדר בו נרשמו.
    1. במקום לספק את ה URL ל jCenter, אנו יכולים להשתמש בפונקציה שמסופקת ע"י גריידל ומחזירה אובייקט Repository עם ה URL הנכון (קיימים כאלו ל repositories נפוצים). פחות מקום לטעויות.
  4. כאן אנו נכנסים ל scope של ה Java Plugin (שהגדרנו בתחילת הקובץ) – ומוסיפים לו הגדרות.
    1. כברירת מחדל, גריידל תשתמש ב build בגרסת הג'אווה שמריצה אותה. זה יכול ליצור חוסר עקביות אם במחשבים שונים מריצים את הבילד בגרסאות ג'אווה שונות.
    2. בכדי "ליישר" את תהליך הבילד בצורה מדויקת יותר, אנו יכולים להגדיר לגריידל באזו גרסת ג'אווה להשתמש בתהליך הבילד. ה toolchain עוזר בקלות רבה לקנפג ולטפל בהגדרה של הגרסה שבחרנו. אם לא מצליחים למצוא מקומית את הגרסה המתאימה – ה toolchain יוריד לצורך ה build עותק מקומי של גרסת הגא'ווה שנבחרה.
    3. גם אם אתם מתכנתים בג'אווה 8 (ממגבלות מוכרות), הרצה של תהליך הבילד בגרסה מתקדמת יותר – יכולה להאיץ אותו מעט.
  5. כאן אנו נכנסים ל scope של ה tasks בפרויקט. אמנם אנו רוצים להתייחס ל Tasks שהגיעו מה Java Plugin, אך מרגע שהוספנו את ה Java Plugin והוא רשם את ה Tasks שלו – הם כבר לא משויכים אליו, אלא פשוט רשמים בפרויקט.
    1. ספציפית יש כאן כתיבה מקוצרת בשורה אחת להכנס גם ל scope של ה test plugin.
    2. ה Test Plugin תומך גם ב Junit וגם ב TestNG – ועלינו לציין לו במי להשתמש. הכל עטוף בפונקציות פשוטות ובטוחות לשימוש.
    3. בניגוד ל Maven בו יש שני Plugins: גם Surefire (לבדיקות יחידה) ו Failsafe (לבדיקות אינטגרציה) – בגריידל ה Java Test Tasks מספק את שני הצרכים, וניתן פשוט לקבוע בהגדרות אם לעצור בכשלון ראשון – או לא. אם אחנו רוצים להגדיר שלב של "בדיקות יחידה איטיות" – קל לעשות זאת ע"י שימוש חוזר ב Test Task.
  6. אנו צריכים להגדיר את התלויות לגרסה הספציפית של Junit שבה אנו רוצים להשתמש. לא פינקו אותנו ב wrapper לקונפיגורציה הזו. כמו במייבן – הגדרה של dependency בקובץ הראשי תשפיע על כל תתי-הפרויקטים (במייבן: מודולים) שלו.
    1. ההגדרה הזו מקבילה במייבן לתחביר הבא:

      אני מניח שלא היה לכם קשה לעשות את המיפוי הבסיסי.

    2. מהן הפונקציות הללו,()testImplenentation ו ()testRuntimeOnly?
      נסביר עליהן בהמשך.

The Java Plugin

כפי שציינתי, מרכיב חשוב בגריידל הוא ה Plugin הליבה בו אנחנו משתמשים. הוא מגדיר הרבה דברים חשובים:
  • Tasks – בהם נוכל להשתמש.
  • LifeCycle Tasks (במייבן: "Goals") – אליהם Plugins שונים יוכלו להתחבר, ולחבר Tasks שונים.
  • מבנה התיקיות של קוד המקור (במקרה זה ה Java Plugin שומר על הקונבנציה המורכת לנו ממייבן)
  • Dependency Management (כללים)  – כללים כיצד תלויות מנוהלות. מכיוון שלסביבות פיתוח שונות (Go, ++C, JVM) יש צרכים שונים – נתנו בגריידל ל Plugin להגדיר את הכללים המדויקים לניהול התלויות.
    גריידל מספקת תשתית/כלים עליהם כל Plugin יכול לבנות את המודל שהוא זקוק לו.
כשאנחנו לומדים לעבוד עם Gradle, חלק מהכללים נובעים מה Plugin הליבה בו אנחנו משתמשים – ולכן חשוב ללמוד ולהבין אותו היטב. במקרה שלנו – זה ה Java Plugin, וחשוב ללמוד אותו.

ל Java Plugin יש כמה הרחבות סטנדרטיות:

  • Java Library Plugin – עבור build script של ספריה המופצת לקהל רחב כספריה.
  • Java Application Plugin – עבור build script של אפליקציה. מוסיף Tasks כגון start או install. הוא מתאים הן לכתיבת שרת או אפליקציה שולחנית.
  • Java Platform Plugin – לכתיבה של סט ספריות קשורות (למשל: Junit 5 מורכב מכמה תתי-פרויקטים, הקשורים זה לזה)
  • גרסאות ספציפיות ל Groovy ו Scala – המרחיבות
ייתכן והיה נכון יותר לקרוא לו "JVM Plugin".
אתם יכולים לעבוד עם ה Java Plugin ישירות, או כל אחת מההרחבות שלו שיכולות להקל על המקרה הספציפי. בכל מקרה, רוב ההתנהגויות בכל הוריאציות – נובעות מה Java Plugin עצמו.
התיעוד הוא כלי חשוב, ובוודאי תמצאו את עצמכם לא פעם ניגשים לתיעוד של ה Java Plugin ישירות. במסגרת הפוסט, אני אתמקד בהסבר של שני רעיונות שמעט שונים ממייבן: Lifecycle Tree, וניהול תלויות.

מחזור החיים של בילד ב Java Plugin בגריידל

נתחיל עם מקור ההשראה: מייבן.
 
בניגוד ל make, ant, וכלים אחרים שהיו מקובלים קודם לכן והיו חסרי-מבנה סטנדרטי – מייבן הגדירה מבנה סטנדרטי – שעזר להשריש best practices בתהליכי build, ולהפוך אותם לסטנדרטיים ומאורגנים יותר.
 
מייבן הגדירה שלושה Lifecycles (נקרא להם build, clean, site), שבכל Lifecycle יש שורה של צעדים קבועים (להלן: "Goals"). כל Plugin (המספקים יכולות) מגדיר באלו צעדים הוא יכול להשתתף (למשל: Fail-Safe יכול להתחבר ל verify או integration-test) ואז המפתח ראשי לחבר אותו לאחד או יותר מהצעדים הנתמכים.

מקור: פוסט על מייבן בבלוג ארכיטקטורת תוכנה. וואהו, זה היה מזמן.

 

המבנה בגריידל הוא דומה, אך גמיש יותר. במקום שרשרת טורית של צעדים – ישנו עץ. העץ מורכב ממשימות (Tasks) כאשר כל משימה תגרום להרצה של המשימות האחרות בהן היא תלויה.
למשל, כאשר אנו מריצים את המשימה classes (המקבילה ל compile במייבן), המשימה תגרום להרצה של המשימות compileJava ו processsResources – ורק אז תרוץ בעצמה.
כאשר מריצים פרויקט המורכב מכמה תתי-פרויקטים בגרדייל, גריידל יכול להריץ במקביל משימות שונות בפרויקטים שונים על מנת להתקדם מהר יותר. למשל: פרויקט X תלוי בפרויקטים a ו b. גריידל יריץ את שניהם במקביל. אם פרויקט a הוא קטן יותר, גריידל עשוי להתקדם בפרויקט a לשלב הבדיקות על אף שפרויקט b עדיין מתקמפל.
כפי שאתם יכולים לראות בתרשים, משימות בגריידל מסווגות למשימות רגילות (עושות משהו ; בכתום) ומשימות Lifecycle (באדום), הנועדו לשמש כעוגן / מבנה לתהליך ה build.
למשל, המשימות assemble, clean, build ו check (באדום מודגש) הן משימות Lifecycle של ה base plugin בגריידל. כלומר: הן צפויות להיות נוכחות בכל מחזור חיים כלשהו. כמפתח Task אני יכול להסתמך על כך שהן יהיו נוכחות – ולבקש שהן ירשמו ל Task שלי כתלות.
לדוגמה, ה CheckStyle Plugin (כלי לבדיקות קוד סטטיות בג'אווה) רושם למשימה Check תלות בכל ה Tasks של ה Plugin – כך שהרצה של המשימה Check תפעיל אותו. אם לפני הרישום המשימות Check ו Test עשו אותו הדבר, לאחר הרישום – יש ביניהן הבדל.
משימות ה Lifecycle מייצרות סטנדרטיזציה ומבנה כדי שה Builds יהיו פשוטים ומובנים יותר.
כאשר מדובר ב Multi-project build (המקבילה של modules במייבן), כל הרצה של משימה על הפרויקט הראשי – תריץ את אותה המשימה על כל ה sub-projects (כמו במייבן).

Dependency Configurations

זוכרים את התלויות שהגדרנו ל JUnit5 ב build script שהצגנו למעלה?
השתמשנו בפונקציות ()testImplenentation ו ()testRuntimeOnly – על מנת להגדיר תלויות.
אנו נוטים לחשוב על גרף התלויות בפרויקט כמבנה קבוע, אך בעצם גרף התלויות הוא תלוי-הקשר:
אנו רוצים למשל שקבצים וספריות מסוימות (למשל: ספריות Mocking) יהיו זמינות רק כאשר מריצים בדיקות, בעוד אנו לא מעוניינים שהקוד בפרודקשיין יהיה תלוי בהן, או יארוז אותן.
היכולת להפריד בין קוד "לבדיקות" לקוד "פרודקטיבי" מתבטאת בגריידל בשני גרפי תלויות:
גרף התלויות של הבדיקות ("testImplementation") יורש את כל התלויות שהוגדרו בגרף של הקוד הפרודקטיבי ("implementation") – ויכול להוסיף עליהן תלויות נוספות.
הפונקציות ()testImplenentation ו ()testRuntimeOnly – פשוט רושמות תלות בגרף תלויות. עדיין לא הצגנו את גרף התלויות של testRuntimeOnly.
בגריידל התלות ברירת-המחדל / המומלצת היא "implementation". יש תלות בסיסי נוספת לקוד פרודקטיבי הנקראת "api" – והיא מדמה את ההתנהגות ברירת-המחדל של מייבן (מה שנקרא במייבן "compile scope"):
נניח שאני כותב ספריה (להלן "My Library") שאפליקציה מסויימת משתמשת בה.
כאשר אני יוצר תלות מסוג "api" בספרייה Jackson, משמע שאני הופך אותה לחלק מה API שלי, ובעצם מעביר (propogate) לאפליקציה את התלות כאילו היא שלה. מעכשיו קוד באפליקציה יכולה להשתמש ב Jackson כאילו ייבאה אותה בעצמה.
כאשר אני יוצר תלות מסוג "implementation" ספריה Gson, משמע שאני משתמש בתלות לצורך המימוש שלי, אבל התלות לא מועברת הלאה. אם האפליקציה רוצה להשתמש ב Gson יהיה עליה להוסיף תלות בעצמה או להסתמך על פונציונליות שהספריה שלי מספקת, שמתשמשת מאחורי הקלעים ב Gson.
לשימוש ב api יש תופעה שלילית שנקראת Dependency Pollution:
  • קוד האפליקציה יכול להשתמש בקוד מספריות שלא הייתה כוונה להשתמש בהן.
    • המקרה הזה בעייתי במיוחד בקוד פנימי שלנו, שאנו רוצים במפורש להימנע מהשימוש בו – אך מפתחים עשויים לא לשים לב (כי ה package name תואמים).
  • בשל האופי הטרנזיטיבי של התלויות, מרחב הקוד שאני שחשוף לשימוש ע"י קוד האפליקציה עשוי להיות גדול מאוד: האפליקציה תלויה ישירות ב 3 ספריות, אך ב 50 ספריות בצורה טרנזיטיבית – הוא תסריט נפוץ.
  • יש עבודה מיותרת לתהליך ה build – בהבאת כל הספריות, טרנזיטיבית, ל classpath בזמן קומפילציה.
  • יש עבודה מיותרת לתהליך ה build – בכך שכל שינוי קוד בספריה שהובאה טרנזיבית, מעלה ספק אולי צריך לקמפל את הספריות שתלויות בו (טרנזיטיבית) – וכך קשה לבסס build cache יעיל.
בקיצור: ההעדפה בתלות מסוג "implementation" חשובה הן מבחינת הנדסת-תוכנה, והן משיקולי ביצועים של ה build.
אם אתם רגילים לעבוד במייבן "הסלחנית" במובן של תלויות – תתחילו להתרגל.
בטווח הבינוני – ארוך, בהחלט משתלם להתרגל להגדרות המדויקות יותר של התלויות כ "implementation". בבאזל, למשל, הגדרת התלויות דורשות דיוק רב אף יותר.
בגריידל יש מנגנון נוסף, המתבטא ב Dependency Configuration בשם "runtimeOnly" ו "compileOnly" הקבילים ל scopes במייבן בשם runtime ו provided – בהתאמה.
התלויות הללו מאפשרות לנו להיות תלויים בזמן הקומפילציה ב API בלבד (לא לבלבל עם ה "api dependency configuration"), ובזמן ריצה להביא מימוש ספציפי של אותו ה API – תוך כדי שאנו מבטיחים שאנו לא יוצרים תלות במימוש הספציפי.
למשל: ספריית SLF4J, מספקת API לקומפילציה בלבד וגם מימושים הכוללים את ה API + יישום ספציפי (למשל LogBack או log4J12).
בכדי להבטיח חוסר תלות ביישום ספציפי – אנו רוצים לא לכלול את המימוש הנוכחי שבחרנו בזמן הקומפיציה – אלא רק בזמן ריצה. אם כך עלינו להוסיף את ה SLF4J API ב "compileOnly" ואת המימוש העכשוי – ב "runtimeOnly".
נראה שאנחנו יכולים להסביר עכשיו את התלויות שראינו בדוגמה למעלה:
ספריית JUnit5 הפרידה את ה API לספריה אחת (להלן "testImplementation", אנו רוצים אותו זמין רק לבדיקות) ואת מנוע הבדיקות – שיהיה זמין רק בעת הרצת הבדיקות ("testRuntimeOnly").
כלומר: המקרה של JUnit 5 מעט שונה מזה של SLF4J – אבל קיימות גם וריאציות נוספות.
יצרתי סיכום של ה Dependencies Configurations הזמינים לנו בגריידל + Java Plugin:

הורשה משמע שכל תלות שהוספנו לאב – זמינה גם לבן. הקוביות הצבעוניות מתארות מה עושים עם כל גרף תלויות בפועל. אציין שוב ש "api" הן התלויות הכלליות ביותר – ושמומלץ להימנע משימוש בה עד כמה שאפשר.

סיכום

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

שיהיה בהצלחה!
—–
קישורים רלוונטיים:

זמן ותאריכים בתוכנה (ועל ה 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.

סיכום

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

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

Microbenchmarking 101

ספריית Jackson היא ספריה פופולרית בעולם ה JVM ליצירה / פענוח של JSON (וגם XML) – פעולה חשובה ושימושית.

במדריך הביצועים של הספרייה מצוין בכלל מספר 1:
בגלל שמדובר בכלל הראשון ברשימה, אני מניח שהוא חשוב – ומתחיל לבצע reuse בקוד ל ObjectMapper, כפי שהמליצו.
  • עד כמה זה חשוב? עד כמה זה משמעותי?
  • האם עלי להימנע לגמרי מיצירה של מופעים ב ObjectMapper באותה המחלקה?
  • האם כדאי ללכת צעד אחד הלאה, ולשתף את המופע בין מחלקות שונות ?(כאן נוספת תלות בין מחלקות – מחיר לאיכות הקוד שאעדיף מאוד שלא לשלם)
טוב… ברוכים הבאים לעולם המתעתע והחמקמק של microbenchmarks (לצורף הפוסט: מדידונים).

  • כאשר אנחנו רוצים למדוד ביצועים של מערכת / מודול / ספריה – אנו עושים בדיקת ביצועים.
  • כאשר אנחנו רוצים למדוד ביצועים של פעולה קטנה / נקודתית – אנו משתמשים במדידון.

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

אז איך בונים מבחני-ביצועים טובים ויעילים, או במקרה שלנו: מדידונים טובים ויעילים לסביבת ה JVM?

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

העצה הזו היא טובה – אך לא שימושית ל 99% ויותר מהאוכלוסייה.
עוד עצה טובה ומקובלת היא לבחון את ה Byte Code שהופק מהמדידון – גם עליה כ 98% מהאוכלוסייה תוותר.

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

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

מדידון – הבסיס

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

מדידון לא-מושלם מאחד הפוסטים האחרונים. כבר במעבר ראשון עולות כמה נקודות לשיפור.
  1. לעבוד עם כמות גדולה של איטרציות, על מנת לקבל תוצאה חזקה יותר סטטיסטית ולבטל במידת האפשר רעשי-מדידה.
    1. למשל: שעון החומרה המספק את הזמן, והבאת הערך ממנו, יכולה לספק טעות מסויימת. מקרה נדיר אך אפשרי הוא NTP שפועל תוך כדי המדידה ומשנה את השעון בעשרות מילי-שניות, ויותר.
    2. כלל האצבע הוא להריץ בדיקה שתארוך כמה שניות לפחות – על מנת לבטל טעויות שעון בבטחה.
  2. כל פעולה שאיננה קשורה לבדיקה, למשל אתחולים או הכנת נתונים – יש להכין לפני.
    1. נקודה לשיפור בדוגמה: גם את היצירה של a,b ו map – היה כדאי לייצר מחוץ למדידה.
  3. לרוץ על הנתונים לפחות פעם אחת על מנת "לחמם" caches. בשפה כמו C – מעבר פשוט על המערך הוא מספיק. בשפת JVM זה לא מספיק, ויש שתי "תקלות" בחימום הזה:
    1. נקודה חשובה לשיפור בדוגמה: להריץ יותר מפעם אחת, ולהריץ את הקוד המלא בכל המסלולים האפשריים – על מנת לתת ל JVM לבצע אופטימיזציות לפני שאנו מתחילים למדוד. אוי.
    2. נקודה חשובה לשיפור בדוגמה: אין קריאה של הנתונים מחוץ ללולאה, וה compiler עלול להסיר את הלולאה הזו לגמרי מהקוד (כי אין לה השפעה על כלל המערכת). אאוץ.
  4. למדוד את זמן הריצה: mesureTimeMillis היא פונקציה פשוטה בקוטלין שמקבלת למבדה, לוקחת שעות לפני, מריצה, אחרי – ומחזירה את ההפרש בין השעונים.
    1. נקודה קטנה לשיפור בדוגמה: למרות ששימוש ב ()System.currentTimeMillis נשמע מספיק טוב, עדיף להשתמש ב ()System.nanoTime.
      Milis – מתאימה לזמן השעון, פחות מדויקת, וזמן השעון עלול להשתנות בזמן הריצה (למשל NTP).
      nano – לא באמת מחזירה רזולוציה של nanos (תלוי בחומרה + זה רק ה scale של המספר), ולא מבטיחה שאכן מדובר בשעה המדויקת (לכן חסרה את המילה current). בכל זאת: יותר מדויקת, ומתאימה יותר למדידות ביצועים.
      אם אתם מתעניינים – הנה נתונים נוספים.
  5. לצמצם את ה overhead של האיטרציה במידת האפשר.
    1. נקודה קטנה לשיפור בדוגמה: היה עדיף לרוץ ב index-based for loop, היעילה יותר מעבודה עם iterator.
    2. ספציפית בקוטלין איטרציה על Range או מערך דווקא כן מתרגמת ל index-based loop. במקרה שלנו מדובר ב collection.
  6. להריץ את כל ה benchmark כמה פעמים. כפי שניתן לראות מה comments, הייתה שונות בין של עשרות אחוזים כבר בין כמה הרצות בודדות.
סה"כ זהו מדידון בינוני מבחינת דיוק. על כן צוין בכתבה המקורית:

ניתן היה לכתוב מדידון נכון יותר.

הסכנות שבמדידון על גבי ה JVM

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

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

סביבת ה JVM היא סביבה קשה במיוחד עבור מדידונים.

הנה דוגמאות עיקריות לבעיות, וכיצד ניתן להתמודד עימן:

  • ה JVM יראה את אופן ריצת הקוד ויבצע שיפורים והתאמות (deoptimization, recompilation effects, וכו') תוך כדי ריצה.
    mitigation: הרצת המדידה עצמה, בשלב ה warmup – על מנת שה JIT יעשה את השיפורים שם ולא בזמן המדידה. כלל האצבע: לעשות כמה עשרות אלפי איטרציות (כלומר: בלולאה) בשלב החימום. כל מסלול קוד אפשרי – צריך להתבצע עוד בשלב החימום.
    שווה גם להשתמש ב XX:+AggressiveOpts- על מנת שהאופטימיזציות יקרו מהר. אתם ממש תראו איך בכמה cycles של ה warmup יש שיפור – ואז יציבות. (ואם לא – הגדילו את ה warmups).
  • עשויות להתבצע פעולות קומפילציה ו/או GC בזמן הרצת המדידון – פעולות שישבשו את התוצאות.
    mitigation השתמשו ב XX:+PrintCompilation- ו verbose:gc- + הדפסה לפני ואחרי המדידה, על מנת שאם זה קרה זה יודפס בזמן ההרצה ותדאו להתעלם מהתוצאות.

    • כמובן שאתם יודעים שהדפסה בפעם הראשונה בתהליך JVM אחראי לחלק מהתאחולים במערכת – וחשוב שהדפסה ראשונה תהיה בשלב החימום.
  • חשוב לרוץ על סביבה נקיה, בה ה JVM "שקט" ככל האפשר.
    mitigation: נסו שהמדידון יהיה ה JVM היחידי, השתמשו ב Xbatch- על מנת שהקומפילציה תפעל רק לפני ההרצה, ו -XX:CICompilerCount=1 שלא יפעל במקביל, דאגו של JVM יש מספיק זכרון ושלא יהיה עליו להקצות בזמן הבדיקה. (Xmx==Mxs). מומלץ גם לקרוא ל ()System.gc בין הבדיקות. יש גם גישה שאומרת: הפעילו JVM חדש בכל הרצת בדיקה.
  • ישנן אופטימיזציות רבות על לולאות, למשל: hoisting של קוד מתוך הלולאה על מנת להפחית את ה overhead שלה. במקרים מסוימים פי 10 הרצות – יגרמו לאופטימיזציה לפעול, ולקוד לרוץ יותר מהר.
    mitigation: להריץ הרבה מאוד איטרציות בדי להנות מכל אופטימיזציה אפשרית. הרבה יותר קשה: לא להשתמש בלולאות של השפה, אלא לקרוא לפונקציה ב reflection ולמדוד את ההרצה בניקוי זמן הקריאה לפונקציה. בעיקרון קריאה לפונקציה, למרות התקורה הגבוהה יותר, נחשבת הדרך המדוייקת יותר לביצוע בדיקות. הנה דוגמה למקרה בו המעבר לפונקציה הפך בדיקה מחסרת חשיבות (אותה תוצאה להרצה ריקה מול הרצת לוגיקה) – למשמעותית.
  • Dead Code elimination: קוד שלא באמת בשימוש, ואין לו השפעה חיצונית – יבוטל ע"י אופטימיזציות של ה JVM.
    mitigation: חשוב שכל משתנה ששמנו בו ערך, יקרא לפחות פעם אחת – אחרת הקומפיילר עשוי "לסלק" את כל הקוד שרק מבצע השמה.
  • Constant Folding optimization – אם המעבד חישוב המבוסס על ערכים שלא משתנים, הוא יכול לשמור את תוצאות החישוב ולהשתמש בה שוב. האופטימיזציה הזו תקרה כאשר קוראים לאותו קוד שוב ושוב, ופחות בתסריט אמיתי ומורכב.
    mitigation: לא קל. עלינו לבלבל את ה JVM שלא יוכל לעשות את האופטימיזציה הזו…
  • אם הקוד צפוי, יהיו אופטימזציות אחרות של ה JVM ושל המעבד, שינצלו pipelining ארוך ו branch prediction.
    mitigation: דורשת מומחיות ברמת ה JVM… אין פתרון קל.
בקיצור: אם אתם לא מחליפים מקצוע למומחי בדיקות-ביצועים, אבל עדיין רוצים להריץ פה ושם מדידונים, מומלץ:
  • להתייחס בזהירות לתוצאות של מדידונים.
    • להסתמך בעיקר על מגמות ("x מהיר משמעותית מ y" או "x מהיר רק מעט יותר מ y") – ולא על מספרים מדויקים ("x מהיר ב 10ns" או "x מהיר ב 10%", הן מסקנות לא חזקות לניבוי תוצאות בהרצה בסביבה אפילו מעט שונה).
    • לזכור שהתוצאות הן לחומרה ספציפית, מערכת הפעלה ספציפית, וגרסת תוכנה ספציפית (כל הרכיבים השונים). לא בהכרח התוצאות יתורגמו בצורה טובה למערכות אחרות.
    • להיות תמיד פתוחים לכך שהמדידון בעצם איננו מתאר את המצב במדיוק, ויש לשקול שנית את המסקנות (בעיקר כאשר ההבדלים בין ההרצות אינם שונים בסדר גודל).
  • להשתמש ב Framework שיגן עליכם בפני חלק גדול מהטעויות וההטיות האפשריות. ספיציפית: jmh.
    • jmh מסייע להתמודד עם כל הבעיות המסומנות בירוק למעלה. חלקן דורשות מעט חשיבה והתייחסות בקוד – אבל זה עולם אחר לגמרי מלנסות להתמודד איתן לבד.
    • יש גם את Caliper, אבל הוא נחשב פחות מדויק. הנה הסבר מפורט מדוע.

חזרה לשאלה המקורית: כמה "יקר" ליצור מופע של ObjectMapper בכל שימוש?

הכל התחיל מכך שרצינו לדעת כמה משמעותי הוא לעשות caching למופע של ObjectMapper מול יצירה של מופע עבור כל פעולת de/)serialization). כפי שאמרנו, בדיקה מספרית היא לא מדוייקת ולא נכון להשתמש בנתון המספרי כמדויק. למשל: "יצירה של מופע ObjectMapper אורך כ 10 מיקרו-שניות"

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

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

בואו נצא לדרך:

  1. המפתח לשימוש ב jmh הוא ה Annotation בשם Benchmark@. הוא יגרום ל jmh לג'נרט את קוד הבדיקה בצורה "בטוחה מהטיות", במידת האפשר.
    פעם אחת (newMapper) אני בודק יצירה של mapper ושימוש בו.
    בפעם אחרת (reuseMapper) שימוש-חוזר ב mapper ואז שימוש בו.
    השתמשתי בפונקציה בשם ()improvedJsonMapper בה אנחנו משתמשים ליצור ObjectMapper ולהגדיר עליו כמה מודולים והגדרות נוסופות. אם כבר – אז כבר.

    1. שימו לב לאובייקט Blackhole, המוזר לכאורה. הוא מסייע לי לבטל אופטימיזציות של Dead code elimination שהזכרנו למעלה, ותפקידו הוא לא-לספק ל JIT שום מידע האם בערך שהועבר אליו נעשה שימוש.
    2. אני מנסה לתת הקשר יותר הגיוני לבדיקה, ולא רק לייצר מופע ל ObjectMapper. כל מופע דורש שימוש. אם השימוש היה יקר בסדרי גודל מהיצירה – לא משנה לי כמה היצירה היא יקרה. זה יהיה "בטל בשישים".
    3. יצרתי שימוש קטן (tiny) ושימוש קטן (small). ראיתי בינהם הבדל לא מוסבר תוצאות (עוד בהמשך), ולכן הוספתי מקרה עם הרבה נתונים (large).
      מבחני-ביצועים הם, בפוטנציה – מחקר בלתי-נגמר.
      דיונים בתוצאות מבחני-ביצועים – עלולים להיגרר לדיון בלתי-נגמר.
      לכן, כדאי לזכור מה רוצים להשיג – ולשים גבולות לזמן המושקע.
  2. אנחנו מעבירים גם אובייקט state המגיע מבחוץ על מנת להתמודד עם אופטימיזציית Constant Folding. חשוב  שכל הפרמטרים שבשימש יועברו מבחוץ ובאופן זהה. למשל: גם לבדיקה newMapper העברתי את ה mapper הגלובאלי שנוצר – על מנת "ליישר קו".
    1. בחרתי ב scope גלובאלי (Benchmark) כי אין פה נושא של מקביליות. שימוש ב scope של thread יצר שונות גדולה יותר בתוצאות הבדיקות, כנראה בגלל אתחולים נוספים של ה state.
    2. Params@ הם המקבילים ב Parameterized Tests של JUnit – לכל ערך תרוץ איטרציה נפרדת של הבדיקה עם הערך הנתון. הפרמטרים תמיד יוגדרו כמחרוזות, אך יומרו לטיפוס של השדה.
      שמות משמעותיים – עוזרים לעקוב ולהבין את התוצאות.
    3. את אתחול ה state מומלץ לעשות במתודת Setup@ – המובטח שתקרא מחוץ ל scope של המדידות.
      ישנן שלוש רמות של הרצה:

      1. trial – כל ההרצות של Benchmark@ לפי Param@ (הרמה הגבוהה ביותר).
      2. iteration – כל הפעלה של warm-up או מדידה אמיתית בתוך ה trial
      3. invocation – הקריאה הבודדת לקוד שב Benchmark@
  3. בצל התוצאות המעט מוזרות, וגם כדי להראות שימוש ביכולת ה TearDown – רציתי לוודא שהבדיקה רצה כפי שהתווכנתי. מה יותר טבעי מהדפסה?
    בכדי לא להפריע, את ההדפסה עושים מחוץ לזמן הבדיקות. הנתונים אכן כפי שציפיתי שיהיו.
  4. כמה הגדרות אחרונות ששוה להזכיר:
    1. אני רוצה למדוד זמן ריצה ממוצע. גישה אחרת היא למדוד Throughput, ויש גם אפשרויות נוספות.
    2. בקלט של הבדיקה נוח לעבוד עם מידת זמן בסדר גודל רלוונטי. במקרה שלנו : מיקרו-שניות.
    3. ה hint ל JIT מבקש ממנו לא לעשות inline לקוד. הוא לא נדרש בבדיקה הזו ספציפית – אך זה הרגל טוב.
    4. שימו לב שה class צריך להיות פתוח (ברירת המחדל ב Java) – על מנת שה generated code יוכל לרשת ממנו.
והנה התוצאה:

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

הסבר יחידי שאני יכול לחשוב עליו, הוא שה mapper מבצע אתחולים נוספים בתוכו בשל הקלט השונה. למשל: טיפול בממשק Map (שלא מופיע ב tiny data) או כמות נתונים גדולה יותר. זו ספקולציה בלבד.

אתמקד בצורך שהביא אותי לכאן: האם / באיזו מידה אני רוצה לעשות שימוש חוזר ב ObjectMapper?
אני יכול להגיע כבר להחלטה שאני מרגיש נוח איתה:

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

את הקוד של ה benchmark, כולל הגדרות maven שלקח לי קצת זמן להגיע אליהן בכדי להריץ את הקוד בקוטלין ניתן למצוא ב github.
ב repository גם תמצאו קובץ shell script שבו אני משתמש להרצה, עם הגדרות ברירת-מחדל שאני מאמין שהן טובות למדי.
שווה לציין שיש plugin ל IntelliJ ל jmh, אבל הוא לא עובד עם קוטלין, ובכלל – אני מאמין שנכון וטוב יותר להריץ את jmh מתוך java -jar.

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

תודה מיוחדת לחיים ידיד, מומחה ביצועים מהמעלה הראשונה, חבר, ואיש תוכנה בכלל – שנתן כמה הערות מועילות.

—-

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

המדריך המועדף עלי ל jmh
JVM Anatomy Park – מדריך לאופטימיזציות השונות של ה JVM
קישור למצגת מאת Aleksey Shipilёv (מומחה עולמי בתחום ה microbenchmarking) שמסבירה ומדגימה כמה בעיות אפשריות בביצוע מדידונים