【ASP.NET Core】在 Windows Forms 中运行 Web 服务器
说回正题如何把 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();var myHost appBuilder.Build(); myHost.Run();}复制代码运行之后咦没反应。那当然了服务你是加了可谁调用不管你添加的服务有多少个它们之间什么依赖关系。但万事总有个头啊起码有一个要先被调用吧。那么……复制代码static void Main(string[] args){HostApplicationBuilder appBuilder new(args);appBuilder.Services.AddTransient();var myHost appBuilder.Build(); IHostApplicationLifetime lifeTime myHost.Services.GetRequiredServiceIHostApplicationLifetime(); lifeTime.ApplicationStarted.Register(() { Console.WriteLine( 应用程序启动 ); MyService svc myHost.Services.GetRequiredServiceMyService(); 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、StartAsyncB、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();appBuilder.Services.AddSingletonIHostedService, 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();appBuilder.Services.AddHostedService();var myHost appBuilder.Build(); myHost.Run();}复制代码现在你应该知道ASP.NET Core 的内置服务器能自动运行的原因了吧就是实现了 IHostedService 接口。如果咱们继续深挖会发生什么呢在 VS 中打开项目属性窗口按下面所示修改属性。imageimage项目文件会变成这样。复制代码WinExe net10.0-windows enable enable True 复制代码 是不是很熟悉这不就是 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()); // 主窗口关闭后终止 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 或 空白 都行。然后打开项目属性窗口。这回知道怎么配置了吧和前文说的一样。imageimage是否把 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 掉。你不能保证这些服务可能都清理内存了。所以说维护这么难搞的状态还不如重新实例化更爽。