מה זה .NET Generic Host - התשתית ל-DI, תצורה ולוגים

· עודכן בתאריך: · · C#, .NET, Generic Host, Worker, תכנון

כשמתחילים לכתוב יישום מסוף או worker ב-‎.NET, בהתחלה מסתדרים עם קצת קוד ב-‎Main. אבל ברגע שהיישום גדל מעט, בדרך כלל מצטברים כמה דברים:

  • רוצים לקרוא את appsettings.json
  • רוצים לדרוס ערכים עם משתני סביבה
  • רוצים להוציא לוגים עם ILogger
  • לא רוצים שיצירת השירותים תהיה גדושה ב-new
  • רוצים להריץ לולאה ברקע
  • רוצים להסתיים בצורה מסודרת עם Ctrl+C או עצירת שירות

כאן נכנס לתמונה Generic Host. אבל גם השם הזה נוטה להתבלבל מעט.

  • מה ההבדל בין Host.CreateApplicationBuilder ל-Host.CreateDefaultBuilder
  • האם IHost זהה למכל DI
  • מה הקשר ל-BackgroundService
  • האם זה משהו נפרד מ-WebApplicationBuilder של ASP.NET Core
  • האם יש ערך בשימוש בו גם ביישום מסוף

כשהדברים האלה מתערבבים, ‏Generic Host נראה לפעמים כ”משהו ייעודי ליישומי Web”, ולפעמים כ”משהו שצריך להפוך אליו כל דבר”. שתי התפיסות קצת רשלניות.

במאמר הזה, בהנחת התחושה המעשית הנוכחית שמתמקדת בעיקר ב-‏.NET 6 ואילך, נסדר קודם ארבעה דברים:

  • מהותו של Generic Host
  • מה הוא מטפל בו יחד
  • הקשר בין Host.CreateApplicationBuilder, ‏Host.CreateDefaultBuilder ו-‏WebApplication.CreateBuilder
  • מאיפה נעים להתחיל

תוכן עניינים

  1. קודם המסקנה (במשפט אחד)
    • 1.1. קודם קובעים מינוח
  2. הטבלה שכדאי לראות ראשונה
    • 2.1. מה Generic Host מחזיק
    • 2.2. ההבדל בין ה-builder-ים
    • 2.3. למה יש כמה נקודות כניסה
  3. התמונה הכוללת של Generic Host (תרשים)
  4. מה טוב ב-Generic Host
    • 4.1. אפשר לרכז את קוד ההפעלה במקום אחד
    • 4.2. DI / תצורה / לוגים מתחברים מההתחלה
    • 4.3. קל לטפל בסיום תקין ובהרצה קבועה
  5. הרכב מינימלי
    • 5.1. דוגמה מינימלית ליישום מסוף
    • 5.2. appsettings.json
    • 5.3. הוספת BackgroundService
  6. תבניות טיפוסיות
    • 6.1. כלי מסוף קצר-חיים
    • 6.2. worker / שירות רקע
    • 6.3. גם מתחת ל-ASP.NET Core
  7. מקרים שמתאימים
  8. מקרים שלא מתאימים / עודפים
  9. מוקשים נפוצים
  10. סיכום
  11. מקורות

מפת הידע של המאמר

‏.NET Generic Host הוא מנגנון המאחד לתוך תשתית אחת הזרקת תלויות, תצורה, לוגים, ותהליך רקע קבוע באמצעות IHostedService או BackgroundService, וכן ניהול מחזור חיים שמגיב ל-Ctrl+C או ל-SIGTERM. ביישום לא-Web חדש מקובל לבנות אותו דרך ‏Host.CreateApplicationBuilder, ולקוד קיים נשאר גם המסלול Host.CreateDefaultBuilder. ‏WebApplication.CreateBuilder של ASP.NET Core הוא הכניסה שמרחיבה את אותה גישה לעולם ה-Web, במקום ה-Web Host הנפרד שהיה קיים בעבר. ל-BackgroundService אין scope כברירת מחדל, ולכן שימוש בשירותים מסוג scoped מחייב יצירת scope במפורש דרך IServiceScopeFactory — וזו נקודת המכשול הנפוצה ביותר.

מפת הידע של .NET Generic Hostתרשים המראה כיצד Generic Host מאחד DI, תצורה, לוגים, IHostedService ו-BackgroundService וניהול מחזור חיים, וכיצד הוא מתחבר ל-Host.CreateApplicationBuilder ול-WebApplication.CreateBuilder.משתמש במשתמש במשתמש במשתמש במשתמש במממש אתמוגדר באמצעותמוגדר באמצעותיורש אתמשתמש במחייבמשתמש במשתמש במחייבמחייבמשתמש בGeneric Hostהזרקת תלויות (DI)מערכת התצורה של ‎.NET‏ (IConfiguration)Microsoft.Extensions.Logging(ILogger)IHostedServiceניהול אורך החיים של ה-Host‏ (IHostApplicationLifetime)BackgroundServiceHost.CreateApplicationBuilderHost.CreateDefaultBuilderWebApplication.CreateBuilderWeb Host(IWebHostBuilder)IServiceScopeFactoryדפוס האפשרויות (Options)שירות Windows‏.NET (מ-Core ואילך)HostApplicationBuilder

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

