09-中间件与请求管道

一、什么是中间件

中间件可以理解成"请求进入应用之后,沿途经过的一道道处理关卡"。浏览器、前端应用或者其他服务发来的 HTTP 请求,并不会直接跳进控制器里执行,而是先进入 ASP.NET Core 的请求管道。这个管道里可以放日志、异常处理、认证、授权、静态文件处理、限流、压缩、跨域等一系列处理中间件。每个中间件都可以在请求继续向后传递之前先做点事情,也可以在后面的代码执行完成之后再补充处理,甚至可以直接结束请求,不让它再继续往下走。

如果你把整个 Web 应用想象成一条流水线,那么中间件就是流水线上的工位。请求像一件待加工的产品,先经过入口检查,再经过身份验证、路由匹配、业务执行,最后再包装成响应发回客户端。理解中间件之后,你就能真正明白 ASP.NET Core 的请求到底是怎样被一步步处理的,也会知道为什么"注册顺序"在这里不是一个无关紧要的细节,而是行为本身的一部分。

二、请求管道的工作原理

ASP.NET Core 的请求处理模型通常会被描述成"洋葱模型"。因为请求是从外层一层层向里走,响应又从里层一层层退回来。你注册的中间件顺序,决定了请求进来的顺序;而响应返回时,顺序正好反过来。

text 复制代码
客户端请求 → 中间件1 → 中间件2 → 中间件3 → 控制器或端点 → 返回响应

这个模型最重要的地方,在于中间件天然拥有两个时机。第一个时机是在调用下一个中间件之前,这时候你可以记录开始时间、读取请求头、做身份验证、决定是否放行。第二个时机是在下游逻辑执行完之后,也就是 await next() 返回之后,这时候你可以记录耗时、修改响应头、打印状态码、统一包装结果。很多横切逻辑之所以适合用中间件来做,原因就在这里,因为它们通常都需要"在业务前做点事,在业务后再补一刀"。

三、中间件的基本写法

先看一个最小示例。它虽然简单,但已经完整展示了中间件的基本结构。

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 定义一个简单的中间件
app.Use(async (context, next) =>
{
    // 请求进来时执行的代码
    Console.WriteLine("请求进来了!");
    
    // 调用下一个中间件
    await next();
    
    // 响应回去时执行的代码(反向顺序)
    Console.WriteLine("响应回去了!");
});

app.MapGet("/", () => "Hello World");

app.Run();

如果你访问根路径,控制台最直观的输出会是下面这样:

text 复制代码
请求进来了!
响应回去了!

这里的 app.Use 就是在请求管道里注册一个中间件。它接收两个核心参数。context 是当前请求的上下文对象,里面包含请求路径、方法、头信息、响应对象、用户信息等几乎所有当前请求有关的数据。next 则代表"调用下一个中间件"的委托。中间件之所以能串成一条链,本质上就是因为每一层都能决定要不要把请求继续交给下一层。

这段代码执行时,先打印"请求进来了!",然后调用 await next() 把请求交给后面的端点。等端点返回 Hello World 之后,执行流程会回到当前中间件,再打印"响应回去了!"。这就是洋葱模型最经典的表现方式。你可以把 await next() 理解成"向里走一步",而它返回之后的代码则是"往外退一步"。

四、实战案例:几个最常见的中间件

理解了基本形态之后,我们来看几个最典型、也最实用的中间件场景。你会发现,中间件真正有价值的地方,是它能把很多跟业务无关、但对整个系统都很重要的逻辑放到统一位置处理。

4.1 日志中间件

日志中间件非常适合作为第一个入门案例,因为它正好会同时用到"前处理"和"后处理"。

csharp 复制代码
// 日志中间件
app.Use(async (context, next) =>
{
    var startTime = DateTime.UtcNow;
    var path = context.Request.Path;
    var method = context.Request.Method;
    
    Console.WriteLine($"[{DateTime.Now}] {method} {path} 开始处理");
    
    await next();
    
    var duration = (DateTime.UtcNow - startTime).TotalMilliseconds;
    Console.WriteLine($"[{DateTime.Now}] {method} {path} 完成,耗时 {duration}ms");
});

app.MapGet("/api/users", () => new[] { "Alice", "Bob" });
app.MapGet("/api/products", () => new[] { "Product1", "Product2" });

