ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ASP.NET Core Startup类全面解析:服务注册、中间件与配置实战

ASP.NET Core Startup类全面解析:服务注册、中间件与配置实战

程序员做久了,容易对“配置”这件事产生一种本能反应:能写代码解决的,就别写在配置文件里;能自动化的,就别用手工点。但 .NET Core 项目里有一个例外,几乎每个 ASP.NET Core 应用都有一个“总开关”,它就是 Startup 类。我第一次正经接触 .NET Core 项目时,打开 Visual Studio 生成的模板,第一眼就是 Program.cs 和 Startup.cs 这两个文件。当时脑子里的问题是:为什么项目入口在 Program.cs,真正干活的却是 Startup.cs?后来才明白,Program.cs 负责启动宿主,Startup 类负责配置服务、搭建请求管道,是一个应用从“进程起来”到“能响应 HTTP 请求”之间的核心桥梁。

对于刚入门 .NET Core 的开发者来说,把 Startup 类吃透,等于掌握了大半个框架的初始化逻辑。这篇文章只讲一件事——把 Startup 类拆开揉碎,从核心方法、服务注册、中间件管道、环境切换到实际踩坑,讲清楚是什么、为什么、怎么用。

1. 先搞清楚 Startup 到底是干什么的

1.1 它为什么存在

在 .NET Framework 时代,一个 ASP.NET 应用靠 Global.asax 里的 Application_Start 事件去完成启动阶段的初始化。到了 .NET Core,框架换了一套思路:启动流程被拆成了两个阶段,服务配置和管道搭建,而这正是 Startup 类两个核心方法的来源。

一个普通模板项目的 Startup 类长这样:

public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } 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。如果你不想写类,也可以在 Program.cs 里用委托的方式,但那样代码会很乱,团队协作也难维护,所以规范一点的项目基本都保留 Startup 类。

这个类的意义在于:把“提供什么能力”和“怎么处理请求”分开。前者交给依赖注入容器,后者交给中间件管道。这个分层思想贯穿整个 ASP.NET Core 生命周期,后面我们会逐一展开。

1.2 框架调用 Startup 的时机与顺序

先把调用顺序理清楚。整个应用启动,从 Main 方法进到 Host 构建,再到最终接收第一个 HTTP 请求,中间经历的步骤大致是:

  1. Program.cs 里用 Host.CreateDefaultBuilder 创建宿主构建器。
  2. 构建器读取 appsettings.json、环境变量等配置来源。
  3. 构建器找到 Startup 类(默认根据环境变量选择具体类型,比如 StartupDevelopment)。
  4. 实例化 Startup,构造器里可以注入 IConfiguration、IWebHostEnvironment 等。
  5. 调用 ConfigureServices,把注册的服务收集进容器。
  6. 调用 Configure,拿到 IApplicationBuilder,开始组装请求处理管道。
  7. Host.Run(),应用正式监听端口,开始接受 HTTP 请求。

这里有个细节刚入门的人容易忽略:ConfigureServices 不是你手动调用的,是框架调用的。你只是把代码写在那个方法里,方法一执行完,容器里就存了你注册的服务清单。真正用到这些服务,要等到 HTTP 请求进入管道的那一刻才开始。所以你会看到 Configure 方法里 IWebHostEnvironment 可以直接用,因为它是框架提前注入的,不用你自己 new。

2. ConfigureServices:项目的服务登记中心

2.1 依赖注入在这里落地

ConfigureServices 里做的最核心的一件事,就是往 IServiceCollection 里注册服务。依赖注入(DI)听起来唬人,其实就是:我不在自己类里 new 依赖,而是声明“我需要什么”,由框架在运行时把实例给我。

比如你有一个商品服务类:

