☰
ASP.NET Core WebAPI核心机制:依赖注入与中间件管道详解
2026/10/12 20:16:48 网站建设 项目流程

这是ASP.NET Core WebAPI学习系列的第五篇。前四篇我们完成了项目创建、控制器编写、路由规则、模型绑定和参数校验,这些内容能让你把接口“跑起来”,但离真正能上线维护还差一段距离。这一篇我想集中啃两个特别容易含糊的机制:依赖注入和中间件管道。可以说,这两个东西是ASP.NET Core WebAPI的骨架和血管——服务水平如何组织、请求如何被一层层处理,都取决于对它们的理解。如果你是刚掌握基础、想要深入理解框架内部运作方式的开发者,那么这篇内容正好接上你的进度。我不会堆砌官方文档式的术语,而是用实际写业务代码时的视角来拆解。

1. 整体设计与思路拆解

1.1 为什么学到第五篇必须啃依赖注入和中间件

很多初学者写WebAPI时,习惯把业务逻辑直接堆在控制器里,用的时候直接new一个服务对象,甚至在多个类里各自创建同一个服务实例。前几篇的示例规模小,这样写没什么问题,但一旦接口多了、业务复杂了,这种写法会让你疲于处理依赖关系。依赖注入(Dependency Injection, DI)解决的就是“对象由谁创建、怎么管理生命周期”这个问题。框架内置的容器会把服务注册、解析、释放全部管起来,你在控制器里只需要在构造函数中声明“我需要什么服务”,框架在创建控制器时自动把对象给你。这样代码更干净,也方便单元测试时替换成Mock对象。

中间件则解决的是“HTTP请求到达控制器之前,以及响应返回之前,经过哪些处理逻辑”的问题。日志记录、异常捕获、身份认证、静态文件处理、路由匹配,本质上都是中间件。如果你不理解管道的执行顺序,很可能会遇到“认证中间件放在路由后面导致匿名请求也能进入控制器”或者“异常处理中间件放错位置导致错误堆栈直接暴露给前端”一类问题。把这两个机制放在同一次学习中,是因为它们经常一起出现:服务生命周期会影响中间件能否获取到实例,中间件里也可能需要调用DI容器解析出来的服务。

1.2 方案选型:内置容器够用吗

ASP.NET Core默认自带一个轻量级依赖注入容器,功能上覆盖了绝大多数场景:构造函数注入、生命周期管理、泛型注册、程序集扫描这些都有了。它完全可以胜任中小型项目。很多团队在初期就引入第三方容器,比如Autofac,理由是功能更丰富,比如属性注入、动态代理、批量注册、基于名称的解析等。我的看法是:如果你还在学习中,先把内置容器用透,反而更容易理解原理。因为内置容器足够透明,代码提示清晰,报错也相对容易定位。当你遇到“真的需要动态批量注册很多服务”或“AOP切面需要动态代理”时,再引入第三方容器不迟。这一篇的实操都基于内置容器,但最后我会补充什么时候该考虑换用第三方容器。

1.3 一条主线串联整篇内容

为了让内容不散,我设计了一条完整的小型业务链路:一个简单的商品查询接口。服务层对外暴露IProductService,实现类负责从内存集合返回商品数据;控制器依赖该服务;然后在请求管道中加入日志中间件,记录每个请求的耗时;最后加上异常处理中间件,保证未捕获的异常都被统一格式化成友好响应。整个过程串下来,依赖注入和中间件的核心用法就都覆盖到了。这样比单独讲概念更容易建立整体印象。

2. 依赖注入核心细节与生命周期管理

2.1 三种生命周期:什么时候该用哪个

依赖注入里最容易出错的就是生命周期。内置容器提供三种注册方式,分别对应三种对象存活时间:

生命周期方法实例存活范围典型使用场景
瞬态AddTransient每次解析都会创建新实例无状态、轻量服务,比如工具类
作用域AddScoped每个HTTP请求作用域内只有一个实例EF Core的DbContext、业务服务
单例AddSingleton整个应用程序生命周期只有一个实例配置对象、缓存、日志提供器等

这里有个容易误解的点:AddScoped并不是“每个请求结束时销毁”。它的准确含义是:在同一个作用域内共享同一个实例。WebAPI里,一次请求通常就是一个作用域,由框架创建和管理。在同一个请求内,无论你从控制器、过滤器还是业务服务里解析同一个Scoped服务,得到的是同一个实例;但下个请求再来,就是新实例。这能避免跨请求的数据串扰,也避免了频繁创建数据库上下文的开销。

