【C#】依赖注入 Dependency Injection

依赖注入是用来实现 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 这些服务。