依赖注入是用来实现 IOC(Inversion Of Control,控制反转) 的一种方式,它可以让一个类不显式地获取它的依赖类,从而实现类与其依赖类的分离。这种不显式的获取方式叫做依赖注入。
例如下面这个 Test 类使用了一个简单的日志配置依赖类:
class Logger { public void Output(string s) { Console.WriteLine(s); } } class Test { private readonly Logger logger = new Logger(); public void Execute() { logger.Output(DateTime.Now.ToString()); } }
这种显式地生成依赖类的方式有以下缺点:
- 如果依赖的实现类变化了,需要替换代码将 Logger 修改为新的实现。
- 如果 Logger 还依赖着其他类,需要提前创建并配置它的依赖类。
- 不利于单元测试。
使用依赖注入的方式可以解决上面的问题。在依赖注入中,依赖的类称为服务,服务在注入之前必须进行注册,.NET 提供了内置的服务容器 IServiceCollection 来管理所有的服务,通过 ServiceCollection 注册服务,然后通过 IServiceProvider 来获取服务。
//创建服务容器 ServiceCollection services = new ServiceCollection(); //注册服务 services.AddSingleton<Logger>(); //创建服务定位器 using (ServiceProvider sp = services.BuildServiceProvider()) { //获取服务 Logger logger = sp.GetService<Logger>(); }
在上面的代码中,我们并没有手动创建 Logger 类的实例,而是通过服务容器来创建的。在依赖注入中,我们无需手动创建任何依赖类的实例。
注册服务,需要通过 AddSingleton/AddScoped/AddTransient 这三个方法,它们之间的区别在于服务的生命周期。
- Singleton 即单例模式,即在任何时候获取服务,总是获取同一个实例。
- Scoped 即范围模式,即在一个 Scope 内获取服务,总是获取同一个实例,但不同的 Scope 内获取的实例不同。
- Transient 即瞬态模式,即在任何时候获取服务,总是获取一个新的实例。
注册服务时,需要在泛型参数内传入服务的类型,在上面的代码中,我们直接传入了 Logger 这个实现类,然后通过 ServiceProvider.GetService<T> 来获取服务。但这样的话我们依旧没有解决<依赖在实现类变化的时候需要改变代码>这个问题:如果下次使用了另一个实现类,那么 GetService 的泛型参数也要跟着修改了。
要解决这个问题的话,必须使用接口。在依赖注入中,可以通过接口来注册服务,只需要传入接口和对应的实现类即可。
class Program { static void Main(string[] args) { //创建服务容器 ServiceCollection services = new ServiceCollection(); //通过接口注册服务 services.AddSingleton<ILogger, Logger>(); //创建服务定位器 using (ServiceProvider sp = services.BuildServiceProvider()) { //获取服务 ILogger logger = sp.GetService<ILogger>(); logger.Output("Hello World!"); } } } interface ILogger { public void Output(string s); } class Logger : ILogger { public void Output(string s) { Console.WriteLine(s); } }
上面的代码中,在注册的时候分别传入了 ILogger 接口(称为注册类型)和 Logger 实现类(称为实现类型),而在获取服务的时候只需要传入 ILogger 接口,这样即使实现类变化了,也只需要改变注册代码,无需改变获取服务的代码。注意,只能通过注册类型获取服务。
同个接口可以注册多个实现类,使用 GetServices 方法可以获取这个接口的所有实现类型,使用 GetService 方法可以获取这个接口的最后一个注册的实现类型。
使用 GetService 获取服务时,如果服务不存在,那么会返回 null,也可以使用GetRequiredService 方法获取服务,它在服务不存在的时候抛出 System.InvalidOperationException 异常。
在获取服务的时候,服务容器不仅会创建这个服务的实例,如果这个服务还依赖于其他的服务,那么这些服务都会被一同创建,我们只需要将依赖的服务写入构造函数的参数,服务容器会自动为它们赋值:
class Program { static void Main(string[] args) { //创建服务容器 ServiceCollection services = new ServiceCollection(); //通过接口注册服务 services.AddSingleton<ILogger, Logger>(); services.AddScoped<Test>(); using (ServiceProvider sp = services.BuildServiceProvider()) { Test t = sp.GetService<Test>(); t.Execute(); } } } interface ILogger { public void Output(string s); } class Logger : ILogger { public void Output(string s) { Console.WriteLine(s); } } class Test { private readonly ILogger logger; public Test(ILogger logger) { this.logger = logger; } public void Execute() { logger.Output(DateTime.Now.ToString()); } }
在 Test 类中,构造函数拥有一个 ILogger 类型的参数,在获取 Test 服务的时候,服务容器会自动查看已经注册的服务中是否有 ILogger 这个服务,如果有就自动为其赋值(取决于生命周期,可能创建新实例,也可能使用已创建的实例),如果没有就抛出异常。
通过构造函数隐式实现依赖获取,这就是 .NET 中的依赖注入,对于 Test 类来说,它并没有任何显式地获取依赖的代码,服务是从外部主动注入的,这就是控制反转(IOC)。
注意:只有通过服务容器获取的对象,才会自动注入服务,如果是自己 new 的对象,是没有办法注入的,需要手动在构造函数里传入服务。
即便是复杂的嵌套的依赖关系,我们获取依赖的方式都是一样的:在程序启动的时候,将所有服务添加进服务容器,然后在需要服务的时候,只需在构造函数的参数里写上,然后在构造函数里赋值即可,服务容器会自动根据依赖关系从里到外进行初始化:
class Program { static void Main(string[] args) { //创建服务容器 ServiceCollection services = new ServiceCollection(); //通过接口注册服务 services.AddScoped<IConsole, MyConsole>(); services.AddSingleton<ILogger, Logger>(); services.AddScoped<Test>(); using (ServiceProvider sp = services.BuildServiceProvider()) { Test t = sp.GetService<Test>(); t.Execute(); } } } interface IConsole { public void WriteLine(string s); } class MyConsole : IConsole { public void WriteLine(string s) { Console.WriteLine(s); } } interface ILogger { public void Output(string s); } class Logger : ILogger { private readonly IConsole myConsole; public Logger(IConsole myConsole) { this.myConsole = myConsole; } public void Output(string s) { Console.WriteLine(s); } } class Test { private readonly ILogger logger; public Test(ILogger logger) { this.logger = logger; } public void Execute() { logger.Output(DateTime.Now.ToString()); } }
使用依赖注入的好处:
- 通过面向接口编程将依赖抽象化,主类只需关注接口,从而和依赖实现分离解耦,依赖实现改变时,主类无需更改。
- 使用服务容器管理服务,使用构造函数隐式注入服务,主类无需显式获取。
- 在服务不再被需要的时候,服务容器会自动 Dispose 这些服务。