1. קודם המסקנה (במשפט אחד)

  • Generic Host הוא תשתית שמטפלת יחד בהפעלה ובאורך החיים של יישום ‎.NET.
  • בתוכה נכללים DI, תצורה, לוגים, IHostedService /‏ BackgroundService, וטיפול בעצירת היישום.
  • ביישום חדש שאינו Web, טבעי להתחיל מ-Host.CreateApplicationBuilder(args).
  • גם WebApplicationBuilder של ASP.NET Core אינו עולם נפרד — הוא שער שמרחיב את אותה תפיסת host לצרכי Web.
  • כלומר Generic Host אינו סיפור של מכל DI לבדו, אלא מנגנון שמאחד את נקודת ההרכבה של היישום עם ניהול אורך החיים שלו.

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

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

איור 1: מרגע שהיישום עובר קצת מעבר ל”מציג פעם אחת ומסתיים”, Generic Host מתחיל להשפיע.

1.1. קודם קובעים מינוח

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

מונח הניסוח המדויק המטפורה שנשתמש בה במאמר
DI (הזרקת תלויות / Dependency Injection) דרך בנייה שבה מחלקה לא יוצרת בעצמה עם new את הצדדים שהיא צריכה, אלא מקבלת אותם מבחוץ. מקום האחסון שבו רושמים מראש את הצדדים שיימסרו הוא מכל ה-DI (IServiceProvider), ול-Generic Host יש אותו מההתחלה חיווט
Builder (HostApplicationBuilder) אובייקט להרכבת ה-host. יש לו מאפיינים כמו Services,‏ Configuration ו-Logging, ואליהם רושמים דברים. עד קריאה ל-Build() היישום לא רץ שולחן הרכבה
Host (IHost) גוף היישום המורכב שמתקבל כתוצאה מ-Build(). הוא מחזיק את מכל ה-DI, את התצורה, את הלוגים ואת ה-hosted service-ים, ומטפל בהם מ-Run() /‏ RunAsync() ועד לעצירה תשתית
Hosted service (IHostedService /‏ BackgroundService) מיכל לתהליך שרץ בהתאם להפעלה ולעצירה של ה-host. כשה-host עולה, נקרא StartAsync, וב-BackgroundService רץ ExecuteAsync עבודת רקע קבועה
Lifetime ניהול מההפעלה ועד לעצירה של היישום. בתגובה לאותות כמו Ctrl+C,‏ SIGTERM או עצירת שירות, מסדירים את דרך הסיום אורך חיים

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

הגבול בין Builder ל-Hostתרשים המראה ש-Builder הוא הצד המרכיב ו-IHost הוא התוצאה המורכבת, וקריאת Build היא הגבול ביניהם.Build()Builder (הצד המרכיב)IHost (התוצאה המורכבת)Run / RunAsync מהפעלה עד עצירה

איור 2: Builder הוא הצד המרכיב ו-IHost הוא התוצאה המורכבת, ו-Build() הוא הגבול ביניהם.

אם DI חדש לכם, כדאי לחשוב כך: במקום לכתוב בעצמכם שרשרת של new, רושמים בזמן ההפעלה “כשיהיה צורך בסוג הזה, תמסרו את המימוש הזה”, והצד המקבל רק מקבל אותו כארגומנט בבנאי. מקום הרישום הזה הוא builder.Services.

2. הטבלה שכדאי לראות ראשונה

2.1. מה Generic Host מחזיק

קודם כל, כדאי מאוד לפרק את תוכן הקופסה הזו.

רכיב מה Generic Host מטפל בו מה יוצא מזה
DI מרכיב שירותים מתוך IServiceCollection קל יותר לצמצם שרשרת של new
Configuration מאחד appsettings.json, משתני סביבה וארגומנטים משורת הפקודה קל יותר להתמודד עם הבדלים בין סביבות
Logging בונה תשתית לשימוש ב-ILogger<T> קל יותר להחליף אחר כך את יעד פלט הלוגים
Hosted service מטפל בהפעלה ובעצירה של IHostedService /‏ BackgroundService קל יותר להפריד תהליך רקע מגוף היישום
Lifetime מטפל בהפעלה ובעצירה דרך IHostApplicationLifetime,‏ IHostEnvironment וכדומה קל יותר להסדיר את דרך הסיום עם Ctrl+C,‏ SIGTERM או עצירת שירות

חשוב כאן להבין ש-Generic Host אינו “עטיפת DI נוחה אחת בלבד”. בפועל, ההשקפה שהכי לא מטעה היא: קופסה שמחווטת יחד את כל סביבת נקודת הכניסה של היישום.

2.2. ההבדל בין ה-builder-ים

גם כאן מהיר יותר לראות הכול על טבלה אחת מראש.

נקודת כניסה שימוש עיקרי סגנון כתיבה הבחירה הראשונה
Host.CreateApplicationBuilder(args) יישום חדש שאינו Web — מסוף / worker וכדומה כותבים ישירות אל builder.Services /‏ builder.Configuration /‏ builder.Logging ליישום חדש — זה
Host.CreateDefaultBuilder(args) קוד קיים או הרכבה שמבוססת בעיקר על שיטות הרחבה ישנות משרשרים קריאות כמו ConfigureServices אם יש נכסים קיימים — זה
WebApplication.CreateBuilder(args) יישום Web /‏ API של ASP.NET Core נקודת כניסה שמוסיפה ל-Generic Host נוחות ייעודית ל-Web ליישום Web — זה