public interface IProductService { List<Product> GetProducts(); } public class ProductService : IProductService { public List<Product> GetProducts() { // 查询数据库或缓存,返回商品列表 return new List<Product>(); } }

在 ConfigureServices 里注册:

services.AddScoped<IProductService, ProductService>();

之后在 Controller 或中间件里通过构造函数接收,框架会自动把 ProductService 的实例塞进来。这样做的好处是解耦——ProductService 底层的数据库连接、缓存策略以后想换掉,调用方代码一行都不用改。

2.2 AddSingleton、AddScoped、AddTransient 到底怎么选

三种生命周期,是新手最容易纠结的地方。

生命周期实例创建时机使用场景
AddSingleton第一次被请求时创建,之后全局复用配置对象、日志提供器、无状态工具类
AddScoped同一个请求作用域内复用EF Core 的 DbContext、数据库仓储
AddTransient每次请求都是全新实例轻量无状态服务、工具类

选择原则其实很简单:有状态就看作用范围,无状态优先考虑性能。Singleton 性能最好,但要注意内部变量线程安全问题,比如一个静态字典在高并发下被多个线程同时读写,很容易出问题。Scoped 最适合数据库上下文,因为一个请求内多个服务共用同一个上下文实例,能避免数据库连接频繁开销。Transient 每次新建,最安全但开销最大,适合很轻的服务。

举个例子,一个人去银行办事,理解成“服务”:

  • Singleton:银行门口只有一台取号机,所有人共用,取号是全局唯一的。
  • Scoped:每人拿到一个排号,整个业务办理流程内一直使用同一个号。
  • Transient:每次叫号都重新发一张新的号,互不关联。

这比喻不算百分百精确,但能帮你建立直觉。实际开发中,注册错了生命周期最典型的现象就是:Singleton 服务的内部状态攒了其他请求的数据,导致测试环境正常、生产环境偶发数据串了。

2.3 扩展方法与选项模式

你还会看到很多 services.AddXxx() 的写法,比如 AddControllers、AddDbContext、AddSwaggerGen。这些全部是扩展方法,本质上是对 IServiceCollection 的一层封装。它们的作用是:把一堆相关服务批量注入容器,你不用自己逐个 AddSingleton。

举一个实际场景:你想注册 Swagger 相关服务,如果手动写,可能要写五六个 AddSingleton;用 AddSwaggerGen 一行就够了。扩展方法内部可能还往容器里塞了配置文件产物(比如 Options 对象),这就是选项模式的来源。所谓选项模式,就是强类型封装配置片段,比如:

{ "EmailSettings": { "SmtpServer": "smtp.example.com", "Port": 587 } }

在 Startup 里这样绑定:

services.Configure<EmailSettings>(Configuration.GetSection("EmailSettings"));

然后在业务类里注入 IOptions ,读取强类型属性。好处是配置变更时,编译器能帮你抓出遗漏的地方,比字符串 key 到处飘安全得多。

3. Configure:构建 HTTP 请求的处理流水线

3.1 中间件到底是什么意思

Configure 方法里有一个重要参数 IApplicationBuilder,它的 app.UseXxx() 调用链,就是整个 HTTP 请求的处理管道。每个 UseXxx 注册一段“中间件”,每个中间件可以决定是否处理请求、是否短路、是否把请求传递给下一个中间件。

理解中间件,最经典的图是“洋葱模型”:

请求进来 → 中间件1 → 中间件2 → 终结点 → 中间件2 → 中间件1 → 响应出去

中间件 1 在请求进来时先做点事,等后续中间件处理完,响应回来时它还能再补一刀。所以中间件的顺序极其重要。同样的中间件,顺序不同,行为完全不同。最经典的例子就是 UseCors 和 UseAuthentication:你要先做跨域校验还是先做身份认证?如果先跨域,那么跨域失败时请求根本到不了认证那一步;如果先认证,那么未认证请求会在跨域之前就被拦截。这就是顺序带来的实际差异。

3.2 常用中间件的正确排序

一个相对完整的 ASP.NET Core 管道,大致是:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseCookiePolicy(); app.UseCors(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); endpoints.MapRazorPages(); }); }

