3、ASP.NET Core 项目结构:Program.cs 与 Startup.cs 演变、中间件管道、依赖注入容器、配置系统
好,咱们直接进入正题。这一章,我打算把 ASP.NET Core 项目里最核心的几个东西给你拆开揉碎了讲清楚。你打开一个 .NET 项目,第一眼看到的无非就是 Program.cs、Startup.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();
});
}
}
嗯,这里要注意,ConfigureServices 和 Configure 这两个方法,名字是固定的,框架会通过反射去调用。你改个名,它就不认了。
到了 .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。一条流水线下来。
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>();
这里 OrderService 和 ProductService 都是 Scoped,它们可以在同一个请求里共享数据。而 Logger 是单例,全局只有一个。
IServiceScopeFactory 手动创建作用域。
3.4 配置系统
ASP.NET Core 的配置系统,支持多种配置源:JSON 文件、环境变量、命令行参数、内存字典……而且它们有优先级顺序。
默认情况下,优先级从低到高是:
appsettings.jsonappsettings.{Environment}.json(比如appsettings.Development.json)- 环境变量
- 命令行参数
后加载的会覆盖先加载的。这个设计很聪明,你想想看,开发环境用 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 方法的。