当大伙伴们看到标题后,是不是在怀疑老周在灌水?非也,老周向来只会造水,从来不灌水。哪怕写的主题和别人一样,但水的内容也肯定与他人不同。这是老周特色,熟悉老周的大伙伴都知道的。
哦,顺便提一下,有人投诉老周写的东西没用,根本不能在项目中使用。项目中用什么,怎么用是你的项目经验和实力问题,老周可不负这个责。老周只传递知识和一些可能对你有用的技巧,如果你想着坐享其成,生吞他人劳动成果的话,请到别处去。
也有人说老周写的代码太"不正经"了,老周问你:什么样的代码才算"正经"?你不用管老周写的什么代码,你看懂了,学到一点点知识就行了,这个才是最重要的。老周瞎编的代码,一方面是防搜索引擎,另一方面是防 AI 侵权。现在想不劳而获的人太多了,不防不行。请记住:任何措施仅限于防小人,而非防君子。
好,说回正题,如何把 APS.net core 框在 Windows Forms 或 WPF 中,网上资料很多了,老周你还扯啥。不,老周还真要扯一下的。最近,老周接了一个很"简单"的项目,就是车间大约有 45 台压条机,姑且这么叫吧,也不知道机器的真名叫什么,反正工人是这么叫的。它的作用是把铁皮压直了,一根根像躺尸似的直挺挺的,对,就是把那一条条的不知名金属弄直了。现在老板太想进步了,请人改造了机器,加了一块 ESP32 C3 模组,机器每拉直一条不知名金属,就会通过 Wifi 上报结果(HTTP POST)。他们请另外的团队做了固件,但问题,它能上传了,没人接收。所以,老周的工作就是搞一个 HTTP 服务器公开 Web API 让 Esp32 调用,接收到数据后,把结果保存到数据库。然后车间的大屏幕上要实时展示,就像股票交易所大厅那样。
由于工厂里面的人可能不会搞那么复杂的配置,最好就是搞个 exe,他们双击运行就完事了。于是自然要把 ASP.NET Core 弄到 WinForms 程序里。为了省事,基础代码打算让打工皇帝 AI 去干,结果它给的代码是寒武纪时期的。然后网上搜搜,也确实很多代码都是寒武纪时期的。现代版的只有微软自己文档有提到。
今天老周干脆分享一下,让新手伙伴们不迷路。接下来老周用最简单的方式讲讲理论部分。
第一,先认准哪些 API 是你要的。ASP.NET Core 其实是用 .NET 的 Host 来启动的,所以,先记住 Microsoft.Extensions.Hosting 这个包。
第二,在同名的命名空间下,你会看到这样的让人迷惑的场景。

那么多 Builder,我用哪个,一句话斯基:凡看到 HostBuilder 就忽略,那是寒武纪时代的遗物,现代版要认准 HostApplicationBuilder 类(基础接口 IHostApplicationBuilder)。
先别急,接下来老周给看看这些玩意儿是怎么玩的。现在,各位不想 ASP.NET Core 的事,暂时把它忘了。
刚刚我们认识了一个类叫 HostApplicationBuilder,它的构造函数:
// 无参版本
public HostApplicationBuilder();
// 需要命令行参数版本
public HostApplicationBuilder(string[]? args);
// 源头版本
public HostApplicationBuilder(HostApplicationBuilderSettings? settings);
说到底,就是需要一个 HostApplicationBuilderSettings 对象,它的作用是配置应用程序的基本信息。其中,ApplicationName 和 Args 属性用得最多。HostApplicationBuilder(string\[\] args) 构造函数就是设置了 Args 属性。
public HostApplicationBuilder(string[]? args)
: this(new HostApplicationBuilderSettings { Args = args })
{
}
于是,动手能力强的你,就会......
// 记得添加 Microsoft.Extensions.Hosting 包的引用
static void Main(string[] args)
{
HostApplicationBuilder appBuilder = new(args);
var myHost = appBuilder.Build();
myHost.Run();
}
这个逻辑对吗?那就得运行下,如果能看到如下输出,说明是对的。

这个能干啥?答:啥也不能干。有.NET 基础的大伙伴一定想到,得加几个东西到服务容器里才行。
public class MyService
{
public void Boom()
{
Console.WriteLine("*** 自爆程序已启动 ***");
}
}
static void Main(string[] args)
{
HostApplicationBuilder appBuilder = new(args);
appBuilder.Services.AddTransient<MyService>();
var myHost = appBuilder.Build();
myHost.Run();
}
运行之后,咦,没反应。那当然了,服务你是加了,可,谁调用?不管你添加的服务有多少个,它们之间什么依赖关系。但万事总有个头啊,起码有一个要先被调用吧。那么......
static void Main(string[] args)
{
HostApplicationBuilder appBuilder = new(args);
appBuilder.Services.AddTransient<MyService>();
var myHost = appBuilder.Build();
IHostApplicationLifetime lifeTime= myHost.Services.GetRequiredService<IHostApplicationLifetime>();
lifeTime.ApplicationStarted.Register(() =>
{
Console.WriteLine("===== 应用程序启动 ====");
MyService svc = myHost.Services.GetRequiredService<MyService>();
svc.Boom();
});
lifeTime.ApplicationStopping.Register(() =>
{
Console.WriteLine("==== 应用程序即将停止 ====");
});
myHost.Run();
}
是个大聪明,获取 IHostApplicationLifetime 服务,毕竟这个服务是框架自动添加的,当 Host 启动后,会触发 ApplicationStarted,而 ApplicationStopping 是在 Host 即将完蛋时触发,而且,它会一直 block,直到注册的委托执行完成。所以,ApplicationStopping 注册的委托中,你的代码不要执行太久,否则会耽误应用程序去投胎的时间。
ApplicationStarted、ApplicationStopping、ApplicationStopped 虽然定义的是属性成员,其实和事件一样。虽然它是 CancellationToken 类型,但这个结构体可以用 Register 方法注册一个委托,当发生取消时调用这个委托。你看看,这不跟事件一个德性吗。所以,咱们上面的代码就是在应用启动后调用 MyService 服务,那不就可以运行了吗。