CreateApplicationBuilder ו-CreateDefaultBuilder אינם מקרה שבו אחד תכונה חדשה והשני משהו נפרד.

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

ביישום חדש שאינו Web, כיום טבעי להתחיל מ-Host.CreateApplicationBuilder(args). כדאי לחשוב על WebApplication.CreateBuilder(args) כעל אותו זרם, מורחב לצרכי Web.

הקשר בין שלוש נקודות הכניסהתרשים המראה ש-CreateApplicationBuilder ו-CreateDefaultBuilder חולקים אותה פונקציונליות ליבה ואותה התנהגות ברירת מחדל אך נבדלים בסגנון הכתיבה, ו-WebApplication.CreateBuilder הוא נקודת כניסה שמרחיבה את אותו זרם לצרכי Web.נקודת כניסה מורחבת ל-WebCreateApplicationBuilderאותה פונקציונליות ליבה והתנהגות ברירת מחדלCreateDefaultBuilderסגנון כתיבה ישירסגנון שרשורWebApplication.CreateBuilder

איור 3: שני ה-builder-ים חולקים אותה פונקציונליות ליבה ואותה התנהגות ברירת מחדל ונבדלים רק בסגנון הכתיבה, ולצורכי Web יש נקודת כניסה שמרחיבה את אותו זרם.

2.3. למה יש כמה נקודות כניסה

יש כמה נקודות כניסה מכיוון שהצד של Web והצד שאינו Web גדלו בנפרד ואז התמזגו.

  • במקור, ל-ASP.NET Core היה Web Host ייעודי ל-Web (‏IWebHostBuilder), ו-Generic Host (‏IHostBuilder) ליישומים שאינם Web הוכן בנפרד.
  • לאחר מכן ASP.NET Core עבר להתבסס על Generic Host, כך שגם Web וגם לא-Web שוכנים על אותה תפיסת host.
  • בנוסף, לצד סגנון הכתיבה שמחבר callback-ים בשרשרת (‏ConfigureServices וכדומה), התווספה נקודת כניסה של כתיבה ישירה אל מאפיינים (‏builder.Services וכדומה). Host.CreateApplicationBuilder ו-WebApplication.CreateBuilder שייכים לצד הזה.

בתיעוד הרשמי הנוכחי, קבוצת Host.CreateApplicationBuilder (‏IHostApplicationBuilder) מוגדרת כמיועדת לפרויקטים חדשים, וברירת המחדל של התבניות הנוכחיות, וקבוצת Host.CreateDefaultBuilder (‏IHostBuilder) מוגדרת כהדרך המסורתית שנשארת לצורך תאימות עם קוד קיים. גם ההסבר ששניהם חולקים אותה פונקציונליות ליבה ואותה התנהגות ברירת מחדל כתוב במפורש.

אם מגיעים מקוד מתקופת ‎.NET Framework או ‎.NET Core 3.1, יש תחושה של “למה יש שתי דרכי כתיבה”. אבל קל יותר להשלים עם זה כשמבינים שזה לא מדובר בשני דברים נפרדים חדש-ישן, אלא בנקודות כניסה שהתרבו בתהליך ההתמזגות. אם אין סיבה להתאים לנכסים קיימים, אפשר בהחלט להשתמש ב-Host.CreateApplicationBuilder ביישום חדש.

הרקע לריבוי נקודות הכניסהתרשים המראה ש-Web Host הייעודי ל-Web ו-Generic Host ליישומים שאינם Web היו נפרדים, ש-ASP.NET Core התמזג לתוך Generic Host, ושבנוסף התווספה נקודת כניסה של כתיבה ישירה אל מאפיינים.Web Host (IWebHostBuilder)ASP.NET Core מתמזג עם Generic HostGeneric Host (IHostBuilder)נוספת נקודת כניסה של כתיבה ישירהCreateApplicationBuilder ו-WebApplication.CreateBuilder

איור 4: Web Host ו-Generic Host שגדלו בנפרד התמזגו, ובתהליך הזה התרבו נקודות כניסה בסגנון הכתיבה הישיר.

3. התמונה הכוללת של Generic Host (תרשים)

בקווים כלליים, התמונה הכוללת נראית כך:

התמונה הכוללת של Generic Hostתרשים המראה כיצד ארגומנטים, משתני סביבה ו-appsettings.json זורמים אל ה-builder, איך רושמים תצורה, שירותים ולוגים, ואיך Build מוביל ל-IHost שמופעל דרך Run או RunAsync ומחובר ל-lifetime ול-hosted service.args / משתני סביבה / appsettings.jsonHost.CreateApplicationBuilder(args)builder.Configurationbuilder.Servicesbuilder.LoggingIHostedService / BackgroundServicebuilder.Build()IHostRun / RunAsyncהפעלה · עצירה · Ctrl+C · SIGTERM

איור 5: רושמים תצורה, שירותים ולוגים אל ה-builder, מריצים את ה-IHost שמתקבל מ-Build() דרך Run/RunAsync, וה-lifetime מתחבר עד להפעלה ולעצירה של ה-hosted service.

