返回 C#常用知识小短篇

关于依赖注入的理解

以下为完整正文内容。

正文

对于依赖注入,示例流程如下: //定义接口:只规定“能启动”,不管怎么启动 //这是解耦的关键,Car 只知道接口,不知道具体实现 interface IEngine { void Start(); } // 汽油发动机:一种实现 //如果以后想切换发动机类型,只需要改注册那一行,Car 完全不用动 class Engine : IEngine { public void Start() => Console.WriteLine("发动机启动"); } //电动发动机:另一种实现 class ElectricEngine : IEngine { public void Start() => Console.WriteLine("电机静音启动"); } //汽车:不关心发动机是怎么造的,只要能 Start就可以 //依赖从构造器注入,Car 自己不负责创建 class Car { private IEngine _engine; //构造器注入: //谁来创建 Car,谁就得给我一个 IEngine。 //容器在创建 Car 时,会自动解析 IEngine 并传进来。 public Car(IEngine engine) { _engine = engine; } public void Run() { _engine.Start(); Console.WriteLine("汽车跑起来了"); } } using Microsoft.Extensions.DependencyInjection; class Program { static void Main() { /* * 第一步:创建“容器” - 一个空箱子 * ServiceCollection 就是一个“配方清单”。 * 此时容器还是空的,不知道怎么创建任何对象。 */ var services = new ServiceCollection(); /* * 第二步:注册(往清单上写配方) * 告诉容器: * “如果有人找你要 IEngine,你就给他 new 一个 ElectricEngine” * * AddTransient 的意思是: * “每次别人要,都给他一个新对象(不共享同一个实例)” * 与之相对的是 AddSingleton(整个程序只创建一个,共享) * 还有AddAddScoped:在ASP.NET Core 中,范围自动规定为一次HTTP请求。在控制台应用中需要自己规定范围。范围内的实例都是同一个。 */ services.AddTransient<IEngine, ElectricEngine>(); /* * 注册 Car 本身。 * Car 的构造器需要 IEngine,容器会自动解决这个依赖。 */ services.AddTransient<Car>(); /* * 第三步:构建容器 * BuildServiceProvider 把这个清单启动起来,该干活的时候就按清单规定的方式干活 * 现在容器知道: * - IEngine → ElectricEngine * - Car → new Car( 自动找 IEngine 的实现 ) */ var provider = services.BuildServiceProvider(); /* * 第四步:从容器里拿对象 * GetRequiredService<Car>() 的执行流程: * * ① 我说"我要 Car" * ② 容器查清单:Car 没有特殊接口,直接找 Car 类 * ③ 容器转到 Car 的构造器:public Car(IEngine engine) * ④ 发现构造器需要 IEngine * ⑤ 容器递归查清单:IEngine → ElectricEngine * ⑥ 容器 new ElectricEngine() * ⑦ 容器 new Car(electricEngine) ← 把 ⑥ 的结果传入 * ⑧ 返回完整的 Car 对象给我 * * 这一切都自动完成,不需要手动 new 任何东西。 */ Car car = provider.GetRequiredService<Car>(); // 直接使用,依赖已全部就绪 car.Run(); } } 总结一下:依赖注入就是从外面把需要的东西先给准备好(接口、实现类) 再通过构造函数来自动把得到的实例塞进字段使得类内的全部方法都能使用 然后构建DI容器,告诉它我如果有需求,你就按我告诉你的方式去做 之后像平常new一样的方式去获取对象并使用 Car car = provider.GetRequiredService<Car>(); 基本就等于↓ IEngine engine = new ElectricEngine(); Car car = new Car(engine); 那DI这玩意new一下就能解决的事,他弄这么长这么复杂,我为什么要用他?你写完DI都够我new七八个了。 是的,DI写起来很复杂,但是,它可以把“谁来做”和“谁用什么做”解耦 Car只管要一个能start的东西,至于是怎么来的,从哪来的无所谓,只要能干活就行。 而传统的new则是全部由Car来决定,既要知道怎么来,又要知道是谁,还得能完成要求。 那写这么麻烦就为了个解耦吗,至于吗? 当然至于! 现在有一个需求,要改实现类。 class Car { Engine e = new Engine(); } // 第一处 class Truck { Engine e = new Engine(); } // 第二处 class Boat { Engine e = new Engine(); } // 第三处 // ... 还有1000处 传统的方式一个个new,new了1000次,还分布在代码的各个位置。这种情况下要手动一个个把全部实现类改掉,非常麻烦而且容易遗漏,万一遗漏了某处没改,而编译能通过,这就出现了一个很麻烦的bug。 // 就这一个地方,改了,所有类拿到的全是新实现 services.AddTransient<IEngine, ElectricEngine>(); 如果是用依赖注入的方式写的,这1000个实现类都是从一个地方取的,那么我只需要改这一个地方就行了,非常便捷而且绝对安全。 除此之外,,DI还有一个new完全不可能替代的优势,就是单元测试 比如这个例子 class Engine { public void Start() { Console.WriteLine("发动机启动"); } } class Car { private Engine _engine = new Engine(); public string Run() { _engine.Start(); return "汽车跑起来了"; } } [Test] public void TestRun() { var car = new Car(); // 造车的同时,发动机已经启动了 var result = car.Run(); // 又启动一次 Assert.AreEqual("汽车跑起来了", result); // 问题: // 1. 你怎么知道发动机真的被调用了?只能看结果 // 2. 如果发动机启动要1000秒呢?测试就要等1000秒 // 3. 如果发动机坏了抛异常呢?测试直接完蛋 } 完全无法控制发动机的行为,因为发动机已经写死在Car了 ,要测试就必须真执行。 如果测试的是邮件分发功能,那更完蛋,用户必须得接收因测试产生的垃圾邮件 现在看看DI interface IEngine { void Start(); } class Car { private IEngine _engine; //Car只依赖接口,你可以在测试的时候造一个“假”发动机 public Car(IEngine engine) { _engine = engine; } public string Run() { _engine.Start(); return "汽车跑起来了"; } } //写测试 [Test] public void TestRun_ShouldStartEngine() { // 造个假发动机, var fakeEngine = new FakeEngine(); // 注入给 Car var car = new Car(fakeEngine); //执行 var result = car.Run(); // 验证 Assert.IsTrue(fakeEngine.WasStarted); // 发动机真被调用了 Assert.AreEqual(1, fakeEngine.StartCount); // 只调用了一次 Assert.AreEqual("汽车跑起来了", result); // 返回值正确 } 测试谁就给谁专门安排一套“假货”,能轻松完成测试,也能避免出现“给客户发送垃圾测试邮件”类似的问题。 这也叫“隔离”,即把被测的东西和它的依赖分割开,给它造个“假”依赖来达成我们单元测试的目的