现在应用程序阻止了主线程,直到程序结束。怎么结束?日志上不是写了吗,按 Ctrl + C。

这样做虽然能调用服务,但一来不好控制,二来万一你那个服务不行,一执行就挂了,或者在那里死循环,就会让后面的程序无法执行了。所以,这个方案还是不佳。
于是,有一个神秘的服务浮出水面------ IHostedService。这个服务接口只有两个方法。
A、StartAsync;
B、StopAsync。
看名字就知道,开始和结束呗。咱们实现一下。
public class MyHostedService : IHostedService
{
MyService src;
public MyHostedService(MyService s) => src = s;
public Task StartAsync(CancellationToken cancellationToken)
{
Console.WriteLine("==== 服务已启动 ====");
src.Boom();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Console.WriteLine("==== 服务停止 ====");
return Task.CompletedTask;
}
}
MyService 服务只要通过构造函数参数就能获得,这就是依赖注入。
现在,我们不需要 IHostApplicationLifetime 服务了,直接把 MyHostedService 放进服务容器就行了。
static void Main(string[] args)
{
HostApplicationBuilder appBuilder = new(args);
appBuilder.Services.AddTransient<MyService>();
appBuilder.Services.AddSingleton\<IHostedService, MyHostedService\>(); var myHost = appBuilder.Build();
myHost.Run();
}
hosted 的服务最好生命周期为单实例,毕竟它不需要多次实例化,很多时候它的生命周期贯穿整应用程序。
代码运行结果如下:

实现了 IHostedService 接口的服务到底神在哪?神就神在框架会自动调用它。
因为这种服务实现的接口必然是 IHostedService,为了方便大家调用,.NET 还提供了 AddHostedService 方法。这个方法添加以 IHostedService 为基础接口的服务类型,且注册为单实例。和我们上面的写法功能一样,只是方便。
static void Main(string[] args)
{
HostApplicationBuilder appBuilder = new(args);
appBuilder.Services.AddTransient<MyService>();
appBuilder.Services.AddHostedService<MyHostedService>();
var myHost = appBuilder.Build();
myHost.Run();
}
现在你应该知道,ASP.NET Core 的内置服务器能自动运行的原因了吧,就是实现了 IHostedService 接口。
如果,咱们继续深挖,会发生什么呢?
在 VS 中打开项目属性窗口,按下面所示修改属性。


项目文件会变成这样。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
\<UseWindowsForms\>True\</UseWindowsForms\>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
</ItemGroup>
</Project>
是不是很熟悉?这不就是 WinForms 项目吗。
public class MyMainForm : Form
{
private Label someText;
public MyMainForm()
{
this.SuspendLayout(); // 控件多的时候,可以减少闪烁
Text = "主窗口";
Size = new Size(600, 480);
someText = new Label()
{
Text = "这是一个简单得要死的程序",
Font = new Font("微软雅黑", 12.0f),
AutoSize = true
};
someText.Location = new Point(35, 40);
this.Controls.Add(someText);
ResumeLayout(); // 控件初始化完成,统一刷新
}
}
\[STAThread\] static async Task Main(string[] args)
{
HostApplicationBuilder appBuilder = new(args);
var myHost = appBuilder.Build();
await myHost.StartAsync();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// 系统 DPI 缩放优先
Application.SetHighDpiMode(HighDpiMode.SystemAware);
Application.Run(newMyMainForm());
// 主窗口关闭后终止 Host
await myHost.StopAsync();
}
1、Main 方法上记得加上 STAThread;
2、由于 Application.Run 会在主窗口上进行消息循环处理,它会阻塞当前线程。所以,要先启动 Application Host,再运行 Run 方法。等 Run 方法退出后,再 Stop Host。
3、其他窗口可以放服务容器中,让它们也可以依赖注入。不过,主窗口如果需要处理消息循环,还是不要放容器中。
好了,折腾到这里,相信换成 ASP.NET Core,你也知道怎么弄了。
我们新建一个 ASP.NET Core 项目(什么类型都可以,MVC、Blazor 或 空白 都行)。
然后,打开项目属性窗口。这回知道怎么配置了吧,和前文说的一样。


