1. 依赖注入的基本模型
一句话总结: 依赖注入把「依赖的创建权」从使用方交出去,容器按注册规则创建并注入,代码从 new 依赖变成声明依赖。
ASP.NET Core 内置一个轻量 DI 容器。注册服务时声明「接口 → 实现」,消费方通过构造函数声明需要什么,容器负责在请求处理过程中把实例送进来。
// 1. 定义接口与实现
public interface IOrderRepository
{
Task<Order?> GetAsync(int id);
}
public class OrderRepository : IOrderRepository
{
public Task<Order?> GetAsync(int id) => Task.FromResult<Order?>(null);
}
// 2. 注册
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// 3. 消费:构造函数注入
public class OrderService(IOrderRepository repo)
{
public async Task<Order?> GetAsync(int id) => await repo.GetAsync(id);
}
| 注册方式 | 说明 |
|---|---|
AddTransient | 每次解析都新建 |
AddScoped | 每个请求作用域一个实例 |
AddSingleton | 整个进程一个实例 |
AddKeyedScoped | 按键区分多个实现 |
TryAdd* | 已注册则跳过,防重复 |
一句话: DI 的价值不在「少写 new」,而在可替换与可测试。接口隔离后,单元测试里注入 mock 实现即可,业务类不再关心依赖的构造细节。
2. 三种生命周期的语义
一句话总结: Transient 每次新建、Scoped 每个请求一个、Singleton 全局一份,选择生命周期就是选择「实例的共享范围」与「线程安全责任」。
生命周期决定实例创建与共享的粒度,也决定状态与并发责任。这是 DI 最核心也最容易出错的概念。
// Transient:每次解析都全新
builder.Services.AddTransient<ICache, InMemoryCache>();
// Scoped:同一个请求作用域内共享
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Singleton:进程级唯一,需线程安全
builder.Services.AddSingleton<IAppConfig, AppConfig>();
| 生命周期 | 创建时机 | 共享范围 | 并发要求 |
|---|---|---|---|
| Transient | 每次解析 | 无共享 | 无要求 |
| Scoped | 每请求一次 | 同一请求 | 请求内可能并发读 |
| Singleton | 首次解析一次 | 全局 | 必须线程安全 |
一句话: 生命周期映射到真实世界——无状态工具类用 Transient,关联请求的数据访问用 Scoped,全局配置/缓存用 Singleton。选错方向,不是多 new 几个对象,就是共享了不该共享的状态。
3. 生命周期陷阱与作用域边界
一句话总结: 最常见的错误是 Singleton 依赖 Scoped 或 Transient,导致作用域泄漏或实例错位,报错信息直指「从 Singleton 解析 Scoped 服务」。
容器会校验捕获依赖:Singleton 里注入 Scoped 服务,编译能过,运行解析时直接抛异常。因为 Singleton 只创建一次,它依赖的 Scoped 实例无法确定「属于哪个请求」。
// 错误:Singleton 捕获 Scoped → 运行时报错
public class ReportService(OrderRepository repo) { } // repo 是 Scoped
// 正确:用 IServiceScopeFactory 手动开作用域
public class ReportService(IServiceScopeFactory scopeFactory)
{
public async Task RunAsync()
{
await using var scope = scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
// 在此作用域内使用 repo
}
}
| 陷阱 | 表现 | 修正 |
|---|---|---|
| Singleton 注入 Scoped | 解析期抛异常 | 改用 IServiceScopeFactory |
| 静态字段持依赖 | 隐式全局共享 | 改 Singleton 并保证线程安全 |
| Scoped 注入 Singleton | 正常 | 合理,Singleton 可被共享 |
| Transient 注入 Scoped | 正常 | Transient 捕获 Scoped 仅在解析作用域内 |
用 GetService 随手拿 | 作用域混乱 | 构造函数声明优先 |
避坑: 后台任务(BackgroundService、Timer、消息队列消费者)没有请求作用域。在后台任务里解析 Scoped 服务,必须自己
CreateScope(),否则要么解析不到、要么拿到僵尸实例。
4. 中间件管线是什么
一句话总结: 中间件是洋葱式请求管线,请求按注册顺序一层层进入,响应按逆序一层层返回;每个中间件可以选择放行或短路。
中间件(Middleware)是 ASP.NET Core 处理请求的管道节点。每个中间件拿到 HttpContext,可以加工请求、调用 next 把请求传给下一个、或直接返回(短路)。管线的组装顺序,就是请求的处理顺序。
请求 → [1] UseHttpsRedirection → [2] UseRouting → [3] UseAuthentication
↓ 放行 ↓ 放行
[4] UseAuthorization → [5] 终端路由执行(控制器)
↑
响应 ← 反向逐层返回(后进先出)
var app = builder.Build();
app.UseHttpsRedirection(); // 1. 先执行的中间件
app.UseRouting(); // 2. 路由匹配
app.UseAuthentication(); // 3. 身份认证
app.UseAuthorization(); // 4. 授权
app.MapControllers(); // 5. 终端执行
app.Run();
一句话: 中间件的核心心智模型是「俄罗斯套娃」——外层包裹内层,请求向内、响应向外。理解这个模型,顺序问题、短路问题、异常传播问题都一目了然。
5. 编写自定义中间件
一句话总结: 自定义中间件有两种形态——基于约定的类与基于委托的内联写法,二者都能在管线中注入横切逻辑。
中间件类实现约定:构造注入 RequestDelegate next,实现 InvokeAsync(HttpContext)。内联写法用 app.Use 直接挂 lambda。横切关注点(日志、异常处理、请求追踪)都适合做成中间件。
// 约定式中间件:记录请求耗时
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
try
{
await _next(context); // 放行
}
finally
{
sw.Stop();
_logger.LogInformation("{Path} 耗时 {Elapsed}ms",
context.Request.Path, sw.ElapsedMilliseconds);
}
}
}
// 注册
app.UseMiddleware<RequestTimingMiddleware>();
// 内联写法
app.Use(async (context, next) =>
{
context.Response.Headers["X-Request-Id"] = Guid.NewGuid().ToString();
await next();
});
| 中间件能力 | 用法 |
|---|---|
| 前置逻辑 | 调用 next 之前执行 |
| 放行 | await next(context) |
| 短路 | 不调用 next 直接写响应 |
| 后置逻辑 | next 返回后执行 |
| 异常捕获 | try/finally 包裹 next |
避坑: 中间件的依赖注入遵循「构造注入仅限 Singleton」的约束。若 InvokeAsync 里需要 Scoped 服务,把参数放进 InvokeAsync 签名(由容器按作用域解析),而不是构造函数——这是中间件特有的依赖注入规则。
6. 分支与短路
一句话总结: Map/MapWhen 按路径或条件分支管线,短路让未授权或静态资源请求不再深钻后续中间件,二者让管线不再是一条直线。
管线不是只能一条直线。Map 按路径前缀分支,MapWhen 按条件分支;中间件也可以短路直接返回,减少无谓开销。
// 按路径前缀分支
app.Map("/healthz", healthApp =>
{
healthApp.Run(async ctx =>
{
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("ok");
});
});
// 按条件分支:只有 GET 进入业务管线
app.MapWhen(ctx => ctx.Request.Method == HttpMethods.Get, getApp =>
{
getApp.UseAuth();
getApp.MapControllers();
});
// 短路:静态缓存命中直接返回,不再往下走
app.Use(async (context, next) =>
{
if (IsCached(context))
{
context.Response.StatusCode = 304; // 短路
return;
}
await next();
});
| 分支手段 | 触发条件 | 典型用途 |
|---|---|---|
Use | 总是执行 | 通用横切逻辑 |
Map | 路径前缀 | 健康检查、独立端点 |
MapWhen | 自定义谓词 | 按方法/头/条件分流 |
| 手动短路 | 业务判定 | 缓存命中、限流拒绝 |
一句话: 分支让「同一个应用」能对外呈现多套行为。健康检查走极简管线、API 走完整认证链、静态资源尽早返回——管线设计本身就是性能与安全的取舍。
7. 作用域、管道边界与可测试性
一句话总结: Scoped 的作用域边界由框架按请求创建,理解「谁在作用域内」是管道设计的命门;把中间件逻辑与业务解耦,测试就能从管线黑盒变成单元级验证。
每个请求进入应用时,框架创建一个请求级作用域(IServiceScope),其中解析的 Scoped 服务在这个请求内共享,请求结束统一释放。控制器、中间件 InvokeAsync 参数都在这个作用域内。
// 测试:单元验证中间件逻辑
public async Task TimingMiddleware_Should_Record_Elapsed()
{
var logger = new Mock<ILogger<RequestTimingMiddleware>>();
var middleware = new RequestTimingMiddleware(
next: ctx => Task.CompletedTask,
logger: logger.Object);
var context = new DefaultHttpContext();
await middleware.InvokeAsync(context);
logger.Verify(l => l.Log(
It.IsAny<LogLevel>(), It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(), null, It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
// 集成验证:用 WebApplicationFactory 测整条管线
var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
var resp = await client.GetAsync("/api/orders/1");
| 测试层级 | 覆盖内容 | 工具 |
|---|---|---|
| 单元测试 | 单个中间件逻辑 | xUnit + Mock |
| 组件测试 | 中间件组合行为 | WebApplicationFactory |
| 集成测试 | 全链路 + 真实依赖 | Testcontainers + WebApplicationFactory |
一句话: 把「请求级作用域」的边界想清楚,后台任务、并发请求、事件消费者三类场景的 DI 用法就能统一到同一套心智模型里。中间件尽量薄、业务尽量下沉,测试成本会大幅下降。
8. 总结
| 环节 | 要点 |
|---|---|
| DI 模型 | 接口声明依赖,容器负责创建与注入 |
| 生命周期 | Transient 每次、Scoped 每请求、Singleton 全局 |
| 陷阱 | Singleton 捕获 Scoped 即异常,后台任务自开作用域 |
| 管线 | 洋葱式请求/响应,顺序即行为 |
| 自定义中间件 | 约定类 + 内联 Use,横切逻辑容器化 |
| 分支与短路 | Map/MapWhen 分流,缓存命中直接返回 |
| 可测试 | 中间件薄、业务下沉,WebApplicationFactory 集成 |
依赖注入与中间件管线是 ASP.NET Core 的「血管与骨架」:DI 决定对象怎么共享,中间件决定请求怎么流转。把作用域边界和顺序心智模型吃透,Web 应用的结构化程度会明显上一个台阶。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。