מערכות בנייה מבוססות חפצים

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

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

נקודת מבט פונקציונלית

קל ליצור אנלוגיה בין מערכות build המבוססות על חפצים לבין תכנות פונקציונלי. בשפות תכנותיות חיוניות מסורתיות (כמו Java , C ו-Python) מציינים רשימות של הצהרות הניתנות לביצוע אחת אחרי השנייה, באותו אופן שבו מערכות build מבוססות משימות מאפשרות למתכנתים להגדיר סדרה של שלבים לביצוע. לעומת זאת, שפות תכנות פונקציונליות (כמו Hakell ו-ML) מובנות יותר כמו סדרה של משוואות מתמטיות. בשפות פונקציונליות, שמתכנתים מתארים חישוב כדי לבצע ביצועים, אבל משאירים את הפרטים מתי ומתי החישוב הזה מבוצע בפועל.

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

הבנת מערכות build שמבוססות על פריטי מידע שנוצרו בתהליך פיתוח (Artifact)

מערכת Blaze של Google&#39 הייתה מערכת הבנייה הראשונה המבוססת על פריטי מידע שנוצרו בתהליך פיתוח (Artifact). Bazel היא גרסת הקוד הפתוח של Blaze.

כך נראה קובץ build (בדרך כלל BUILD) ב-Bazel:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

ב-Bazel קבצים של BUILD מגדירים יעדים – שני סוגי היעדים כאן הם java_binary ו-java_library. כל יעד מתייחס לפריט מידע שנוצר על ידי המערכת: יעדים בינאריים מפיקים קבצים בינאריים שניתן להפעיל ישירות, ויעדים של ספריות מייצרים ספריות שיכולות לשמש בינאריות או ספריות אחרות. לכל יעד יש:

  • name: איך מוזכר היעד בשורת הפקודה וביעדים אחרים
  • srcs: קובצי המקור שהורכבו כדי ליצור את פריט המידע שנוצר בתהליך היעד
  • deps: יעדים אחרים שצריך לבנות לפני הקישור של היעד הזה ולקשר אליו

התלויות יכולות להיות בתוך אותה חבילה (למשל, התלות של MyBinary ב-:mylib) או בחבילה אחרת באותה היררכיית מקור (כמו התלות של mylib ב-//java/com/example/common).

בדומה למערכות build מבוססות משימות, אתם מבצעים גרסאות build באמצעות כלי שורת הפקודה של Bazel. כדי ליצור את היעד MyBinary, יש להריץ bazel build :MyBinary. אחרי שהזנתם את הפקודה בפעם הראשונה במאגר נקי, בזל:

  1. מנתח כל קובץ BUILD בסביבת העבודה כדי ליצור תרשים של יחסי תלות בין פריטים.
  2. מבוסס על התרשים כדי לקבוע את תלויות התלות של MyBinary. כלומר, כל יעד ש-MyBinary תלוי בו וכל יעד שהיעדים האלה תלויים בו, באופן חוזר ונשנה.
  3. בונה כל אחד מהתלויים האלה, לפי הסדר. Bazel מתחילה בבניית כל יעד שאין לו תלויות נוספות, ועוקבת אחר התלויות שעדיין יש לבנות עבור כל יעד. מיד אחרי שכל יחסי תלות של יעדים נוצרים, בזל מתחילה לבנות את היעד. התהליך הזה ממשיך עד שתווצר כל אחת מהאפשרויות התלויות של MyBinary.
  4. בונה את MyBinary כדי ליצור קובץ בינארי להפעלה שניתן לקשר לכל יחסי תלות שנוצרו בשלב 3.

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

עם זאת, יש יתרונות שהרבה מעבר לתפיסה המקבילה. הדבר הבא בגישה הזאת מאפשר לנו להיות ברורים כשהמפתח מקליד bazel build :MyBinary בפעם השנייה בלי לבצע שינויים: Bazel יוצא פחות משנייה עם הודעה על כך שהיעד מעודכן. זה אפשרי עקב פרדיגמה של תכנות פונקציונלית שדיברנו עליה קודם - בזל יודע שכל יעד הוא תוצאה של הרצת מהדר Java בלבד, והיא יודעת שהפלט מהדר ה-Java תלוי רק בקלטים שלו, כך שכל עוד הקלט לא השתנה, ניתן לעשות שימוש חוזר בפלט. וגם הניתוח הזה פועל בכל רמה; אם MyBinary.java ישתנה, Bazel יודעת לבנות מחדש את MyBinary אבל לעשות שימוש חוזר ב-mylib. אם קובץ מקור עבור //java/com/example/common משתנה, Bazel יודעת לבנות את הספרייה מחדש, mylib ו-MyBinary, אבל לעשות שימוש חוזר ב-//java/com/example/myproduct/otherlib. מאחר ש-Bazel יודעת על המאפיינים של הכלים שהיא מפעילה בכל שלב, היא יכולה לבנות מחדש רק את הקבוצה המינימלית של פריטי המידע שנוצרים בכל פעם, ולהבטיח שהיא לא תיצור גרסאות לא עדכניות.

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

טריקים מגניבים אחרים

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

כלים כתלויים

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

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

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

  • תצורת מארח: כלי בנייה שפועלים במהלך ה-build
  • הגדרת היעד: יצירת הקובץ הבינארי שביקשתם בסופו של דבר

הרחבה של מערכת ה-build

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

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

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

בידוד הסביבה

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

הגדרת יחסי תלות חיצוניים תלויים

נותר עוד בעיה אחת: מערכות בנייה לעיתים קרובות צריכות להוריד תלות (כלים או ספריות) ממקורות חיצוניים במקום ליצור אותם באופן ישיר. ניתן לראות זאת בדוגמה של @com_google_common_guava_guava//jar, שתלויה בקובץ JAR ב-Maven.

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

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

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

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

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