对于AddSingleton,要注意线程安全问题。比如一个单例服务内部使用了静态集合,并且有并发写入的需求,那就要自己加锁或者使用线程安全集合。否则并发请求同时修改可能导致数据不一致。我刚学的时候不在乎这些,后来在模拟高并发时吃了亏,才记住这个教训。

2.2 构造函数注入:接口与实现分离的真正价值

写服务注册时,最常见的形式是:

builder.Services.AddScoped<IProductService, ProductService>();

这行代码的意思是:“当某个控制器需要IProductService时,容器创建一个ProductService实例给它”。接口在这里的意义不是强制规范,而是为了解耦。控制器只依赖抽象,不关心具体实现是内存数据还是数据库,也不关心实现类构造函数里还依赖了哪些其他服务。这样替换实现时不需要修改控制器。

看一个简单的接口和实现:

public interface IProductService { Task<Product?> GetByIdAsync(int id); Task<List<Product>> GetAllAsync(); } public class ProductService : IProductService { public Task<Product?> GetByIdAsync(int id) { // 从内存集合或数据库查询 throw new NotImplementedException(); } public Task<List<Product>> GetAllAsync() { // 返回全部商品 throw new NotImplementedException(); } }

控制器里的写法是:

[ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService = productService; } [HttpGet("{id}")] public async Task<ActionResult<Product>> GetById(int id) { var product = await _productService.GetByIdAsync(id); return product is null ? NotFound() : Ok(product); } }

构造函数注入口碑好,是因为它把依赖关系变成了类接口的一部分,测试时一眼就能看出这个控制器依赖哪些东西。你不必像以前那样在控制器内部到处new服务,构造函数参数列表就是依赖清单。

2.3 注册方式的几个高级细节

除了最基础的接口到实现类映射,内置容器还支持:

