一、什么是中间件

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

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

二、请求管道的工作原理

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

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

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

三、中间件的基本写法

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

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();

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

请求进来了!
响应回去了!

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

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

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

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

4.1 日志中间件

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

// 日志中间件
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 时,输出大致会像下面这样:

[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 一遍。

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,你会得到一个统一的错误响应:

{
  "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 认证中间件

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

// 模拟用户存储
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);

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

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 就足够了;但如果逻辑开始变复杂,或者你希望这段中间件能复用、能单独测试、还能注入别的服务,那就更适合把它写成独立类。

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 会在处理请求时自动调用它。

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

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 自带了大量中间件,很多时候你并不需要自己从零造轮子,而是把已有能力按正确顺序组合起来。

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 时都会低估。事实上,同样一组中间件,顺序一旦改错,系统行为就可能完全不同。

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

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

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

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

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

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 错误响应。

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

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 确保后续清理代码一定执行。

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

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

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 字段表示剩余可用次数。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