.NET Generic Host 是什么 - DI、配置、日志的基础
· 更新日期: · 小村 豪 · C#, .NET, Generic Host, Worker, 设计
引用本文(DOI: 10.5281/zenodo.21615454)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《.NET Generic Host 是什么 - DI、配置、日志的基础》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615454 https://comcomponent.com/zh-CN/blog/2026/03/14/000-dotnet-generic-host-what-is/
- DOI(最新版本)
- 10.5281/zenodo.21615454
- DOI(此版本)
- 10.5281/zenodo.22281971
开始用 .NET 写控制台应用或 worker 时,最初只需要在 Main 中写一点处理就够了。
但稍微发展一段时间后,通常会逐渐增加下面这些需求。
- 想读取
appsettings.json - 想用环境变量覆盖配置
- 想用
ILogger输出日志 - 不想让服务的创建全是
new - 想在后台跑一个循环
- 想通过
Ctrl+C或服务停止来干净地结束
这时就会出现 Generic Host。
不过,这个名字本身也容易让人混淆。
Host.CreateApplicationBuilder和Host.CreateDefaultBuilder有什么区别IHost和 DI 容器是同一个东西吗- 它和
BackgroundService之间是什么关系 - 它和 ASP.NET Core 的
WebApplicationBuilder是完全不同的东西吗 - 在控制台应用中使用它有价值吗
一旦这些概念混杂在一起,Generic Host 有时会被看成“似乎是 Web 应用专用的东西”,有时又会被看成“什么都应该做成 host”。这两种看法都有点草率。
本文主要以 .NET 6 及以后版本的实务感受为前提,先整理清楚下面这四点。
- Generic Host 的真正面貌
- 它统一负责照看的是什么
Host.CreateApplicationBuilder/Host.CreateDefaultBuilder/WebApplication.CreateBuilder之间的关系- 从哪里入手比较稳妥
目录
- 先说结论(一句话)
- 1.1. 先把术语定下来
- 先看整理表
- 2.1. Generic Host 承担的内容
- 2.2. builder 之间的区别
- 2.3. 为什么入口会有多个
- Generic Host 的整体概览(图)
- Generic Host 好在哪里
- 4.1. 可以把启动处理集中到一处
- 4.2. DI / 配置 / 日志从一开始就连在一起
- 4.3. 便于处理正常退出与常驻运行
- 最小配置
- 5.1. 在控制台应用中使用的最小示例
- 5.2.
appsettings.json - 5.3. 加载
BackgroundService
- 典型模式
- 6.1. 短命的控制台工具
- 6.2. worker / 后台服务
- 6.3. 也存在于 ASP.NET Core 之下
- 适合的场景
- 不适合 / 过度使用的场景
- 容易踩坑的地方
- 总结
- 参考资料
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 16 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
1. 先说结论(一句话)
- Generic Host 是统一处理 .NET 应用启动与生命周期的基础设施。
- 其中包含 DI、配置、日志、
IHostedService/BackgroundService,以及应用的停止处理。 - 对于新的非 Web 应用,直接从
Host.CreateApplicationBuilder(args)入手会更自然。 - ASP.NET Core 的
WebApplicationBuilder也并非另一个世界的东西,而是把同样的 host 理念扩展到 Web 场景的一个入口。 - 也就是说,Generic Host 并不只是 DI 容器本身的话题,而是统一管理应用组装点与生命周期的机制。
归根结底,一旦应用超出了“读取参数、输出一次就结束”的范畴,Generic Host 就会相当有效。 反过来,对于还没发展到那个程度的小工具,也不必每次都硬套上去。
flowchart TB
accTitle: Generic Host 开始发挥作用的范围
accDescr: 说明一个判断标准:对只输出一次就结束的小工具不必每次都引入,而一旦稍微超出这个范围,Generic Host 就开始发挥作用。
small1["输出一次就结束的工具"] -.->|"不必每次都引入"| ghost0["Generic Host"]
grown1["稍微超出这个范围的应用"] -->|"会相当有效"| ghost0
图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() 就是两者的分界线。只要抓住这一点,后面出现的“基础”“箱子”“窗口”“入口”这些说法分别指什么,读起来就不会迷糊。
flowchart TB
accTitle: Builder 与 Host 的分界线
accDescr: 说明 Builder 是负责组装的一侧,IHost 是组装出来的结果,而对 Build 的调用就是两者的分界线。
bld1["Builder(负责组装的一侧)"] -->|"Build()"| hst1["IHost(组装出来的结果)"]
hst1 --> life1["用 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) |
控制台 / worker 等新的非 Web 应用 | 直接写在 builder.Services / builder.Configuration / builder.Logging 上 |
新项目首选 |
Host.CreateDefaultBuilder(args) |
既有代码或以旧版扩展方法为主的结构 | 以链式调用 ConfigureServices 等方法 |
有既有资产时选用 |
WebApplication.CreateBuilder(args) |
ASP.NET Core Web 应用 / API | 在 Generic Host 之上补充 Web 场景所需的入口 | Web 应用首选 |
CreateApplicationBuilder 和 CreateDefaultBuilder,
并不是一方是新功能、另一方是完全不同的东西这样的关系。
两者拥有相同的核心功能和默认行为。 不同的地方主要在于写法风格。
对于新的非 Web 应用,现在直接从 Host.CreateApplicationBuilder(args) 入手会更自然。
可以把 WebApplication.CreateBuilder(args) 理解为把这条思路扩展到 Web 场景的入口。
flowchart TB
accTitle: 三个入口之间的关系
accDescr: 说明 CreateApplicationBuilder 和 CreateDefaultBuilder 拥有相同的核心功能与默认行为,只是写法风格不同,而 WebApplication.CreateBuilder 是把这条思路扩展到 Web 场景的入口。
appb1["CreateApplicationBuilder"] --> core1["相同的核心功能与默认行为"]
defb1["CreateDefaultBuilder"] --> core1
appb1 -.-> sty1["直接书写的风格"]
defb1 -.-> sty2["链式调用的风格"]
core1 -.->|"扩展到 Web 场景的入口"| webb1["WebApplication.CreateBuilder"]
图3:两个 builder 建立在相同的核心功能与默认行为之上,只是写法风格不同,而 Web 场景另有一个由此扩展出来的入口。
2.3. 为什么入口会有多个
入口之所以有多个,是因为 Web 一侧和非 Web 一侧各自发展了一段时间之后才合流,有这样一段来历。
- 最初 ASP.NET Core 有 Web 专用的 Web Host(
IWebHostBuilder),面向非 Web 应用的 Generic Host(IHostBuilder)是另外准备的。 - 之后 ASP.NET Core 一侧向 Generic Host 靠拢,Web 和非 Web 都建立在同一套 host 理念之上。
- 再往后,除了把回调串接起来的写法(如
ConfigureServices),又增加了直接写在属性上的写法(如builder.Services)的入口。Host.CreateApplicationBuilder和WebApplication.CreateBuilder属于后者。
在目前的官方文档中,Host.CreateApplicationBuilder 一系(IHostApplicationBuilder)被归纳为 面向新项目、也是现行模板的默认选择,Host.CreateDefaultBuilder 一系(IHostBuilder)则被归纳为 为兼容既有代码而保留下来的传统做法。文档中也明确写着,两者拥有相同的核心功能与默认行为。
从 .NET Framework 或 .NET Core 3.1 时代的代码过来的人,会觉得“为什么写法会有两种”,但如果理解为 并不是新旧两种不同的东西并列存在,而是在合流的过程中入口变多了,就比较容易接受。只要没有必须贴合既有资产的理由,新项目用 Host.CreateApplicationBuilder 就可以。
flowchart TB
accTitle: 入口有多个的来历
accDescr: 说明 Web 专用的 Web Host 与面向非 Web 的 Generic Host 原本各自存在,ASP.NET Core 一侧向 Generic Host 靠拢后合流,之后又增加了直接写在属性上的入口这一段来历。
wh1["Web Host(IWebHostBuilder)"] --> mg1["ASP.NET Core 向 Generic Host 合流"]
gh1["Generic Host(IHostBuilder)"] --> mg1
mg1 --> ad1["新增直接写在属性上的入口"]
ad1 -.-> ex1["CreateApplicationBuilder 与 WebApplication.CreateBuilder"]
图4:各自发展起来的 Web Host 与 Generic Host 合流,在这个过程中又多出了直接书写风格的入口。
3. Generic Host 的整体概览(图)
大致画一张整体概览图,就是下面这样。
flowchart LR
Args["args / 环境变量 / appsettings.json"] --> Builder["Host.CreateApplicationBuilder(args)"]
Builder --> Config["builder.Configuration"]
Builder --> Services["builder.Services"]
Builder --> Logging["builder.Logging"]
Services --> Hosted["IHostedService / BackgroundService"]
Builder --> Build["builder.Build()"]
Build --> Host["IHost"]
Host --> Run["Run / RunAsync"]
Run --> Lifetime["启动・停止・Ctrl+C・SIGTERM"]
Lifetime --> Hosted
图5:向 builder 注册配置、服务、日志,再把 Build() 得到的 IHost 用 Run/RunAsync 跑起来,lifetime 就一路连到 hosted service 的启动与停止。
通常会在 Program.cs 中创建 builder,
向 builder.Services 中添加服务,
根据需要调整 builder.Configuration 或 builder.Logging,
最后调用 Build() 得到 IHost,再用 Run() / RunAsync() 让它运行起来。
一个不太起眼但影响很大的地方是,在 Host.CreateApplicationBuilder(args) 这一步,其实已经加载了相当多的内容。
默认情况下,例如会包含以下这些:
- 内容根目录是当前目录
- host 配置来自带
DOTNET_前缀的环境变量和命令行参数 - 应用配置来自
appsettings.json、appsettings.{Environment}.json、Development 环境下的 user secrets、环境变量、命令行参数 - 日志输出到 Console / Debug / EventSource / EventLog(仅 Windows)
- 在
Development环境下会进行 scope 验证和依赖关系验证
也就是说,并不是从零开始手动搭建所有配线, 而是一开始就放置好了一个“日常使用基本足够”的基础。
flowchart TB
accTitle: 一开始就已经加载好的默认项
accDescr: 说明在 Host.CreateApplicationBuilder 这一步,host 配置、应用配置、默认日志等就已经加载好,日常使用基本足够的基础从一开始就放在那里。
ent1["CreateApplicationBuilder(args)"] --> dz1["host 配置(DOTNET_ 系与命令行参数)"]
ent1 --> dz2["应用配置(appsettings.json 等)"]
ent1 --> dz3["默认日志(Console 等)"]
dz1 --> rdy1["日常使用基本足够的基础"]
dz2 --> rdy1
dz3 --> rdy1
图6:在创建 builder 的那一刻,配置与日志的默认项就已经加载好,并不是从零开始配线。
4. Generic Host 好在哪里
4.1. 可以把启动处理集中到一处
Generic Host 最不起眼却影响最大的好处,就是应用的入口不容易变得分散。
随着应用的发展,Main 周围通常会逐渐增加以下内容。
- 读取配置文件
- 按环境替换配置
- 初始化 logger
- 组装
HttpClient、repository、service - 启动后台处理
- 收到结束信号时的收尾工作
如果不用 host,把这些全部手动连接起来, 一开始可能还比较轻松,但入口会慢慢变得越来越难缠。
使用 Generic Host 之后,Program.cs 就能清晰地成为“统一组装依赖关系的地方”。
仅凭这一点整理,代码评审的难易度就会有很大改善。
flowchart TB
accTitle: 启动处理集中到一处
accDescr: 说明不用 host 而全部手动连接时入口会越来越难缠,而使用 Generic Host 后 Program.cs 会清晰地成为统一组装依赖关系的地方。
no1["不用 host,全部手动连接"] --> st1["入口后来越来越难缠"]
yes1["使用 Generic Host"] --> pg1["Program.cs 成为组装的地方"]
pg1 -.-> rv1["代码评审更容易"]
图7:手动配线会让入口越来越难缠,而集中到 host 上之后,Program.cs 就明确成为组装的地方。
4.2. DI / 配置 / 日志从一开始就连在一起
使用 Generic Host 之后,DI、配置、日志从一开始就建立在同一个基础之上。
例如在类的一侧,可以很自然地接收下面这些:
ILogger<T>IConfigurationIHostEnvironmentIOptions<T>
这里的好处在于,读取配置的方式和创建服务的方式不容易变成两套不同的风格。
如果只有一两个配置项,直接读取 IConfiguration["Section:Key"] 也能正常运行。
不过,在实际项目中配置项一旦增多,用 IOptions<T> 按 section 绑定到类上会更稳妥。大致的界线是配置键字符串超过 5 个。到了这个规模,打错字会变成运行时才发现的故障,而且哪个键在哪里被读取也变得难以追踪。
同样,日志也不必到处手动创建 ILoggerFactory,
而是向需要的类注入 ILogger<T>,这样更清晰。
Generic Host 的好处在于,它不会把这些当作各自独立的话题, 而是作为整个应用的基础统一处理。
flowchart TB
accTitle: 配置读取方式的演进
accDescr: 说明配置只有一两个时直接读取 IConfiguration 也能跑,而当配置键字符串超过 5 个时,用 IOptions 按 section 绑定到类上会更稳妥。
few1["配置有 1~2 个"] --> dr1["直接读取 IConfiguration"]
many1["配置键字符串超过 5 个"] --> op1["用 IOptions 绑定到类上"]
many1 -.-> rk1["打错字要到运行时才发现"]
图8:配置还少的时候直接读取就够,一旦键超过 5 个,用 IOptions 绑定起来会更稳妥。
4.3. 便于处理正常退出与常驻运行
Generic Host 不仅负责“如何启动”,也负责“如何停止”。
host 启动后,注册的各个 IHostedService 的 StartAsync 就会被调用。
在 worker 服务中,包括 BackgroundService 在内的 hosted service 的 ExecuteAsync 就会开始运行。
这里所说的“正常退出”,不是突然切断处理, 而是按照下面的顺序结束:
- 发出停止信号
- 跳出循环或等待
- 整理连接与资源
在长时间运行的应用中,这一点相当重要。
面对 Ctrl+C、SIGTERM、服务停止之类的事件,可以统一整个应用的收尾方式。
另外,如果想从应用一侧主动请求退出,可以使用 IHostApplicationLifetime.StopApplication()。
“工作已经完成,希望干净地退出”这样的信号,可以在 host 的语境下发出。
flowchart TB
accTitle: 正常退出的顺序
accDescr: 说明在收到 Ctrl+C、SIGTERM 或服务停止事件后,按照发出停止信号、跳出循环或等待、整理连接与资源的顺序结束。
sg1["Ctrl+C / SIGTERM / 服务停止"] --> p1["发出停止信号"]
p1 --> p2["跳出循环或等待"]
p2 --> p3["整理连接与资源"]
ap1["应用一侧发起的退出请求"] -.->|"StopApplication()"| p1
图9:正常退出会依次经过发出停止信号、跳出循环、收尾整理,应用一侧则可以用 StopApplication() 向同一条流程发出信号。
5. 最小配置
5.1. 在控制台应用中使用的最小示例
首先重要的是,使用 Generic Host 并不意味着一定要创建 BackgroundService。
即使是只运行一次的控制台工具, 如果想要 DI、配置、日志,Generic Host 也完全可以使用。
如果要在普通的控制台项目中后续加入,首先需要引用 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 的值来自下一节 5.2 中放置的 appsettings.json)。
info: JobRunner[0]
Environment: Production
info: JobRunner[0]
Message: hello from Generic Host
info: 右边是日志类别(这里是 ILogger<JobRunner>,所以显示的是类型名)和事件 ID。默认的控制台日志记录器就是按照“第一行是类别,第二行是正文”这种形式输出的。Environment 之所以是 Production,是因为在既没有设置环境变量 DOTNET_ENVIRONMENT 也没有设置 ASPNETCORE_ENVIRONMENT 时,这就是默认值。开发时如果要切换,设置 DOTNET_ENVIRONMENT=Development 再运行即可。
如果不需要长期常驻,也不必走到 RunAsync() 这一步。
Build() 之后解析所需的服务,完成工作就直接结束。
这样同样能充分享受到 Generic Host 的好处。
这里其实相当重要。 不需要给每一个短命的任务都套上 Worker 模板。
flowchart TB
accTitle: 短命任务中的用法
accDescr: 说明如果不需要长期常驻,可以不走到 RunAsync 那一步,而是 Build 之后解析所需的服务,完成工作就直接结束,同样能享受到 Generic Host 的好处。
st2["执行 Build()"] --> rs1["解析所需的服务"]
rs1 --> jb1["完成工作"]
jb1 --> fin1["直接结束"]
fin1 -.-> nt1["不必走到 RunAsync()"]
图10:短命任务只要 Build() 之后解析服务并执行完就结束,也一样能享受到 host 的好处。
5.2. appsettings.json
对于上面的示例,配置文件用下面这种最小形式就足够了。
{
"Sample": {
"Message": "hello from Generic Host"
}
}
这里有一个常见的坑。在控制台项目中,仅仅把 appsettings.json 添加进来,它 并不会被复制到输出文件夹。可以在项目属性中把“复制到输出目录”设为“如果较新则复制”,或者在 csproj 中写入下面的内容。
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
事先知道忘记设置时会出现什么症状,排查起来会更快。Generic Host 把 appsettings.json 当作 可选文件 来读取,所以即使文件不存在也不会抛出异常,只是取不到值而已。在上面的最小示例中,会显示 Message: (no message)。当遇到“没有报错,配置却不生效”的情况时,请先确认输出文件夹中是否存在 appsettings.json。
flowchart TB
accTitle: appsettings.json 缺失时的症状
accDescr: 说明即使 appsettings.json 没有被复制到输出文件夹,由于它是作为可选文件读取的,所以不会抛出异常,只是取不到值这一症状的流程。
ms1["输出文件夹中没有该文件"] --> rd1["作为可选文件读取"]
rd1 --> ne1["不会抛出异常"]
ne1 --> nv1["只是取不到值"]
nv1 -.-> ck1["先确认输出文件夹"]
图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;
}
}
这个示例中要关注两点。
BackgroundService的主体是ExecuteAsync- 如果需要 scoped 依赖关系,用
IServiceScopeFactory来创建 scope
BackgroundService 本身没有默认的 scope。
比如想使用像 DbContext 这样的 scoped 服务时,
像上面这样在 scope 内解析任务一侧的服务会更安全。
flowchart TB
accTitle: 在 BackgroundService 中使用 scoped 的写法
accDescr: 说明由于 BackgroundService 本身没有默认的 scope,注入 IServiceScopeFactory 并在 ExecuteAsync 中创建 scope、再在其中解析任务一侧服务的写法更安全。
bg1["BackgroundService(没有默认 scope)"] --> fc1["注入 IServiceScopeFactory"]
fc1 --> mk1["在 ExecuteAsync 中创建 scope"]
mk1 --> rv2["在 scope 中解析 job"]
图12:在没有默认 scope 的 BackgroundService 中,用 IServiceScopeFactory 创建 scope,并在其中解析 job。
另外,定期执行本身工具的选择是另一个话题,
如果以 async 为基础编写,PeriodicTimer 会相当稳妥。
这一点也与相关文章中的定时器话题相连。
6. 典型模式
6.1. 短命的控制台工具
像批处理、转换工具、维护命令这类只执行一次工作就结束的应用, 同样可以正常使用 Generic Host。
适合的场景包括:
- 想读取配置文件
- 想输出日志
- 想注入
HttpClient或 repository - 想返回结束代码
对于这类应用,如果一开始就引入 BackgroundService 和 RunAsync(),
会显得有些沉重,也会过度使用 host 的生命周期管理。
对于短命的任务,像前面的最小示例那样解析并执行 JobRunner 就足够了。
flowchart TB
accTitle: 短命控制台工具的选择方式
accDescr: 说明对于只执行一次工作就结束的应用,引入 BackgroundService 和 RunAsync 会过度使用生命周期管理,只要解析并执行服务就足够了。
on1["只执行一次工作就结束的应用"] -->|"足够"| lg1["解析并执行 JobRunner"]
on1 -.->|"容易显得过度"| hv1["BackgroundService 与 RunAsync()"]
图13:对只跑一次的应用引入 BackgroundService 属于过度设计,解析并执行服务就够了。
6.2. worker / 后台服务
对于常驻 worker、轮询、队列消费、监控、定期执行这类处理,
Generic Host 与 BackgroundService 的组合会相当自然。
尤其令人满意的地方包括:
- 启动与停止的流程在 host 一侧统一
- 日志、配置、DI 从一开始就可以使用
- 便于通过
Ctrl+C或停止信号传递取消操作 - 便于把常驻处理的主体从
Program.cs中分离出来
而且,它也很容易与 Windows Service 或容器的语境相连接。 如果想把应用发展成常驻应用,Generic Host 是相当自然的基础。
在做成 Windows Service 的场景中,与依赖当前目录去查找文件相比,
以 IHostEnvironment.ContentRootPath 为起点来考虑会更不容易出问题。
因为“应用的基准路径”是在 host 的语境下确定的。
flowchart TB
accTitle: 常驻 worker 的基础
accDescr: 说明常驻 worker 与定期执行用 Generic Host 加 BackgroundService 的组合最自然,也容易与 Windows Service 或容器的语境相连接。
wk1["常驻 worker / 定期执行"] --> cb1["Generic Host 与 BackgroundService"]
cb1 --> ws1["Windows Service"]
cb1 --> ct1["容器常驻"]
ws1 -.-> cr1["以 ContentRootPath 为起点查找"]
图14:常驻处理用 host 加 BackgroundService 的组合最自然,也容易向 Windows Service 或容器发展。
6.3. 也存在于 ASP.NET Core 之下
在 Web 应用 / API 中会使用 WebApplication.CreateBuilder(args),
乍一看可能会觉得它和 Generic Host 是完全不同的世界。
但从感觉上来说,两者其实是相通的。
builder.Servicesbuilder.Configurationbuilder.Logging
写法风格相似,正是因为这个原因。
在 ASP.NET Core 中,HTTP 服务器的启动也纳入了 host 的 lifetime 之中。
也就是说,当你阅读 Web 一侧的 Program.cs,理解“为什么在这里操作 DI、配置、日志”时,理解 Generic Host 也同样能发挥作用。
flowchart TB
accTitle: Web 也建立在同一个 host 之上
accDescr: 说明 ASP.NET Core 应用通过 WebApplication.CreateBuilder 同样建立在相同的 host 理念之上,HTTP 服务器的启动也纳入了 host 的 lifetime 之中。
wa1["ASP.NET Core 应用"] --> wb2["WebApplication.CreateBuilder"]
wb2 --> gh2["建立在同一套 host 理念之上"]
gh2 -.-> ht1["HTTP 服务器的启动也在 lifetime 之中"]
图15:Web 一侧的 builder 也建立在同一套 host 理念之上,HTTP 服务器的启动同样包含在 lifetime 中。
7. 适合的场景
列举一下 Generic Host 容易派上用场的场景。
- 使用配置、日志、DI 的控制台应用
- queue consumer、poller、watchdog、scheduler 这类 worker
- 想在
Ctrl+C或 SIGTERM 时进行收尾的长时间运行应用 - 未来有可能发展成 Windows Service / 容器常驻的应用
- 想与 ASP.NET Core 保持同一套扩展方法风格的应用
共同点在于, “不想草率对待应用的入口和生命周期管理”。
话虽如此,只有这些还不太好划线,所以再给出一组判断标准。如果下面这些当中有两条以上符合,那么一开始就把应用放到 Generic Host 上,之后往往会轻松很多。
flowchart TB
accTitle: 采用与否的判断标准
accDescr: 说明如果判断标准表中有两条以上符合就一开始把应用放到 Generic Host 上,如果一条都不符合则可以认为不需要它的判断流程。
qn1["数一数符合判断标准的条数"] -->|"两条以上"| ok1["一开始就放到 Generic Host 上"]
qn1 -->|"一条都没有"| ng1["可以认为不需要 Generic Host"]
图16:判断标准符合两条以上就一开始放上去,一条都不符合就不必引入。
| 判断标准 | 具体的界线 |
|---|---|
| 配置的数量 | 随环境变化的配置有 3 个以上(连接目标、阈值、输出目标等) |
| 日志 | 需要留存到文件或 Event Log。不是写到标准输出就完事 |
| 运行形态 | 常驻运行。或者每天至少一次,按固定间隔运行 |
| 依赖关系 | 想通过构造函数接收的对象有 3 个以上。有想在测试中替换掉的对象 |
| 生命周期 | 在 Ctrl+C 或服务停止时,需要做中途的收尾工作 |
| 将来 | 有可能在 Windows Service 或容器中运行 |
8. 不适合 / 过度使用的场景
反过来,也有一些场景不需要一开始就以 Generic Host 为主角。
- 只读取一次参数、输出一次结果就结束的小工具
- 只使用数十分钟的粗略验证代码
- 类库项目
- 只读取一个配置项,用不到 DI、日志、生命周期管理的场景
在这些场景中,比起搭建 host,直接写在 Main 里,需要读的代码量和文件数都更少。作为判断标准,如果第 7 章的表中一条都不符合,那么完全可以认为不需要 Generic Host。
重要的是, Generic Host 虽然强大,但并非所有可执行文件都必须使用它。
9. 容易踩坑的地方
最后,整理一下初次使用 Generic Host 时容易踩的坑。
- 只把 Generic Host 看作 DI 容器
- 实际上,它是包含启动、停止、配置、日志、hosted service 的基础设施。
- 明明是新应用,却出于惯性从
Host.CreateDefaultBuilder开始- 如果没有需要贴合既有代码的理由,先从
Host.CreateApplicationBuilder开始会更自然。
- 如果没有需要贴合既有代码的理由,先从
- 在
BackgroundService中直接放入 scoped 服务- hosted service 没有默认的 scope。用
IServiceScopeFactory创建 scope 会更安全。
- hosted service 没有默认的 scope。用
- 只执行一次就结束的 worker,却没有把停止信号告知 host
- 如果用 Worker 模板实现“run once”,在工作完成时不调用
IHostApplicationLifetime.StopApplication(),host 就会一直运行下去。
- 如果用 Worker 模板实现“run once”,在工作完成时不调用
- 想正常退出,却用
Environment.Exit直接切断- 既然使用了 host,想干净退出时,用
StopApplication()会更合理。
- 既然使用了 host,想干净退出时,用
- 在 Windows Service 中以当前目录为前提
- 以
IHostEnvironment.ContentRootPath为起点来查找文件会更稳定。
- 以
- 明明是短命的 CLI,却一开始就用
BackgroundService包起来- 如果只是一次性的工作,解析并执行一个普通的服务类就足够了。
BackgroundService的定期执行中随意使用 callback 定时器- 如果以
async的方式编写,用PeriodicTimer往往更易读、更不容易出乱子。
- 如果以
在 Generic Host 中, 先区分“这是短命任务,还是常驻任务”, 仅凭这一点就能减少很多迷茫。
flowchart TB
accTitle: 最先要分清的问题
accDescr: 说明先分清是短命任务还是常驻任务,短命的只要解析并执行普通服务类,常驻的则使用 BackgroundService 与 host 的 lifetime 管理。
qq1["是短命任务,还是常驻任务"] -->|"短命"| sj1["只要解析并执行服务"]
qq1 -->|"常驻"| lj1["BackgroundService 与 lifetime 管理"]
图17:只要先分清是短命还是常驻,就不容易在“该用到 host 的哪些工具”上犹豫。
10. 总结
用一句话概括 Generic Host, 就是统一管理 .NET 应用入口与生命周期的基础设施。
回顾一下需要关注的要点。
- Generic Host 不仅包含 DI,还包含配置、日志、停止处理、hosted service
- 对于新的非 Web 应用,首先从
Host.CreateApplicationBuilder(args)入手会更自然 - 对于短命的任务,不使用
BackgroundService,只是 build 之后执行也可以 - 对于常驻处理,
BackgroundService与 host 的 lifetime 管理会相当有效 - 由于
BackgroundService没有默认的 scope,scoped 服务需要显式创建 scope - ASP.NET Core 的
WebApplicationBuilder,从思路上来说也是站在同一条脉络之上
Generic Host 并不是为了某种沉重仪式而存在的工具。 当配置、日志、依赖关系、启动、退出稍有增多时, 它就是一个能把这些东西集中到入口、而不是任其散落在各处的工具。
反过来,对于还不需要这些的小工具,也不必硬套上去。 一旦能做到这种判断,Generic Host 就不再是“不明所以就加进去的东西”, 而会成为一个用途明确的实务基础设施。
11. 参考资料
- .NET Generic Host - .NET
- Worker Services in .NET
- Use scoped services within a BackgroundService - .NET
- Configuration in .NET
- Options pattern in .NET
- .NET Generic Host in ASP.NET Core
- Create Windows Service using BackgroundService - .NET
- 相关文章:.NET 三种定时器的使用区分 - PeriodicTimer/Timer/DispatcherTimer
- 相关文章:C# async/await 实务判断表 - Task.Run 与 ConfigureAwait
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
多线程实战最佳实践 .NET 篇——增加线程之前必须先定好的事
面向 .NET/C# 梳理防止“线程一开就偶尔崩溃、偶尔卡死”的设计做法,涵盖不自己创建线程而依托 Task、减少共享可变状态、加锁的纪律、用 CancellationToken 设计停止流程,直到 UI 线程的处理方式。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
Windows 的进程间通信该怎么选 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
整理 Windows 应用程序之间应该如何选择通信方式。用判断表整理命名管道、本地 TCP、gRPC、共享内存、文件对接、COM 各自的优势与陷阱,并从实务角度说明 UI+服务分离・32bit/64bit 桥接・权限边界等常见架构,以及命名管道的实现示例。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
Generic Host & 应用程序架构
整理 Generic Host、BackgroundService、DI、配置、日志以及应用程序生命周期设计的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
本文讲的是组装一个包含常驻处理、退出处理、日志、配置的 Windows 应用,所以作为实现类项目,它与 Windows 应用开发的契合度很高。
技术咨询 & 设计评审
如果正处在实现之前想先梳理 DI、生命周期、职责划分的阶段,可以作为技术咨询与设计评审,从方针梳理开始着手。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 的好处。反过来,对于只读取一次参数、输出一次结果就结束的小工具或粗略的验证代码来说,这就显得有些多余,不必每次都硬套上去。
- 在 BackgroundService 中如何使用 scoped 服务?
- 由于 BackgroundService 没有默认的 scope,直接把 scoped 服务注入到构造函数中并不安全。更安全的做法是注入 IServiceScopeFactory,在 ExecuteAsync 中显式创建 scope,再在其中解析任务一侧的服务。如果想使用像 DbContext 这样的 scoped 服务,尤其需要注意这种写法。