  • TryAddScoped:只在未注册过的时候才注册,适合库开发时避免重复注册。
  • RemoveAll:移除之前注册的所有同类服务,一般用于测试环境替换。
  • 工厂注册:不直接指定实现类型,而是通过一个委托在解析时创建对象,比如需要根据配置项决定具体实现名。
  • 泛型注册:AddScoped(typeof(IRepository<>), typeof(Repository<>)),适合仓储模式。

我建议你至少把前三项记住,实际项目中经常用到。尤其是工厂注册,当你需要从配置读取一个开关,决定用A还是B实现时,非常灵活。比如:

builder.Services.AddScoped<IProductService>(serviceProvider => { var config = serviceProvider.GetRequiredService<IConfiguration>(); var useCache = config.GetValue<bool>("UseCache"); return useCache ? new ProductServiceWithCache() : new ProductService(); });

这里注意,工厂委托里还可以通过serviceProvider.GetRequiredService<T>()解析其他依赖,比如ILogger<T>,然后再传给实现类的构造函数。这比在实现类构造函数里直接new一个依赖要规范。

2.4 我踩过的生命周期坑

有一段时间,我给一个服务类用了AddSingleton,结果它内部注入了IServiceScopeFactory,又在某个方法里创建了子作用域来解析DbContext。一开始没发现问题,后来数据库连接数飙升,每次请求都新建一个DbContext导致连接长期占用。排查半天发现,是因为单例服务长期持有子作用域创建的 DbContext,而那个 DbContext 没有及时释放。后来我改成注入IServiceScopeFactory,每次在方法内部创建短生命周期的作用域,操作完立即释放,问题就消失了。

如果你遇到“无法从单例中解析Scoped服务”这样一个异常,那就说明你的代码在单例服务里直接注入了Scoped服务。要么把消费方改成Scoped,要么在方法里动态创建作用域。不能简单粗暴地把Scoped服务改成Singleton,因为像DbContext这类服务本身就不适合跨请求共享。

3. 中间件管道:请求是怎么被处理的

3.1 管道模型到底是什么

ASP.NET Core的请求处理模型是一条管道,每个中间件就是管道上的一段。请求从管道入口进入,依次经过注册过的中间件,直到某个中间件调用next时将请求传给下一段,或者直接生成响应返回。你可以想象成机场安检:旅客(请求)要经过多个闸口,每个闸口可以做检查、放行、拦截或盖章。中间件既可以处理请求,也可以截断请求并直接返回结果。

中间的代码结构是:

app.Use(async (context, next) => { // 请求进来时执行 await next(); // 调用下一个中间件 // 响应返回时执行 });

这种“请求进来走一遍,响应回去再走一遍”的结构,可以用来记录耗时:请求进来时记下时间,await next()之后计算总耗时。日志中间件大多是这个套路。

3.2 三种自定义中间件写法

第一种,用 lambda 写在Program.cs里,适合简单逻辑:

app.Use(async (context, next) => { var sw = Stopwatch.StartNew(); await next(); sw.Stop(); // 写入日志 });

第二种,定义一个中间件类,适合逻辑复杂、需要复用的情况。约定是构造函数接收RequestDelegate next,然后定义一个InvokeAsync(HttpContext context)方法:

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 sw = Stopwatch.StartNew(); await _next(context); sw.Stop(); _logger.LogInformation("HTTP {Method} {Path} 耗时 {ElapsedMilliseconds} ms", context.Request.Method, context.Request.Path, sw.ElapsedMilliseconds); } }

然后在管道中注册:

app.UseMiddleware<RequestLoggingMiddleware>();

第三种,用工厂方式让中间件支持额外配置参数。实际上当你的中间件依赖其他Scoped服务时,上面这个方式已经能满足,因为InvokeAsync方法里可以直接注入服务,比如InvokeAsync(HttpContext context, IProductService productService),容器会帮你把服务作为方法参数传进来。这一点后面排查问题时会专门讲。

使用Run可以让管道短路。Run是终结式中间件,不会调用next,通常用来构造最简单的演示:

app.Run(async context => { await context.Response.WriteAsync("Hello"); });

真正项目中一般不会随便用Run,因为一旦注册在那个位置,后续中间件就不会执行了。

3.3 管道的顺序决定一切

中间件的注册顺序,就是执行顺序。框架默认模板里通常有类似下面这样的顺序:

app.UseExceptionHandler(); app.UseHsts(); app.UseHttpsRedirection(); app.UseRouting(); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();

说几个关键点:

  • UseExceptionHandler一定要靠前,这样后续中间件抛出的异常它才能捕获。
  • UseRouting必须在MapControllers之前,否则控制器路由无法匹配。
  • UseAuthentication必须在UseAuthorization之前,因为授权需要先知道当前用户身份。
  • UseCors的位置取决于是否需要对所有接口跨域,一般放在UseRouting之后、UseAuthentication之前。

如果你把UseAuthentication写在了MapControllers后面,你可能会发现匿名请求也能进控制器,因为认证中间件根本没有被请求管道执行到。这类顺序问题不会报错,只会表现为鉴权失效,需要靠日志一条条排查,很容易忽视。

3.4 中间件和过滤器的边界

很多初学者看到“日志”“异常”“鉴权”这些词,会纠结到底用中间件还是过滤器。我的经验是:凡是跟HTTP请求、响应整体流程相关的能力,优先用中间件;凡是跟控制器Action方法、模型状态、参数绑定相关的能力,优先用过滤器。比如“对所有接口统一返回格式”适合用中间件或结果过滤器;而“检查某个Action是否具有某权限”适合用授权过滤器。中间件看不到控制器内部的具体情况,比如某个Action的参数对不对、模型绑定是否成功,过滤器则可以。反过来,过滤器无法在路由匹配之前介入请求。

具体到项目里,异常处理就可以两层搭配:中间件捕获管道级异常,过滤器捕获Action执行时的异常。不要过度设计,能在中间件解决的不要到处加过滤器。

4. 完整实操:搭建一个带日志和异常处理的商品服务接口

4.1 项目结构与准备

假设我们要做一个简单的商品查询服务,代码就落在一个模拟项目X里。我用dotnet new webapi创建了一个空模板项目,目标框架是.NET 8。项目里包含以下文件:

  • Models/Product.cs:商品实体。
  • Services/IProductService.cs和Services/ProductService.cs:服务接口和内存版实现。
  • Controllers/ProductsController.cs:暴露查询接口。
  • Middlewares/RequestLoggingMiddleware.cs:自定义日志中间件。
  • Program.cs:注册服务和管道。

之所以用内存数据而不是数据库,是为了把注意力集中在依赖注入和中间件上。你要换成数据库时,只需要改ProductService的实现,控制器的代码不用动。

4.2 核心代码实现

先定义一个简单实体:

namespace SampleApi.Models; public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } }

再写服务接口和实现。这里为了让依赖注入展示得更有层次,我在ProductService构造函数里注入了一个ILogger<ProductService>,这是框架自带的日志服务,也由DI容器管理:

using SampleApi.Models; namespace SampleApi.Services; public interface IProductService { Task<Product?> GetByIdAsync(int id); Task<List<Product>> GetAllAsync(); } public class ProductService : IProductService { private readonly ILogger<ProductService> _logger; private readonly List<Product> _products = new() { new Product { Id = 1, Name = "机械键盘", Price = 399m }, new Product { Id = 2, Name = "电竞鼠标", Price = 259m }, new Product { Id = 3, Name = "显示器支架", Price = 189m } }; public ProductService(ILogger<ProductService> logger) { _logger = logger; } public Task<Product?> GetByIdAsync(int id) { _logger.LogInformation("查询商品,Id={Id}", id); var product = _products.FirstOrDefault(p => p.Id == id); return Task.FromResult(product); } public Task<List<Product>> GetAllAsync() { return Task.FromResult(_products); } }

控制器和之前类似,我加了一个手动抛异常的接口,用来演示异常处理中间件:

using Microsoft.AspNetCore.Mvc; using SampleApi.Models; using SampleApi.Services; namespace SampleApi.Controllers; [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService = productService; } [HttpGet] public async Task<ActionResult<List<Product>>> GetAll() { var products = await _productService.GetAllAsync(); return Ok(products); } [HttpGet("{id:int}")] public async Task<ActionResult<Product>> GetById(int id) { var product = await _productService.GetByIdAsync(id); return product is null ? NotFound() : Ok(product); } [HttpGet("boom")] public IActionResult Boom() { throw new InvalidOperationException("演示用的异常"); } }

