依赖注入:控制反转究竟是如何发生的






4.80/5 (17投票s)
依赖注入、DI 容器、IoC 容器和控制反转常常令人困惑。让我们来一一剖析。
引言
这是我两部分文章的第二部分,总体上我试图涵盖依赖倒置原则(DIP)、依赖注入(DI)、IoC、DI 容器和 IoC 容器的方方面面。我将使用第一部分中的许多术语,因此理解我第一篇文章非常重要,这将使你理解依赖倒置原则(DIP)的要点。该文章链接如下:
这是我当前文章的议程
- 什么是依赖注入?这是最基础的实现。要明白依赖倒置原则(DIP)不是依赖注入(DI),尽管它们的首字母缩写相同。它们就是不一样。反之亦然。
- 要理解,即使你的模块和组件不遵循或不体现依赖倒置原则,也可以实现依赖注入。
- 依赖注入(DI)和依赖倒置原则(DIP)如何协同工作以产生最佳解决方案。
关于附带代码的说明
要在你的计算机上运行附带的代码,你需要以下软件作为先决条件
- Visual Studio 2010 或更高版本
- Microsoft SQL Server - 即使是 Express 版本也足够了
附带的代码包含一个名为“DatabaseScripts.sql”的文件,你需要在本地 SQL Server 实例上运行此文件才能无误地运行代码。如果你没有 SQL Server,仍然可以理解代码。要不带 SQL Server 运行代码,你需要注释掉几行代码,这些代码在“SaveDataToStorage
”函数中负责将记录保存到数据库。 “SaveDataToStorage
”函数位于“EmployeeComponentWithConstructorDI.cs”和“EmployeeComponentWithoutDIP.cs”代码文件中。在这种情况下,行号是 66 和 67,应该被注释掉。
背景:客户需求
以下是我所面临或试图为所有人解决的问题的简要历史。我的客户告诉我,应该有一个员工实体,包含员工的基本属性,如name
(姓名)和salary
(薪水)。它还应该公开一个public
(公共)接口,可以调用该接口将员工的当前属性保存到某个持久介质,例如数据库、磁盘上的文本文件等。Customer
(客户)告诉我,目前我正在使用 SQL 数据库作为持久介质,员工详细信息将保存在那里。这是一个简单明了的需求!不是吗?
第一部分初始故事回顾
每个开发者都面临的最大问题是,当他们不得不为业务/客户(不断变化的)需求**更改**他们的软件时。开发者希望通过遵循最佳编码实践,将软件中的更改降至最低,如果可能的话降至零。所以有一天,我的客户带着一个需求变更来了:“SQL 服务器的许可证对我来说有点贵,所以我将使用基于 Microsoft Access 的数据库存储,这是一个相对便宜的选择。所以我想让你将所有员工信息保存到 Microsoft Access 而不是 Microsoft SQL Server”。我被指派修改employee
(员工)类以完成这个需求。现在我了解到我的customer
(客户)有改变持久介质的习惯,他们希望我们将员工详细信息保存在那里。将来很有可能他会尝试使用更便宜或不同的选项来更改持久介质。我开始考虑提出一个通用的解决方案,以满足这种不断变化的需求,这样每次客户提出这种新的持久存储想法时,我只需要对我的核心组件进行最少或几乎不进行更改。所以我的导师 uncle bob 告诉我,如果你的模块/类遵循依赖倒置原则(SOLID 设计原则的原则之一),你就能应对这些问题。所以我尝试了一下,并尝试按照本系列文章前一部分的讨论来优化我的模块,该文章可以在 这里找到。如果不理解依赖倒置原则,将很难理解依赖注入设计模式,所以我再次坚持让你阅读我之前的文章。
一个基本概念的快速介绍
在我们进一步讨论之前,我想快速介绍一个术语,因为我将在本文中大量使用这个术语。
依赖 (Dependency):在我之前的文章中,我将这个概念称为“低级模块”。本质上,依赖是你的当前组件委托其某些职责来完成工作所依赖的任何类、模块或组件。看看下面从 我之前的文章引用的代码片段 # 1。在这里,employee
(员工)类使用“DatabaseStorageAgentForSqlServer
”类来委托将员工详细信息保存到持久存储的工作。因此,“DatabaseStorageAgentForSqlServer
”类是“Employee
”类的依赖项。“Employee
”类依赖于“DatabaseStorageAgentForSqlServer
”类来完成某些工作。
代码片段 # 1
using System.Data;
using System.Data.SqlClient;
using System.Data.OleDb;
namespace DependencyInversionPrincipleGenericStorageImplementation
{
public class Employee
{
//Trivial code has been removed for brevity and clarity.
//Please download attached code from my previous article of this series for complete reference
public void SaveEmployeeDetails()
{
//Instantiate low-level Dependency
IStorageService persistanceStorageAgent = new DatabaseStorageAgentForSqlServer();
if (IsValidEmployee(empName,salary))
{
//save employee details
persistanceStorageAgent.SaveData(empName, salary);
}
}
}
public interface IStorageService
{
void SaveData(string name, int salary);
}
public class DatabaseStorageAgentForSqlServer : IStorageService
{
public void SaveData(string name, int salary)
{
//create query text
string queryText = string.Format
("insert into Employee (Name,Salary) values ('{0}',{1})", name, salary);
//create persistence storage connection
var oleDbCon = GetPersistantStorageConnection();
//save employee details into the persistence storage
SaveDataToStorage(oleDbCon, queryText);
}
private IDbConnection GetPersistantStorageConnection()
{
return new SqlConnection("Data Source=(local);
Initial Catalog=EmployeeDB;Integrated Security=SSPI;");
}
private void SaveDataToStorage(IDbConnection dbConnection, string queryText)
{
var sqlCmd = new SqlCommand(queryText, (SqlConnection)dbConnection);
dbConnection.Open();
sqlCmd.ExecuteNonQuery();
}
}
}
第一部分文章解决的问题
我试图让我的类中的依赖项尽可能地无缝插入/替换/更改,以便当依赖项从基于 SQL Server 的实现更改为基于 Microsoft Access 的实现或任何其他持久存储实现时,我的employee
(员工)组件遭受的更改最少。正如你在上面的代码片段 # 1 中看到的,要切换我的依赖项,我只需要更改一行代码,即在SaveEmployeeDetails
(保存员工详细信息)函数中实例化我的依赖项。我将类中发生的更改降到了最低。要理解IStorageService
(存储服务接口)的相关性,你需要理解我 上一篇文章中的依赖倒置原则。但问题仍然存在?
遗留问题陈述
是的。我的组件仍然存在以下问题:
- 最大可插入性未实现:让我们接受 100% 的可插入性是不可能实现的。每当你更改软件以适应客户需求时,你都必须在某个地方或另一个地方对代码进行更改。但我们的目标应该是使更改最小化、集中在一个地方、影响最小且涉及最少的软件重新测试。我的依赖项仍然**未**完全可插入/可替换。尽管我只需要更改一行代码即可更改持久存储的依赖项实现,但更改仍然存在。此更改将导致我的组件/程序集在生产环境中重新构建和重新部署。
- 依赖项数量多的情况:如果我的“
Employee
”类有许多依赖项。假设它有四到五个其他依赖项,“Employee
”类委托其他工作来完成。在这种情况下,将有多个更改点。如果所有五个依赖项都更改,那么我将不得不更改所有五个依赖项对象的实例化点。所以,本质上,这是我最初试图最小化或完全消除的大量更改。 - 依赖项对象生命周期管理:是的。这是一个我们无法直接观察到的问题,但显然,无论你创建了什么依赖项实例,你都有责任管理它们,并在不需要时将其销毁。你自己的
employee
(员工)组件/类负责执行此操作。令人沮丧,不是吗?你正在给你的组件增加多重职责的负担。 - 组件的单元可测试性:每当我需要测试我的
employee
(员工)组件时,我都严重依赖于底层持久存储介质(即 SQL Server)的功能。如果 SQL Server 碰巧无法工作,那么我就无法单元测试我的employee
(员工)组件的逻辑。这本质上意味着我无法模拟外部资源或在运行时替换外部依赖项来限制测试范围仅限于我的组件。这一点现在可能有点难以消化,但应该会在文章结束时变得清晰。有大量的模拟框架被发明出来以满足这个需求。
那么,先生们,摆脱上述问题的出路是什么?
解决方案:依赖注入设计模式
是的。对于开发者日复一日遇到的这个重复性问题,有一个设计模式。如何管理你的依赖项?如何管理我们依赖项的生命周期?当我的依赖项更改时,如何最小化更改?所以,本质上,我如何设计我的组件,以便我的“Employee
”类不必实例化或管理依赖项。当然,如果你想放弃这些职责,那么必须有人代替你的组件来承担。将有一个外部/第三方组件(我们很快就会谈到它)来创建和管理你的依赖项。所以,如果有人为你做了这些,那么必须有一种方法可以让那个外部代理将这些依赖项对象传递给你使用。这种传递依赖项对象的方式称为**注入 (INJECTION)**。外部代理会将依赖项对象注入到你的类/组件中。一旦你完成了对依赖项对象的使用,你就无需再担心它的生命周期管理。它将由创建你依赖项的外部代理负责。让我尝试以一种非常基本的方式来实现它,我将让我的主方法负责依赖项管理和注入。让我们看看我们的“Employee
”类的结构也如何改变以符合依赖注入设计模式。
代码片段 # 2
ClientAppWithoutDIContainer.csproj 中的 Program.cs
using System;
namespace DependencyInjection
{
class Program
{
static void Main(string[] args)
{
//create dependency instance
IStorageAgent sqlServerPersistanceStorageAgent = new DatabaseStorageAgent();
//inject dependency through constructor injection
var employee = new Employee(sqlServerPersistanceStorageAgent);
employee.EmployeeName = "Rasik";
employee.Salary = 200;
employee.SaveEmployeeDetails();
//Nullify the dependency instance
sqlServerPersistanceStorageAgent = null;
Console.WriteLine("Dependency Injection accomplished with
main method acting as DI container. Press enter to end the program.");
Console.ReadLine();
}
}
}
EmployeeComponentWithConstructorDI.cs
using System.Data;
using System.Data.SqlClient;
namespace DependencyInjection
{
public class Employee
{
private string empName;
private int salary;
IStorageAgent persistanceStorageDependency;
public Employee(IStorageAgent persistanceStorageAgent)
{
//Assigning the injected dependency to local reference.
persistanceStorageDependency = persistanceStorageAgent;
}
public string EmployeeName
{
get { return empName; }
set { empName = value; }
}
public int Salary
{
get { return salary; }
set { salary = value; }
}
public void SaveEmployeeDetails()
{
if (IsValidEmployee(empName,salary))
{
//create query text
string queryText = string.Format
("insert into Employee (Name,Salary) values ('{0}',{1})", empName, salary);
//create persistence storage connection
var connection = persistanceStorageDependency.GetPersistantStorageConnection();
//save employee details into the persistence storage
persistanceStorageDependency.SaveDataToStorage(connection, queryText);
}
}
private bool IsValidEmployee(string inputName, int inputSalary)
{
return !string.IsNullOrEmpty(inputName) && inputSalary > 0;
}
}
public interface IStorageAgent
{
IDbConnection GetPersistantStorageConnection();
void SaveDataToStorage(IDbConnection Connection, string queryText);
}
public class DatabaseStorageAgent : IStorageAgent
{
public IDbConnection GetPersistantStorageConnection()
{
return new SqlConnection("Data Source=(local);
Initial Catalog=EmployeeDB;Integrated Security=SSPI;");
}
public void SaveDataToStorage(IDbConnection dbConnection, string queryText)
{
var sqlCmd = new SqlCommand(queryText, (SqlConnection)dbConnection);
dbConnection.Open();
sqlCmd.ExecuteNonQuery();
}
}
}
我试图以与上一篇文章相同的方式逐步演进概念。你是否能注意到“Employee
”类的演进?是的。我选择构造函数参数作为我的注入点。我从“Employee
”类中删除了所有对象实例化代码,因为我不想承担这项责任。我期望通过构造函数将依赖项对象传递到我的对象实例中。另外,仅供你参考,这**不是**将依赖项注入类的唯一方法。你能想到其他可能的注入点吗?我们刚刚学到的当前方法称为**构造函数依赖注入**。恭喜!你刚刚学到了一个新的技术概念,可以给你的同事留下深刻印象。
但这里还有更多需要学习的地方。
控制反转原则
现在,你应该注意到一个重要的事情。为了在运行时使你的依赖项可插入,你已经**反转**了你的依赖项对象的创建和管理控制。依赖项的创建职责已从你的组件中移走,或者在某种意义上已反转到主方法(此处为调用组件)。这本质上是依赖注入模式所基于的核心原则——**控制反转 (IoC)**。所以,基本上,我们通过自下而上的方法理解了这两个概念。我们首先学习了依赖注入模式,然后学习了它所基于的更高级别的控制反转设计原则。我希望你能欣赏它。
但是,这足以确保你的依赖项的可插入性吗?不。请继续阅读。
IoC/DI 容器
你仍然可以说,虽然我将对象创建的控制从“Employee
”类反转到主方法,通过在运行时注入依赖项,但主方法仍然是你程序的一部分,不是吗?所以,如果我的依赖项发生变化,那么主方法也会发生变化,这本质上是我自己程序的变化。我想要完全摆脱它。为了让你能够实现这个目标,通常会使用一个单独的组件。那个软件片段本质上是一个工厂,其唯一职责是创建/管理你的依赖项的对象或实例。那个软件/组件被称为 **IoC/DI 容器**。这是 IoC/DI 容器的工作方式:
- 你配置一个 DI 容器。这意味着你告诉 DI 容器,如果我向你请求一个接口/类的引用,那么应该实例化哪个类,即,你注册类名与相应接口/类名的映射。例如,在我的当前案例中,当我需要
IStorageAgent
(存储代理接口)的引用时,DI 容器应该创建一个DatabaseStorageAgent
(数据库存储代理)类的实例并返回该引用给我。我在附带的示例代码中使用了 Windsor Castle DI 容器。看看代码片段 # 3 中DIContainer.cs文件中的RegisterDependencyMapping
(注册依赖项映射)函数。它告诉你当前使用的 DI 容器是如何配置的。 - 你在运行时从主方法调用 DI 容器来解析依赖项。这本质上意味着你正在调用 DI 容器来根据上面步骤 1 中设置的配置获取所需的依赖项对象实例。
让我们看看代码,看看Main.cs是如何改变的
代码片段 # 3
ClientAppWithDIContainer.csproj 中的 Main.cs
using System;
using Castle.Windsor;
using DependencyInjection;
using DIContainer;
namespace DependencyInjectionWithDIContainer
{
class Program
{
static void Main(string[] args)
{
//Get DI container reference
IWindsorContainer windsorContainer = WindsorCastleDIContainer.Container;
//Initialize windsor castle DI container dependency mappings
WindsorCastleDIContainer.RegisterDependencyMapping(windsorContainer);
//Get dependency instance reference using DI container
//This is also called Resolving a dependency using DI container.
IStorageAgent sqlServerPersistanceStorageAgent = windsorContainer.Resolve<IStorageAgent>();
//inject dependency through constructor injection
var employee = new Employee(sqlServerPersistanceStorageAgent);
employee.EmployeeName = "Rasik";
employee.Salary = 200;
employee.SaveEmployeeDetails();
Console.WriteLine("Dependency Injection accomplished
with the help of Windsor Castle DI container. Press enter to end the program.");
Console.ReadLine();
}
}
}
DIContainer.csproj 中的 DIContainer.cs
using Castle.MicroKernel.Registration;
using Castle.Windsor;
using DependencyInjection;
namespace DIContainer
{
public class WindsorCastleDIContainer
{
private static WindsorContainer container = new WindsorContainer();
public static IWindsorContainer Container
{
get
{
return container;
}
}
public static void DisposeDIContainer()
{
if (container != null)
{
container.Dispose();
container = null;
}
}
public static void RegisterDependencyMapping(IWindsorContainer container)
{
container.Register(Component.For<IStorageAgent>().ImplementedBy<DatabaseStorageAgent>());
}
}
}
你的核心组件(包含employee
(员工)类)保持完全不变。现在,当这个 DI 容器项目(它是我依赖项对象的工厂)出现时,我获得了哪些优势?
- DI 容器位于一个单独的项目/程序集中。它可以独立更改、编译和部署,而不会影响你的其他核心组件。
- 每当依赖项映射发生更改时,**只有**我的 DI 容器组件会受到影响。在
RegisterDependencyMapping
(注册依赖项映射)方法中进行更改,一切就绪。例如,在我的案例中,我的客户希望切换到基于 Microsoft Access 的持久存储实现。我已经有了“MSAccessStorageAgent
”(Microsoft Access 存储代理)类。因此,只需在RegisterDependencyMapping
(注册依赖项映射)函数中将“DatabaseStorageAgent
”(数据库存储代理)类名替换为“MSAccessStorageAgent
”(Microsoft Access 存储代理),你就完成了。 - 你无需担心依赖项对象的生命周期和管理。DI 容器足够智能,可以处理这些。
DI 容器可以配置为在每次调用时返回单例实例或新的依赖项对象实例。你应该对此进行更多探索,因为 DI 容器是一个独立的世界,我在本文中无法包含更多细节。市场上存在许多 DI 容器。对于 .NET 环境,你可以在 这里找到一个详尽的列表。
如果缺少依赖倒置原则
employee
(员工)的当前结构表现出两个关键特征:
- 它遵循依赖倒置原则。我们在本文第一部分讨论了这一点。
- 它实现了依赖注入设计模式,以实现依赖项创建的控制反转。我们在本文中讨论了这一点。
如果我放弃第一个特征而只保留第二个特征,影响很简单:每次你需要替换/更新你的依赖项时,你都需要进行大量的更改——employee
(员工)组件、main
(主)方法以及 DI 容器配置。我还附上了一个符合第二个特征但**不**符合第一个特征的employee
(员工)类。我相信你总是希望为任何新的客户需求最小化软件更改。选择权在你。**DIP 和 IoC 一起使用时能产生最佳影响**。这是已经实现了依赖注入(IoC 原则)但**不**遵循 DIP(依赖倒置原则)的代码片段。将其与EmployeeComponentWithConstructorDI.cs文件进行比较,看看“Employee
”类的构造函数中的注入参数如何从IStorageAgent
(存储代理接口)更改为DatabaseStorageAgent
(数据库存储代理)。这种违反 DIP 的行为会带来高昂的成本。
代码片段 # 4
EmployeeComponentWithoutDIP.cs
using System.Data;
using System.Data.SqlClient;
using System.Data.OleDb;
namespace DependencyInjectionWihtoutDIP
{
public class Employee
{
private string empName;
private int salary;
DatabaseStorageAgent persistanceStorageDependency;
public Employee(DatabaseStorageAgent persistanceStorageAgent)
{
//Assigning the injected dependency to local reference.
persistanceStorageDependency = persistanceStorageAgent;
}
public string EmployeeName
{
get { return empName; }
set { empName = value; }
}
public int Salary
{
get { return salary; }
set { salary = value; }
}
public void SaveEmployeeDetails()
{
if (IsValidEmployee(empName,salary))
{
//create query text
string queryText = string.Format
("insert into Employee (Name,Salary) values ('{0}',{1})", empName, salary);
//create persistence storage connection
var connection = persistanceStorageDependency.GetPersistantStorageConnection();
//save employee details into the persistence storage
persistanceStorageDependency.SaveDataToStorage(connection, queryText);
}
}
private bool IsValidEmployee(string inputName, int inputSalary)
{
return !string.IsNullOrEmpty(inputName) && inputSalary > 0;
}
}
public interface IStorageAgent
{
IDbConnection GetPersistantStorageConnection();
void SaveDataToStorage(IDbConnection Connection, string queryText);
}
public class DatabaseStorageAgent : IStorageAgent
{
public IDbConnection GetPersistantStorageConnection()
{
return new SqlConnection("Data Source=(local);
Initial Catalog=EmployeeDB;Integrated Security=SSPI;");
}
public void SaveDataToStorage(IDbConnection dbConnection, string queryText)
{
var sqlCmd = new SqlCommand(queryText, (SqlConnection)dbConnection);
dbConnection.Open();
sqlCmd.ExecuteNonQuery();
}
}
public class MSAccessStorageAgent : IStorageAgent
{
public IDbConnection GetPersistantStorageConnection()
{
return new OleDbConnection("Provider=Microsoft.Jet.OLEDB.4.0;
Data Source=C:\\AccessDatabases\\EmployeeDb\\employee.mdb;Persist Security Info=True");
}
public void SaveDataToStorage(IDbConnection dbConnection, string queryText)
{
var oleDbCmd = new OleDbCommand(queryText, (OleDbConnection)dbConnection);
oleDbCmd.ExecuteNonQuery();
}
}
}
依赖注入带来的优势
除了最小化因需求变化而带来的更改外,依赖注入模式的另一个核心优势是通过模拟实现组件的单元可测试性,即在运行时,你可以在注入实际/真实的代表外部资源的依赖项类时,注入假的/虚拟的组件,这些组件可以像真实的依赖项类一样工作,而无需连接到外部资源。这有助于你将单元测试的范围限制在**仅**你的组件中构建的逻辑。
关注点
- 探索依赖注入的其他方法。
- 探索控制反转的其他场景。目前,你反转了依赖项实例化的控制。
- 通过配置文件而不是代码配置 IoC 容器。
- 什么是服务定位器 (Service Locators)。它们是 IoC 容器的同类,遵循服务定位设计模式。
- 如何对依赖项组件进行模拟以进行单元测试。
- 模拟框架 (Mocking Frameworks)。
历史
- 根据读者反馈更新