בדרך כלל יוצרים את ה-builder ב-Program.cs, מוסיפים שירותים אל builder.Services, מכווננים לפי הצורך את builder.Configuration ואת builder.Logging, ולבסוף קוראים ל-Build() כדי לקבל IHost ומריצים אותו עם Run() /‏ RunAsync().

מה שקצת חבוי אבל משמעותי הוא שכבר בשלב Host.CreateApplicationBuilder(args) הרבה דברים כבר טעונים. כברירת מחדל נכללים למשל אלה:

  • שורש התוכן הוא הספרייה הנוכחית
  • תצורת ה-host היא משתני סביבה עם הקידומת DOTNET_ וארגומנטים משורת הפקודה
  • תצורת היישום היא appsettings.json,‏ appsettings.{Environment}.json, סודות משתמש (‏user secrets) בסביבת Development, משתני סביבה וארגומנטים משורת הפקודה
  • הלוגים הם Console /‏ Debug /‏ EventSource /‏ EventLog (‏Windows בלבד)
  • בסביבת Development יש אימות scope ואימות תלויות

כלומר לא מחווטים מאפס בלי לחשוב, אלא מונחת מההתחלה “תשתית שמספיקה יפה לשימוש רגיל”.

ברירות המחדל שכבר טעונות מההתחלהתרשים המראה שכבר בשלב CreateApplicationBuilder טעונות תצורת host, תצורת יישום ולוגים בברירת מחדל, ושתשתית שמספיקה לשימוש רגיל מונחת מההתחלה.CreateApplicationBuilder(args)תצורת host (משתני DOTNET_ וארגומנטים)תצורת יישום (appsettings.json ועוד)לוגים בברירת מחדל (Console ועוד)תשתית שמספיקה לשימוש רגיל

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

4. מה טוב ב-Generic Host

4.1. אפשר לרכז את קוד ההפעלה במקום אחד

ההשפעה הכי שקטה והכי גדולה של Generic Host היא שקוד ההפעלה של היישום פחות נוטה להתפזר.

כשיישום גדל מעט, אלה הדברים שמצטברים סביב Main:

  • טעינת קבצי תצורה
  • החלפה בין סביבות
  • אתחול הלוגר
  • הרכבת HttpClient, repository או service
  • הפעלת תהליך רקע
  • ניקוי בעת אות סיום

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

כשמשתמשים ב-Generic Host, Program.cs נהיה בבירור “המקום שבו מרכיבים יחד את התלויות”. רק הסידור הזה משנה משמעותית עד כמה קל לעשות code review.

קוד ההפעלה מתרכז במקום אחדתרשים המראה שחיווט ידני בלי host גורם לנקודת הכניסה להיות דביקה בהמשך, ואילו שימוש ב-Generic Host הופך את Program.cs למקום ברור שבו מרכיבים את התלויות.חיווט ידני בלי hostנקודת הכניסה נהיית דביקה בהמשךשימוש ב-Generic HostProgram.cs נהיה מקום הרכבהקל יותר לעשות code review

איור 7: בחיווט ידני נקודת הכניסה נעשית דביקה בהמשך, אבל כשמרכזים אל ה-host, Program.cs נהיה בבירור מקום ההרכבה.

4.2. DI / תצורה / לוגים מתחברים מההתחלה

כשמשתמשים ב-Generic Host, DI, תצורה ולוגים יושבים מההתחלה על אותה תשתית.

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

  • ILogger<T>
  • IConfiguration
  • IHostEnvironment
  • IOptions<T>

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

אם יש הגדרה אחת או שתיים, אפשר להסתדר גם עם קריאה ישירה של IConfiguration["Section:Key"]. אבל כשההגדרות גדלות בעבודה מעשית, בטוח יותר לאגד אותן לפי section למחלקה עם IOptions<T>. קו הגבול הוא בערך כשמספר מחרוזות המפתח עובר 5. בהיקף הזה, טעויות הקלדה מתחילות להופיע ככשלים שלא מתגלים עד זמן הריצה, וגם קשה יותר לעקוב היכן נקרא כל מפתח.

באופן דומה, גם בלוגים, במקום לבנות ידנית ILoggerFactory בכל מקום, עדיף להזריק ILogger<T> למחלקה הרלוונטית — כך קל יותר לעקוב.

מה ש-Generic Host נוח בו הוא שהוא לא הופך את הדברים האלה לנושאים נפרדים, אלא מאפשר לטפל בהם יחד כתשתית אחת של כל היישום.

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

איור 8: כשההגדרות מעטות מספיקה קריאה ישירה, אבל משמספר המפתחות עובר 5 בטוח יותר לאגד אותן עם IOptions.

4.3. קל לטפל בסיום תקין ובהרצה קבועה

Generic Host מטפל לא רק ב”איך מפעילים” אלא גם ב”איך עוצרים”.

כשה-host עולה, נקרא StartAsync של כל IHostedService שנרשם. בשירות worker, רץ ExecuteAsync של ה-hosted service, כולל BackgroundService.

