十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ASP.NET Core中间件与请求管道:从原理到实战全解析

ASP.NET Core中间件与请求管道:从原理到实战全解析 刚在调试一个接口慢查询发现耗时全卡在一个不起眼的中间件里这让我又一次体会到ASP.NET Core 里最容易被低估、也最容易出问题的就是中间件与请求管道。这个09-中间件与请求管道的主题我在面试里问过不少人也在生产环境里踩过不少坑。如果你正在学 C# 中间件有哪些、想搞懂管道是怎么串起来的或者准备中间件面试这篇文章应该能帮你把这些事一次理顺。我用一个真实项目的视角来拆解从最基础的概念讲起到自定义中间件的几种写法再到注册顺序、内置中间件清单、常见故障排查最后顺手把面试里那些高频问题也一起消化掉。每个部分都会给出可以直接抄的代码和配置也会说明为什么这么做、不这么做会踩什么坑。1. 内容整体设计与思路拆解1.1 核心需求解析中间件到底解决了什么问题先说人话。你在浏览器里输入一个地址请求到服务器后不是直接进 Controller 的。它会先经过一条管道管道里站着一排“检查员”每个检查员只看自己负责的那点事有的管 HTTPS 跳转、有的管静态文件、有的管身份验证、有的管 CORS、最后才轮到你的业务代码。这些“检查员”在 ASP.NET Core 里就叫中间件Middleware。为什么要设计成这样最直接的原因是横切关注点需要统一处理。日志、异常、认证、缓存这些逻辑如果散落在每个 Controller 里代码会变成灾难。中间件把公共逻辑抽到管道里每个请求进来都自动走一遍干净又统一。而且它是可插拔的想加就加想换就换不影响业务代码。这也是面试里最喜欢问的一个点中间件的好处是什么。标准回答思路就是解耦横切关注点、请求处理可组合、管道顺序可控、组件可复用。1.2 请求管道的执行模型洋葱模型与短路机制中间件管道最经典的理解方式是“洋葱模型”。一个请求从最外层中间件进入一层一层往里走走到最内层的业务代码比如 Controller然后响应再从内层一层一层往外返。请求 - M1(进入) - M2(进入) - 业务代码 - M2(返回) - M1(返回) - 响应每个中间件可以在调用next()之前做“请求阶段”的处理在next()之后做“响应阶段”的处理。所以日志中间件可以在请求进来时记录“开始处理”在响应出去时记录“处理完成”两次记录自然环绕着整条管道。还有一个非常关键的机制叫短路。中间件可以不调用next()直接返回响应这样后面所有的中间件都不会执行。典型的例子是静态文件中间件如果请求的是favicon.ico之类的静态资源它直接返回文件根本不会进 Controller。Run方法就是一个永不调用next()的终止型中间件。这个机制在实现“黑名单”“接口开关”“请求限流”时特别好用后面实操部分我会给出代码。1.3 方案选型为什么用管道组装而不是逐个 if 嵌套你可能会有疑问这些逻辑我用if嵌套也能实现啊比如先判断异常、再判断静态文件、再判断认证……为什么不直接嵌套呢这里面有三个现实原因。第一顺序和职责会耦合。用 if 嵌套逻辑一多代码就是一座大粪山想调整顺序就得动整个结构。用中间件管道每个功能是一个独立组件增减、排序都通过配置完成可维护性完全不在一个量级。第二组件复用性差。你的项目如果想复用一套日志逻辑if 嵌套得复制粘贴中间件则可以在多个项目里直接引用。第三框架生态就是这样设计的。ASP.NET Core 的认证、授权、CORS、静态文件、路由全部都是中间件你不用这个模型就等于自己重新写一套框架还得跟框架打架。所以思路就是从“面向过程”切换成“面向管道”把每一步当成管道上的一个节点。2. 核心细节解析与实操要点中间件的实现方式2.1 约定式中间件类最传统也最底层的写法先看最简单的自定义中间件长什么样。约定式中间件是一个普通类构造函数接收RequestDelegate类里有一个InvokeAsync方法参数是HttpContext或者HttpContext加其他依赖服务。public class RequestLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILoggerRequestLoggingMiddleware _logger; public RequestLoggingMiddleware(RequestDelegate next, ILoggerRequestLoggingMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { var start DateTime.UtcNow; _logger.LogInformation(Request started: {Method} {Path}, context.Request.Method, context.Request.Path); await _next(context); var elapsed DateTime.UtcNow - start; _logger.LogInformation(Request finished: {Method} {Path} with {StatusCode}, took {Elapsed} ms, context.Request.Method, context.Request.Path, context.Response.StatusCode, elapsed.TotalMilliseconds); } }用法是注册到管道里app.UseMiddlewareRequestLoggingMiddleware();有几个细节值得注意。RequestDelegate是管道里“下一个节点”的委托不调用_next就直接返回的话后面的中间件就不会执行——这就是短路。另外构造函数里不能注入 Scoped 服务因为中间件实例本身是单例的App 启动时创建Scoped 服务的生命周期跟请求走注入会出问题。要注入 Scoped 服务可以把服务作为InvokeAsync的参数由框架在请求时从 DI 容器解析。2.2 基于工厂的中间件解决依赖注入的优雅方案如果你注册的是app.UseMiddlewareMyMiddleware()框架默认会用ActivatorUtilities来创建中间件实例构造函数里的依赖除了RequestDelegate之外都从 DI 容器取。这在大部分场景下够用了。但如果你需要更精细的控制比如每次请求都重新创建中间件实例或者想用自己写好的工厂类来构建中间件就可以用IMiddlewareFactory和IMiddleware。public class CustomMiddleware : IMiddleware { public async Task InvokeAsync(HttpContext context, RequestDelegate next) { // 处理请求 await next(context); // 处理响应 } } // 在 DI 里注册 builder.Services.AddScopedCustomMiddleware();注意这里的区别IMiddleware是需要注册到 DI 的而且建议用AddScoped或者AddTransient因为它可以被请求生命周期管理。相比之下UseMiddlewareT的默认行为更接近单例中间件实例只创建一次。这一点在面试里如果答得出来会很加分。2.3 内联中间件与 Use/Map/When 扩展方法快速原型神器不想为了一个小功能单独建一个类没问题直接用Use内联写入app.Use(async (context, next) { // 请求进入时做的事情 Console.WriteLine($Before: {context.Request.Path}); await next(); // 响应返回时做的事情 Console.WriteLine($After: {context.Response.StatusCode}); });除了Use还有几个专门做分支的扩展方法Map根据请求路径前缀分叉管道比如/admin走管理员中间件链其他走主链。MapWhen当满足某个条件时走另一条管道。UseWhen当满足条件时在现有管道中额外插入一段中间件但执行完还会回到主管道继续往下走注意和MapWhen的区别。用Map写分支的典型例子app.Map(/health, healthApp { healthApp.Run(async context { await context.Response.WriteAsync(Healthy); }); });这种行为在我的实践中很常用尤其是健康检查接口根本不用写 Controller一个Run就搞定了。2.4 到底该用哪种写法决策对比与推荐为了让你看得更清楚我整理了一张对比表方式适用场景依赖注入请求作用域复杂度app.Use(lambda)简单日志、响应头、短小逻辑无直接闭包捕获每次请求执行低UseMiddlewareT约定式常规中间件逻辑较多构造注入 Singleton/TransientInvoke 注入 Scoped实例单例Invoke 每次调用中IMiddleware AddScoped需要请求级实例、需要工厂全部由 DI 管理请求级中高Map/MapWhen/UseWhen按路径、按条件分叉管道无需专门类正常低我的个人经验是80% 的场景用UseMiddlewareT约定式就够了。只有当你确定中间件里依赖的服务需要请求级实例或者你要做单元测试、想通过 DI 替换中间件实现时才去考虑IMiddleware。内联 lambda 适合非常轻量的逻辑写多了Program.cs会变得很丑而且没法复用。3. 实操过程与核心环节实现注册顺序与内置中间件配置3.1 注册顺序为什么决定生死从一次 404 事故说起我印象特别深的一次事故有个同事在app里先写了app.UseRouting()然后紧接着写了一个自定义中间件中间件里想访问路由数据结果context.GetRouteData()永远拿不到值。当时他百思不得其解。原因很简单中间件是按注册顺序执行的。路由数据的填充发生在UseRouting()这个节点之后你如果在此之前去取自然取不到。同理如果你想在某个中间件里拿到User认证后的用户信息那个中间件必须注册在UseAuthentication()之后。这个问题的本质是请求管道是有“状态推进”的每一步可能在前一步的基础上补充一些数据。你把自己放的中间件当成管道里的一个工位前一个工位没做完的事你这里就看不到。所以注册顺序不是“建议”而是功能能否正常工作的前提。3.2 ASP.NET Core 内置中间件全景C# 中间件有哪些面试里经常被问“C# 中间件有哪些”其实标准答案就是 ASP.NET Core 内置的那一串。我按标准的推荐顺序列一下顺序中间件作用1UseExceptionHandler异常处理生产环境避免暴露异常堆栈2UseHsts强制 HTTPS 安全策略头3UseHttpsRedirectionHTTP 重定向到 HTTPS4UseStaticFiles处理静态文件5UseRouting路由匹配确定请求对应的端点6UseCors跨域7UseAuthentication身份认证8UseAuthorization授权9UseEndpoints/MapControllers执行业务端点10UseResponseCaching/UseResponseCompression响应缓存 / 压缩这只是推荐顺序不是死规矩。比如你用了UseResponseCompression响应压缩它通常应该放在靠前的位置因为在响应返回到客户端之前要把数据压缩好。如果你加了UseStaticFiles之后想给静态文件也做压缩那可能要调整顺序。关键还是要理解每个中间件做了什么、它需要前置哪些信息。一个比较常见的疑问是.NET 7之后的简化写法比如WebApplication模板里你可能看不到UseRouting和UseEndpoints的显式调用而是直接app.MapControllers()。框架层面会自动把路由相关的中间件放到合适的位置。但这不意味着顺序不重要你自定义中间件依然要遵守“需要什么数据就必须在对应中间件之后”的原则。3.3 手写三个高复用自定义中间件异常处理、响应头、接口耗时统计实操要有东西能落地。我建议你跟着写这三个覆盖日常开发最常用的场景。第一个是全局异常处理中间件。注意我这里说的是“手动版的异常处理”和UseExceptionHandler有区别但更能体现中间件思想public class ExceptionHandlingMiddleware { private readonly RequestDelegate _next; private readonly IHostEnvironment _env; public ExceptionHandlingMiddleware(RequestDelegate next, IHostEnvironment env) { _next next; _env env; } public async Task InvokeAsync(HttpContext context) { try { await _next(context); } catch (Exception ex) { context.Response.StatusCode 500; context.Response.ContentType application/json; var response new { Message 服务器内部错误, Detail _env.IsDevelopment() ? ex.Message : null }; await context.Response.WriteAsJsonAsync(response); } } }注册的时候记得放在最前面因为它要兜住后面所有中间件抛出的异常app.UseMiddlewareExceptionHandlingMiddleware();第二个是自定义响应头中间件用来输出X-Request-Id或者安全响应头public class SecurityHeadersMiddleware { private readonly RequestDelegate _next; public SecurityHeadersMiddleware(RequestDelegate next) { _next next; } public async Task InvokeAsync(HttpContext context) { context.Response.OnStarting(() { context.Response.Headers[X-Content-Type-Options] nosniff; context.Response.Headers[X-Frame-Options] DENY; return Task.CompletedTask; }); await _next(context); } }这里有个很重要的知识点在_next(context)之前直接设置响应头其实是有风险的因为响应一旦开始发送Header 就定死了不能再改。用OnStarting注册回调框架会在响应将要发送之前执行这个回调这时候改 Header 才是安全可靠的。第三个是接口耗时统计这个配合日志中间件一起用特别方便还能输出性能数据到监控系统public class PerformanceMiddleware { private readonly RequestDelegate _next; private readonly ILoggerPerformanceMiddleware _logger; public PerformanceMiddleware(RequestDelegate next, ILoggerPerformanceMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { var sw Stopwatch.StartNew(); await _next(context); sw.Stop(); if (sw.ElapsedMilliseconds 500) { _logger.LogWarning(Slow request: {Method} {Path} took {Elapsed} ms, context.Request.Method, context.Request.Path, sw.ElapsedMilliseconds); } } }这三个中间件加起来就形成了一个非常实用的基础管道异常处理在最外层兜底、性能中间件记录耗时、响应头中间件给输出加安全头、日志中间件记录访问明细。3.4 检查管道执行轨迹按步骤打印中间件顺序新手经常困惑“代码明明写了为什么没执行”。这时候最高效的排查方法就是写一个临时的中间件在管道里打印每个节点的执行轨迹。我一般这样搞app.Use(async (context, next) { Console.WriteLine($ 中间件: {context.Request.Path}); await next(); Console.WriteLine($ 返回: {context.Response.StatusCode}); });放到任意位置就能看到它前后发生了什么。把所有自定义中间件都加上这种调试日志跑一次请求管道顺序一目了然。我曾经用这个方法在五分钟内定位了一个 CORS 中间件放错位置导致跨域失败的问题——当时请求已经进了 CORS但因为注册顺序在授权之后浏览器预检请求没被正确处理。4. 常见问题与排查技巧实录4.1 中间件不执行可能栽在 Rrun 型节点上一个典型的坑你在管道中间放了一个Run中间件后面的东西全不执行了。很多人会忽略Run的本质——它不调用next直接短路管道。举个例子app.UseMiddlewareRequestLoggingMiddleware(); app.Run(async context { await context.Response.WriteAsync(Hello); }); app.UseMiddlewareSecurityHeadersMiddleware();这个SecurityHeadersMiddleware永远不会执行因为Run已经画上了管道终点。你在项目里如果看到某个中间件“不生效”第一件事检查它前面是不是有个Run或者有哪个中间件有条件地没有调用next。4.2 读取请求体两次会卡死Body 只能读一次这是一个非常经典的中间件问题。你在一个中间件里读取请求体context.Request.Body读完之后发现 Controller 里绑定参数全部变成 null 了。原因是请求体是一个流流被读完之后位置停留在末尾再读就什么都读不到了。框架去绑定参数的时候读到的就是空。解决办法是开启请求体缓冲然后在读取后重新将位置重置为 0public async Task InvokeAsync(HttpContext context) { context.Request.EnableBuffering(); using (var reader new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true)) { var body await reader.ReadToEndAsync(); // 处理 body 内容 } context.Request.Body.Position 0; await _next(context); }注意两点EnableBuffering()必须放在第一次读取之前StreamReader构造参数leaveOpen: true保证读完不释放底层流。这两个细节漏一个都会在运行时给你惊喜。4.3 响应阶段改 Header 报错OnStarting 的正确用法我见过有人这么写在_next(context)之后、还没返回之前直接设置context.Response.Headers[X-Foo] bar。大多数时候没问题但遇到大响应体或者流式响应时可能还没执行到那行代码响应头就已经发出去了然后框架抛异常“Headers are read-only, response has already started”。这是响应管道里最容易踩的坑。像我在 3.3 节里写的那样要改响应头推荐用context.Response.OnStarting注册回调。这个回调会在响应发送之前同步执行是框架留给中间件“最后修改 Header 的机会”。如果是IResult或者 MVC 里动态设置也要注意时机尽量在业务代码返回前处理。4.4 Scoped 服务注入中间件失败两种解决方案对照中间件的生命周期问题用一句话总结就是中间件本身倾向于单例但你可能需要请求作用域的服务。那么在约定式中间件里怎么拿到 Scoped 服务呢方案一在InvokeAsync方法里注入public async Task InvokeAsync(HttpContext context, IUserService userService)它会被框架从当前请求的 DI 作用域里解析出来该是 Scoped 就给你 Scoped 实例。这是官方推荐的方式。方案二用IServiceScopeFactory手动创建一个小作用域public async Task InvokeAsync(HttpContext context) { using (var scope _scopeFactory.CreateScope()) { var userService scope.ServiceProvider.GetRequiredServiceIUserService(); // 使用 userService } await _next(context); }方案二有点“绕过框架”的意思一般用于你需要在中间件内部做多线程任务或者从某个服务里拿数据必须拥有独立作用域时。日常代码优先用方案一简洁且不会引起作用域混乱。4.5 性能陷阱中间件里的同步阻塞 IO中间件是跑在请求线程上的如果你是同步调用.Result或.Wait()去等待一个异步方法在高并发下很可能把线程池耗尽。这不仅是中间件的问题但中间件因为“每个请求必定经过”放大了性能风险。经验准则是能async就async同步Task.Run也不要乱用IO 操作一定要用await。我的性能监控中间件就曾经把调用第三方 HttpClient 的同步写法暴露出去了压测 100 并发时线程池直接打爆换成了await httpClient.GetAsync()之后P95 延迟下降了接近一半。这种问题不压测根本发现不了所以要提前预防。4.6 中间件常见问题速查表我整理了日常排查问题的一个快速索引现象可能原因排查建议中间件整体没执行前面有Run或短路分支检查管道里是否有无条件终止节点读不到路由数据中间件顺序在UseRouting之前调后顺序或使用UseRouting之后的分支Controller 接收 body 为 null中间件读了 Body 没重置 Position使用EnableBufferingPosition 0设置 Header 抛异常响应已开始发送改用OnStarting回调Scoped 服务注入报错中间件构造函数注入 Scoped改为InvokeAsync参数注入接口跨域失败CORS 中间件顺序不对确保UseCors在UseAuthorization之前权限校验不生效中间件在授权之前短路了检查是否有中间件吞掉请求这张表我和团队同学一起完善过很多次基本上能覆盖日常开发 90% 的中间件问题。5. 中间件面试高频问题与深度扩展5.1 面试官到底在考什么三个维度拆解面试里问中间件通常有三个层次。第一层概念题。比如“什么是中间件”“中间件和过滤器的区别”。这一层考察你是否理解请求管道模型。中间件是管道级、关注横切逻辑过滤器Filter是 MVC 生命周期内的概念更关注 Action 执行前后的行为。两者不是替代关系而是层级不同的机制。第二层原理题。比如“Use 和 Run 区别”“如何自定义中间件”“中间件顺序的影响”。这一层考察你是否有真实项目经验能不能讲清楚RequestDelegate的本质和next调用的意义。第三层实践题。比如“压测发现接口很慢如何用中间件定位”“如何实现一个通用的接口耗时监控”“如何设计一个网关的限流中间件”。这一层考察你的设计能力和排障能力有实际踩坑经验的人会明显更有优势。5.2 两个容易被忽略的扩展点MapWhen 分支与终端中间件除了基础概念想要在面试里秀一把可以主动聊聊两个进阶用法。第一个是MapWhen实现按条件切分支管道app.MapWhen(context context.Request.Query.ContainsKey(internal), builder { builder.UseMiddlewareInternalApiKeyMiddleware(); });比如你有一些内部接口需要单独的 API Key 校验正常接口走认证中间件链内部接口走自己的 Key 校验链。用分支管道可以把这两种校验逻辑完全隔离不会互相污染。第二个是“终端中间件”设计模式。网关类项目经常用管道走到你的自定义中间件后它不是简单调用next而是直接作为请求的终点把请求通过 HttpClient 转发到下游服务再把下游响应原样返回。这种模式在 YARP 这类反向代理库里非常常见理解了这个你对中间件的掌控能力就上一个台阶。5.3 一段能背的面试回答模板面试官问“谈谈你对中间件的理解”可以按这个思路答中间件是 ASP.NET Core 请求管道的基本组成单元。每个中间件接收一个RequestDelegate代表管道的下一步。它可以在调用下一步之前处理请求也可以在返回后处理响应还可以选择不调用下一步实现请求短路。框架内置了一系列中间件比如静态文件、路由、认证、授权等我们通过Use、Map、When等方式组合它们。自定义中间件时要注意注册顺序对功能的影响也要注意生命周期问题——构造函数不能注入 Scoped 服务但InvokeAsync参数可以。响应阶段修改 Header 或者读取请求体都要在正确的时间点操作否则会遇到运行时错误。这个回答涵盖了定义、原理、内置列表、自定义要点、常见坑面试官一般会顺着往下深挖其中一两点你就自然进入自己擅长的领域了。5.4 写在最后的经验之谈说了这么多最想强调的一点是中间件虽然简单但它决定了你整个应用的请求边界。很多系统在前期开发时感觉中间件没什么用等到排查跨域、认证、慢请求、日志格式时才发现全部积攒在管道这一层。我自己现在设计新项目的第一件事就是把中间件管道画出来异常处理放哪、日志放哪、认证放哪、自定义业务中间件放哪一条条列清楚。花十分钟做这件事能省掉后期好几个小时的排查时间。另一个实用的习惯是给中间件加简单的 “Pipeline 预览” 方法在启动日志里打出当前生效的中间件列表每次启动都能确认当前环境到底跑了哪些中间件避免配置不同导致的环境差异问题。这个习惯帮助我多次在生产环境快速定位到“为什么这里生效、那里不生效”的环境类问题。建议你也试着在自己的项目里这么做一次实测下来你会回来感谢这个习惯的。
返回列表