第一次把一个八个参数的构造函数“拆掉”的那天,我印象特别深。那是我经手的一个老项目,核心订单服务里的依赖全靠手工new,OrderRepository内部又new了 Logger,Logger 内部又读了一堆配置类。改一处实现,所有引用点全跟着遭殃,越是核心的类越没人敢碰。后来我把依赖管理从“对象内部自己解决”改成了“由一个容器统一装配”,用的就是 Autofac。
Autofac 是 .NET 生态里非常老牌的 IoC 类库,注册方式灵活、生命周期控制精准、还能和 ASP.NET Core 深度集成,社区里用得非常广。这篇文章不复述官方文档,我想把实战里真正有价值的部分讲透:控制反转到底解决了什么问题、注册和解析的底层逻辑、生命周期怎么选、程序集扫描和模块化怎么写、Web 项目接入后的细节,以及我自己踩过的那些坑。不管你是在重构老项目,还是第一次给新项目引入依赖注入,这套内容应该都能直接派上用场。
1. 控制反转为什么是拆解“构造函数爆炸”的关键
1.1 没有 IoC 的代码是怎么一点点腐烂的
IoC 的中文叫控制反转,理解起来并不玄乎:一个类不再自己决定依赖对象怎么来,而是由外部在合适的时机把对象交给它。Autofac 就是那个“外部”。它根据注册信息,在解析对象时把构造函数参数填好,同时管理每个人的生命周期。
看看没有 IoC 的典型写法:
public class OrderService { private readonly OrderRepository _repo; private readonly EmailNotifier _notifier; public OrderService() { _repo = new OrderRepository(); _notifier = new EmailNotifier(); } }问题不在new本身,而在于OrderRepository内部可能还会new SqlConnection,EmailNotifier内部又new SmtpClient。每个类都同时承担了“定义依赖”和“创建依赖”两件事,等于把整个对象图的组装逻辑散落在所有业务类里。
模块少的时候这套写法还能凑合,模块一多,调用关系就成了一团乱麻。改OrderRepository构造函数,所有调用点都要跟着改;想测试OrderService,还得真连数据库、真发邮件,代码自然越来越不敢动。控制反转的意义,就是把“创建依赖”这件事从业务类里剥离出去,让它统一留在应用入口的组装层。
1.2 Autofac 在 IoC 容器里承担的装配职责
IoC 是一种思想,容器是它的具体实现。Autofac 在.NET 平台里算是一个“中央厨房”,核心只干两件事:注册和解析。
注册,是告诉容器“某个接口对应哪个实现、生命周期是多久、要不要拦截”等信息。解析,是在运行时按这些规则把对象创建出来,并自动填好它需要的依赖。再加上基于构造函数注入的自动装配,调用方只需要在自己的构造函数里声明“我依赖什么”,不必关心“去哪里拿”。
选择 Autofac 而不是自己写一个工厂类,我的考虑是这几个:
- 注册 API 直观,既支持类型注册也支持程序集扫描;
- 生命周期类型覆盖了最常见的场景,不用自己管理对象释放;
- 模块化能力强,适合按功能拆程序集的大型项目;
- 和 ASP.NET Core 容器的切换成本低,并且支持 AOP 拦截扩展。
很多团队调试依赖注入问题时喜欢直接看 Autofac 的异常链,实际上 Autofac 的解析错误已经写得很清楚,只是需要理解它内部的几个关键概念。这篇文章后面会专门讲排查套路。
2. 注册、解析与生命周期:先把三个地基动作打牢
2.1 注册:ContainerBuilder 与类型映射
Autofac 里所有注册统一通过ContainerBuilder完成。一个最基础的注册是这样:
var builder = new ContainerBuilder(); builder.RegisterType<OrderRepository>() .As<IOrderRepository>() .InstancePerLifetimeScope(); builder.RegisterType<OrderService>() .As<IOrderService>(); var container = builder.Build();两个容易忽略的细节。
第一,RegisterType注册的是具体类,As决定对外暴露的类型。不写As时默认只暴露具体类型,这时候你Resolve<IOrderRepository>()是拿不到的,必须写As或AsImplementedInterfaces。接口与实现分离的项目里,这条格外重要。
第二,Build()之后再往 builder 里注册是无效的。Autofac 的注册阶段只在启动时发生,业务运行期间只能解析。所以项目里容器通常建在组合根,也就是应用入口。
实际项目中我常用的注册方式可以整理成一张表:
| 注册方式 | 典型写法 | 适用场景 |
|---|---|---|
| 单类型注册 | RegisterType<A>().As<IA>() | 少量显式映射 |
| 程序集扫描 | RegisterAssemblyTypes(asm).AsImplementedInterfaces() | 成百上千服务的项目 |
| 泛型注册 | RegisterGeneric(typeof(A<>)).As(typeof(IA<>)) | 泛型仓储等模式 |
| 模块化注册 | RegisterModule(new MyModule()) | 按功能模块收口注册 |
| 实例注册 | RegisterInstance(instance).As<IA>() | 接管已有对象 |
| Lambda 工厂 | Register(c => new A(c.Resolve<B>())).As<IA>() | 特殊构造逻辑 |
2.2 解析与自动装配:为什么大多数构造函数只需要声明依赖
container.Resolve<T>()是解析入口,但很多读者对“在代码里不写Resolve”这件事不太适应。Autofac 提供自动装配能力:它分析构造函数参数,尝试从容器里找到对应注册并填充。
比如这样一个类:
public class OrderService { private readonly IOrderRepository _repository; private readonly ILogger<OrderService> _logger; public OrderService(IOrderRepository repository, ILogger<OrderService> logger) { _repository = repository; _logger = logger; } }只要IOrderRepository和ILogger<OrderService>都有注册,Resolve<IOrderService>()就会自动完成整棵对象图的创建。
这里有个新手容易误解的点:Autofac 默认不需要任何特性标注,它靠“类型匹配 + 构造函数参数名 + 可选依赖”来判断。它会自动选一个能解析依赖最多的构造函数。如果两个构造函数都能解析且参数个数相同,容器会抛出歧义构造函数的异常,这时候才需要显式指定构造函数。相比之下,属性注入虽然可以用PropertiesAutowired()开启,但业务类里我不建议用,依赖关系会变得不明显,单元测试也难准备。
2.3 生命周期:选错了,迟早会出事
生命周期决定了两个关键问题:实例什么时候被复用,什么时候被释放。Autofac 三种最常用的生命周期如下:
| 生命周期 | 实例个数 | 释放时机 | 适合场景 |
|---|---|---|---|
| InstancePerDependency | 每次解析都是新实例 | scope 结束时由容器释放 | 无状态短时对象 |
| SingleInstance | 全局唯一 | 容器 Dispose 时 | 无状态服务、缓存、工具类 |
| InstancePerLifetimeScope | 同一 scope 内共享,不同 scope 独立 | scope 结束时 | EF Core DbContext、请求级服务 |
在 ASP.NET Core 中,每个 HTTP 请求都会创建一个独立 lifetime scope,所以“每次请求一个实例”在现在的版本里基本等价于InstancePerLifetimeScope。旧项目里常见的InstancePerRequest更多是老版本 Web 专用的遗留写法,新代码直接从InstancePerLifetimeScope起步就好。
这里最需要警惕的是 SingleInstance 直接依赖 InstancePerLifetimeScope 或 InstancePerDependency 组件。很多项目最开始都能正常启动,但运行到第二个请求就炸了,具体原因我放到最后的坑列表里专门讲。
3. 程序集扫描与模块化注册:让几百行配置压缩到几行
3.1 RegisterAssemblyTypes 与命名约定的配合
小项目逐条注册没问题,服务一旦上百,逐条写就是灾难。Autofac 提供RegisterAssemblyTypes按程序集批量扫描:
builder.RegisterAssemblyTypes(typeof(MyModule).Assembly) .Where(t => t.Name.EndsWith("Service")) .AsImplementedInterfaces() .InstancePerLifetimeScope();这段代码会把当前程序集中所有类名以 Service 结尾的类,注册为“它们实现的所有接口”的实例,并且每个请求作用域共享同一个实例。
三个参数值得展开。
Where子句相当于自定义约定,有的团队喜欢按IUserService / UserService的后缀匹配,也有团队喜欢按CommandHandler、EventHandler这类职责后缀匹配。只要约定一致,这个扫描规则就能长期复用。
AsImplementedInterfaces会把类实现的所有公共接口都暴露出去。如果只想暴露其中某一个,可以换成As(t => t.GetInterfaces().Where(i => i.Name.EndsWith("Service")))这种过滤写法。
生命周期放在扫描语句末尾,等于一次性给这批服务定了默认周期。我见过很多项目把生命周期漏写,结果默认按 InstancePerDependency 走,每次请求都新建大量服务,排查才知道是这里的问题。
3.2 模块化注册:把模块边界做成物理事实
大型项目通常按功能拆分程序集。如果启动项目写一次全量扫描,团队都在同一个文件里加注册,冲突几乎是必然的。Autofac 的Module机制非常适合解决这件事。
public class InventoryModule : Module { protected override void Load(ContainerBuilder builder) { builder.RegisterAssemblyTypes(typeof(InventoryModule).Assembly) .Where(t => t.Name.EndsWith("Repository")) .AsImplementedInterfaces() .InstancePerLifetimeScope(); } }启动入口只需要:
builder.RegisterModule<InventoryModule>();这样每个功能模块自己知道自己依赖什么、往容器里注册什么。团队并行开发时,各写各的 Module,谁也不用在别人负责的区域上动代码。模块的职责边界,在注册层就变成了一种物理约束。
3.3 Keyed 与 Named:策略模式的注册解法
同一接口有多个实现时,Autofac 的按键服务(Keyed)比普通接口注册更直观:
builder.RegisterType<SqlOrderRepository>().Keyed<IOrderRepository>("sql"); builder.RegisterType<RedisOrderRepository>().Keyed<IOrderRepository>("redis");解析时:
var sqlRepo = container.ResolveKeyed<IOrderRepository>("sql");Keyed 服务适合策略选择,比如按开关配置决定走 SQL 还是 Redis。构造函数注入时也可以解析,但往往要多写一个解析工厂类。我通常会在配置读取的地方做一个简单的选择器,把Keyed服务转换为明确的接口依赖,业务层就不需要感知具体实现了。
4. 与 ASP.NET Core 深度集成:请求作用域是这样被 Autofac 接管的
4.1 替换 ServiceProviderFactory,接管请求生命周期
ASP.NET Core 自带一个轻量 DI 容器,平时够用,但少了 Autofac 的批量注册、Keyed 服务和 AOP 能力。接入方式非常简单,引入Autofac.Extensions.DependencyInjection后,在 Program.cs 里:
var builder = WebApplication.CreateBuilder(args); builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); builder.Host.ConfigureContainer<ContainerBuilder>((context, containerBuilder) => { containerBuilder.RegisterModule<MyApplicationModule>(); });执行之后,架子里的IServiceProvider就由 Autofac 实现了。原来框架内置容器注册的那些服务不会丢,Autofac 会读取并保留IServiceCollection中的注册,你可以在ConfigureContainer里继续对其扩展。
这里最需要理解的是:ASP.NET Core 的请求生命周期和 Autofac 的 lifetime scope 是映射的。每个 HTTP 请求进来,Autofac 就创建一个 scope,请求结束,scope 里所有由容器创建的对象都会被 Dispose。DbContext 为什么通常注册成 InstancePerLifetimeScope?因为这样刚好一个请求一个上下文,用完自动释放。
4.2 Controller、中间件、Filter 这三类注入怎么挂
Controller 的构造函数注入最省事。注册完服务后,直接写构造函数即可:
public class OrderController : ControllerBase { private readonly IOrderService _orderService; public OrderController(IOrderService orderService) { _orderService = orderService; } }中间件稍微特殊。中间件本身是单例,它的构造函数只会在实例化时调用一次,所以构造函数里注入的依赖并不是“当前请求”的依赖。想在中间件里用请求级服务,正确姿势是把依赖放到InvokeAsync方法的参数里:
public async Task InvokeAsync(HttpContext context, IOrderService orderService) { // 这里拿到的 orderService 属于当前请求的 scope }Filter 里的依赖解析也一样,建议用[ServiceFilter]或[TypeFilter]配合注册,让 Autofac 能从容器里解析 Filter 依赖。
我见过不少人在中间件构造函数里注入DbContext,项目启动一切正常,运行时却莫名出现 disposed 异常。原因就在没搞清楚中间件的构造时机和请求 scope 的关系。
4.3 配置对象的注册与读取
不想在服务里到处读IConfiguration的话,可以把配置直接注册成强类型对象:
var mailOptions = context.Configuration .GetSection("Mail") .Get<MailOptions>(); containerBuilder.RegisterInstance(mailOptions) .As<MailOptions>() .SingleInstance();之后构造函数里直接注入MailOptions就能拿到强类型配置,还省了反复做一个字符串 Key 的拼接。至于标准的IOptions<T>方案,并不需要额外注册,框架本身已经处理了。
5. 泛型注册与 AOP 拦截:适合中后期提升架构档次的配置
5.1 RegisterGeneric:一个注册吃下所有仓储
泛型仓储是我在项目里最常用到的泛型注册场景。定义一个通用接口和实现:
public interface IRepository<T> where T : class { Task<T?> GetByIdAsync(int id); Task AddAsync(T entity); } public class Repository<T> : IRepository<T> where T : class { private readonly AppDbContext _context; public Repository(AppDbContext context) => _context = context; // ... }注册只需要一行:
builder.RegisterGeneric(typeof(Repository<>)) .As(typeof(IRepository<>)) .InstancePerLifetimeScope();之后任何地方消费IRepository<Order>、IRepository<User>,Autofac 都会自动构建Repository<Order>、Repository<User>。如果某个实体有特殊仓储需求,再单独注册该实体的实现覆盖默认泛型注册即可,Autofac 的规则是“最具体的注册优先”。
5.2 用拦截器统一处理日志、缓存、权限
AOP 是 Autofac 在架构层面最惊艳的扩展之一。引入Autofac.Extras.DynamicProxy包后,可以给组件加拦截器。
定义拦截器:
public class LoggingInterceptor : IInterceptor { public void Intercept(IInvocation invocation) { Console.WriteLine($"Calling {invocation.Method.Name}"); invocation.Proceed(); Console.WriteLine($"Completed {invocation.Method.Name}"); } }注册并使用:
builder.RegisterType<LoggingInterceptor>(); builder.RegisterType<OrderService>() .As<IOrderService>() .EnableInterfaceInterceptors() .InterceptedBy(typeof(LoggingInterceptor));运行时 Autofac 会生成动态代理,调用OrderService方法前先经过拦截器,之后才进入真实方法。日志、缓存、权限校验这些横切逻辑就不需要在每个业务方法里重复写了。
这里有个限制必须知道:接口拦截只能拦截接口上声明的方法;如果注册的是具体类并启用类拦截,那么方法必须标记为virtual,否则代理无法覆盖它。不少人配置后日志不打印,多半是栽在这一条上。
5.3 拦截器与异步方法的一个隐藏问题
Castle 动态代理的IInterceptor.Intercept返回类型是void,而异步方法的ReturnValue是Task。如果拦截器只是写完日志立刻invocation.Proceed(),日志会提前打印“已完成”,但实际业务方法还在跑。
要正确支持异步,最简单的做法是使用带Async支持的拦截器扩展,或者在拦截器里反射处理Task:
if (typeof(Task).IsAssignableFrom(invocation.Method.ReturnType)) { var task = (Task)invocation.ReturnValue; // 在这里给 task 挂上 ContinueWith }这个细节在官方文档里只会一带而过,但实际项目中一旦接口全面异步化,几乎每个人都会踩一遍。
6. 我踩过的五个坑,以及现在的排查套路
6.1 单例依赖瞬态组件:半夜线上才暴露的问题
这是 Autofac 项目里最经典的生命周期坑。比如把缓存服务注册为SingleInstance,构造函数注入一个InstancePerLifetimeScope的DbContext:
builder.RegisterType<AppDbContext>().InstancePerLifetimeScope(); builder.RegisterType<CacheManager>().As<ICacheManager>().SingleInstance();系统启动没问题,第一个请求没问题,第二个请求就抛ObjectDisposedException。原因在于 SingleInstance 在根容器创建,第一次请求时它捕获的是第一个请求 scope 里的 DbContext,第一个 scope 在请求结束时已经 Dispose,第二个请求复用了这个单例,DbContext 自然就处在已释放状态。
修法不是简单把 DbContext 改成 SingleInstance,那会引入跨请求共享数据库上下文的新问题。正确的思路是让单例只接收配置或工厂,用的时候再去解析作用域内的服务。
6.2 构造函数参数不符合要求,异常链看起来很长
依赖缺失时 Autofac 抛出的DependencyResolutionException带一长串内部异常,读起来很吓人。我的排查套路是:从最内部的InnerException往外看,核心信息永远在最后一条。
“接口未注册”异常最常见三种来源:
- 程序集没被扫描到,
RegisterAssemblyTypes引用的程序集写错了; - 命名约定和
Where过滤条件不匹配; - 注册代码放在了
Build()之后,等于没注册。
调试时可以直接查看容器里的注册数据:
var registrations = container.ComponentRegistry.Registrations;遍历一下就能确认某个类型到底有没有被扫进去。
6.3 服务定位器反模式:绕开容器反而把架构搞乱
不少项目为了解决“部分类拿不到依赖”的问题,写了一个静态ServiceLocator,业务代码里到处ServiceLocator.Current.Resolve<T>()。眼前问题解决了,长期来看所有类都隐式依赖容器,依赖关系不可见,测试和重构的难度不降反升。
我现在的底线是:业务层只通过构造函数声明依赖,只有组合根才可以接触容器。如果确实有地方需要按运行时条件选择实现,可以用工厂类或策略模式包装,而不是直接暴露Resolve。
6.4 RegisterInstance 的实例不受容器管理
builder.RegisterInstance(someService).As<IService>();这种注册方式容易产生误解:实例虽然注册进去了,但容器不会在停止时 Dispose 它,因为对象不是容器创建的。如果someService又依赖了 scope 内的组件,还容易出现生命周期越界。
除非这个对象的生命周期确实完全由自己管理,否则优先用RegisterType或 Lambda 工厂形式。真要注册已有实例时,要格外清楚它不会随容器释放。
6.5 循环依赖要摸清真实业务,别急着在容器里解
A 构造函数注入 B,B 构造函数注入 A,注册阶段完全正常,第一次Resolve就会抛循环依赖异常。这类问题在容器层面没有优雅解,它不是配置问题,是设计问题。
我处理过的订单模块案例里,循环最终通过抽取一个新职责类解决,既消除了循环,又让类职责更清晰。如果临时要绕开,可以把其中一个依赖改成属性注入或事件订阅,但长期来看还是建议重构依赖方向。
6.6 定位问题的一个简单技巧
如果你怀疑是生命周期配置导致问题,最直接的办法是临时写一个最小复现:只注册两个服务,一个 SingleInstance,一个 InstancePerLifetimeScope,用嵌套 scope 模拟两次请求,看第二次解析时是否抛异常。这个几行代码就能跑完的小实验,比我盯着异常链猜半天快得多。
Autofac 在解析阶段生成的对象图谱越复杂,排错的成本越高。配置上多花点时间把生命周期规则定清楚,长期来看节省的是整个团队反复排障的时间。
如果你打算在团队里推广 Autofac,我的一点体会是:别一上来就上 AOP、泛型注册那些大招。先把生命周期规则写清楚,把模块划分做扎实,让每个服务都通过构造函数声明依赖,这已经能解决绝大多数混乱。工具本身并不复杂,复杂的是你愿不愿意把那些“顺手new一下”的习惯丢掉。