“סיום תקין” כאן פירושו לא לחתוך את התהליך בפתאומיות, אלא לסיים לפי הסדר הבא:

  • לשדר את אות העצירה
  • לצאת מהלולאה או מההמתנה
  • לנקות חיבורים ומשאבים

ביישום שרץ זמן רב, זה קריטי. אירועים כמו Ctrl+C,‏ SIGTERM או עצירת שירות מאפשרים להסדיר את דרך העצירה של כל היישום.

בנוסף, כשרוצים לבקש סיום מצד היישום עצמו, אפשר להשתמש ב-IHostApplicationLifetime.StopApplication(). אפשר לשדר בהקשר ה-host את האות “העבודה הסתיימה, אפשר לרדת בצורה מסודרת”.

סדר הסיום התקיןתרשים המראה שבתגובה לאירוע Ctrl+C, SIGTERM או עצירת שירות, משדרים אות עצירה, יוצאים מהלולאה או מההמתנה, ואז מנקים חיבורים ומשאבים.StopApplication()Ctrl+C / SIGTERM / עצירת שירותשידור אות עצירהיציאה מלולאה או המתנהניקוי חיבורים ומשאביםבקשת סיום מצד היישום

איור 9: סיום תקין עובר לפי הסדר של אות עצירה, יציאה מלולאה וניקוי, וגם מצד היישום אפשר לשדר את אותו אות דרך StopApplication().

5. הרכב מינימלי

5.1. דוגמה מינימלית ליישום מסוף

חשוב קודם: השימוש ב-Generic Host לא מחייב ליצור דווקא BackgroundService.

גם בכלי מסוף שרץ פעם אחת, אם רוצים DI, תצורה ולוגים, Generic Host שימושי לגמרי.

אם מוסיפים אותו בדיעבד לפרויקט console רגיל, קודם מוסיפים הפניה ל-Microsoft.Extensions.Hosting.

dotnet add package Microsoft.Extensions.Hosting

הדוגמה המינימלית ל-Program.cs יכולה להיראות כך:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<JobRunner>();

using IHost host = builder.Build();

try
{
    JobRunner runner = host.Services.GetRequiredService<JobRunner>();
    await runner.RunAsync();
    return 0;
}
catch (Exception ex)
{
    ILogger logger = host.Services
        .GetRequiredService<ILoggerFactory>()
        .CreateLogger("Program");

    logger.LogError(ex, "Unhandled exception occurred during job execution.");
    return 1;
}

internal sealed class JobRunner(
    ILogger<JobRunner> logger,
    IConfiguration configuration,
    IHostEnvironment hostEnvironment)
{
    public Task RunAsync()
    {
        string message = configuration["Sample:Message"] ?? "(no message)";

        logger.LogInformation("Environment: {EnvironmentName}", hostEnvironment.EnvironmentName);
        logger.LogInformation("Message: {Message}", message);

        return Task.CompletedTask;
    }
}

בהרצת dotnet run, המסוף מציג כך (הערך של Message מגיע מ-appsettings.json שנניח בסעיף 5.2 הבא):

info: JobRunner[0]
      Environment: Production
info: JobRunner[0]
      Message: hello from Generic Host

מה שמופיע מימין ל-info: הוא קטגוריית הלוג (כאן שם הסוג, כי מדובר ב-ILogger<JobRunner>) ומזהה האירוע. הלוגר בברירת המחדל של הקונסולה מציג בצורה של “שורה ראשונה — קטגוריה, שורה שנייה — גוף ההודעה”. הסיבה ש-Environment הוא Production היא שזו ברירת המחדל כשלא הוגדרו לא DOTNET_ENVIRONMENT ולא ASPNETCORE_ENVIRONMENT. כדי להחליף בזמן פיתוח, מריצים עם DOTNET_ENVIRONMENT=Development מוגדר.

אם לא צריך הרצה קבועה לאורך זמן, לא חייבים להגיע עד RunAsync(). אפשר לעשות Build(), לפתור את השירות הנדרש, ולסיים בתום העבודה. גם כך מקבלים במלואו את היתרון של Generic Host.

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

איך משתמשים בעבודה קצרת-חייםתרשים המראה שאם לא צריך הרצה קבועה, לא חייבים להגיע עד RunAsync, אלא אפשר לעשות Build, לפתור את השירות הנדרש, לבצע את העבודה ולסיים ישירות — וגם כך מקבלים את היתרון של Generic Host.ביצוע Build()פתרון השירות הנדרשביצוע העבודהסיום ישירלא חייבים להגיע עד RunAsync()

איור 10: בעבודה קצרת-חיים, גם עם Build(), פתרון שירות, ביצוע וסיום — כבר מקבלים את היתרון של ה-host.

5.2. appsettings.json

בדוגמה שלמעלה, מספיקה צורה מינימלית כזו לקובץ התצורה:

{
  "Sample": {
    "Message": "hello from Generic Host"
  }
}

יש מוקש קלאסי אחד. בפרויקט console, רק הוספת appsettings.json אינה גורמת להעתקה לתיקיית הפלט. או שמכוונים במאפייני הפרויקט את “העתק לתיקיית הפלט” ל”העתק אם חדש יותר”, או שכותבים ב-csproj את הבא:

<ItemGroup>
  <Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

