文章元信息
- 作者:刚子
- 系列名称:.NET 8 现代Web开发实战指南
- 原文链接:https://www.codeobservatory.cn/post/dotnet-8-web-development-part3-dependency-injection-middleware
- 关键词:ASP.NET Core, 依赖注入, IoC容器, 中间件, 请求管道, 服务生命周期
- 摘要:本文深入解析ASP.NET Core的两大核心支柱:依赖注入(DI)与中间件。通过构建一个模拟的性能监控模块,实战演示服务的注册与消费,以及HTTP请求在管道中的流转机制,帮助新手掌握构建高扩展性Web应用的关键架构模式。
一、前言:从“作坊”到“工厂”
在上一篇文章中,我们学会了C#的现代语法,就像掌握了制造精密零件的技术。现在,我们需要把这些零件组装成一台能运转的发动机。
在ASP.NET Core中,有两样东西构成了这台发动机的骨架:依赖注入(DI) 和 中间件。
如果你不理解它们,你写的代码可能会变成紧紧缠绕的一团乱麻(我们称之为“面条代码”),难以测试、难以修改。理解了它们,你就掌握了现代Web开发的“设计模式之钥”。
二、灵魂机制:依赖注入(DI)
2.1 为什么要“注入”?——解决紧耦合
假设你需要在一个API中记录日志。最直观的写法可能是直接在代码里 new 一个对象:
app.MapGet("/bad", () =>
{
var logger = new FileLogger(); // 直接依赖具体的实现类
logger.Log("这是一条日志");
return "日志已记录";
});
这种写法看似简单,实则隐患重重:
- 紧耦合:你的API代码死死地绑定了
FileLogger。如果哪天老板说“改成存数据库”,你得修改每一处new FileLogger()。 - 难以测试:做单元测试时,你不想真的去写文件,想用一个假的记录器,但现在你无法替换。
依赖注入的核心思想是:“不要自己new,需要什么向容器要”(控制反转,IoC)。
2.2 服务的三生三世:生命周期
在.NET的DI容器中,注册的服务有三种主要生命周期。这是新手最容易踩坑的地方,请务必理解:
- Transient(瞬态):用完即弃。每次请求该服务,容器都会给你一个全新的实例。适合轻量级、无状态的服务(如简单的计算器、格式化工具)。
- Scoped(范围):一次请求一生。在一次HTTP请求范围内,无论你在多少个地方请求它,拿到的都是同一个实例。这是Web开发中最常用的模式,特别是用于数据库上下文(DbContext)。
- Singleton(单例):万世一系。整个应用程序生命周期内,只存在一个实例。适合全局缓存、全局配置。注意:单例服务必须是线程安全的!
【配图建议 1】 位置:生命周期概念讲解之后。 内容:示意图。
- Transient:三个请求 -> 三个不同的对象实例。
- Scoped:一个请求内的三个调用 -> 指向同一个对象实例。
- Singleton:三个请求 -> 指向唯一的一个全局对象实例。 Alt文本:.NET依赖注入服务生命周期示意图,展示Transient、Scoped、Singleton的区别。
2.3 实战:构建一个性能监控服务
我们来写一个真实的案例:统计API的执行耗时。
第一步:定义契约(接口) 良好的架构总是面向接口编程。
// IPerformanceTracker.cs
public interface IPerformanceTracker
{
void Start();
void Stop();
long GetElapsedTime();
}
第二步:实现服务
// PerformanceTracker.cs
public class PerformanceTracker : IPerformanceTracker
{
private Stopwatch _stopwatch = new Stopwatch();
public void Start() => _stopwatch.Restart();
public void Stop() => _stopwatch.Stop();
public long GetElapsedTime() => _stopwatch.ElapsedMilliseconds;
}
第三步:在Program.cs中注册服务
var builder = WebApplication.CreateBuilder(args);
// --- 注册服务 ---
// 这里我们使用 Scoped,因为耗时统计通常是针对单个请求的
builder.Services.AddScoped<IPerformanceTracker, PerformanceTracker>();
// 添加Swagger等基础服务
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// ... 中间件配置 ...
第四步:在API中注入并使用 在Minimal API中,我们通过方法参数注入服务。
app.MapGet("/test-performance", (IPerformanceTracker tracker) =>
{
tracker.Start();
// 模拟耗时操作
Thread.Sleep(500);
tracker.Stop();
return $"接口执行耗时: {tracker.GetElapsedTime()} ms";
});
架构师视角的深意:
注意看,我们的API代码里完全没有 new PerformanceTracker()。这意味着,如果明天我们需要升级监控逻辑(比如加上日志记录),我们只需要修改 PerformanceTracker.cs 类,而API接口的代码一行都不用动。这就是解耦带来的维护性提升。
三、传动装置:中间件管道
如果说DI是提供动力的气缸,那么中间件就是负责传递动力的齿轮和传送带。
3.1 管道模型:俄罗斯套娃
ASP.NET Core 处理HTTP请求的方式,就像水流通过一系列过滤层。
- 请求进入管道。
- 经过一个个中间件。
- 中间件可以在处理前做事(如记录请求日志)。
- 中间件调用
next()将请求传给下一个中间件。 - 到达最终处理逻辑(你的API代码)。
- 响应沿着管道反向流出。
- 中间件可以在处理后做事(如记录响应日志、处理异常)。
【配图建议 2】 位置:管道模型讲解之后。 内容:经典的ASP.NET Core管道流向图。
- 左侧是Request箭头进入。
- 中间是一层层嵌套的矩形(Middleware 1, Middleware 2...)。
- 最内部是Endpoint(你的API代码)。
- 右侧是Response箭头流出。
- 展示“请求进入 -> 深入 -> 响应返回”的U型流程。 Alt文本:ASP.NET Core中间件请求管道流向示意图,展示请求与响应的双向流动。
3.2 编写你的第一个自定义中间件
我们来写一个最简单的中间件:请求计时器。它将在控制台打印每个请求的耗时。
var app = builder.Build();
// --- 自定义中间件 ---
app.Use(async (context, next) =>
{
var stopwatch = new Stopwatch();
stopwatch.Start();
Console.WriteLine($"[中间件] 请求开始: {context.Request.Path}");
// 关键步骤:调用下一个中间件
// 这里使用 await 等待后续管道全部执行完毕
await next(context);
stopwatch.Stop();
Console.WriteLine($"[中间件] 请求结束: {context.Request.Path}, 耗时: {stopwatch.ElapsedMilliseconds}ms");
});
// 确保有Swagger中间件
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapGet("/", () => "Hello World!");
app.Run();
运行这段代码,并在浏览器访问 http://localhost:5000/,你会看到控制台输出了耗时信息。
3.3 “短路”机制:权限守门员
中间件有一个极其重要的能力:短路。如果中间件决定不调用 next(),管道就会直接折返,后续的逻辑(如你的API代码)将不会执行。
这非常适合做权限验证。
app.Use(async (context, next) =>
{
// 模拟:检查Header里是否有密码
if (!context.Request.Headers.ContainsKey("X-Secret-Key"))
{
// 没有密钥,直接返回401,不调用 next()
context.Response.StatusCode = 401;
await context.Response.WriteAsync("抱歉,你无权访问!");
return; // 结束处理,管道短路
}
// 有密钥,放行
await next(context);
});
这个特性让我们可以把横切关注点(如日志、权限、异常处理)从业务代码中剥离出来,放在管道的最外层统一处理。
四、DI与中间件的完美结合
作为本篇的压轴,我们将展示如何在一个中间件中使用依赖注入的服务。这是架构设计中非常常见的模式:在中间件里实现全局的异常捕获或性能监控。
让我们把刚才的 IPerformanceTracker 服务集成到中间件里。
// 注册服务
builder.Services.AddScoped<IPerformanceTracker, PerformanceTracker>();
var app = builder.Build();
// 注册一个使用了DI的中间件
// 注意:这里我们不能直接在 Use 方法里通过参数注入 Scoped 服务,
// 因为中间件构造函数是在应用启动时执行的(Singleton行为),
// 但我们需要在请求上下文中获取 Scoped 服务。
// 以下是正确的写法:
app.Use(async (context, next) =>
{
// 1. 从 HttpContext.RequestServices 中获取当前请求的服务容器
var tracker = context.RequestServices.GetRequiredService<IPerformanceTracker>();
tracker.Start();
await next(context); // 执行后续管道
tracker.Stop();
// 假设我们想把耗时加到响应头里
context.Response.Headers.Append("X-Response-Time", $"{tracker.GetElapsedTime()}ms");
});
app.MapGet("/heavy-task", async (IPerformanceTracker tracker) =>
{
// 在API内部也可以再次注入使用,且因为是 Scoped,拿到的是同一个实例
await Task.Delay(1000);
return "任务完成";
});
app.Run();
关键知识点:
context.RequestServices 是访问当前请求作用域内服务的入口。虽然Minimal API支持直接在参数里注入,但在中间件这种早期阶段,我们必须手动从 HttpContext 中拉取服务。
五、常见误区与架构师建议
在多年的架构生涯中,我见过很多新手在使用DI和中间件时犯过以下错误,这里逐一提醒:
5.1 服务生命周期陷阱:Capturing Dependencies(依赖捕获)
错误做法:在一个 Singleton 服务中注入了一个 Scoped 服务。 后果:Scoped 服务本该在一次请求后销毁,但因为被 Singleton 服务长期持有,它变成了事实上的 Singleton。这会导致你的 DbContext 无法正确释放,内存泄漏,甚至并发错误。 原则:服务依赖的方向应该是:Transient -> Scoped -> Singleton。或者 Scoped -> Scoped。永远不要让生命周期长的服务依赖生命周期短的服务。
5.2 中间件顺序很重要
中间件的注册顺序直接决定了执行顺序。
UseExceptionHandler/UseDeveloperExceptionPage应该放在最前面,这样才能捕获后续所有中间件的异常。UseStaticFiles应该放在UseAuthorization之前,否则静态文件(如图片、CSS)也需要权限验证,这通常是不必要的性能损耗。UseSwagger通常放在开发环境判断内部。
六、总结与下篇预告
恭喜你!读到这里,你已经触摸到了ASP.NET Core的骨架。
- 依赖注入(DI):解耦的神器,让代码结构清晰,易于测试。记住 Transient、Scoped、Singleton 三种生命周期的区别。
- 中间件:请求的筛子和过滤器,用于处理横切逻辑。记住
next()是通往下一关的钥匙。
现在,我们的“引擎”已经组装完毕,具备了处理请求的核心能力。但是,引擎需要“燃料”才能源源不断地输出动力。在Web开发中,最核心的燃料就是数据。
下一篇预告:
在第四篇文章中,我们将连接数据库,引入 Entity Framework Core (EF Core)。你将学会如何用 C# 代码定义数据库结构(Code First),如何进行数据迁移,以及如何通过 EF Core 进行高效的增删改查。数据持久化的大门即将打开,敬请期待!