资讯动态

ASP.NET Core Startup类核心机制:启动链路、依赖注入与中间件管道详解

发布时间:2026/10/13 18:22:46 来源:尧图企业网站定制
说实话很多人在第一次打开 .Net Core 的默认 Web 模板时目光都会停留在 Program.cs 上。我也是。但真正决定一个应用怎么对外提供服务、怎么把各种组件拼装在一起的其实是旁边那个看起来平平无奇的 Startup 类。如果你正处于 .Net Core 的学习阶段或者正在维护一个老版本项目这篇笔记就是为你写的。我会从启动执行链路讲起把 Startup 类里最常见的用法、最容易踩的坑、以及为什么框架要设计成这样一次说清楚。1. 每次新建项目都会见的 Startup 类到底在管什么1.1 从 Program.cs 到 Startup 的执行链路假设你用默认模板建了一个 Web API 项目早期版本的 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.UseStartupStartup(); }); }重点就是webBuilder.UseStartupStartup()这一行。框架拿到 Startup 类型之后会在宿主构建完成、应用正式监听端口之前按一套严格的约定去实例化它并调用特定方法。大致顺序是这样宿主构建阶段加载配置源、配置日志、配置 Kestrel 等进程级内容。框架从程序集里找到 Startup 类默认是按命名约定自动找也可以像上面那样显式传入。调用 Startup 构造函数。构造函数能注入的只有框架自带的那一批服务比如 IConfiguration、IHostEnvironment。调用 ConfigureServices 方法如果有把业务需要的服务注册到容器。根据当前环境调用 Configure 或 ConfigureDevelopment 等方法组装中间件管道。host.Run() 之后才真正开始监听请求。把整个启动过程拆成这几步只是为了说明一点Program.cs 和 Startup 的职责是分层的。Program.cs 管的是进程级的事——服务器用哪个、日志怎么输出、配置从哪里读Startup 管的是应用级的事——哪些服务可以用、每个请求进来以后要走哪些工序。这个分层在项目小的时候看不出价值一旦部署到多环境、接上复杂的认证和组件谁要是把所有逻辑都塞进 Program.cs后面维护会相当痛苦。1.2 两个顶级方法的分工逻辑启动流程里真正值得仔细看的就是这两个方法ConfigureServices 和 Configure。名字很像但职责完全不同。ConfigureServices 负责注册服务。它拿到的是 IServiceCollection你可以把它理解为一张零件清单。这里通常不写具体逻辑而是把服务、接口、中间件用的依赖全部登记进去public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddDbContextAppDbContext(options options.UseSqlServer(Configuration.GetConnectionString(Default))); services.AddScopedIUserRepository, UserRepository(); }Configure 负责组装中间件管道。它拿到的是 IApplicationBuilder可以把它理解成一条流水线。每个请求进来之后会按你在 Configure 里安排好的顺序依次经过各个工序public void Configure(IApplicationBuilder app) { app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints { endpoints.MapControllers(); }); }这两个方法不是随便分出来的它们正好对应了请求处理的准备阶段和执行阶段。ConfigureServices 先执行保证 Configure 里用到的服务都已经在容器里了Configure 后执行把这些服务按顺序编排成真正能处理请求的管道。想通这一点后面看任何第三方组件Swagger、JWT、静态文件、健康检查都只是往清单里加一项和往流水线里放一道工序的问题。2. 服务注册不是填表理解依赖注入容器的构建时机2.1 为什么所有服务都要集中到这里注册我在刚开始写 .NET 的时候习惯直接在 Controller 里 new 一个数据进行操作。项目小的时候确实没毛病但到了第五六个 Controller、每个都要访问数据库和缓存时重复代码就像滚雪球。依赖注入解决的就是这个问题把创建依赖这个动作上收到一个统一的地方需要某个接口的时候容器帮你搞定实例的创建和销毁。ConfigureServices 就是这个统一的地方。它发生在应用启动阶段、任何请求到来之前所以在这里注册服务是最安全的时机。实际项目里没人会把几十行 services.AddXxx 全写在一个方法里一般会拆成扩展方法public void ConfigureServices(IServiceCollection services) { services.AddControllers(); services.AddInfrastructureServices(Configuration); services.AddApplicationServices(); }然后每个 AddXxx 扩展方法内部只负责自己那一块的服务注册这样 Startup 始终只保留一个目录式的视图。后来代码规模大了翻 Startup 就能快速知道系统有哪些模块比一个个翻文件夹快得多。2.2 生命周期选错会引发的问题服务注册的核心不是注册没注册而是用什么生命周期注册。生命周期选错接口可能还能用但并发一上来问题就全部暴露。三种生命周期分别适合不同场景生命周期创建时机适合场景最常见的问题Singleton第一次被请求时创建一次无状态的服务、缓存、配置类内部保存状态可能跨请求串数据Scoped每个请求范围内创建一个DbContext、与请求绑定的工作单元从根容器解析时会悄悄变成单例Transient每次请求都新建一个轻量级、无状态的服务创建成本高时造成性能浪费举一个实际例子。如果我把 DbContext 注册成 Singleton就等于让所有请求共享同一个数据库上下文。EF Core 的 DbContext 不是线程安全的并发请求一多就会出现各种诡异的异常时而报连接已关闭时而报数据被覆盖。把它注册成 Scoped 才是正确的因为 Scoped 对应一个请求只创建一个实例既避免了并发冲突又能在请求结束时统一释放。这里还有一个容易忽略的坑在 Startup 的 Configure 方法里可以通过 app.ApplicationServices 拿到容器根然后直接 GetService 解析某个 Scoped 服务。这样做服务也能拿到但它的生命周期会从 Scoped 变成类单例——所有请求共用一份。有的项目就是这样在启动阶段注册了一个看起来正常的服务结果跑线上后数据到处串。解决方法是临时造一个 scope 再解析或者把解析动作推迟到真正处理请求的时候。3. 中间件管道Configure 方法就是你的请求处理流水线3.1 Use / UseMiddleware / Run 到底差在哪中间件管道是整个 ASP.NET Core 最核心的模型也是 Startup 类里最值得花时间理解的部分。你只需要记住一句话每个中间件都是一道工序请求按顺序穿过所有工序响应再按原路返回。把管道想成快递分拣线包裹请求到达经过第一步分拣路由第二步贴标签认证第三步打包缓存。如果某个工序发现包裹有问题可以直接退回不让它继续往下走这就是中间件的短路功能。管道的实现就是一次套一次的函数委托前面工序决定要不要调用 next 进入下一道。在 Configure 方法里你常用的就三样app.Use(...)注册一个中间件委托可以在中间继续调用 next() 传递请求也可以选择不调用从而短路。app.UseMiddlewareT()注册一个中间件类中间件类里写 InvokeAsync 方法处理逻辑。app.Run(...)终结点中间件它不接收 next也就意味着它是管道的最后一站。一个简单的耗时记录中间件可以这样写public void Configure(IApplicationBuilder app, ILoggerStartup logger) { app.Use(async (context, next) { var stopwatch Stopwatch.StartNew(); await next(); stopwatch.Stop(); logger.LogInformation({Path} 耗时 {Elapsed}ms, context.Request.Path, stopwatch.ElapsedMilliseconds); }); app.UseRouting(); app.UseEndpoints(endpoints endpoints.MapControllers()); }这种包一层的感觉理解之后再看第三方组件的文档就不会懵了——所有 UseXxx 方法本质上都是往这条链上添加工序。3.2 顺序决定行为别等坑出现才着急中间件另一个关键点是顺序。管道是线性执行的工序排错了功能和预期就会完全不同。有一次我接手一个项目接口在本地测得好好的一部署到测试环境所有需要登录态的接口全变成了匿名可访问。排查了很久最后发现是认证中间件被写到了 UseEndpoints 后面。请求都已经被终结点处理返回了认证工序才排在后面自然永远不会执行。把它调回 UseAuthorization 之前一切恢复正常。这里有一个比较经典的顺序参考具体版本之间可能略有差异但原则一致顺序中间件作用第一层UseExceptionHandler / UseDeveloperExceptionPage兜底异常处理第二层UseHttpsRedirection强制 HTTPS第三层UseStaticFiles静态资源第四层UseRouting路由匹配第五层UseAuthentication识别用户身份第六层UseAuthorization权限校验第七层UseEndpoints执行终结点注意 UseRouting 一定要出现在 UseEndpoints 之前没有路由匹配终结点就跑不起来UseAuthentication 一定要出现在 UseAuthorization 之前还没识别身份就开始权限校验逻辑肯定过不去。另外如果有 CORS、请求压缩之类的中间件还得根据它们的依赖关系调整位置。我个人建议是每个中间件的顺序都不要靠想当然排。先想一想请求经过管道时你希望它先被处理还是后被处理。比如静态文件就应该在最前面因为静态资源不需要走控制器、不需要数据库能提前返回就提前返回异常处理必须在最外层因为只有它包裹住全部工序才能接住任何地方抛出来的异常。4. 环境切换与多环境配置Startup 里最容易忽略的设计4.1 Startup 构造函数里的参数是哪来的很多教程会直接写一个带 IConfiguration 的构造函数但没有解释这个参数是怎么传进来的。在启动流程里框架会先构建好宿主再从容器里拿一批服务来创建 Startup。所以 Startup 构造函数能拿到的服务只限于框架在这一阶段就注册好的那批最常用的就是 IConfiguration 和 IHostEnvironment。这个时间点非常早业务服务还没注册自然也没法注入。利用这个特性可以在构造函数里把配置快速存起来供两个方法共用public class Startup { private readonly IConfiguration _configuration; public Startup(IConfiguration configuration) { _configuration configuration; } public void ConfigureServices(IServiceCollection services) { services.AddDbContextAppDbContext(options options.UseSqlServer(_configuration.GetConnectionString(Default))); } public void Configure(IApplicationBuilder app) { Console.WriteLine($当前环境: {_environment.EnvironmentName}); } }记住一点不要在构造函数里做耗时操作。容器在这里只负责配置对象的注入如果构造函数里挂了个远程请求或者磁盘扫描整个应用启动都得陪着等。4.2 环境后缀方法和 appsettings 的加载逻辑Startup 类里最容易被忽略的约定就是带环境后缀的方法名。比如我写了 ConfigureDevelopment 和 ConfigureProduction 两个方法框架会根据当前环境优先调用带后缀的那一个。没有匹配的就退化到无后缀的 Configure。这种设计非常实用。开发环境可能要挂 Swagger、把异常页面打开生产环境则要隐藏细节、启用 HSTS。把这些差异写进不同方法比在一个 Configure 里堆大量 if (env.IsProduction()) 干净得多。环境变量 ASPNETCORE_ENVIRONMENT 决定走哪一套对应配置文件也会发生变化。appsettings.json 是公共配置appsettings.Development.json 覆盖其中一部分appsettings.Production.json 再覆盖一部分。加载顺序是先加载公共文件再按环境加载对应的后面的覆盖前面的。连接字符串、第三方密钥这些不该进代码的东西就应该放进环境对应的配置文件里。再说一个常见问题开发时改了环境变量Swagger 时有时没有、调试时配置时有时没有。真正源头往往就两个launchSettings.json 里的环境变量没改或者这台机器的系统环境变量还残留旧值。遇到明明设置了生产环境但启动还是开发环境的情况先查 launchSettings.json再查系统环境变量最后看看是不是多项目同时启动时选了不同项目。平时写代码时的启动配置和部署后的环境变量是两回事务必区分开。5. 进阶与踩坑记录关于 Startup 的隐藏细节5.1 新项目里已经没有 Startup 了需要说明的是新版框架的默认模板里Program.cs 直接写builder.Services.Add...和app.Use...Startup 类从默认模板里消失了。这时有人会误以为 Startup 被彻底废除。准确的说法是Startup 不再是必须的但它的职责没有消失。新的最小托管模型只是把 ConfigureServices 和 Configure 两个方法合并回 Program.cs 的顶层语句里罢了。理解 Startup 里的那一套逻辑再看最小托管模型的代码反而更容易看懂——你仍然在注册服务、仍然在组装管道只不过少了类的壳、少了反射约定。存量项目和老版本代码里Startup 依然大量存在维护老项目时如果没有这个概念连日志里没有与约定匹配的方法这类错误都看不懂。5.2 方法名改错一个字母应用照常跑但功能全丢Startup 是基于约定驱动的方法调度框架通过反射去找 ConfigureServices 和 Configure。这就带来一个隐蔽问题这两个方法名不能写错。我有一次在项目里看到 Configure 方法被写成了 Configur编译完全不会报警应用也能跑起来。但请求进来自动跳过管道所有中间件都不生效路由也全 404。排查时一度以为路由配置有问题最后才发现是方法名不匹配约定框架根本没有调用这个方法。日志里可能出现一些无关紧要的警告也可能什么都没有否则就是一脸懵。想避免最简单的方法是新建 Startup 时让 IDE 生成模板不要手敲方法名或者在 Program.cs 里显式写UseStartupStartup()至少让代码里的人知道入口在哪。另外做单元测试时可以反射调用 Startup 里的方法验证管道构建。方法名这种细节恰恰是反射约定最容易埋雷的地方。5.3 启动阶段别做重活记得给 Startup 减负稍微大型一点的项目启动时可能需要连接数据库、检查第三方服务、预热缓存。这些逻辑放到 Startup 构造或 Configure 里也能跑但不是一个好的选择。应用启动变成一次漫长的串行等待任何一个外部依赖抖动都会导致服务起不来。更好的做法是放在后台托管服务里比如实现 IHostedService 或 BackgroundService。这类服务会在宿主启动后异步执行不会阻塞请求管道还能配合健康检查判断预热是否完成。我习惯把配置加载、服务注册、管道组装留在 Startup把数据初始化、缓存预热、后台监听丢给托管服务各管各的应用冷启动能快很多。最后分享一个我在实际项目里常用的观察法如果怀疑某个功能在启动阶段没接上先别急着翻业务代码直接看 Startup 的两个方法把 Configure 里的工序从头到尾读一遍按异常处理、静态文件、路由、认证、授权、终结点的顺序查。八成的启动相关疑难杂症都出在这条链的排序上。Startup 类很小但整个应用的骨架都浓缩在这几十行里把它读透.Net Core 的学习才算真正开了个好头。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