接下来是自定义日志中间件。我按照约定式写法实现,注意我在方法里直接通过参数注入了ILogger<RequestLoggingMiddleware>,而构造函数里只接收RequestDelegate。这是框架支持的一种写法,Invoke方法的参数由DI容器解析。很多初学者不知道这点,会试图在构造函数里注入Scoped服务,结果运行时报错。后面问题部分我会专门解释。

using System.Diagnostics; namespace SampleApi.Middlewares; public class RequestLoggingMiddleware { private readonly RequestDelegate _next; public RequestLoggingMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context, ILogger<RequestLoggingMiddleware> logger) { var sw = Stopwatch.StartNew(); try { await _next(context); } finally { sw.Stop(); logger.LogInformation("HTTP {Method} {Path} 状态码 {StatusCode} 耗时 {ElapsedMilliseconds} ms", context.Request.Method, context.Request.Path, context.Response.StatusCode, sw.ElapsedMilliseconds); } } }

4.3 注册服务与配置管道

所有配置都集中在Program.cs。我会把异常处理中间件放在最前面,紧跟着自定义日志中间件,然后路由、控制器。完整代码:

using SampleApi.Middlewares; using SampleApi.Services; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddScoped<IProductService, ProductService>(); var app = builder.Build(); app.UseExceptionHandler(); // 开启内置异常处理,会读取 AddProblemDetails 配置 app.UseMiddleware<RequestLoggingMiddleware>(); app.UseRouting(); app.MapControllers(); app.Run();

这里需要注意,内置的UseExceptionHandler()在WebAPI项目里默认会把异常转换成RFC 7807格式的ProblemDetails响应。如果你希望返回统一格式的JSON,可以自己写一个异常处理中间件,或者在项目里启用AddProblemDetails:

builder.Services.AddProblemDetails();

这样当接口抛出未捕获异常时,客户端会收到包含status,title,traceId等字段的JSON。对前端调试非常友好,生产环境也不会暴露堆栈详情。

4.4 启动测试验证效果

使用dotnet run启动项目,然后通过浏览器或curl访问:

curl http://localhost:5299/api/products/1 curl http://localhost:5299/api/products/boom

第一个请求返回商品实体,第二个请求返回ProblemDetails响应。控制台日志输出会显示类似:

info: SampleApi.Middlewares.RequestLoggingMiddleware[0] HTTP GET /api/products/1 状态码 200 耗时 35 ms info: SampleApi.Services.ProductService[0] 查询商品,Id=1

这段实操的意义在于你亲眼看到中间件、日志、服务、控制器之间的关系。请求先进中间件,然后进入控制器;控制器调用服务;服务返回后,响应再沿管道返回,日志中间件打印耗时。整个过程都在框架的掌控之下,你只需要把注册顺序摆对,其余由容器和管道自动完成。

5. 常见问题与排查技巧实录

5.1 单例注入作用域服务导致运行时异常