כדאי גם להכיר את התסמין כשזה נשכח. Generic Host קורא את appsettings.json כקובץ אופציונלי, כך שגם אם הוא לא קיים, לא נזרק חריג. פשוט לא מתקבל ערך. בדוגמה המינימלית שלמעלה, יוצג Message: (no message). כשיש “אין שגיאה אבל התצורה לא עובדת”, קודם בדקו האם appsettings.json נמצא בתיקיית הפלט.

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

איור 11: גם כש-appsettings.json חסר, לא נזרק חריג — פשוט “לא מתקבל ערך” — אז קודם בודקים את תיקיית הפלט.

בדוגמה הזו, קוראים ישירות configuration["Sample:Message"]. אם צריך לבדוק ערך אחד או שניים, זה מספיק.

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

  • מפרידים למחלקה לפי section
  • מזריקים עם IOptions<T>
  • מאמתים בזמן ההפעלה

בנוסף, בברירת המחדל של Generic Host מתחברים לא רק appsettings.json אלא גם appsettings.{Environment}.json, משתני סביבה וארגומנטים משורת הפקודה. לכן “להחליף רק בזמן פיתוח” או “לדרוס בייצור עם משתני סביבה” מתאפשרים בטבעיות רבה.

5.3. הוספת BackgroundService

בתהליך שרץ זמן רב, שימוש ב-BackgroundService הוא הבחירה הטבעית.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddScoped<PollingJob>();
builder.Services.AddHostedService<PollingWorker>();

using IHost host = builder.Build();
await host.RunAsync();

internal sealed class PollingWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<PollingWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(TimeSpan.FromSeconds(30));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using IServiceScope scope = scopeFactory.CreateScope();
            PollingJob job = scope.ServiceProvider.GetRequiredService<PollingJob>();

            await job.RunAsync(stoppingToken);
            logger.LogInformation("Polling completed.");
        }
    }
}

internal sealed class PollingJob(ILogger<PollingJob> logger)
{
    public Task RunAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("Do work here.");
        return Task.CompletedTask;
    }
}

בדוגמה הזו כדאי לשים לב לשתי נקודות:

  1. הגוף של BackgroundService הוא ExecuteAsync
  2. אם רוצים תלות scoped, יוצרים scope עם IServiceScopeFactory

ל-BackgroundService עצמו אין scope כברירת מחדל. אם רוצים להשתמש בשירות scoped כמו DbContext, בטוח יותר לפתור את צד ה-job בתוך scope, כפי שמופיע למעלה.

איך משתמשים ב-scoped בתוך BackgroundServiceתרשים המראה של-BackgroundService עצמו אין scope כברירת מחדל, ולכן מזריקים IServiceScopeFactory, יוצרים scope בתוך ExecuteAsync, ופותרים שם את שירות ה-job.BackgroundService (בלי scope כברירת מחדל)הזרקת IServiceScopeFactoryיצירת scope בתוך ExecuteAsyncפתרון ה-job בתוך ה-scope

איור 12: ל-BackgroundService שאין לו scope כברירת מחדל, יוצרים scope עם IServiceScopeFactory ופותרים בתוכו את ה-job.

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

6. תבניות טיפוסיות

6.1. כלי מסוף קצר-חיים

גם ביישום שעושה עבודה פעם אחת ומסתיים — כמו batch, כלי המרה או פקודת תחזוקה — Generic Host שימושי לגמרי.

הוא מתאים למצבים כאלה:

  • רוצים לקרוא קובץ תצורה
  • רוצים להוציא לוגים
  • רוצים להזריק HttpClient או repository
  • רוצים להחזיר קוד יציאה

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

בעבודה קצרת-חיים, מספיק כמו בדוגמה המינימלית הקודמת — לפתור את JobRunner ולהריץ אותו.

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

איור 13: להביא BackgroundService ליישום חד-פעמי זה עודף, ומספיק לפתור את השירות ולהריץ אותו.

6.2. worker / שירות רקע

בתהליכים כמו worker קבוע, polling, צריכת תור, ניטור או הרצה תקופתית, השילוב של Generic Host עם BackgroundService טבעי מאוד.

מה שנוח במיוחד:

  • זרימת ההפעלה והעצירה מסודרת בצד ה-host
  • לוגים, תצורה ו-DI זמינים מההתחלה
  • קל לזרום ביטול (cancellation) עם Ctrl+C או אות עצירה
  • קל להפריד את גוף התהליך הקבוע מ-Program.cs

בנוסף, קל לחבר גם להקשר של Windows Service או מכולה (container). אם מפתחים את היישום כתהליך קבוע, Generic Host הוא תשתית טבעית למדי.

במעבר ל-Windows Service, פחות תקלות אם מחפשים קבצים מנקודת מוצא IHostEnvironment.ContentRootPath, ולא מהנחת הספרייה הנוכחית. הסיבה היא ש”נתיב הבסיס של היישום” נקבע בהקשר ה-host.

התשתית ל-worker קבועתרשים המראה שב-worker קבוע או בהרצה תקופתית השילוב של Generic Host עם BackgroundService טבעי, וקל לחבר גם להקשר של Windows Service ומכולה.worker קבוע / הרצה תקופתיתGeneric Host עם BackgroundServiceשירות Windowsהרצה קבועה במכולהחיפוש מנקודת מוצא ContentRootPath

