【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 这个包。

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

那么多 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 数据库,基本上做到双击运行,啥都不用他们去配置,也不用部署数据库服务器什么的。

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