错误信息通常类似于“Cannot consume scoped service from singleton”。这个是最常见的DI错误。原因很直接:单例生命周期是整个应用,而Scoped服务是一个请求一个实例。如果单例在启动时解析了Scoped服务,那么这个Scoped服务就被“长命”的实例持有,它的生命周期变得混乱,后续无法按请求隔离。

解决办法有三种:

  • 将消费服务的类改为Scoped生命周期,这是最推荐的。
  • 如果必须用单例,则在方法内部创建子作用域:
var scope = serviceProvider.CreateScope(); var productService = scope.ServiceProvider.GetRequiredService<IProductService>();

操作完记得释放scope,或者使用await using。我通常不会在项目里大量使用这种方式,除非是后台任务或者静态方法。

5.2 中间件构造函数注入Scoped服务为什么不行

中间件类的构造函数是在应用启动时被解析的,此时还没有“当前请求作用域”,所以无法注入Scoped服务。但把Scoped服务写在InvokeAsync方法参数里却是可行的,因为方法调用发生在请求管道执行时,容器会为当前请求创建作用域,并从该作用域中解析方法参数。

因此标准写法是:

public async Task InvokeAsync(HttpContext context, IProductService productService) { // productService 是每个请求作用域的实例 }

如果你已经在构造函数里注入了ILogger<...>这类Singleton服务,那是安全的;但如果注入的是Scoped服务,就会在启动时崩溃。这是很多人第一次写中间件时栽跟头的地方。

5.3 中间件顺序导致的鉴权失效或异常捕获失效

现象:接口返回401前就已经执行了控制器逻辑,或者异常信息没有按统一格式返回。

排查思路:仔细检查Program.cs中UseAuthentication、UseAuthorization、UseExceptionHandler、MapControllers的相对位置。要记住几个排序原则:

  • 异常处理最外层,即放在最前。
  • 路由匹配之后才能按端点授权,但认证要在路由之后、授权之前。
  • MapControllers是端点执行的地方,放在所有能影响它的中间件之后。

我习惯用一张简化的顺序模板:

app.UseExceptionHandler(); // 其他安全相关 app.UseRouting(); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();

连HTTP日志中间件也要放在异常处理之后,否则如果后续中间件抛出异常,日志中间件不会执行,响应状态码就记录不到。

5.4 循环依赖:A依赖B,B依赖A

构造函数注入如果形成循环,应用启动时就会抛出异常。内置容器的检测很严格,不会等到请求才炸。解决办法是拆类或者引入接口隔离,让依赖变成单向依赖。比如A依赖B,B不能再回过来依赖A,而是通过事件、拉姆达委托或者共同的抽象接口来解耦。实际业务中很少会写这么糟糕的代码,但很容易出现“服务A依赖服务B,服务B依赖服务C,服务C又依赖服务A”这种间接循环。遇到这种问题,我会先把服务拆分,重新审视边界,而不是用手工办法绕过容器。

5.5 过滤器与中间件让我反复纠结的场景

一次我需要在请求进入时检查某个自定义Header是否存在,不存在就返回400。我起初写了一堆代码放在自定义中间件里,后来发现这个逻辑只需要作用于特定控制器的特定Action,放在中间件里需要自己写路径匹配,非常笨重。换成过滤器后,直接在Action上加一个特性就能搞定。反过来,要全局统计每个请求的耗时,过滤器就没办法覆盖到所有请求,尤其是静态文件和路由匹配失败的404,这时候中间件才是对的。

我的建议是建立两条原则:全局且不关心业务的状态,用中间件;局部且与Action逻辑相关,用过滤器。这样代码会清晰很多。

最后聊点实际体会

学到这里,你会发现ASP.NET Core WebAPI的骨架已经慢慢成型了。第五篇把依赖注入和中间件讲透,后面再学EF Core、身份认证、多环境配置、日志扩展都会顺手很多。我个人的经验是,学框架中间件时不要只看一遍文档,最好的办法是自己写一个最简中间件,打印请求和响应的时间,再人为制造一次异常,观察整个链路的行为变化。踩过几次坑之后,你对管道顺序、服务生命周期这些概念的理解会明显上一个台阶。

另外,强烈建议你把自己写过的项目代码回头看一遍,找出所有直接在控制器里new服务的位置,改成构造函数注入。不要小看这个动作,它会逼你去思考每个服务的生命周期和数据释放问题。做一次这样的重构,比你再看十篇教程都管用。后续的系列文章里,我会继续沿着这条业务链路,把EF Core和数据库持久化加进来,到时候依赖注入和中间件打下的基础会派上大用场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询