当你访问 /api/users 时,输出大致会像下面这样:

text 复制代码
[2024-01-15 10:30:00] GET /api/users 开始处理
[2024-01-15 10:30:01] GET /api/users 完成,耗时 12.5ms

这段代码进入中间件时先记录当前时间、请求路径和请求方法,然后打印"开始处理"的日志。接着调用 await next(),让请求继续往后走。等后面的端点执行完以后,再回到这里计算总耗时,并打印"完成处理"的日志。你会发现,这种结构非常适合用来记录接口耗时、追踪慢请求和排查问题。

真正值得你理解的是日志中间件并没有参与业务计算,但它却能给每一个请求附上一层稳定的观察能力。后面你接入正式日志框架时,这种写法仍然成立,只是把 Console.WriteLine 换成结构化日志而已。

4.2 异常处理中间件

异常处理中间件的价值在于,它能把系统里散落的异常统一收口,而不是让每个控制器都自己 try-catch 一遍。

csharp 复制代码
app.UseExceptionHandler("/error");

app.Map("/error", (HttpContext context) =>
{
    var exception = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
    return Results.Problem(
        detail: exception?.Error.Message,
        statusCode: 500
    );
});

app.MapGet("/test-error", () =>
{
    throw new Exception("测试异常!");
});

如果访问 /test-error,你会得到一个统一的错误响应:

json 复制代码
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
  "title": "An error occurred while processing your request.",
  "detail": "测试异常!",
  "status": 500
}

这里的关键点在于 UseExceptionHandler("/error")。它告诉 ASP.NET Core 如果后面的管道抛出了未处理异常,不要把异常细节直接裸露给客户端,而是跳转到 /error 这个专门的处理端点,由它来生成统一的错误响应。这样做的好处非常直接。第一,返回格式统一了;第二,后续你想接日志、错误追踪、监控告警时,入口也统一了;第三,业务代码可以少写很多重复的异常包装逻辑。

异常处理中间件通常应该尽量放在请求管道靠外层的位置,因为这样它才能捕获后面更多中间件和控制器里抛出的异常。如果它放得太靠后,前面抛出的异常它就接不到。

4.3 认证中间件

认证中间件展示的是另一个非常重要的能力。中间件可以决定是否让请求继续往后走。如果条件不满足,它完全可以直接返回响应,这种行为通常叫"短路"。

csharp 复制代码
// 模拟用户存储
var users = new Dictionary<string, string>
{
    { "admin", "123456" },
    { "user", "111111" }
};

// 认证中间件
app.Use(async (context, next) =>
{
    // 登录接口跳过检查
    if (context.Request.Path.StartsWithSegments("/login"))
    {
        await next();
        return;
    }
    
    // 检查是否有认证信息
    var token = context.Request.Headers["Authorization"].FirstOrDefault();
    
    if (string.IsNullOrEmpty(token))
    {
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("未登录,请先登录");
        return;
    }
    
    // 验证 token(简化版,实际应该用 JWT)
    if (!users.ContainsKey(token))
    {
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("认证失败");
        return;
    }
    
    // 验证通过,继续
    await next();
});

// 登录接口
app.MapPost("/login", (UserLogin login) =>
{
    if (users.TryGetValue(login.Username, out var password) && password == login.Password)
    {
        return Results.Ok(new { token = login.Username });
    }
    return Results.Unauthorized();
});

// 受保护的接口
app.MapGet("/api/data", () => "这是受保护的数据")
    .RequireAuthorization();

record UserLogin(string Username, string Password);

如果你测试这组接口,大致会看到这样的结果:

text 复制代码
POST /login {"username":"admin","password":"123456"}
返回: {"token":"admin"}

GET /api/data (不带 Authorization)
返回: 未登录,请先登录

GET /api/data (带 Authorization: admin)
返回: 这是受保护的数据

这段代码里最值得理解的,不是"认证成功返回什么",而是 return 的意义。当请求没有携带 Authorization 头,或者 Token 校验失败时,中间件直接写入响应并返回,没有执行 await next()。这就意味着请求在当前中间件就被终止了,后面的端点、控制器、中间件全部不会再执行。也正因为这种短路能力,中间件非常适合做认证、授权、限流和访问控制这类"先过门槛再往下走"的逻辑。

