【ASP.NET Core】在 Windows Forms 中运行 Web 服务器

【ASP.NET Core】在 Windows Forms 中运行 Web 服务器

当大伙伴们看到标题后,是不是在怀疑老周在灌水?非也,老周向来只会造水,从来不灌水。哪怕写的主题和别人一样,但水的内容也肯定与他人不同。这是老周特色,熟悉老周的大伙伴都知道的。

哦,顺便提一下,有人投诉老周写的东西没用,根本不能在项目中使用。项目中用什么,怎么用是你的项目经验和实力问题,老周可不负这个责。老周只传递知识和一些可能对你有用的技巧,如果你想着坐享其成,生吞他人劳动成果的话,请到别处去。

也有人说老周写的代码太“不正经”了,老周问你:什么样的代码才算“正经”?你不用管老周写的什么代码,你看懂了,学到一点点知识就行了,这个才是最重要的。老周瞎编的代码,一方面是防搜索引擎,另一方面是防 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 这个包。

第二,在同名的命名空间下,你会看到这样的让人迷惑的场景。

image

那么多 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();
}

这个逻辑对吗?那就得运行下,如果能看到如下输出,说明是对的。

image

这个能干啥?答:啥也不能干。有.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 服务,那不就可以运行了吗。

image

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

image

这样做虽然能调用服务,但一来不好控制,二来万一你那个服务不行,一执行就挂了,或者在那里死循环,就会让后面的程序无法执行了。所以,这个方案还是不佳。

于是,有一个神秘的服务浮出水面—— 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 的服务最好生命周期为单实例,毕竟它不需要多次实例化,很多时候它的生命周期贯穿整应用程序。

代码运行结果如下:

image

实现了 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 中打开项目属性窗口,按下面所示修改属性。

image

image

项目文件会变成这样。

<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(new MyMainForm());// 主窗口关闭后终止 Hostawait myHost.StopAsync();}

1、Main 方法上记得加上 [STAThread];

2、由于 Application.Run 会在主窗口上进行消息循环处理,它会阻塞当前线程。所以,要先启动 Application Host,再运行 Run 方法。等 Run 方法退出后,再 Stop Host。

3、其他窗口可以放服务容器中,让它们也可以依赖注入。不过,主窗口如果需要处理消息循环,还是不要放容器中。

 

好了,折腾到这里,相信换成 ASP.NET Core,你也知道怎么弄了。

我们新建一个 ASP.NET Core 项目(什么类型都可以,MVC、Blazor 或 空白 都行)。

然后,打开项目属性窗口。这回知道怎么配置了吧,和前文说的一样。

image

image

是否把 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();await app.StartAsync();Application.EnableVisualStyles();Application.SetCompatibleTextRenderingDefault(false);Application.SetHighDpiMode(HighDpiMode.SystemAware);Application.Run(new MainForm());await app.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 实例在内存中就好。

然后,窗口上你可以放两个按钮。一个用来启动,一个停止。

image

分别处理两个按钮的 Click 事件。

private async void btnStart_Click(object sender, EventArgs e)
{(sender as Button)?.Enabled = false;InitWebApp();
    if (webApp != null){await webApp.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 数据库,基本上做到双击运行,啥都不用他们去配置,也不用部署数据库服务器什么的。

 

好了,就分享到这里了。这个方案只在特殊需求下用,毕竟这么一搞,项目就不能跨平台了。