每个中间件的职责:

  • UseDeveloperExceptionPage:开发环境显示详细异常页面,方便追踪堆栈。
  • UseExceptionHandler:生产环境把异常转成友好的错误页。
  • UseHsts:强制 HTTPS 策略。
  • UseHttpsRedirection:把 HTTP 请求重定向到 HTTPS。
  • UseStaticFiles:处理 wwwroot 下的静态文件,图片、CSS、JS 不需要进 MVC 管道。
  • UseRouting:根据路由表匹配请求的目标终结点,但还没真正执行。
  • UseAuthentication:识别用户身份,通常会解析登录 Cookie 或 JWT。
  • UseAuthorization:判断当前用户是否有权限访问终结点。
  • UseEndpoints:真正执行选中的终结点逻辑。

这个顺序是微软默认模板的顺序,也是社区验证过的最不易踩坑的顺序。新手最容易犯的错误是:把 UseAuthentication 放在 UseRouting 前面,结果认证中间件拿不到路由信息,导致请求身份识别失败,503 或者 401 飘出来,排查半天还以为是代码问题。

3.3 自己写一个中间件

中间件不只能“用别人写的”,还能自己写。最常见的写法是基于约定的中间件类和一个扩展方法。比如我想记录每次请求的处理耗时:

public class RequestTimingMiddleware { private readonly RequestDelegate _next; public RequestTimingMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); try { await _next(context); } finally { stopwatch.Stop(); Console.WriteLine($"{context.Request.Path} 耗时 {stopwatch.ElapsedMilliseconds} ms"); } } }

注册方式:

app.UseMiddleware<RequestTimingMiddleware>();

这个中间件只是打印日志,你完全可以扩展成记录到日志系统的耗时指标。理解了中间件机制,你会发现框架里很多功能本质上都是中间件:身份认证是,静态文件是,异常处理也是。这就是 ASP.NET Core 管道的精髓——一切都是管道的一环,你可以在任何环节介入。

4. 开发环境和后端环境的 Startup 变体

4.1 环境变量的作用

实际项目不会只有一个环境,本地开发、测试、预发布、生产,各自有连的数据库、API 地址、密钥策略。.NET Core 提供了环境概念:Development、Staging、Production,通过 ASPNETCORE_ENVIRONMENT 环境变量来区分。

如果当前环境变量是 Development,框架会自动寻找名字带 Development 的 Startup 变体,这就是 StartupDevelopment.cs 的来源。如果没有找到匹配的,就用默认的 Startup.cs。这个机制有什么好处?你可以在开发环境注册 Swagger 和内存数据库,生产环境却不注册;也可以在开发环境打开详细异常页面,生产环境则用隐藏堆栈的错误页。

常见写法:

public class StartupDevelopment : Startup { public StartupDevelopment(IConfiguration configuration) : base(configuration) { } public override void ConfigureServices(IServiceCollection services) { base.ConfigureServices(services); services.AddSwaggerGen(); } public override void Configure(IApplicationBuilder app, IWebHostEnvironment env) { base.Configure(app, env); app.UseSwagger(); app.UseSwaggerUI(); } }

把公共逻辑放在基类 Startup,环境变体只覆盖差异部分。这样既不会一下子把代码重复两遍,又能灵活应对环境之间的差异。

不过这个写法有两个使用前提:基类的 ConfigureServices 和 Configure 要声明为 virtual 或 abstract,环境变量要正确识别。我自己更常用的一种做法是:不在类名上区分环境,而是直接在 Configure 方法里判断 env.IsDevelopment(),然后分支处理。两种方式各有优劣,类名变体更直观,环境判断更集中,看团队习惯了。

4.2 IWebHostEnvironment 与 IConfiguration

Startup 的构造器可以注入 IWebHostEnvironment 和 IConfiguration,框架会在创建 Startup 实例时自动传入。有了 IWebHostEnvironment,你在 ConfigureServices 里也能判断环境,决定是否注册开发专用的服务。比如说:

public void ConfigureServices(IServiceCollection services) { services.AddControllers(); if (_env.IsEnvironment("Staging")) { services.AddScoped<IEmailSender, StagingEmailSender>(); } else { services.AddScoped<IEmailSender, SmtpEmailSender>(); } }

注意,在 ConfigureServices 阶段判断环境时,你只能依赖构造器注入的 IWebHostEnvironment,而不能在 Configure 里那样直接从 app 参数拿,因为 ConfigureServices 执行时管道还不存在。这个细节刚接触时很容易忽略。