איור 14: תהליך קבוע נוח יותר עם השילוב של host ו-BackgroundService, וקל לפתח אותו גם אל Windows Service או מכולה.

6.3. גם מתחת ל-ASP.NET Core

ביישום Web /‏ API משתמשים ב-WebApplication.CreateBuilder(args), ולכן במבט ראשון זה יכול להיראות כעולם נפרד מ-Generic Host.

אבל התחושה בפועל מחוברת מאוד.

  • builder.Services
  • builder.Configuration
  • builder.Logging

הדמיון בסגנון הכתיבה נובע מכך.

ב-ASP.NET Core, גם הפעלת שרת ה-HTTP נכללת בתוך ה-lifetime של ה-host. כלומר, כשקוראים את Program.cs בצד Web, ההבנה של Generic Host עוזרת גם להבין “למה כאן נוגעים ב-DI, בתצורה ובלוגים”.

גם Web על אותו hostתרשים המראה שגם יישום ASP.NET Core נמצא דרך WebApplication.CreateBuilder על אותה תפיסת host, ושהפעלת שרת ה-HTTP נכללת בתוך ה-lifetime של ה-host.יישום ASP.NET CoreWebApplication.CreateBuilderאותה תפיסת hostגם הפעלת שרת ה-HTTP בתוך ה-lifetime

איור 15: גם ה-builder בצד Web נמצא על אותה תפיסת host, וגם הפעלת שרת ה-HTTP נכללת ב-lifetime.

7. מקרים שמתאימים

הנה כמה מצבים שבהם Generic Host נכנס בנוחות רבה.

  • יישום מסוף שמשתמש בתצורה, לוגים ו-DI
  • worker בסגנון queue consumer,‏ poller,‏ watchdog או scheduler
  • יישום שרץ זמן רב ורוצה לנקות בעת Ctrl+C או SIGTERM
  • יישום שעשוי לגדול בעתיד לכיוון Windows Service או הרצה קבועה במכולה
  • יישום שרוצה להתאים לסגנון אותה קבוצת הרחבות כמו ASP.NET Core

המשותף לכולם הוא “לא רוצים לזלזל בנקודת הכניסה של היישום ובניהול אורך החיים שלו”.

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

קו מנחה להחלטת אימוץתרשים המראה שאם מתקיימים שניים או יותר מהקווים המנחים, מעמיסים מההתחלה על Generic Host, ואם אף אחד לא מתקיים אפשר להחליט ש-Generic Host מיותר.שניים או יותראף אחדסופרים כמה קווים מנחים מתקיימיםמעמיסים מההתחלה על Generic Hostאפשר להחליט ש-Generic Host מיותר

איור 16: אם מתקיימים שניים או יותר מהקווים המנחים, מעמיסים מההתחלה, ואם אף אחד לא מתקיים לא חייבים להביא אותו.

קו מנחה קו הגבול המעשי
מספר הגדרות לפחות 3 הגדרות שמשתנות בין סביבות (יעד חיבור, סף, יעד פלט וכדומה)
לוגים צריך לשמור לקובץ או ל-Event Log. לא מספיק לכתוב לפלט הסטנדרטי בלבד
צורת ההרצה רץ באופן קבוע, או פועל לפחות פעם ביום במרווח קבוע
תלויות לפחות 3 צדדים שרוצים לקבל דרך הבנאי, או שיש צד שרוצים להחליף בבדיקות
אורך חיים נדרש ניקוי באמצע בעת Ctrl+C או עצירת שירות
עתיד קיים סיכוי להריץ כ-Windows Service או במכולה

8. מקרים שלא מתאימים / עודפים

מהצד השני, יש גם מצבים שבהם לא צריך להפוך את Generic Host לשחקן הראשי מההתחלה.

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

כאן, כתיבה ישירה ב-Main במקום הקמת host חוסכת גם בכמות הקריאה וגם במספר הקבצים. כקו מנחה, אם אף אחד מהתנאים בטבלה שבפרק 7 לא מתקיים, אפשר להחליט ללא חשש ש-Generic Host מיותר.

חשוב להבין: חוזק של Generic Host לא הופך אותו לחובה בכל executable.

9. מוקשים נפוצים

לבסוף, נסכם נקודות שקל למעוד בהן בפעם הראשונה עם Generic Host.

  • לראות ב-Generic Host רק מכל DI
    • בפועל, זו תשתית שכוללת הפעלה, עצירה, תצורה, לוגים ו-hosted service.
  • להתחיל מ-Host.CreateDefaultBuilder מתוך הרגל, למרות שזה יישום חדש
    • אם אין סיבה להתאים לקוד קיים, Host.CreateApplicationBuilder הוא הבחירה הטבעית יותר.
  • להכניס שירות scoped ישירות אל BackgroundService
    • ל-hosted service אין scope כברירת מחדל. בטוח יותר ליצור scope עם IServiceScopeFactory.
  • לא להודיע ל-host על עצירה ב-worker שרץ פעם אחת בלבד
    • אם עושים “run once” בתבנית Worker, בלי לקרוא ל-IHostApplicationLifetime.StopApplication() בסיום העבודה, ה-host ימשיך לרוץ כרגיל.
  • לחתוך עם Environment.Exit כשרוצים סיום תקין
    • אם משתמשים ב-host, כשרוצים לעצור בצורה מסודרת, StopApplication() היא הדרך הנכונה יותר.
  • להניח את הספרייה הנוכחית כמובנת מאליה ב-Windows Service
    • יציב יותר לחפש קבצים מנקודת מוצא IHostEnvironment.ContentRootPath.
  • לעטוף ב-BackgroundService מההתחלה כלי CLI קצר-חיים
    • בעבודה חד-פעמית, מספיק לפתור מחלקת שירות רגילה ולהריץ אותה.
  • להכניס בגסות טיימר מבוסס callback להרצה תקופתית של BackgroundService
    • אם כותבים בזרימה async, PeriodicTimer לרוב קריא יותר ופחות נוטה להתפרע.

