摘要:
DI(IoC)是当前软件架构设计中比较时髦的技术。DI(IoC)可以使代码耦合性更低,更容易维护,更容易测试。现在有很多开源的依赖反转的框架,Ninject是其中一个轻量级开源的.net DI(IoC)框架。目前已经非常成熟,已经在很多项目中使用。这篇文章讲DI概念以及使用它的优势。使用一个简单的例子,重构这个例子让他逐步符合DI设计原则。
思考和设计代码的方法远比如何使用工具和技术更重要。– Mark Seemann
1、什么是DI(依赖反转)
DI(依赖反转)是一个软件设计方面的技术,通过管理依赖组件,提高软件应用程序的可维护性。用一个实际的例子来描述什么是DI以及DI的要素。
定义一个木匠类Carpenter,木匠对象(手里)有工具Saw对象,木匠有制造椅子MakeChair方法。MakeChair方法使用saw对象的Cut方法来制作椅子。
1 class Carpenter 2 { 3 Saw saw = new Saw(); 4 void MakeChair() 5 { 6 saw.Cut(); 7 // ... 8 } 9 }
定义一个手术医生类,手术医生对象有手术钳Forceps对象,手术医生做手术方法Operate。Operate方法使用手术钳对象的Grab方法来做手术。手术医生不需要知道他用的手术钳去哪里找,这是他助理的任务。他只需要关注做手术这一个关注点就行了。
1 class Surgeon 2 { 3 private Forceps forceps; 4 5 // The forceps object will be injected into the constructor 6 // method by a third party while the class is being created. 7 public Surgeon(Forceps forceps) 8 { 9 this.forceps = forceps; 10 } 11 12 public void Operate() 13 { 14 forceps.Grab(); 15 //... 16 } 17 }
上面两个例子木匠和医生都依赖于一个工具类,他们需要的工具是他们的依赖组件。依赖反转是指如何获得他们需要的工具的过程。第一个例子,木匠和锯子强依赖。第二个例子,医生的构造函数将他跟手术钳产生了依赖。
Martin Fowler给控制反转(IoC)下的定义是:Ioc是一种编程方式,这种编程方式使用框架来控制流程而不是通过你自己写的代码。比较处理事件和调用函数来理解IoC。当你自己写代码调用框架里的函数时,你在控制流程,因为你自己决定调用函数的顺序。但是使用事件时,你将函数绑定到事件上,然后触发事件,通过框架反过来调用函数。这时候控制反转到由框架来定义而不是你自己手写代码。DI是一个具体的IoC类型。组件不需要关心它自己的依赖项,依赖关系由框架来提供。实际上,根据Mark Seemann所说,DI in .NET,IoC是一个很宽的概念,不局限于DI,尽管他们两个概念经常互相通用。用好莱坞一句著名的台词来描述IoC就是:“不要找我们,我们来找你”。
2、 DI是如何工作的
每一个软件都不可避免地改变。当新的需求到来的时候,你修改你的代码导致代码量增加。维护你的代码的重要性变得很明显,一个可维护性差的软件系统是不可能进行下去的。一个指导设计可维护性代码的设计原则叫Separation of Concerns(SoC)【中文:分离关注点】。SoC是一个宽泛的概念而不仅限于软件设计。在软件组件设计方面,SoC设计一些不同的类,这些类各自有自己单独的责任。在上一个手术医生例子中,找工具和做手术是两个不同的关注点,分离他们为两个不同的关注点是开发可维护性的代码的一个前提。
SoC不能必然产生一个可维护性的代码,如果这些关注点相互之间的代码很紧密的耦合在一起。
尽管手术医生在做手术的过程中需要很多不同类型的手术钳,但是他没必要说具体哪一种是他需要的。他只需要说他要手术钳,他的助理来决定哪个手术钳是他最需要的。如果医生说的具体的那个手术钳暂时没有,助手可以给他提供另一个合适的,因为助手知道只要手术钳合适医生并不关心是哪种类型的。换句话说,手术医生不是跟手术钳紧密耦合在一起的。
对接口编程,而不是对具体实现编程。
我们用抽象元素(接口或类)来实现依赖,而不用具体类。我们就能够很容易地替换具体的依赖类而不影响上层的调用组件。
1 class Surgeon 2 { 3 private IForceps forceps; 4 5 public Surgeon(IForceps forceps) 6 { 7 this.forceps = forceps; 8 } 9 10 public void Operate() 11 { 12 forceps.Grab(); 13 //... 14 } 15 }
类Surgeon现在依赖于接口IForceps,而不用关心在构造函数中注入的对象具体的类型。C#编译器能够保证传入构造函数的对象的类型实现了IForceps接口并且有Grab方法。下面的代码是上层调用。
1 var forceps = assistant.Get<IForceps>(); 2 var surgeon = new Surgeon (forceps);
因为Surgeon类依赖IForceps接口而不是具体的类,我们能够自由地初始化任何实现了IForceps接口的类对象作为他的助手。
通过对接口编程和分离关注点,我们得到了一个可维护性的代码。
3、第一个DI应用程序
首先创建一个服务类,在这个服务类里关注点没有被分离。然后,一步一步改进程序的可维护性。第一步分离关注点,然后面向接口编程,使程序松耦合。最后,得到第一个DI应用程序。
服务类主要的责任是使用提供的信息发送邮件。
1 using System.Net.Mail; 2 3 namespace Demo.Ninject 4 { 5 public class MailService 6 { 7 public void SendEmail(string address, string subject, string body) 8 { 9 var mail = new MailMessage(); 10 mail.To.Add(address); 11 mail.Subject = subject; 12 mail.Body = body; 13 var client = new SmtpClient(); 14 // Setup client with smtp server address and port here 15 client.Send(mail); 16 } 17 } 18 }
然后给程序添加日志功能。
1 using System; 2 using System.Net.Mail; 3 4 namespace Demo.Ninject 5 { 6 public class MailService 7 { 8 public void SendEmail(string address, string subject, string body) 9 { 10 Console.WriteLine("Creating mail message..."); 11 var mail = new MailMessage(); 12 mail.To.Add(address); 13 mail.Subject = subject; 14 mail.Body = body; 15 var client = new SmtpClient(); 16 // Setup client with smtp server address and port here 17 Console.WriteLine("Sending message..."); 18 client.Send(mail); 19 Console.WriteLine("Message sent successfully."); 20 } 21 } 22 }
过了一会后,我们发现给日志信息添加时间信息很有用。在这个例子里,发送邮件和记录日志是两个不同的关注点,这两个关注点同时写在了同一个类里面。如果要修改日志功能必须要修改MailService类。因此,为了给日志添加时间,需要修改MailService类。所以,让我们重构这个类分离添加日志和发送邮件这两个关注点。
1 using System; 2 using System.Net.Mail; 3 4 namespace Demo.Ninject 5 { 6 public class MailService 7 { 8 private ConsoleLogger logger; 9 public MailService() 10 { 11 logger = new ConsoleLogger(); 12 } 13 14 public void SendMail(string address, string subject, string body) 15 { 16 logger.Log("Creating mail message..."); 17 var mail = new MailMessage(); 18 mail.To.Add(address); 19 mail.Subject = subject; 20 mail.Body = body; 21 var client = new SmtpClient(); 22 // Setup client with smtp server address and port here 23 logger.Log("Sending message..."); 24 client.Send(mail); 25 logger.Log("Message sent successfully."); 26 } 27 } 28 29 class ConsoleLogger 30 { 31 public void Log(string message) 32 { 33 Console.WriteLine("{0}: {1}", DateTime.Now, message); 34 } 35 } 36 }
类ConsoleLogger只负责记录日志,将记录日志的关注点从MailService类中移除了。现在,就可以在不影响MailService的条件下修改日志功能了。
现在,新需求来了。需要将日志写在Windows Event Log里,而不写在控制台。看起来需要添加一个EventLog类。
1 class EventLogger 2 { 3 public void Log(string message) 4 { 5 System.Diagnostics.EventLog.WriteEntry("MailService", message);6 } 7 }
尽管发送邮件和记录日志分离到两个不同的类,MailService还是跟ConsoleLogger类紧密耦合,如果要换一种日志方式必须要修改MailService类。我们离打破MailService和Logger的耦合仅一步之遥。需要引入依赖接口而不是具体类。
1 public interface ILogger 2 { 3 void Log(string message); 4 }
ConsoleLogger和EventLogger都继承ILogger接口。
1 class ConsoleLogger : ILogger 2 { 3 public void Log(string message) 4 { 5 Console.WriteLine("{0}: {1}", DateTime.Now, message); 6 } 7 } 8 9 class EventLogger : ILogger 10 { 11 public void Log(string message) 12 { 13 System.Diagnostics.EventLog.WriteEntry("MailService", message); 14 } 15 }
现在可以移除对具体类ConsoleLogger的引用,而是使用ILogger接口。
1 private ILogger logger; 2 public MailService(ILogger logger) 3 { 4 this.logger = logger; 5 }
在此时,我们的类是松耦合的,可以自由地修改日志类而不影响MailService类。使用DI,将创建新的Logger类对象的关注点(创建具体哪一个日志类对象)和MailService的主要责任发送邮件分开。
修改Main函数,调用MailService。
1 namespace Demo.Ninject 2 { 3 class Program 4 { 5 static void Main(string[] args) 6 { 7 var mailService = new MailService(new EventLogger()); 8 mailService.SendMail("someone@somewhere.com", "My first DI App", "Hello World!"); 9 } 10 } 11 }
4、DI容器
DI容器是一个注入对象,用来向对象注入依赖项。上一个例子中我们看到,实现DI并不一定需要DI容器。然而,在更复杂的情况下,DI容器自动完成这些工作比我们手写代码节省很多的时间。在现实的应用程序中,一个简单的类可能有许多的依赖项,每一个依赖项有有各自的其他的依赖项,这些依赖组成一个庞大的依赖图。DI容器就是用来解决这个依赖的复杂性问题的,在DI容器里决定抽象类需要选择哪一个具体类实例化对象。这个决定依赖于一个映射表,映射表可以用配置文件定义也可以用代码定义。来看一个例子:
<bind service="ILogger" to="ConsoleLogger" />
也可以用代码定义。
Bind<ILogger>().To<ConsoleLogger>();
也可以用条件规则定义映射,而不是这样一个一个具体类型进行分开定义。
容器负责管理创建对象的生命周期,他应当知道他创建的对象要保持活跃状态多长时间,什么时候处理,什么时候返回已经存在的实例,什么时候创建一个新的实例。
除了Ninject,还有其他的DI容器可以选择。可以看Scott Hanselman's博客(http://www.hanselman.com/blog/ListOfNETDependencyInjectionContainersIOC.aspx)。有Unity, Castle Windsor, StructureMap, Spring.NET和Autofac
Unity |
Castle Windsor |
StructureMap |
Spring.NET |
Autofac | |
---|---|---|---|---|---|
License |
MS-PL |
Apache 2 |
Apache 2 |
Apache 2 |
MIT |
Description |
Build on the "kernel" of ObjectBuilder. |
Well documented and used by many. |
Written by Jeremy D. Miller. |
Written by Mark Pollack. |
Written by Nicholas Blumhardt and Rinat Abdullin. |
5、为什么使用Ninject
Ninject是一个轻量级的.NET应用程序DI框架。他帮助你将你的应用程序分解成松耦合高内聚的片段集合,然后将他们灵活地连接在一起。在你的软件架构中使用Ninject,你的代码将变得更容易容易写、更容易重用、测试和修改。不依赖于引用反射,Ninject利用CLR的轻量级代码生成技术。可以在很多情况下大幅度提高反应效率。Ninject包含很多先进的特征。例如,Ninject是第一个提供环境绑定依赖注入的。根据请求的上下文注入不同的具体实现。Ninject提供几乎所有其他框架能提供的所有重要功能(许多功能都是通过在核心类上扩展插件实现的)。可以访问Ninject官方wiki https://github.com/ninject/ninject/wiki 获得更多Ninject成为最好的DI容器的详细列表。