4.3 配置文件怎么读进来

配置文件的核心是 appsettings.json,但它不是唯一的配置来源。Host 构建时默认的配置源顺序大致是:appsettings.json → appsettings.{Environment}.json → 环境变量 → 命令行参数。后面的配置源会覆盖前面的。所以你在生产环境可以通过环境变量、或者部署平台的配置服务设置连接字符串,而不需要把秘钥提交到代码仓库。

在 Startup 里使用配置,最直接的方式是前面的 Options 模式。还有一种场景是直接在 Configure 里通过 Configuration 对象读取某个键值,但这种“临时拿来用一下”的做法不好维护,我建议尽量都走到强类型绑定。这里要特别提醒:不要在生产配置文件里存数据库密码、密钥、证书口令。.NET Core 提供 Secret Manager 专用于开发阶段敏感配置,生产环境应该使用环境变量或专门的密钥管理服务。

5. 动手搭建一个真实可行的 Startup 示例

5.1 一个带服务注册、中间件、配置绑定的完整案例

假设我们做一个简单的商品接口,包含数据库访问、日志记录、Swagger 文档。完整的 Startup 长这样:

public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddDbContext<AppDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddScoped<IProductRepository, ProductRepository>(); services.AddAutoMapper(typeof(Startup)); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "ProductAPI", Version = "v1" }); }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "ProductAPI v1")); } else { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } }

对应的 appsettings.json 里放连接字符串:

{ "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=ProductDB;Trusted_Connection=True;" } }

这样跑起来之后,依赖注入容器里已经有 DbContext、仓储、自动映射服务,Swagger 文档也能正常打开。这套结构是我在实际项目里反复用过的骨架,从普通接口到微服务都能直接铺开。

5.2 注册服务的顺序有讲究吗

很多人问:ConfigureServices 里几十个 services.AddXxx(),顺序真的无所谓?大部分情况下确实无所谓,因为各个 Add 方法之间不存在强依赖。但有三个例外:

  • AddAutoMapper 一定要在注册业务服务之前调用,否则后续注册的服务无法使用 AutoMapper 的映射能力。
  • AddDbContext 注册的 DbContext 生命周期是 Scoped,如果某个 Singleton 服务依赖它,运行时会因为生命周期不匹配直接抛异常。
  • AddControllers 和 AddMvc 不要重复调用,AddMvc 是 AddControllers 的父集,重复注册会导致一些功能行为异常。

注意这些点之后,大部分顺序问题都能避免。

5.3 运行时报错“Unable to resolve service”怎么排查

这是新手碰到最多的问题:启动时报错提示某个服务无法解析。排查步骤我基本固定:

  1. 确认你是不是在 ConfigureServices 里漏写了 AddXxx()。比如控制器里注入了 IProductRepository,但容器里没注册,那么控制器实例化时就会失败。
  2. 确认注册的生命周期和构造函数消费的生命周期匹配。Singleton 里注入 Singleton 或 Transient 可以,注入 Scoped 就不行。
  3. 确认 ConfigureServices 的代码是否真的被执行了,如果 Startup 类命名不对,比如环境是 Development 但你写了 StartupProduction.cs,那么框架根本不会加载你的注册代码。
  4. 打开异常日志,看清具体是哪一个服务无法解析。通常错误信息会直接告诉你类型名,比到处问人效率高得多。

这类错误有个规律:80% 是漏注册,15% 是生命周期冲突,5% 是命名约定问题。后面我们在常见问题里再展开。

6. 常见问题与避坑记录

6.1 Startup 类不生效

如果启动后发现 Configure 里的代码根本没执行,最常见的原因是类名不对。默认约定是 Startup 或 Startup{EnvironmentName},如果你把类改成 MyStartup,框架就认不出来了。Program.cs 里可以手动指定,但没必要自找麻烦,保持默认约定即可。

还有一种情况是环境变量整成了“DEBUG”而不是“Development”。环境变量值区分大小写敏感度较低,但变量名必须是对的,最好在你的 launchSettings.json 里检查 ASPNETCORE_ENVIRONMENT 的值。

6.2 中间件顺序导致 401