五、自定义中间件类

当中间件逻辑比较简单时,用 app.Use 写 Lambda 就足够了;但如果逻辑开始变复杂,或者你希望这段中间件能复用、能单独测试、还能注入别的服务,那就更适合把它写成独立类。

csharp 复制代码
public class TimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TimingMiddleware> _logger;
    
    public TimingMiddleware(RequestDelegate next, ILogger<TimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = System.Diagnostics.Stopwatch.StartNew();
        
        await _next(context);
        
        stopwatch.Stop();
        _logger.LogInformation($"请求 {context.Request.Path} 耗时 {stopwatch.ElapsedMilliseconds}ms");
    }
}

// 使用
app.UseMiddleware<TimingMiddleware>();

这段代码和前面的 Lambda 中间件做的是同一类事情,但形式更适合工程化。RequestDelegate _next 代表下一个中间件,和前面示例里的 next 是同一个概念;ILogger<TimingMiddleware> 则通过依赖注入自动传进来,说明中间件类本身也可以使用容器里的服务。InvokeAsync 是中间件真正执行的入口,ASP.NET Core 会在处理请求时自动调用它。

如果你希望使用方式更清晰,还可以再包一层扩展方法:

csharp 复制代码
public static class TimingMiddlewareExtensions
{
    public static IApplicationBuilder UseTiming(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<TimingMiddleware>();
    }
}

// 使用
app.UseTiming();

扩展方法的价值在于,它让 Program.cs 的可读性更好。以后你看到 app.UseTiming(),比看到一长串 UseMiddleware<TimingMiddleware>() 更容易一眼知道"这里注册了一个耗时统计中间件"。在大型项目里,这种表达层面的清晰度很重要。

六、常用内置中间件

ASP.NET Core 自带了大量中间件,很多时候你并不需要自己从零造轮子,而是把已有能力按正确顺序组合起来。

csharp 复制代码
var app = builder.Build();

app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

这段代码虽然只有几行,但每一行都承担着不同职责。UseExceptionHandler 负责兜底异常;UseHttpsRedirection 会把 HTTP 请求重定向到 HTTPS;UseStaticFiles 让静态资源请求可以直接返回,不必进入 MVC 或 API 路由;UseRouting 负责建立路由匹配上下文;UseAuthentication 识别当前请求是谁;UseAuthorization 再根据策略判断这个身份有没有访问权限;最后 MapControllers() 或其他终结端点负责真正处理业务逻辑。

你可以把它们理解成一条"先建立安全边界,再进入业务处理"的标准通道。这里最需要你避免的误区是不要把它们当成"抄模板"。每一个中间件的顺序都不是随意摆放的,而是和它解决的问题直接相关。

七、中间件顺序为什么这么重要

中间件顺序的重要性,很多人在第一次写 ASP.NET Core 时都会低估。事实上,同样一组中间件,顺序一旦改错,系统行为就可能完全不同。

csharp 复制代码
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

异常处理中间件通常要放在最外层,因为它要尽可能捕获后面所有处理阶段的异常。静态文件处理中间件要放在路由和控制器之前,这样请求到 /css/site.css 这类资源时,可以直接命中静态文件而不是继续走业务路由。认证和授权则通常要放在路由之后、端点执行之前,因为它们需要知道当前匹配到了哪个端点、使用了什么授权策略,然后才能正确判断是否允许访问。

可以这样理解,顺序不是语法问题,而是执行语义问题。你在 Program.cs 里写下去的顺序,实际上就是请求经过系统的真实路线图。

八、综合案例:API 请求监控系统

下面我们把前面讲过的思路放到一个完整示例里,做一个包含限流、性能监控、请求日志和错误处理的请求监控系统。这个案例适合你从"单个中间件怎么写"进阶到"多个中间件如何组合工作"。

csharp 复制代码
using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

// 1. 限流中间件
var requestCounts = new ConcurrentDictionary<string, int>();
app.Use(async (context, next) =>
{
    var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    
    // 每个IP每分钟最多100个请求
    var count = requestCounts.AddOrUpdate(ip, 1, (k, v) => v + 1);
    
    if (count > 100)
    {
        context.Response.StatusCode = 429;
        await context.Response.WriteAsync("请求过于频繁,请稍后再试");
        return;
    }
    
    // 每分钟重置计数(简化版)
    await next();
});

