ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

ASP.NET Core依赖注入实战:生命周期、作用域与高级玩法

ASP.NET Core依赖注入实战:生命周期、作用域与高级玩法 1. 为什么说依赖注入是 ASP.NET Core 的“地基”我第一次接触 ASP.NET Core 的时候最直观的感受就是这和旧的 .NET Framework 时代写代码的方式完全不一样了。以前在 ASP.NET MVC 5 里想要在控制器里用一个服务通常就是new一个出来或者借助某个静态类、某个单例工厂去拿。那时候项目里的服务之间互相 new、互相依赖代码耦合得像一团乱麻测试更是难以下手。而 ASP.NET Core 从出生那天起就把依赖注入DI做进了框架的骨髓里。你看到的Startup类里的ConfigureServices方法、控制器构造函数里直接声明接口参数、中间件里通过RequestServices获取服务这些都是依赖注入在背后起作用。可以说不搞懂依赖注入你根本没法理解 ASP.NET Core 的应用是怎么被组装起来的。那依赖注入到底解决了什么问题我用一句话概括它把“创建依赖”这件事从你手里拿走交给你注册的容器统一管理。比如你的业务服务OrderService依赖一个IOrderRepository传统写法里你必须在OrderService内部亲手new一个具体的SqlOrderRepository。一旦换了数据库、换了实现方式你得跑到每一处new的地方去改。而依赖注入的做法是OrderService只需要在构造函数里声明“我需要一个IOrderRepository”至于这个接口到底由哪个类来实现、生命周期是多久全部交给容器在注册时决定。这不只是“解耦”两个字那么简单。它直接改变了你组织代码的方式。以前你写代码的粒度通常是“类”而现在你会下意识地按照“接口 实现 注册”的方式来组织。你会更自然地拆出小服务、小组件每个组件只干一件事组件之间通过接口通信。这种代码风格一旦形成习惯项目的可维护性会肉眼可见地提升。我在带团队的时候经常说依赖注入不只是一个框架功能它是一套编码思维。你理解了它再看 ASP.NET Core 的管道、中间件、配置系统、日志系统都会觉得处处相通因为这些底层机制全都建立在 DI 容器之上。反过来如果你只是照着模板抄代码碰到“在构造函数里注入某个服务”的写法也只会在用到的时候照猫画虎一旦遇到循环依赖、生命周期冲突这种疑难杂症就会一脸懵。所以这篇文章我想把自己在实际项目里折腾依赖注入的经验整理出来。从核心概念、生命周期到实战中绕不开的高级玩法比如工厂模式、装饰器模式、Scope 验证再到踩过的坑和调试技巧争取讲得既有原理又有干货。不管你是刚接触 ASP.NET Core 的新手还是写着写着总觉得哪里不对劲的老手应该都能从里面找到一些有价值的东西。2. 从IServiceProvider说起容器到底替我们做了什么很多人上来就背“构造函数注入”“AddScoped”“AddTransient”但这些概念背熟以后脑子里依然是一团浆糊。我的建议是先弄懂最底层的那个对象——IServiceProvider。在 ASP.NET Core 里整个应用其实就是一个“服务集合”的组装结果。你写builder.Services.AddXxx()的时候本质上是在往一个ServiceCollection里添加服务描述。这个ServiceCollection就像一张菜单列着“什么接口对应什么实现以什么生命周期来创建”。等到应用启动框架根据这张菜单构建出一个IServiceProvider实例也就是真正的“服务容器”。IServiceProvider的核心只有一个方法object GetService(Type serviceType)。你告诉它你想要什么类型它就根据之前注册的规则帮你把这个类型以及它依赖的东西完整地实例化出来。听起来简单但这背后牵扯出几个关键问题构造函数里要的参数怎么来同一个服务被请求多次时是返回同一个实例还是每次都新建如果服务之间有循环依赖怎么处理我用一个生活里的类比来解释服务容器就像一个全能中介。你要租房子解析IOrderRepository中介会根据你的需求去匹配房东具体实现类SqlOrderRepository。如果房子需要配家具构造函数依赖DbContext中介还会顺手联系家具商解析DbContext把家具也安排到位。你只需要告诉中介“我要什么”剩下的一堆复杂流程都不用管。实际写代码的时候最常用的入口有四个IServiceProvider直接获取服务实例IServiceScopeFactory创建子作用域IServiceScope作用域本身ActivatorUtilities在容器之外辅助创建对象同时尽量解析其依赖其中IServiceScopeFactory是很多人一开始容易忽略的。在后台任务、控制台应用、或者某些需要“手动解析服务”的场景里你往往会用serviceProvider.CreateScope()创建一个作用域来拿服务。如果不这么做直接拿IServiceProvider.GetService(typeof(MyService))去解析一个 Scoped 服务大概率会得到一个异常或者拿到一个不按预期生命周期工作的实例。关于这点我踩过比较经典的坑是在 ASP.NET Core 的 Startup 阶段尝试解析 Scoped 服务。比如有人想在Configure方法里通过app.ApplicationServices.GetServiceMyDbContext()直接拿数据库上下文去初始化数据。运行起来会发现这种方式在某些版本里能拿到对象但 DbContext 的跟踪行为、连接管理并不符合预期甚至可能因为作用域缺失导致连接没有及时释放。正确做法是手动创建一个 Scope在这个 Scope 里拿 DbContext 用用完释放。这个细节我在后面的生命周期章节里还会重点展开。理解了IServiceProvider你就抓住了 DI 容器的“心脏”。后面聊生命周期、聊扩展点都是围绕这个核心展开的。3. 生命周期三兄弟Transient、Scoped、Singleton 到底怎么选ASP.NET Core 的 DI 容器支持三种生命周期模式这恐怕是日常开发里讨论最多、也最容易出问题的地方。先说定义再说我的理解最后讲实操选择。生命周期创建时机特点典型场景Transient瞬态每次从容器解析时都新建实例最轻、无状态轻量工具类、无状态服务Scoped作用域每个作用域内只创建一个实例一个请求一个实例DbContext、业务服务Singleton单例整个应用生命周期内只有一个实例全局共享缓存、配置、日志提供者定义大家都懂但实际选择时经常有争议。我觉得关键要把握两条原则有没有状态状态的生命周期应该有多长Singleton适合“无状态或有意共享状态”的服务。比如一个MemoryCache包装类全局只有一个实例里面的数据在所有请求之间共享这很合理。但如果你把一个有状态的类比如包含可变字段的服务注册成 Singleton就要警惕多线程并发问题。多个请求同时读写同一个实例的字段轻则数据错乱重则出现难以复现的 bug。Scoped适合“一个请求内保持一致”的服务。最典型的就是DbContext。一个 HTTP 请求里你可能会在多个地方使用数据库上下文如果每个地方都拿到不同的 DbContext 实例那么它们各自跟踪不同的实体状态事务和性能都是灾难。Scoped 保证同一个请求范围内拿到的都是同一个实例这个问题就自然解决了。Transient适合“每次使用都独立”的服务。比如一个EmailValidator它内部只保存验证规则不保存用户状态每次新建一个实例成本很低也无所谓共享。再比如有些服务内部依赖了另一个 Transient 服务每次解析都需要一个干净的组合那么 Transient 就是合适的选择。但实际项目里经常出现“看似无状态其实有隐含状态”的服务这是最坑的。我举一个例子你写了一个AuditLogger构造函数里注入了一个IHttpContextAccessor用来记录当前请求的用户信息。表面上看它没有字段、没有状态非常适合注册成 Singleton。但IHttpContextAccessor本身是 Scoped 的它的内部逻辑依赖请求上下文如果你把AuditLogger注册成 Singleton而它又注入了 Scoped 的IHttpContextAccessor这时候容器就会抛异常。为什么因为 Singleton 的实例在应用启动时就创建了它不像请求那样有明确的生命周期边界。如果允许一个 Singleton 持有 Scoped 服务的引用那么这个 Scoped 服务到底是哪个请求的容器没法回答这个问题所以干脆在注册时就不允许这种依赖关系。这就是著名的“生命周期捕获”问题是 ASP.NET Core DI 新手最容易踩的大坑之一。我也碰到过这种情况一个实现类被同时注册成了 Scoped 和 Singleton 两种方式代码里有些地方用的是 Scoped 的解析有些地方用的是 Singleton 的解析。结果是同一个逻辑在某些请求里共享状态在某些请求里又各自独立排查问题的时候让人非常抓狂。我后来给自己定了一条规矩同一个服务类型在整个容器中只注册一种生命周期除非有极其特殊的需求否则绝不在多个地方用不同生命周期重复注册。4. Scope 与请求管道为什么你的服务在一个“虚拟边界”里工作前面反复提到“作用域Scope”这个词对新手来说有点抽象。我尽量用最直白的方式拆开讲。其实 Scope 就是容器创建的一组服务实例的“集合边界”。在 ASP.NET Core 的请求管道里框架会在每个 HTTP 请求开始时自动创建一个 Scope并在请求结束时把它释放。这个 Scope 里解析的所有 Scoped 服务生命周期都跟这一个请求绑定。请求开始服务创建请求结束服务释放。整个过程你基本不需要手动干预框架已经帮你处理好了。但这个“自动”背后有一个重要前提你的代码必须沿着请求管道走。如果你的代码是在请求之外的上下文里执行比如后台托管服务、定时任务、消息队列消费者那就没有现成的 Scope 可用。这时候如果你直接解析 Scoped 服务就会遇到我前面说的麻烦。我举个例子。假定你写了一个IHostedService在应用启动后每 30 秒执行一次数据清理任务。任务里要用到MyDbContextScoped如果你直接注入MyDbContext到后台服务构造函数里应用启动时容器就会报错因为IHostedService本身是 Singleton 的它不能依赖 Scoped 服务。解决方式是在后台任务的每次执行中手动创建 Scopepublic class CleanupHostedService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; public CleanupHostedService(IServiceScopeFactory scopeFactory) { _scopeFactory scopeFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using (var scope _scopeFactory.CreateScope()) { var dbContext scope.ServiceProvider.GetRequiredServiceMyDbContext(); // 执行清理逻辑 } await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } } }这里的关键是IServiceScopeFactory本身是 Singleton 安全的你可以放心地把它注入到任何地方。每次要干活的时候用CreateScope()开一个新的“小容器”在这个小容器里解析 Scoped 服务用完立刻释放。这样既遵守了生命周期规则又不会出现“Singleton 依赖 Scoped”的冲突。我在实际项目里把这个模式用得非常频繁尤其是写后台任务、定时任务、消息队列消费者的时候。可以这么理解凡是离开了 HTTP 请求管道、又需要解析 Scoped 服务的场景都要养成“手动开 Scope”的习惯。这不是可选项是必须项否则你拿到的要么是异常要么是行为诡异的服务实例。还有一个容易被忽略的场景你在中间件里拿服务。中间件本身是在请求管道里的所以它默认就有 Scope 的上下文。但如果你用RequestServices.GetService()和直接用构造函数注入结果是不太一样的。构造函数注入的中间件实例是 Singleton 创建的它拿不到当前请求的 Scope 服务而通过RequestServices拿服务是在当前请求的 Scope 里拿的这才是正确姿势。我的经验是中间件里如果需要依赖 Scoped 服务优先用RequestServices不要指望构造函数注入。构造函数注入适合 Scoped 服务那种“每个请求都不同”的场合但中间件只有一个实例所以它没法持有请求级别的服务引用。5. 实战技巧工厂模式、装饰器模式与条件注册聊完生命周期我想分享几个实际项目中经常用到的高级玩法。这些玩法不是 AspNet Core DI 的“必学操作”但如果你经历过某个痛点就会知道它们有多香。5.1 工厂模式同一个接口多个实现怎么处理一个接口多个实现类是 DI 里最直接的挑战。最常见的是接口IPaymentGateway有支付宝实现、微信支付实现、银联实现你想在运行时根据上下文选择具体用哪个。最笨的办法是把所有实现都注册上去然后靠IEnumerableIPaymentGateway注入进来自己循环判断。但这样代码会越写越乱。更优雅的方案是注册一个工厂委托services.AddScopedIPaymentGateway(serviceProvider { var config serviceProvider.GetRequiredServiceIConfiguration(); var providerName config[Payment:Provider]; switch (providerName) { case Alipay: return new AlipayGateway(...); case WeChat: return new WeChatGateway(...); default: throw new InvalidOperationException($Unknown provider: {providerName}); } });工厂委托的优势是你可以在创建服务时做复杂的初始化、根据配置选择实现类、或者做延迟初始化。所以当你发现某个服务的构造过程不只是一句new Xxx(params...)的时候就该考虑用工厂注册来代替默认的反射构造。5.2 装饰器模式在不改原代码的情况下增加横切逻辑装饰器模式是 DI 里用来做“横切关注点”的利器。什么叫横切关注点比如日志、缓存、性能监控、故障恢复这些逻辑纵向贯穿业务代码把它写在业务服务内部会让代码很脏。举个例子。你有一个IWeatherService里面提供了GetWeatherAsync。你不想改动WeatherService本身但想在每次调用时记录耗时、加缓存、做重试。这时候你写一个LoggingWeatherService它实现同一个接口内部包装一个真实的IWeatherServicepublic class LoggingWeatherService : IWeatherService { private readonly IWeatherService _inner; private readonly ILoggerLoggingWeatherService _logger; public LoggingWeatherService(IWeatherService inner, ILoggerLoggingWeatherService logger) { _inner inner; _logger logger; } public async TaskWeatherResult GetWeatherAsync(string city) { var sw Stopwatch.StartNew(); try { return await _inner.GetWeatherAsync(city); } finally { _logger.LogInformation(GetWeatherAsync took {Elapsed}ms, sw.ElapsedMilliseconds); } } }注册的时候稍微绕一下services.AddTransientWeatherService(); // 真实实现 services.AddTransientIWeatherService(sp new LoggingWeatherService(sp.GetRequiredServiceWeatherService(), sp.GetRequiredServiceILoggerLoggingWeatherService()));虽然代码看起来多了一点点但好处是业务逻辑类根本不知道日志的存在日志逻辑独立成一个类可复用、可测试。如果你用 Scrutor 这类库还能自动扫描程序集批量给所有实现了某个接口的类套上装饰器省去大量手工注册代码。我在项目里用装饰器做过缓存、重试、审计、分布式锁等横切能力效果非常棒。它和 AOP面向切面编程的思想一致但在 .NET 世界里不需要引入复杂的 AOP 框架用 DI 原生的方式就能实现。5.3 条件注册根据运行时配置决定要不要注册某个服务依赖注入的条件注册不一定要用第三方库简单的判断完全可以在ConfigureServices里完成if (environment.IsDevelopment()) { services.AddScopedINotificationSender, FakeNotificationSender(); } else { services.AddScopedINotificationSender, EmailNotificationSender(); }这种写法最常见于“开发环境用假数据/假服务生产环境用真实服务”的场景。不过我建议尽量少用这种分支逻辑因为它会让注册信息分散、难审计。更规范的做法是把不同环境的注册逻辑封装成IServiceCollection扩展方法按环境命名比如AddFakeNotificationServices()和AddRealNotificationServices()然后在Program.cs里按环境调用。6. 调试与性能IEnumerable 注入、验证作用域与避免频繁解析依赖注入用多了你会发现大部分问题不是“不会用”而是“出问题时不知道怎么查”。这一章我聊聊调试和性能优化的一些实操经验。6.1 所有注册的服务都能通过IEnumerableT获取在 ASP.NET Core 的容器里如果你注册了多个IFoo的实现那么注入IEnumerableIFoo时会拿到所有注册的实例顺序按照注册顺序。这个特性有两个常见的用途实现某种“策略集合”循环调用每个策略。典型的例子是多个校验器按顺序执行直到有一个不通过。做插件化架构时动态加载程序集里所有实现了某个接口的类然后统一注册统一解析。我见过不少人为了拿到多个实现自己维护一个列表清单然后在注册时逐个 new。其实完全可以依赖容器本身用IEnumerableT注入。但注意这个特性和“按名字获取具体实现”不一样。如果你需要“根据 key 精确获取某一个”还是得回到工厂模式或字典映射。6.2 启动时验证作用域把问题暴露在启动阶段而不是请求阶段ASP.NET Core 提供了ValidateScopes选项用于在启动时检测“从根容器解析 Scoped 服务”这类错误。开发环境下默认是开启的生产环境默认是关闭的出于性能考虑。但如果你吃了“Singleton 依赖 Scoped”的亏建议在生产环境也开启这个验证代价只是启动时稍微多做一点检查但能在部署早期就把问题暴露出来。在 .NET 6 的程序里可以这样配置var builder WebApplication.CreateBuilder(args); builder.Host.UseDefaultServiceProvider(options { options.ValidateScopes true; // 验证作用域 options.ValidateOnBuild true; // 启动时构建验证 });ValidateOnBuild更狠一点会在容器构建完成后立即尝试解析所有注册的服务检查构造函数依赖是否都能满足、有没有明显的循环依赖。这个对大型项目特别友好因为很多问题不用等到运行时请求才暴露。我记得有一次在重构一个老项目把几个服务从静态类改成 DI 注册结果启动到一个数据库服务时总是报“Unable to resolve service”错误。打开ValidateOnBuild后启动日志直接把缺失依赖的服务名打印得清清楚楚我顺着依赖链一个个补注册十分钟就搞定了。以前要是靠跑起来之后某个请求才触发那排查时间得多花好几倍。6.3 性能陷阱频繁从 IServiceProvider 解析不是免费的IServiceProvider.GetService()的解析成本虽然不算高但也绝不是零成本。在热路径比如循环、高频接口调用里反复解析服务会造成可感知的 CPU 开销。我见过一个性能问题某个中间件在每次请求里用了十几次RequestServices.GetService()虽然每次只有微秒级消耗但叠加到每秒几万请求的规模上浪费就很明显了。最直接的优化方式是把服务依赖挪到构造函数里注入而不是在方法内部每次解析。对于必须动态解析的场景比如策略模式、选路逻辑可以将解析结果缓存到一个局部变量中循环外解析循环内复用。6.4 陷阱容器释放顺序与 IDisposable服务如果实现了IDisposable容器负责在生命周期结束时释放它。Singleton 在应用停止时释放Scoped 在请求结束时释放Transient 在解析者释放时释放如果解析者没有释放容器也会在 Scope 释放时统一释放。关于这块有个容易被误解的点如果你手动new出来一个服务没有经过容器容器是不知道也不负责释放的你需要自己管理它的生命周期。还有一个小坑如果你用工厂注册AddTransientIFoo(sp new Foo())返回一个实现了IDisposable的对象容器会跟踪并释放它前提是它是在容器解析时创建的。如果你的工厂返回的是一个已经存在的单例比如静态实例容器可能会重复释放同一个对象这时候要格外小心。经验是让容器管理生命周期不要在工厂里返回自己管理生命周期的“外来对象”除非你确认不会出现重复释放问题。7. 架构层面的实践接口、模块化注册与可测试性依赖注入的最终胜利不在于“会用”而在于“用得顺手”。我自己在架构层面有几条心得分享给大家。7.1 每个项目都按模块拆分注册扩展方法我参与过的项目中最容易失控的是那个巨无霸Program.cs或Startup.cs。几十个AddSomething()挤在一起谁也不敢动改一行都心惊胆战。后来我养成了一个习惯每个业务模块自己写一个服务注册扩展方法内部只负责本模块依赖的服务然后在Program.cs里调用它。举个例子一个“订单模块”可以有一个public static class OrderServiceCollectionExtensions { public static IServiceCollection AddOrderModule(this IServiceCollection services) { services.AddScopedIOrderRepository, SqlOrderRepository(); services.AddScopedIOrderService, OrderService(); services.AddScopedIOrderDomainEventHandler, OrderDomainEventHandler(); return services; } }Program.cs里三五行就搞定builder.Services.AddInfrastructureModule(); builder.Services.AddOrderModule(); builder.Services.AddPaymentModule();这样每个模块的依赖关系集中在模块自己的扩展方法里找问题的时候直接进模块文件不用在启动文件里捞针。配合上模块化项目的层次结构应用层、领域层、基础设施层、表现层每一层的 DI 注册职责也能被清晰划分。7.2 用容器做依赖注入但也别把一切都扔进容器有一类东西我不建议放容器里。比如配置对象直接用IOptionsT、静态工具类如MathHelper、简单的数据传输对象DTO。容器存在的意义是管理复杂依赖关系和生命周期如果什么东西都硬塞进容器解析链会越来越长性能下降排查也麻烦。还有一类需要小心的是“长期存在的服务”和“短期请求”混合的情况。比如 Singleton 的缓存服务里不小心引用了 Scoped 的DbContext这就是架构层面的错误。我给出的建议很简单画一条依赖规则——Singleton 只能依赖 SingletonScoped 可以依赖 Scoped 和 SingletonTransient 可以依赖任意生命周期。写进团队规范当作评审时的关注点。7.3 依赖注入如何让测试变得更简单依赖注入最大的工程红利是“可测试性”。因为一个类的依赖都是通过接口注进来的测试时你可以随手替换成假的实现不需要启动整个应用不需要碰数据库不需要真实发邮件。我写单元测试时的做法是[Fact] public async Task GetWeatherAsync_WhenCityIsEmpty_ThrowsArgumentException() { var weatherService new WeatherService( Mock.OfIWeatherRepository(), Mock.OfICacheService()); await Assert.ThrowsAsyncArgumentException( () weatherService.GetWeatherAsync()); }这里每个依赖都能用 mock 对象替代测试用例短小明确。如果没有依赖注入你可能要花大力气构造WeatherService的内部依赖甚至要起一个完整的服务宿主测试效率和可维护性都差很多。7.4 依赖注入与模块化边界别让容器变成“上帝对象”有一种反模式是为了让某个服务在任何地方都能拿到把它注册成 Singleton然后在几乎每个类里注入它。这会造成隐式的全局依赖代码之间通过这个“上帝对象”互相通信模块边界名存实亡。我在一些老项目里见过这种现象整改起来特别痛苦。正确的做法是依赖关系要显式地通过构造函数表达出来。如果一个类需要“当前用户信息”不要让它自己去全局容器里拿而是让调用方把用户信息显式传进来。容器只用来组装那些“真正需要容器管理的服务”不要用它作为传递参数的通道。这一点做得好模块边界才能保持清晰。8. 那些坑循环依赖、作用域失控与我没能早发现的错误最后一个大章节聊几个实际项目中经常踩的坑。这些坑每个我都见过或者亲身体验过希望你们能提前避雷。8.1 循环依赖两个服务互相引用彼此依赖注入最经典的报错之一A依赖BB又依赖A。容器在尝试解析时会发现依赖链成了一个环然后抛出异常。解决办法一般有三种重构拆分把 A 和 B 之间互相依赖的那部分逻辑抽出来放到第三个类 C 里A 和 B 都依赖 C循环自然解除。这是最彻底的方案也是我最推荐的方式。改变生命周期如果 A 和 B 都是 Transient改成其中一个为 Singleton 或 Scoped有时能绕过构建循环的问题。但这治标不治本潜在风险仍然在。延迟解析某个服务在真正用到时才去容器解析通过注入IServiceProvider而不是在构造函数里直接依赖。可以解决问题但代价是“依赖关系变隐式了”而且会破坏构造函数注入的清晰性。我的经验是遇到循环依赖先停下来想想设计问题而不是急着找 hack。循环依赖通常意味着职责边界划分不当。重构虽然工作量大一点但以后维护的舒适度完全值得。8.2 作用域失控一个 Scoped 服务被 Singleton 持有了这个坑我在生命周期章节提过但值得再具体演示一下。假设你有一个ICacheService注册成了 Singleton而它内部注入了IBillingRepositoryScoped。容器启动时不会立刻检测到错误除非开启了ValidateOnBuild但等到某个请求使用了这个缓存服务运行时就会抛出异常。这类问题最阴险的地方是它不一定在启动阶段出现而是取决于哪个请求先触发。A 客户端的请求触发了一切正常B 客户端的请求触发了直接 500。我见过一个项目因为这个原因偶发崩溃排查了好久才定位到这层关系。最好的防线就是我在前面说的同一个服务生命周期统一Singleton 依赖链上全部是 Singleton在开发环境开启服务作用域验证。这三条做到了这类坑基本能全部躲掉。8.3 过度注入构造函数动不动就七八个参数如果一个服务类构造函数里要注入七八个东西这个类八成违背了“单一职责原则”。七八个依赖意味着它同时在做好几件事。我见过最夸张的某个服务构造函数里塞了 12 个依赖每次修改都要传新参数测试时 mock 也要写 12 行。重构思路是拆类把数据库访问拆出去、把通知拆出去、把缓存拆出去然后用门面模式或组合模式把几个小服务组合起来。依赖注入容器擅长组装所以“很多小对象组合成一个大对象”是合理的但“一个大对象硬吞进无数依赖”是不合理的。8.4 忘记释放 scope 资源在手动创建 Scope 的场景里后台任务、控制台应用如果忘记using释放 ScopeScoped 服务里创建的DbContext、HTTP 客户端连接等资源就会一直占着内存时间长了可能引发连接池耗尽、内存泄漏。我给自己定的习惯是所有手动创建的 Scope 一律用using或await using包起来确保离开代码块就释放。哪怕只是解析一个不痛不痒的小服务也养成这个习惯免得哪天换成DbContext时踩雷。8.5 启动时解析 Scoped 服务的“想当然”错误前面说过在Program.cs或Startup.cs里直接解析 Scoped 服务是个常见的坑。比如有人想在启动时初始化 admin 用户var adminService app.Services.GetRequiredServiceIAdminService(); adminService.Initialize();如果IAdminService注册的是 Scoped这段代码会直接报错未开启ValidateScopes时也可能不报错但行为不保证。正确写法using (var scope app.Services.CreateScope()) { var adminService scope.ServiceProvider.GetRequiredServiceIAdminService(); adminService.Initialize(); }这个“手动开 Scope 解析服务”的模式在 .NET 6 的WebApplication环境里尤其常见因为Program.cs里写启动逻辑的人很多。记住一条根容器的ApplicationServices是用来解析 Singleton 和通过工厂解析服务的不是给你解析 Scoped 服务的。9. 我的推荐实践从开发到生产环境一路稳最后我给出一份我在实际项目里会用的“依赖注入实践清单”。这些条不是什么金科玉律但经过多个项目的锤炼确实能帮我少踩很多坑。注册入口尽量集中在模块扩展方法里不要在Program.cs里散落大量注册代码。每个模块对自己负责注册代码的查找成本极大降低。生命周期统一且显式同一个服务类型只注册一种生命周期。注册 Unity 时团队规范里写明“所有服务必须显式指定生命周期”不允许使用默认的自动注册工具把一切都变成 Transient 或 Singleton。在开发环境和生产环境都开启ValidateScopes。虽然生产环境有一些性能开销但换来的是早期发现生命周期错误。如果对性能极度敏感可以在生产环境关闭但要保证测试和预发布环境开启。坚持构造函数注入避免属性注入和服务定位器模式。构造函数注入最显式依赖关系一目了然。虽然属性注入写起来更少但“依赖关系隐式化”后代码的可读性和可测性会变差。使用IOptionsT来管理配置不要直接把配置对象注册成 Singleton。这样配置的刷新、热更新才有标准支持测试时替换配置也更灵活。对资源型服务比如 DbContext、数据库连接优先使用 Scoped并且确保在请求管道或手动 Scope 里使用。如果需要在后台任务里使用就手动创建并释放 Scope。依赖方向只允许单向流动接口定义在一个模块实现放在基础设施层应用层只依赖接口。这样 Web API、后台任务、测试都能共用同一个接口层模块之间的耦合被降到最低。定期做依赖关系审计可以写一个简单的构建脚本在 CI 阶段把服务注册列表输出到日志里看看有没有某个模块的注册依赖异常。这个对团队协作特别有帮助。我可以坦诚地说依赖注入这套机制我自己从“会用”到“觉得香”经历了挺长时间。很多经验也是靠踩坑换来的——特别是那几个生命周期相关的坑我至今记忆犹新。希望这篇长文能帮你把 ASP.NET Core 依赖注入从“知道概念”推向“理解本质”以后在项目里用它建模、设计服务边界、排查疑难问题的时候能更有底气。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表