
这是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.AddScopedIProductService, ProductService();这行代码的意思是“当某个控制器需要IProductService时容器创建一个ProductService实例给它”。接口在这里的意义不是强制规范而是为了解耦。控制器只依赖抽象不关心具体实现是内存数据还是数据库也不关心实现类构造函数里还依赖了哪些其他服务。这样替换实现时不需要修改控制器。看一个简单的接口和实现public interface IProductService { TaskProduct? GetByIdAsync(int id); TaskListProduct GetAllAsync(); } public class ProductService : IProductService { public TaskProduct? GetByIdAsync(int id) { // 从内存集合或数据库查询 throw new NotImplementedException(); } public TaskListProduct 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 TaskActionResultProduct 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.AddScopedIProductService(serviceProvider { var config serviceProvider.GetRequiredServiceIConfiguration(); var useCache config.GetValuebool(UseCache); return useCache ? new ProductServiceWithCache() : new ProductService(); });这里注意工厂委托里还可以通过serviceProvider.GetRequiredServiceT()解析其他依赖比如ILoggerT然后再传给实现类的构造函数。这比在实现类构造函数里直接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 ILoggerRequestLoggingMiddleware _logger; public RequestLoggingMiddleware(RequestDelegate next, ILoggerRequestLoggingMiddleware 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.UseMiddlewareRequestLoggingMiddleware();第三种用工厂方式让中间件支持额外配置参数。实际上当你的中间件依赖其他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构造函数里注入了一个ILoggerProductService这是框架自带的日志服务也由DI容器管理using SampleApi.Models; namespace SampleApi.Services; public interface IProductService { TaskProduct? GetByIdAsync(int id); TaskListProduct GetAllAsync(); } public class ProductService : IProductService { private readonly ILoggerProductService _logger; private readonly ListProduct _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(ILoggerProductService logger) { _logger logger; } public TaskProduct? GetByIdAsync(int id) { _logger.LogInformation(查询商品Id{Id}, id); var product _products.FirstOrDefault(p p.Id id); return Task.FromResult(product); } public TaskListProduct 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 TaskActionResultListProduct GetAll() { var products await _productService.GetAllAsync(); return Ok(products); } [HttpGet({id:int})] public async TaskActionResultProduct GetById(int id) { var product await _productService.GetByIdAsync(id); return product is null ? NotFound() : Ok(product); } [HttpGet(boom)] public IActionResult Boom() { throw new InvalidOperationException(演示用的异常); } }接下来是自定义日志中间件。我按照约定式写法实现注意我在方法里直接通过参数注入了ILoggerRequestLoggingMiddleware而构造函数里只接收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, ILoggerRequestLoggingMiddleware 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.AddScopedIProductService, ProductService(); var app builder.Build(); app.UseExceptionHandler(); // 开启内置异常处理会读取 AddProblemDetails 配置 app.UseMiddlewareRequestLoggingMiddleware(); app.UseRouting(); app.MapControllers(); app.Run();这里需要注意内置的UseExceptionHandler()在WebAPI项目里默认会把异常转换成RFC 7807格式的ProblemDetails响应。如果你希望返回统一格式的JSON可以自己写一个异常处理中间件或者在项目里启用AddProblemDetailsbuilder.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] 查询商品Id1这段实操的意义在于你亲眼看到中间件、日志、服务、控制器之间的关系。请求先进中间件然后进入控制器控制器调用服务服务返回后响应再沿管道返回日志中间件打印耗时。整个过程都在框架的掌控之下你只需要把注册顺序摆对其余由容器和管道自动完成。5. 常见问题与排查技巧实录5.1 单例注入作用域服务导致运行时异常错误信息通常类似于“Cannot consume scoped service from singleton”。这个是最常见的DI错误。原因很直接单例生命周期是整个应用而Scoped服务是一个请求一个实例。如果单例在启动时解析了Scoped服务那么这个Scoped服务就被“长命”的实例持有它的生命周期变得混乱后续无法按请求隔离。解决办法有三种将消费服务的类改为Scoped生命周期这是最推荐的。如果必须用单例则在方法内部创建子作用域var scope serviceProvider.CreateScope(); var productService scope.ServiceProvider.GetRequiredServiceIProductService();操作完记得释放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依赖BB依赖A构造函数注入如果形成循环应用启动时就会抛出异常。内置容器的检测很严格不会等到请求才炸。解决办法是拆类或者引入接口隔离让依赖变成单向依赖。比如A依赖BB不能再回过来依赖A而是通过事件、拉姆达委托或者共同的抽象接口来解耦。实际业务中很少会写这么糟糕的代码但很容易出现“服务A依赖服务B服务B依赖服务C服务C又依赖服务A”这种间接循环。遇到这种问题我会先把服务拆分重新审视边界而不是用手工办法绕过容器。5.5 过滤器与中间件让我反复纠结的场景一次我需要在请求进入时检查某个自定义Header是否存在不存在就返回400。我起初写了一堆代码放在自定义中间件里后来发现这个逻辑只需要作用于特定控制器的特定Action放在中间件里需要自己写路径匹配非常笨重。换成过滤器后直接在Action上加一个特性就能搞定。反过来要全局统计每个请求的耗时过滤器就没办法覆盖到所有请求尤其是静态文件和路由匹配失败的404这时候中间件才是对的。我的建议是建立两条原则全局且不关心业务的状态用中间件局部且与Action逻辑相关用过滤器。这样代码会清晰很多。最后聊点实际体会学到这里你会发现ASP.NET Core WebAPI的骨架已经慢慢成型了。第五篇把依赖注入和中间件讲透后面再学EF Core、身份认证、多环境配置、日志扩展都会顺手很多。我个人的经验是学框架中间件时不要只看一遍文档最好的办法是自己写一个最简中间件打印请求和响应的时间再人为制造一次异常观察整个链路的行为变化。踩过几次坑之后你对管道顺序、服务生命周期这些概念的理解会明显上一个台阶。另外强烈建议你把自己写过的项目代码回头看一遍找出所有直接在控制器里new服务的位置改成构造函数注入。不要小看这个动作它会逼你去思考每个服务的生命周期和数据释放问题。做一次这样的重构比你再看十篇教程都管用。后续的系列文章里我会继续沿着这条业务链路把EF Core和数据库持久化加进来到时候依赖注入和中间件打下的基础会派上大用场。