// 2. 性能监控中间件
app.Use(async (context, next) =>
{
    var watch = System.Diagnostics.Stopwatch.StartNew();
    
    await next();
    
    watch.Stop();
    
    if (watch.ElapsedMilliseconds > 1000)
    {
        Console.WriteLine($"警告: {context.Request.Path} 耗时 {watch.ElapsedMilliseconds}ms");
    }
});

// 3. 请求日志中间件
app.Use(async (context, next) =>
{
    Console.WriteLine($"请求: {context.Request.Method} {context.Request.Path}");
    
    await next();
    
    Console.WriteLine($"响应: {context.Response.StatusCode}");
});

// 4. 全局异常处理
app.UseExceptionHandler("/error");

app.Map("/error", (HttpContext context) =>
{
    var exception = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
    Console.WriteLine($"错误: {exception?.Error.Message}");
    return Results.Problem(
        detail: "服务器出错,请联系管理员",
        statusCode: 500
    );
});

// 模拟慢接口
app.MapGet("/api/slow", async () =>
{
    await Task.Delay(1500);
    return new { message = "处理完成" };
});

// 模拟出错接口
app.MapGet("/api/error", () =>
{
    throw new InvalidOperationException("模拟的错误!");
});

// 正常接口
app.MapGet("/api/users", () => new[] { "Alice", "Bob", "Charlie" })
    .WithName("GetUsers")
    .WithOpenApi();

app.MapPost("/api/users", (User user) =>
{
    Console.WriteLine($"新用户: {user.Name}");
    return Results.Created($"/api/users/{user.Id}", user);
})
    .WithName("CreateUser")
    .WithOpenApi();

record User(int Id, string Name, string Email);

app.Run();

这组中间件组合起来之后,请求会按顺序依次经过限流、性能监控、请求日志和异常处理。假设请求的是 /api/slow,它会先判断当前 IP 是否超限,如果没有超限就继续向后;然后开始计时;再打印请求日志;最后进入端点逻辑。端点逻辑执行完以后,流程会反过来返回,日志中间件拿到状态码,性能监控中间件拿到总耗时。如果请求的是 /api/error,异常处理中间件就会接住异常并统一返回 500 错误响应。

你可以把几个典型结果理解成下面这样:

text 复制代码
GET /api/users
输出:
请求: GET /api/users
响应: 200

GET /api/slow
输出:
请求: GET /api/slow
警告: /api/slow 耗时 1503ms
响应: 200

GET /api/error
输出:
请求: GET /api/error
错误: 模拟的错误!
响应: 500

这个案例最值得你掌握的地方,是多个中间件叠加之后的执行顺序。它不是"各做各的、互不影响",而是一个统一的请求路径。也正因为如此,中间件之间的先后顺序、短路条件和前后处理逻辑,都会共同决定最终行为。

九、中间件和过滤器有什么区别

初学 ASP.NET Core 时,很多人会把中间件和过滤器混在一起,因为它们看起来都能"在业务代码前后做点事情"。但两者的作用范围并不一样。中间件工作在整个 HTTP 请求管道层面,几乎所有请求都会经过它;过滤器则主要工作在 MVC 或 Web API 的动作执行阶段,更贴近控制器和 Action。

所以一个比较实用的判断方式是,如果这段逻辑属于全局请求处理,比如日志、异常处理、认证、跨域、静态文件、压缩,通常优先考虑中间件;如果这段逻辑强依赖 MVC 本身,比如模型验证、动作前后钩子、特定控制器的权限控制,那么过滤器会更合适。简单说,中间件更靠近 HTTP 管道本身,过滤器更靠近 MVC 执行过程。

十、最佳实践

真正写中间件时,一个非常重要的原则是保持职责单一。一个中间件最好只解决一个清晰问题,比如只做日志、只做认证、只做异常处理,而不是在同一个中间件里把认证、缓存、限流、日志全都堆进去。职责越单一,顺序越容易调整,测试和排错也越容易。

