刚在调试一个接口慢查询,发现耗时全卡在一个不起眼的中间件里,这让我又一次体会到: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 ILogger<RequestLoggingMiddleware> _logger; public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> 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.UseMiddleware<RequestLoggingMiddleware>();有几个细节值得注意。RequestDelegate是管道里“下一个节点”的委托,不调用_next就直接返回的话,后面的中间件就不会执行——这就是短路。另外,构造函数里不能注入 Scoped 服务,因为中间件实例本身是单例的(App 启动时创建),Scoped 服务的生命周期跟请求走,注入会出问题。要注入 Scoped 服务,可以把服务作为InvokeAsync的参数,由框架在请求时从 DI 容器解析。
2.2 基于工厂的中间件:解决依赖注入的优雅方案
如果你注册的是app.UseMiddleware<MyMiddleware>(),框架默认会用ActivatorUtilities来创建中间件实例,构造函数里的依赖除了RequestDelegate之外,都从 DI 容器取。这在大部分场景下够用了。
但如果你需要更精细的控制,比如每次请求都重新创建中间件实例,或者想用自己写好的工厂类来构建中间件,就可以用IMiddlewareFactory和IMiddleware。
public class CustomMiddleware : IMiddleware { public async Task InvokeAsync(HttpContext context, RequestDelegate next) { // 处理请求 await next(context); // 处理响应 } } // 在 DI 里注册 builder.Services.AddScoped<CustomMiddleware>();注意这里的区别:IMiddleware是需要注册到 DI 的,而且建议用AddScoped或者AddTransient,因为它可以被请求生命周期管理。相比之下,UseMiddleware<T>的默认行为更接近单例(中间件实例只创建一次)。这一点在面试里如果答得出来,会很加分。
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) | 简单日志、响应头、短小逻辑 | 无,直接闭包捕获 | 每次请求执行 | 低 |
UseMiddleware<T>约定式 | 常规中间件,逻辑较多 | 构造注入 Singleton/Transient,Invoke 注入 Scoped | 实例单例,Invoke 每次调用 | 中 |
IMiddleware + AddScoped | 需要请求级实例、需要工厂 | 全部由 DI 管理 | 请求级 | 中高 |
Map/MapWhen/UseWhen | 按路径、按条件分叉管道 | 无需专门类 | 正常 | 低 |
我的个人经验是:80% 的场景用UseMiddleware<T>约定式就够了。只有当你确定中间件里依赖的服务需要请求级实例,或者你要做单元测试、想通过 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 内置的那一串。我按标准的推荐顺序列一下:
| 顺序 | 中间件 | 作用 |
|---|---|---|
| 1 | UseExceptionHandler | 异常处理,生产环境避免暴露异常堆栈 |
| 2 | UseHsts | 强制 HTTPS 安全策略头 |
| 3 | UseHttpsRedirection | HTTP 重定向到 HTTPS |
| 4 | UseStaticFiles | 处理静态文件 |
| 5 | UseRouting | 路由匹配,确定请求对应的端点 |
| 6 | UseCors | 跨域 |
| 7 | UseAuthentication | 身份认证 |
| 8 | UseAuthorization | 授权 |
| 9 | UseEndpoints/MapControllers | 执行业务端点 |
| 10 | UseResponseCaching/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.UseMiddleware<ExceptionHandlingMiddleware>();第二个是自定义响应头中间件,用来输出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 ILogger<PerformanceMiddleware> _logger; public PerformanceMiddleware(RequestDelegate next, ILogger<PerformanceMiddleware> 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.UseMiddleware<RequestLoggingMiddleware>(); app.Run(async context => { await context.Response.WriteAsync("Hello"); }); app.UseMiddleware<SecurityHeadersMiddleware>();这个SecurityHeadersMiddleware永远不会执行,因为Run已经画上了管道终点。你在项目里如果看到某个中间件“不生效”,第一件事检查它前面是不是有个Run或者有哪个中间件有条件地没有调用next。
4.2 读取请求体两次会卡死:Body 只能读一次
这是一个非常经典的中间件问题。你在一个中间件里读取请求体context.Request.Body,读完之后发现 Controller 里绑定参数全部变成 null 了。原因是请求体是一个流,流被读完之后,位置停留在末尾,再读就什么都读不到了。框架去绑定参数的时候读到的就是空。
解决办法是开启请求体缓冲,然后在读取后重新将位置重置为 0:
public 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.GetRequiredService<IUserService>(); // 使用 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 | 使用EnableBuffering+Position = 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.UseMiddleware<InternalApiKeyMiddleware>(); });比如你有一些内部接口需要单独的 API Key 校验,正常接口走认证中间件链,内部接口走自己的 Key 校验链。用分支管道,可以把这两种校验逻辑完全隔离,不会互相污染。
第二个是“终端中间件”设计模式。网关类项目经常用:管道走到你的自定义中间件后,它不是简单调用next,而是直接作为请求的终点,把请求通过 HttpClient 转发到下游服务,再把下游响应原样返回。这种模式在 YARP 这类反向代理库里非常常见,理解了这个,你对中间件的掌控能力就上一个台阶。
5.3 一段能背的面试回答模板
面试官问“谈谈你对中间件的理解”,可以按这个思路答:
中间件是 ASP.NET Core 请求管道的基本组成单元。每个中间件接收一个
RequestDelegate,代表管道的下一步。它可以在调用下一步之前处理请求,也可以在返回后处理响应,还可以选择不调用下一步,实现请求短路。框架内置了一系列中间件,比如静态文件、路由、认证、授权等,我们通过Use、Map、When等方式组合它们。自定义中间件时,要注意注册顺序对功能的影响,也要注意生命周期问题——构造函数不能注入 Scoped 服务,但InvokeAsync参数可以。响应阶段修改 Header 或者读取请求体,都要在正确的时间点操作,否则会遇到运行时错误。
这个回答涵盖了定义、原理、内置列表、自定义要点、常见坑,面试官一般会顺着往下深挖其中一两点,你就自然进入自己擅长的领域了。
5.4 写在最后的经验之谈
说了这么多,最想强调的一点是:中间件虽然简单,但它决定了你整个应用的请求边界。很多系统在前期开发时感觉中间件没什么用,等到排查跨域、认证、慢请求、日志格式时,才发现全部积攒在管道这一层。
我自己现在设计新项目的第一件事,就是把中间件管道画出来:异常处理放哪、日志放哪、认证放哪、自定义业务中间件放哪,一条条列清楚。花十分钟做这件事,能省掉后期好几个小时的排查时间。另一个实用的习惯是给中间件加简单的 “Pipeline 预览” 方法,在启动日志里打出当前生效的中间件列表,每次启动都能确认当前环境到底跑了哪些中间件,避免配置不同导致的环境差异问题。这个习惯帮助我多次在生产环境快速定位到“为什么这里生效、那里不生效”的环境类问题。建议你也试着在自己的项目里这么做一次,实测下来你会回来感谢这个习惯的。