ב-Generic Host, חלוקה מראש בין “עבודה קצרת-חיים” ל”עבודה קבועה” לבדה מפחיתה משמעותית את הבלבול.

השאלה שמחלקים ראשונהתרשים המראה שקודם מחלקים בין עבודה קצרת-חיים לעבודה קבועה — בקצרת-חיים מספיק לפתור שירות ולהריץ אותו, ובקבועה משתמשים ב-BackgroundService ובניהול ה-lifetime של ה-host.קצרת-חייםקבועהעבודה קצרת-חיים או קבועה?רק פתרון שירות והרצהBackgroundService וניהול lifetime

איור 17: החלוקה הראשונית בין קצר-חיים לקבוע מפחיתה בלבול לגבי כמה מכלי ה-host להשתמש.

10. סיכום

במשפט אחד, Generic Host הוא תשתית שמאחדת את נקודת הכניסה ואת ניהול אורך החיים של יישום ‎.NET.

נסכם את הנקודות שכדאי לזכור.

  1. Generic Host כולל לא רק DI, אלא גם תצורה, לוגים, טיפול בעצירה ו-hosted service
  2. ביישום חדש שאינו Web, טבעי להתחיל מ-Host.CreateApplicationBuilder(args)
  3. בעבודה קצרת-חיים, אפשר בלי BackgroundService — רק build והרצה
  4. בתהליך קבוע, BackgroundService וניהול ה-lifetime של ה-host משמעותיים מאוד
  5. ל-BackgroundService אין scope כברירת מחדל, אז שירותים scoped דורשים יצירת scope במפורש
  6. גם WebApplicationBuilder של ASP.NET Core נמצא, מבחינה רעיונית, על אותו זרם

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

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

11. מקורות

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

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

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

מה זה Generic Host?
זו תשתית שמטפלת יחד בהפעלה ובאורך החיים של יישום ‎.NET. בתוכה נכללים DI, תצורה (Configuration), לוגים,‏ IHostedService /‏ BackgroundService, וטיפול בעצירת היישום. זה לא רק עטיפה למכל DI, אלא מנגנון שמאחד את נקודת ההרכבה של היישום עם ניהול אורך החיים שלו — כך קשה לטעות בהבנה. הוא מוכיח את עצמו ביישום שבו התצורה, הלוגים, התלויות, ההפעלה והסיום גדלים ולו במעט.
מה עדיף להשתמש בו — ‎Host.CreateApplicationBuilder‏ או ‎Host.CreateDefaultBuilder?
ביישום חדש שאינו יישום Web, טבעי להתחיל מ-‏Host.CreateApplicationBuilder(args)‏. לשניהם אותה פונקציונליות ליבה ואותה התנהגות ברירת מחדל, ולא מדובר בכך שאחד הוא תכונה חדשה והשני משהו נפרד. ההבדל הוא בעיקר בסגנון הכתיבה: ‎CreateApplicationBuilder הוא סגנון שכותבים בו ישירות אל builder.Services וכדומה, ואילו ‎CreateDefaultBuilder הוא סגנון שמשרשרים בו קריאות כמו ConfigureServices. אם יש צורך להתאים לקוד קיים או להרכבה שמבוססת בעיקר על שיטות הרחבה ישנות, בוחרים ב-‎CreateDefaultBuilder.
האם יש ערך בשימוש ב-Generic Host גם ביישום מסוף?
אם רוצים DI, תצורה ולוגים, הוא שימושי לגמרי גם בכלי מסוף שרץ פעם אחת. אין חובה ליצור דווקא BackgroundService — אפשר לעשות ‎Build()‏, לפתור את השירותים הנדרשים, ולסיים ישירות בתום העבודה, וגם כך מקבלים את היתרון של Generic Host. מהצד השני, בכלי קטן שרק קורא ארגומנט אחד, מדפיס פלט אחד ומסתיים, או בקוד בדיקה גס, זה עודף — אז לא חייבים להביא אותו בכל פעם.
איך משתמשים בשירות scoped בתוך BackgroundService?
ל-BackgroundService אין scope כברירת מחדל, ולכן לא בטוח להזריק שירות scoped ישירות דרך הבנאי. הצורה הבטוחה היא להזריק IServiceScopeFactory, ליצור scope במפורש בתוך ExecuteAsync, ולפתור שם את שירותי העבודה. אם רוצים להשתמש בשירות scoped כמו DbContext, חשוב במיוחד לשמור על הצורה הזו.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג