.NET依赖注入

.NET Core 程序的各个部分是通过依赖注入功能被组装在一起的,可以说依赖注入是.NET Core的骨架,它支撑起了.NET Core程序的运行。

控制反转(Inversion Of Control,IOC)是设计模式中非常重要的思想,而依赖注入(Dependency Injection,DI)是控制反转思想的一种重要的实现方式。

依赖注入简化了模块的组装过程,减小了模块之间的耦合度,因此.NET Core中大量应用了依赖注入的开发模式。

1. 什么是控制反转、服务定位器和依赖注入

在传统的软件开发中,代码依赖的对象都是由调用者自己编写代码进行创建和组装的。

如以下代码所示,先从配置文件appsettings读取数据库连接字符串,然后创建程序到数据库的连接。

C#
  string connstr = "Server=DESKTOP-8DO7T4C\\SQLEXPRESS01;Database=demo1;uid=sa;pwd=123456;Encrypt=false;";

  SqlConnection conn = new(connstr);

  conn.StatisticsEnabled = true;

这样做的确定是开发人员需要清楚每个类的作用,以及如何构建和组装它们,代码也对这些组件有强依赖性。

这增加了开发人员的负担,使开发人员无法专注于业务逻辑代码的开发。

同时,这些代码也与ConfigurationManager、SqlConnection等类强耦合。

如果需要把从配置文件appsettings读取改为从Web.Config读取或者改为连接MySQL数据库,就要对代码进行修改。

控制反转的目的就是把”创建和组装对象”操作的控制权从业务逻辑的代码中转移到框架中,这样业务代码中只要说明”我需要某个类型的对象”,框架就会帮助我们创建这个对象。

框架甚至可以用装饰者模式把原始的对象包装起来,透明地提供权限控制、缓存、日志等功能,从而实现面向切面编程。

如果我们要实现上面连接数据库代码使用控制反转思想的改造的话,有以下两种主要的改造方法。

第一种方式是服务定位器(Service Locator)。

假设框架中有一个类叫ServiceLocator,只要调用它的GetService方法就可以获取想要的对象,至于对象是如何创建的我们不用关心。

服务定位器如以下代码所示:

C#
 IDbConnection conn = ServiceLocator.Current.GetInstance<IDbConnection>();

第二种方式是依赖注入。

假设框架中有一个自动为类的属性赋值的功能,只要在代码中通过属性声明说明我们需要什么类型的对象,框架就会创建这个对象。

依赖注入如以下代码所示:

C#
   class Demo
   {
       public IDbConnection Conn { get; set; }

       public void InsertDB()
       {
           IDbCommand cmd = Conn.CreateCommand();
       }

   }

上面的代码运行在某个框架中,这个框架创建Demo的对象之后,它会自动为Conn属性赋值一个合适的对象。

这种框架自动创建对象的动作就叫作”注入”(injection)。

无论是哪种方式,只要采用了控制反转的思想,我们在编写代码的时候就可以不用关心对象的创建过程。

当然,这一切仍然是需要通过代码来创建对象并且把对象注册到框架中的,这些注册功能可以由框架提供,也可以由开发人员自己进行注册。

我们把负责提供对象的注册和获取功能的框架叫作”容器”,注册到容器中的对象叫作”服务”。

从以上代码可以看出,依赖注入的方式更简单,只要容器给它们赋值即可,不需要像服务定位器那样需要我们通过代码去获取服务。

因此我们优先选择依赖注入的方式,只有在依赖注入不满足要求的情况下,才使用服务定位器。

综上所述,控制反转就是把”我创建对象”,变成”我要对象”。

实现控制反转的时候,我们可以采用依赖注入或者服务定位器两种方式。

程序启动的时候,需要把服务注册到容器中,由容器负责服务的管理。

2. .NET Core依赖注入的基本使用

.NET Core中内置了控制反转机制,它同时支持依赖注入和服务定位器两种方式。

由于依赖注入是推荐的方式,因此微软把内置的控制反转组件命名为DependencyInjection。

但是它包含了服务定位器的功能。

我门将尊重微软的习惯,把这个功能统一称为依赖注入。

依赖注入框架中注册的服务有一个重要的概念叫作”生命周期”,通俗地说就是”获取服务的时候是创建一个新对象还是用之前的对象”。

依赖注入框架中服务的生命周期有三种。

(1)瞬态(transient):

每次被请求的时候都会创建一个新对象。

这种生命周期适合有状态的对象,可以避免多段代码用于同一个对象而造成状态混乱,其缺点是生成的对象比较多,会浪费内存。

(2)范围(scoped):

在给定的范围内,多次请求共享同一个服务对象,服务每次被请求的时候都会返回同一个对象;

在不同的范围内,服务每次被请求的时候会返回不同的对象。

这个范围可以由框架定义,也可以由开发人员自定义。

在ASP.NET Core中,服务默认的范围是一次HTTP请求,也就是在同一次HTTP请求中,不同的注入会获得同一个对象;

在不同HTTP请求中,不同的注入会获得不同的对象。

这种方式适用于在同一个范围内共享同一个对象的情况。

(3)单例(singleton):

全局共享同一个服务对象。

这种生命周期可以节省创建新对象的资源。

为了避免并非修改等问题,单例的服务对象最好是无状态对象。

本书作者的建议是:

如果一个类没有状态,建议把服务的生命周期设置为单例;

如果一个类有状态,并且是在框架环境中有范围控制,在这种情况下建议把服务的生命周期设置为范围。

因为通常在范围控制下,代码都是运行在同一线程中的,没有并发修改的问题;

在使用瞬态生命周期的时候要谨慎,尽量在子范围中使用它们,而不要在根范围中使用它们。

因为如果我们控制不好,容易造成程序中出现内存泄漏的问题。

依赖注入框架是根据服务的类型来获取服务的,因此在获取服务的时候必须指定获取什么类型的服务。

依赖注入框架中注册服务的时候可以分别指定服务类型和实现类型,这两者可能相同,也可能不同。

比如在注册服务的时候,可以设定服务类型和实现类型都是SqlConnection,这样在获取SqlConnection类型服务的时候,容器就会返回注册的SqlConnection类型的对象;

也可以在注册服务的时候,设定服务类型是IDbConnection,实现类型是SqlConnection,这样在获取IDbConnection类型服务的时候,容器就返回注册的SqlConnection类型的对象。

在面向对象编程中,推荐使用面向接口编程,这样我们的代码就依赖于服务接口,而不是依赖于实现类,可以实现代码解耦。

因此在使用依赖注入的时候,推荐服务类型用接口类型。

为了方便演示,我们先准备一个测试用的服务的接口ITestService和实现类TestServiceImpl,如以下代码所示:

C#
    public interface ITestService
    {
        public string Name { get; set; }

        public void SayHi();
    }

    public class TestServiceImpl : ITestService
    {
        public string Name { get ; set ; }

        public void SayHi()
        {
            Console.WriteLine($"Hi,i'm {Name}");
        }
    }

因为在获取服务之前,需要先注册服务,所以我们先要创建用于注册服务的容器。

容器的接口是IServiceCollection,其默认实现类是ServiceCollection。

IServiceCollection接口中定义了AddTransient、AddSingleton和AddScoped这三组扩展方法,分别用来注册瞬态、单例和范围服务。

注册完成后,我们调用IServiceCollection的BuildServiceProvider方法创建ServiceProvider对象,这个ServiceProvider对象就是一个服务定位器。

由于ServiceProvider对象实现了IDisposable接口,因此需要使用using对其进行资源的释放。

在我们需要获取服务的时候,可以调用ServiceProvider类的GetRequiredService方法。

服务的注册及获取过程如以下代码所示:

C#
    static void Main(string[] args)
    {

        ServiceCollection services = new();

        services.AddTransient<TestServiceImpl>();

        using(ServiceProvider sp = services.BuildServiceProvider())
        {
            TestServiceImpl testServiceImpl = sp.GetRequiredService<TestServiceImpl>();

            testServiceImpl.Name = "Bob";

            testServiceImpl.SayHi();
        }


    }

上面第六行代码中,我们把TestServiceImpl注册为瞬态服务,然后在第十行代码中通过GetRequiredService方法来获取TestServiceImpl对象,很显然,这种用法属于服务定位器方式。

只有涉及服务之间的依赖的时候才会体现出依赖注入的优点。

3. 依赖注入的魅力所在

假设一个场景:我们要开发一个传统的分层项目结构,UI访问业务逻辑类,业务逻辑类访问数据访问类,然后数据访问类访问ADO.NET来操作数据库。

我们需要实现一个简单检查用户名、密码是否匹配的功能。

因此,在SQL Server数据库中建一个名为T_Users的表,这张表中有Id(主键)、UserName(用户名)、Password(密码)三列。

首先,基于面向接口编程的原则,先定义业务逻辑接口和数据访问接口,如下代码所示:

C#
    record Users(long Id,string UserName,string Password);

    interface IUserDAO
    {
        public User GetByUserName(string userName);
    }

    interface IUserBiz
    {
        public bool CheckLogin(string userName,string password);
    }

接下来,编写IUserDAO的实现类UserDAO,如下代码所示:

C#
  public class UserDAO : IUserDAO
  {
      private readonly IDbConnection conn;

      public UserDAO(IDbConnection conn)
      {
          this.conn = conn;
      }

      public User GetByUserName(string userName)
      {

          using var dt = SqlHelper.ExecuteQuery(conn, $"select * from T_Users where UserName = {userName}");

          if (dt.Rows.Count <= 0) return null;

          DataRow row = dt.Rows[0];

          long id = (long)row["id"];

          string uname = (string)row["UserName"];

          string password = (string)row["Password"];

          return new User() { Id = id, Name = uname};

      }
  }

UserDAO类通过构造方法要求依赖注入容器为其注入一个IDbConnection对象。

接下来,编写IUserBiz的实现类UserBiz,如下代码所示:

C#
 class UserBiz
 {
     private readonly IUserDAO userDao;

     public UserBiz(IUserDAO userDao )
     {
         this.userDao = userDao;
     }

     public bool CheckLogin(string username,string password)
     {
         var user = userDao.GetByUserName(username);

         if (user == null) return false;

         else return user.password == password;
     }

 }

UserBiz类通过构造方法要求注入IUserDAO服务,注意这里要求注入的是IUserDAO接口,而不是UserDAO类,因为我们要面向接口编程,而不是面向实现编程。

由于这里是基于接口编程的,因此其实我们可以在注册服务的时候使用IUserDAO接口的其他实现类;

比如我们要把数据库从SQL Server改成MySQL,只要把注册服务代码中的SqlConnection改成MySQLConnection即可,而UserDAO中的代码不受影响;

又如UserBiz和UserDAO要由不同的开发人员开发,那么只要约定好IUserDAO接口就可以了。

UserDAO的开发人员可以独立开发而UserBiz的开发人员可以先暂时开发一个实现了IUserDAO接口的Mock类。

然后使用这个Mock类作为IUserDAO的实现类进行开发测试,等UserDAO类被开发好之后再切换使用UserDAO类进行测试。

最后,通过编写容器的代码把服务组装起来:

C#
 static void Main(string[] args)
 {

     ServiceCollection services = new();

     services.AddTransient<TestServiceImpl>();

     using(ServiceProvider sp = services.BuildServiceProvider())
     {
         TestServiceImpl testServiceImpl = sp.GetRequiredService<TestServiceImpl>();

         testServiceImpl.Name = "Bob";

         testServiceImpl.SayHi();
     }

     services.AddScoped<IUserDAO,UserDAO>();
     services.AddScoped<IUserBiz, UserBiz>();


 }

还可以通过在构造方法中声明多个服务类型来注入多个服务;

如果有一个服务有多个实现对象,可以把参数声明为IEnumerable<T>类型,那么这个服务的多个实现对象都会被注入。

订阅评论
提醒
0 评论
最旧
最新 最多投票
内联反馈
查看所有评论
滚动至顶部