手写IoC容器核心是理解依赖注入本质而非套用API;只需三个字典(类型映射、单例缓存、循环检测栈)和一个Resolve方法,明确实例创建/获取路径、生命周期管理及循环依赖处理。

为什么不用第三方容器也能手写 IoC?
因为理解依赖注入本质,比记住 IServiceCollection 的 17 个扩展方法更重要。手写一个最小可行容器,能让你看清:谁在创建实例、生命周期怎么管、循环依赖如何破、Resolve 调用时到底发生了什么。
这不是为了替代 Microsoft.Extensions.DependencyInjection,而是避免下次看到 InvalidOperationException: Cannot resolve scoped service from root provider 时只能搜错误码。
最简容器核心:三个字典 + 一个 Resolve 方法
所有主流容器底层都绕不开这三类注册信息:
-
Dictionary<type registration></type>:存类型映射关系(比如ILogger→ConsoleLogger) -
Dictionary<type object></type>:单例缓存(Singleton实例只 new 一次) -
Stack<type></type>:检测循环依赖(递归Resolve时压入当前类型,再遇到就抛异常)
关键不是代码多,而是每个分支必须明确回答:这个服务是 new 出来的?还是从缓存取的?它的构造函数参数又怎么递归解析?
示例片段(仅示意逻辑):
public object Resolve(Type type)
{
if (_singletons.TryGetValue(type, out var inst)) return inst;
var reg = _registrations[type];
var args = reg.Constructor.GetParameters()
.Select(p => Resolve(p.ParameterType))
.ToArray();
var inst = Activator.CreateInstance(reg.ImplementationType, args);
if (reg.Lifetime == Lifetime.Singleton)
_singletons[type] = inst;
return inst;
}
Register<tservice timpl></tservice> 和 RegisterInstance 的区别在哪?
前者注册的是“创建规则”,后者注册的是“最终实例”——它们在容器里走的是完全不同的路径:
-
Register<ilogger consolelogger>()</ilogger>:每次Resolve都会调用new ConsoleLogger()(除非设为 Singleton) -
RegisterInstance(new ConsoleLogger()):不管生命周期设置,直接把传入对象塞进_singletons,后续全返回它 - 混用时注意:如果先
RegisterInstance(x),再Register<i x>()</i>,后者不会覆盖前者——你得自己加冲突检测逻辑
没做这层判断,线上可能跑着跑着发现日志突然不输出了:因为某处误用了 RegisterInstance(null),而容器默默接受了。
手动实现 Scoped 生命周期有多危险?
危险点不在“怎么存”,而在“什么时候清”。.NET 原生的 IServiceScope 是靠 using 块 + IDisposable 隐式触发清理;你手写的容器若只提供 BeginScope() 却没暴露 Dispose 或等价机制,Scoped 就退化成 Transient。
- 真实场景中,数据库上下文
DbContext必须是 Scoped,否则并发请求可能共享同一实例导致状态错乱 - 如果你用
ThreadLocal<dictionary object>></dictionary>模拟作用域,记得检查线程切换(比如 async/await 后可能跳到另一线程) - 没有
AsyncLocal支持的话,Scoped在异步流程里基本不可用
所以多数人手写容器时,要么干脆不支持 Scoped,要么只在同步上下文中测试——这点很容易被忽略,直到压测时出现诡异的 NullReferenceException。


