是否把 WinForms 和 WPF 都开启那看实际情况了,如果你两个都用上就一起开,如果你只用一个就开一个。
项目文件中的 SDK 名称不要改,保留 Microsoft.NET.Sdk.Web。网上有些资料是把这个改为 .NET.Sdk 了,这样就没有了 ASP.NET Core 的 API 子集了,然后又要手动引用 Nuget 包。那是错误的!开启 Windows 桌面支持与 SDK 子集是无关的,因为这个功能只针对 Windows,不跨平台。它只作为 API 的扩展集处理。所以,.NET.Sdk.Web 对使用桌面 API 是没有影响的。
然后 Main 方法改成以下代码就好了。
STAThread
public static async Task Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
//app.Run();
awaitapp.StartAsync();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.SetHighDpiMode(HighDpiMode.SystemAware);
Application.Run(new MainForm());
awaitapp.StopAsync();
}
由于现在是 WinForms 来控制应用生命周期,不再调用 app.Run 方法。Run 方法和 WinForms 里面的 Application.Run 方法都会导致当前线程被阻塞,因此,必须让其中一个不阻塞。WinForms Application 类是不可能的了,Run 必须在所有窗口都结束后才能返回(或消息队列结束),那只能让 WebApplication 不阻塞了,StartAsync 和 StopAsync 都是可以异步等待的。StartAsync 方法调用后,遇到 Application.Run 方法,由于它一直阻塞,使得 StopAsync 方法不会马上调用,所以 Web 服务器也不会启动后就立马挂掉。等到所有窗口都被关闭了,Run 方法返回,StopAsync 方法就会调用,Web 服务器也停止,然后程序退出。
当你按下【F5】运行后,你会看到,控制台窗口还跑出来。这个不用担心,这是调用试器搞的鬼。你直接运行程序 exe 是不会出现控制台窗口的。如果没有去配置,那么脱离调试运行,默认的端口是 5000。
上面的做法是 Web 服务器随着应用程序启动的,但有些时候,客户会觉得要手动启动。这时候可以将 Web 的初始化搬到窗口代码里。Main 方法只保留 WinForms 或 WPF 的初始化代码。
[STAThread]
public static async Task Main(string[] args)
{
......
Application.Run(new MainForm());
}
我们在主窗口类中搞个 InitWebApp 方法。
private void InitWebApp()
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
// 添加所需服务
builder.Services.AddRouting();
// 生成Web应用程序
webApp =builder.Build();
// 配置终结点
webApp.MapGet("/", () => "你好,宇宙!");
}
webApp 是在窗口类中声明的私有字段。
WebApplication? webApp;
WebApplicationBuilder 的作用只是构建内部服务,Build 了之后就没用了,所以它不用声明到类的成员级别,声明为局部变量就行了,用完就扔,只留着 WebApplication 实例在内存中就好。
然后,窗口上你可以放两个按钮。一个用来启动,一个停止。

分别处理两个按钮的 Click 事件。
private async void btnStart_Click(object sender, EventArgs e)
{
(sender as Button)?.Enabled = false;
InitWebApp(); if (webApp != null)
{
awaitwebApp.StartAsync();
lbMsg.Text = "Web 服务器正在服徭役";
lbMsg.Text += "\n地址:" + webApp?.Urls.First();
}
(sender as Button)?.Enabled = true;
}
private async void btnStop_Click(object sender, EventArgs e)
{
if(webApp != null)
{
await webApp.StopAsync();
await webApp.DisposeAsync();
webApp = null;
lbMsg.Text = "Web 服务器已投胎";
}
}
有大伙伴们会说,老周你真笨,为什么每次启动都要初始化 Web 服务器,直接保留实例,调用 StartAsync、StopAsync 方法不就好了吗。其实是不行的,服务器 Stop 后再调用就有问题,问题就出在 CancellationTokenSource 上。
public async Task StartAsync(CancellationToken cancellationToken = default)
{
_logger.Starting();
using (var cts =CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _applicationLifetime.ApplicationStopping))
{
if (_options.StartupTimeout != Timeout.InfiniteTimeSpan)
cts.CancelAfter(_options.StartupTimeout);
cancellationToken = cts.Token;
......
}
哪怕你自己定义了 CancellationTokenSource 对象传进去也不行,因为 CreateLinkedTokenSource 和 ApplicationStopping 生命周期事件也关联了,只要服务被 stop ,相关的 cancel token 全都取消了。
更重要的是,前面咱们说过,所有 IHostedService 服务都会自动启动,而在 stop 时,也会将所有 IHostedService 全部 stop 掉。你不能保证,这些服务可能都清理内存了。所以说,维护这么难搞的状态还不如重新实例化更爽。
这么一来,ASP.NET Core 就可以作为一个普通的 exe 程序发布,客户在他们的电脑上双击运行就完事了。老周特意用了 SQLite 数据库,基本上做到双击运行,啥都不用他们去配置,也不用部署数据库服务器什么的。
好了,就分享到这里了。这个方案只在特殊需求下用,毕竟这么一搞,项目就不能跨平台了。