前面提过,UseAuthentication 必须放在 UseRouting 之后、UseAuthorization 之前。实际项目里,很多人把认证中间件当成“一个没什么关系的组件”随手放,结果就会出现:路由能访问、用户却能拿到 401。为什么?因为认证中间件找不到路由上下文,就无法正确读取终结点上的 AllowAnonymous 特性。这种情况排查很久才发现是顺序问题——其实看一眼中间件注册顺序,一眼就能定位。

6.3 生产环境用了开发环境的异常页

如果你把 UseDeveloperExceptionPage 无条件放在 Configure 里,那么生产环境请求出错时,用户会看到包含堆栈、变量值甚至连接字符串的异常页面,这是安全大忌。正确做法是通过 env.IsDevelopment() 或环境变体隔离。我见过不止一次,上了生产发现异常页还在,就是因为把两个中间件没有做环境判断,直接写死了。

6.4 配置读不到连接字符串

连接字符串读不到通常有三种原因:

  • 配置文件里键值名字和代码里不一致,比如代码里写 GetConnectionString("Default"),配置里是 DefaultConnection。
  • appsettings.{Environment}.json 覆盖了 appsettings.json 的键,而覆盖值是空的。
  • 配置文件没有复制到输出目录,造成运行时找不到文件。

检查顺序从前往后,通常第一、第二个原因占大多数。顺手提一个技巧:可以在 Startup 构造函数里临时打印 Configuration.GetConnectionString(...) ,先确认配置读进来了,再排查服务注册问题。

6.5 多个数据库上下文和多个仓储的注册策略

项目大了之后,一个项目会有多个 DbContext,此时按原来 AddDbContext 逐个注册即可。但要注意,如果多个 DbContext 的服务生命周期都是 Scoped,而它们之间又有依赖关系,会出现循环依赖或者作用域嵌套问题。我的经验是不要让仓储之间相互注入,尽量让控制器作为合成根来调度仓储。

7. 我对 Startup 类的几点心得体会

做了几年 .NET Core 项目,我越来越觉得,Startup 类的设计就是整个框架最精华的地方。它把服务的注册和请求的处理拆得干干净净,让团队里不同人负责不同模块时,不容易互相踩脚。新人接手项目,先读 Startup,能快速了解项目有哪些服务、哪些中间件、请求是怎么流转的。这比看一百个控制器都管用。

我个人习惯在 ConfigureServices 里按模块分区写注释:认证区、数据区、业务区、文档区、杂项区。这样几十行注册代码不至于变成一锅粥。每次新增服务,大家也知道该往哪里放,而不是随手插一行。

还有一点想多说一句:很多人从 .NET Core 3.x 升级到 .NET 6,发现模板里的 Startup 类消失了,变成了 Program.cs 顶部一堆 Add 方法、下面一堆 Use 方法,就觉得 Startup 过期了。其实不是。Minimal API 只是把启动逻辑压缩到一个文件里,底层依赖注入和管道模型完全没变。对于复杂项目,我仍然建议保留独立的 Startup 类,它在结构上更清晰,也方便团队协作。

另外,我还会在生产环境增加一个“启运行检查”:在 Configure 里映射 HealthCheck 终结点,端点通过数据库连通性、缓存连通性来返回健康状态。这一步虽然不算 Startup 的必需功能,但往往能救命——部署后发现新版本连不上数据库,至少让负载均衡器先把流量切走,而不是全部 502。

最后再分享一个小技巧:调试 Startup 配置问题时,可以在 Program.cs 的构建过程里加日志,把配置源、环境变量都打出来。启动是最难调试的阶段,因为此时很多可视化工具还没起来,日志是你唯一的眼睛。看懂日志里每一步加载了什么配置、注册了什么服务,比你人肉走查代码快得多。

.NET Core 的学习曲线,Startup 是绕不开的第一道坎。这道坎过了,后面的中间件、依赖注入、配置系统、认证授权,你会发现它们都是同一个思想的不同面向。把 Startup 吃透,不只是在学一个类,而是在理解整个框架的“开机流程”。希望这篇整理能帮你少走一些弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进