另一个实践重点是尽量使用异步写法。中间件处在请求主路径上,只要它阻塞线程,就会直接影响系统吞吐量。像数据库访问、网络调用、文件读取这类 I/O 操作,都应该优先使用异步 API。还有一个经常被忽略的点,是资源清理。很多中间件需要在前后两个阶段成对处理逻辑,比如开启计时器、创建作用域、写入临时数据,这时候可以借助 try/finally 确保后续清理代码一定执行。

csharp 复制代码
app.Use(async (context, next) =>
{
    try
    {
        await next();
    }
    finally
    {
        // 清理资源
    }
});

最后要牢牢记住,只要你没有调用 await next(),这个请求就会在当前中间件被短路。短路不是坏事,它本来就是认证、限流、权限控制这类逻辑的正常手段;但如果你不是故意这么做,就很容易导致"后面端点为什么完全没执行"的问题。所以排查中间件 bug 时,第一件事通常就是看顺序对不对,next 有没有被正确调用,短路是不是符合预期。

csharp 复制代码
if (!IsAuthorized(context))
{
    context.Response.StatusCode = 401;
    return;  // 短路,不继续
}

十一、总结

中间件是 ASP.NET Core 的核心概念之一。理解了中间件,你就真正理解了请求是如何进入系统、如何被逐层处理、又如何一步步返回客户端的。后面无论你接认证、日志、配置、AI 服务调用、性能监控还是异常追踪,本质上都还是在这条请求管道上做文章。

你现在应该重点记住三件事。第一,中间件的作用不只是"拦截请求",而是可以在请求前后都做处理。第二,注册顺序会直接决定行为,顺序错了,系统行为就会变。第三,中间件非常适合承载横切逻辑,而不适合塞进具体业务细节。把这三点吃透,后面再看 ASP.NET Core 的很多基础设施,你会发现它们其实都在这套模型里。

下一章我们学习配置系统和日志框架,这两部分和中间件结合得非常紧,尤其是在真实项目里,很多配置读取和日志记录都会贯穿整个请求处理过程。

练习题:

  1. 新建一个 ASP.NET Core 项目,注册三个 app.Use 中间件,分别打印"中间件1进入"、"中间件2进入"、"中间件3进入"以及对应的退出日志,运行后观察控制台输出顺序,验证洋葱模型的执行流程。
  2. 实现一个 RequestIdMiddleware,在每个请求进入时生成一个 Guid 作为请求 ID,将其写入响应头 X-Request-Id,并在日志中把请求 ID 和请求路径一起打印出来。
  3. 将第四节的认证中间件改写成独立的中间件类,通过构造函数注入 ILogger,并为它编写一个 UseSimpleAuth 扩展方法,使 Program.cs 只需调用 app.UseSimpleAuth() 即可启用。
  4. 调整 UseExceptionHandlerUseAuthenticationUseStaticFiles 三个中间件的注册顺序,观察并记录不同顺序下系统行为的变化(例如:异常处理放到最后会发生什么,静态文件中间件放在路由之后会有什么影响)。
  5. 在第八节的综合案例基础上,将限流逻辑改为按分钟滑动窗口计数:记录每个 IP 最近 60 秒内的请求时间戳列表,超过 100 次则返回 429,并在响应头中附上 X-RateLimit-Remaining 字段表示剩余可用次数。
相关推荐
阿昌喜欢吃黄桃12 天前
RocketMq事务消息原理
java·中间件·消息队列·rocketmq·mq
半夜修仙13 天前
延迟队列的介绍及常见问题
java·数据库·中间件·rabbitmq
手握风云-13 天前
一条消息的旅程:RabbitMQ 学习与实践(一)
中间件·rabbitmq
RH23121113 天前
2026.6.8Linux
java·数据库·中间件
理人综艺好会14 天前
双Token机制在实际项目中的应用与实践
中间件·token
番茄去哪了15 天前
神领物流面试题(一)
java·大数据·中间件
念何架构之路15 天前
消息中间件
中间件
都说名字长不会被发现15 天前
Spring Boot Starter 中间件账号密码加密方案设计与实现
java·spring boot·后端·中间件
瀚高PG实验室15 天前
java中间件无法连接数据库
java·数据库·中间件·瀚高数据库
之歆16 天前
Day11_Express 深入解析:从中间件到项目实战
中间件·express