3、ASP.NET Core 项目结构:Program.cs 与 Startup.cs 演变、中间件管道、依赖注入容器、配置系统

好,咱们直接进入正题。这一章,我打算把 ASP.NET Core 项目里最核心的几个东西给你拆开揉碎了讲清楚。你打开一个 .NET 项目,第一眼看到的无非就是 Program.csStartup.cs(或者现在没了),然后就是一堆 app.UseXXX()services.AddXXX()。很多人照着模板写,但真问起来,可能说不清为什么这么写。

我个人习惯,先把骨架搭明白,再往里填肉。所以这一章,咱们就聊聊这个骨架。

3.1 Program.cs 与 Startup.cs 的演变

先说个历史背景。在 .NET 5 及之前,项目模板默认是两文件结构:Program.cs 负责启动,Startup.cs 负责配置。到了 .NET 6,微软搞了个大统一,把 Startup.cs 的内容直接塞进了 Program.cs,用顶级语句(Top-level statements)来写。

为什么会这样?说白了,就是简化。以前你写个最小 API,还得建两个文件,来回切,烦不烦?现在一个 Program.cs 搞定。

我举个例子,这是 .NET 5 时代的写法:

// Program.cs
public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

// Startup.cs
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

嗯,这里要注意,ConfigureServicesConfigure 这两个方法,名字是固定的,框架会通过反射去调用。你改个名,它就不认了。

到了 .NET 6,变成了这样:

var builder = WebApplication.CreateBuilder(args);

// 这里就是以前的 ConfigureServices
builder.Services.AddControllers();

var app = builder.Build();

// 这里就是以前的 Configure
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});

app.Run();

你看,是不是清爽多了?我个人觉得,这种写法更符合直觉:先建 builder,配置服务,然后 Build,再配置中间件,最后 Run。一条流水线下来。

我的建议: 如果你还在用 .NET 6+,就别再用 Startup.cs 了。虽然你硬要分文件也可以,但没必要。一个 Program.cs 搞定,团队协作时反而少了一个文件需要理解。

3.2 中间件管道

中间件管道,这是 ASP.NET Core 最核心的概念之一。你想想看,一个 HTTP 请求进来,它要经过一系列的处理环节:异常处理、认证、授权、静态文件、路由、MVC……每个环节就是一个中间件。

这些中间件像水管一样,一节一节接起来。请求从入口进来,经过每一节,最后到达你的控制器。响应再原路返回。

关键点在于:顺序决定一切

我曾经在项目中遇到过一个问题:我把 app.UseAuthorization() 放在了 app.UseRouting() 之前,结果认证中间件拿不到路由信息,导致权限判断失败。排查了半天,最后发现是顺序错了。

所以,记住这个标准顺序:

app.UseExceptionHandler();   // 异常处理,放最前面
app.UseHttpsRedirection();   // HTTPS 重定向
app.UseStaticFiles();        // 静态文件
app.UseRouting();            // 路由
app.UseCors();               // 跨域(如果需要)
app.UseAuthentication();     // 认证
app.UseAuthorization();      // 授权
app.UseEndpoints();          // 终结点

这个顺序不是随便定的。比如异常处理放最前面,是为了能捕获后面所有中间件抛出的异常。认证和授权必须放在路由之后,因为需要知道请求的是哪个资源。

避坑指南: 我曾经见过有人把 app.UseStaticFiles() 放在 app.UseRouting() 后面,结果静态文件请求也走了路由,白白浪费性能。静态文件中间件一旦匹配到文件,就会短路管道,不再往下走。所以放前面效率更高。

另外,你还可以自己写中间件。比如我需要记录每个请求的处理时间:

app.Use(async (context, next) =>
{
    var stopwatch = Stopwatch.StartNew();
    await next();
    stopwatch.Stop();
    Console.WriteLine($"{context.Request.Path} 耗时 {stopwatch.ElapsedMilliseconds}ms");
});

这种内联中间件,适合简单场景。复杂逻辑,我建议封装成类。

3.3 依赖注入容器

依赖注入(DI)在 ASP.NET Core 里是原生支持的。你不需要额外装 Autofac 之类的第三方库(虽然也可以集成)。框架自带了一个轻量级的 DI 容器。

三种生命周期,你得搞清楚:

生命周期 方法 说明
瞬态(Transient) AddTransient<T>() 每次请求都创建新实例。适合轻量级、无状态的服务。
作用域(Scoped) AddScoped<T>() 每个 HTTP 请求内共享同一个实例。适合 DbContext 这类。
单例(Singleton) AddSingleton<T>() 整个应用程序生命周期内只有一个实例。适合配置、日志等。

我个人习惯,默认用 Scoped。为什么?因为 Transient 如果依赖了 Scoped,就会产生生命周期捕获问题。说白了,就是短命的服务依赖了长命的服务,容易出 bug。

举个例子:

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddSingleton<ILogger, Logger>();

这里 OrderServiceProductService 都是 Scoped,它们可以在同一个请求里共享数据。而 Logger 是单例,全局只有一个。

重要提醒: 千万不要在 Singleton 服务里注入 Scoped 服务!这会导致 Scoped 服务变成事实上的 Singleton,引发数据混乱。如果你非要在 Singleton 里用 Scoped,可以用 IServiceScopeFactory 手动创建作用域。

3.4 配置系统

ASP.NET Core 的配置系统,支持多种配置源:JSON 文件、环境变量、命令行参数、内存字典……而且它们有优先级顺序。

默认情况下,优先级从低到高是:

  1. appsettings.json
  2. appsettings.{Environment}.json(比如 appsettings.Development.json
  3. 环境变量
  4. 命令行参数

后加载的会覆盖先加载的。这个设计很聪明,你想想看,开发环境用 appsettings.Development.json,生产环境用环境变量或 Kubernetes Secrets,不用改代码。

我常用的做法是:

// 读取配置
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

// 绑定到强类型对象
var smtpSettings = builder.Configuration.GetSection("SmtpSettings").Get<SmtpSettings>();
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("SmtpSettings"));

这里要注意,Get<T>()Configure<T>() 的区别:前者是直接读取并绑定,后者是注册为选项模式(Options Pattern),可以在后续通过 IOptions<T> 注入。

我个人更推荐选项模式,因为它支持热更新(IOptionsSnapshot<T>)和命名选项(IOptionsMonitor<T>)。

一个小技巧: 如果你在开发环境需要覆盖某个配置,不用改文件,直接设环境变量就行。比如 set SmtpSettings__Host=smtp.example.com(注意双下划线)。命令行参数也是类似,--SmtpSettings:Host=smtp.example.com

嗯,这一章的内容差不多就这些。总结一下:

  • Program.cs 与 Startup.cs:.NET 6 之后合二为一,用顶级语句更简洁。
  • 中间件管道:顺序决定一切,记住标准顺序,自己写中间件也不难。
  • 依赖注入容器:三种生命周期要分清,避免生命周期捕获问题。
  • 配置系统:多源配置,优先级明确,选项模式是推荐做法。

下一章,咱们聊聊路由和控制器,看看请求是怎么找到你的 Action 方法的。