敏捷开发-SOLID-依赖注入原则
简单的开始
假设你在开发一个用户可以用来管理待办事项清单的任务管理应用程序。此外,还假定此时项目依然处于早期的开发阶段,同时也决
定了使用WPF开发用户界面。此时,你已经有了一个只能从持久存储中读取并显示待办事项列表的主窗口
除了描述外,待办事项还包括了优先级、截止日期和完成情况等状态 |
---|
因为是一个WPF应用程序,你正在使用模型—视图—视图模型(Model-View-ViewModel,MVVM)模式确保隔离各个层之间的依赖。虽然还没有使用依赖注入,但是该应用程序已经在努力使用其他的最佳实践。其中的一个视图模型是主窗口的后台控制器。TaskListController
类实例委托一个TaskService
类实例来获取所有代办事项数据。
控制器并没有使用依赖注入
public class TaskListController : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
private readonly ITaskService taskService;
private readonly IObjectMapper mapper;
private ObservableCollection<TaskViewModel> allTasks;
public TaskListController()
{
this.taskService = new TaskServiceAdo();
this.mapper = new MapperAutoMapper();
var taskDtos = taskService.GetAllTasks();
AllTasks = new
ObservableCollection<TaskViewModel>(mapper.Map<IEnumerable<TaskViewModel>>(taskDtos));
}
public ObservableCollection<TaskViewModel> AllTasks
{
get
{
return allTasks;
}
set
{
allTasks = value;
PropertyChanged(this, new PropertyChangedEventArgs("AllTasks"));
}
}
}
上面示例中的实现方式存在如下一些问题:
- 很难做单元测试,因为控制器依赖某个具体实现。
- 不清楚视图模型的依赖点,除非查看它的源代码。
- 隐式地依赖服务的所有依赖。
- 无法灵活地替换服务实现。
重构后,控制器使用了依赖注入
public class TaskListController : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
private readonly ITaskService taskService;
private readonly IObjectMapper mapper;
private ObservableCollection<TaskViewModel> allTasks;
public TaskListController(ITaskService taskService, IObjectMapper mapper)
{
this.taskService = taskService;
this.mapper = mapper;
}
public void OnLoad()
{
var taskDtos = taskService.GetAllTasks();
AllTasks = new
ObservableCollection<TaskViewModel>(mapper.Map<IEnumerable<TaskViewModel>>(taskDtos));
}
public ObservableCollection<TaskViewModel> AllTasks
{
get
{
return allTasks;
}
set
{
allTasks = value;
PropertyChanged(this, new PropertyChangedEventArgs("AllTasks"));
}
}
}
TaskListController
类的第一个版本,就需要模拟TaskService
类。然而,很难通过常规方式去模拟TaskService
类,而TaskService
类并不是个可代理的类,或者说你应该把它改造成可代理的.
TaskListController
类的第二个版本,它仅接受ITaskService
接口,而不是像第一个版本中那样直接依赖某个实现类。这样重构后就更容易测试了,因为接口实例总是可以被替换的。
只有能为用户提供替换实现(也称为代理,proxy)的类才称之为是可代理的。可代理的类必须把所有方法声明为虚方法,而接口总是直接可代理的。
如果一个类在它的方法内部能随意构造类的实例,你就无法从外部知道该类到底需要什么才可以正常工作。没有应用依赖注入的第一个示例就是一个依赖的黑盒子。
你只有通过查看类实现的源代码才能知道真相,因为它没有通过该类的接口或者方法签名声明任何依赖。第二个示例中应用了依赖注入,它清楚地表明了需要一个任务服务的实现才能正常工作。
当类A和类B之间存在依赖关系时,如果类B依赖类C,那么类A也隐式地依赖类C。随从反模式就是这样引入了交错复杂的依赖关系网,而这种依赖关系网一旦形成,就很难再整理清楚。
如果你能确保你的接口对自己行为做了正确的抽象,那么客户在使用该接口时就不再需要其他任何东西了。即使该接口的实现可能依赖了一些大型的外部组件,比如数据库,也不会影响到使用该接口的客户端代码。这就是正确应用阶梯模式的结果。
直接实例化实现对象,你也会失去接口能提供的另外一个扩展能力:你将无法继承TaskService
类并增强已有方法的功能。
即使方法已经被声明为虚方法也一样,因为无论如何你都要改动客户端代码去直接实例化这个派生的子类。接口允许应用各种强大的模式来为自己提供多种实现或增强现有的实现。
此外你也已经知道,即使接口的初始版本类实现已经编写好,只要新的接口需求还没出现,现有接口的这种允许增加新的实现或增强现有实现的扩展能力是一直存在的。这也是代码适应能力的关键点。
任务列表应用
下图展示了你使用任务列表应用想要实现的包级别和类级别组织。
分三层的任务列表应用的UML类图,包含包 |
---|
用户界面层包括了WPF、控制器以及视图模型相关的代码。服务层通过一个接口对控制器的依赖进行了抽象,它的实现简单地使用了ADO.NET来从持久存储中获取所有任务数据。
服务实现返回的TaskDto
类是从存储中获取的一行任务数据在内存中的表示。这只是一个普通的CLR对象(Plain Old CLR Object,POCO),就其本身而言,它并不具备WPF视图模型应有的丰富功能。
因此,当TaskController
类从ITaskService
接口获取TaskDto
类的对象时,它会请求一个IObjectMapper
接口来把这些TaskDto
类的对象转换为TaskViewModel
类的对象,后者实现了INotifyPropertyChanged
接口,所以可以与其他的视图相关的特性结合使用。
TaskService
负责检索任务列表数据
public class TaskServiceAdo : ITaskService
{
public TaskServiceAdo(ISettings settings)
{
this.settings = settings;
}
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
private readonly ISettings settings;
private const int IDIndex = 0;
private const int DescriptionIndex = 1;
private const int PriorityIndex = 2;
private const int DueDateIndex = 3;
private const int CompletedIndex = 4;
using(var connection = new
SqlConnection(settings.GetSetting("TaskDatabaseConnectionString")))
{
connection.Open();
using(var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "[dbo].[get_all_tasks]";
using(var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
{
if (reader.HasRows)
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
}
}
return allTasks;
}
}
ISettings
接口从TaskService
类中抽象了获取连接字符串的细节。该接口的一个可能实现就是直接适配Microsoft .NET Framework提供的ConfigurationManager
类。不难想象代码中某处肯定会使用ISettings
接口来存储设置数据。另外一个问题就是ConfigurationManager
类是静态的,因此难以进行模拟。直接使用该静态类会给获取诸如连接字符串等应用程序配置带来局限,也会降低TaskServiceAdo
类的可测性
对象图的构建
有两种主要的注入方式:穷人的依赖注入
和控制反转容器
。
1.穷人的依赖注入
顾名思义,穷人的依赖注入(Poor Man’s Dependency Injection),这个模式不需要任何其他外部依赖就可以实现注入。它需要提前为控制器创建必需的对象图。
如何构建重构后的TaskListController类以及如何给它传递作为应用程序主窗口的TaskListView类实例
穷人的依赖注入比较繁琐但却很灵活
public partial class App : Application
{
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
var settings = new ApplicationSettings();
var taskService = new TaskServiceAdo(settings);
var objectMapper = new MapperAutoMapper();
controller = new TaskListController(taskService, objectMapper);
MainWindow = new TaskListView(controller);
MainWindow.Show();
controller.OnLoad();
}
private void CreateMappings()
{
AutoMapper.Mapper.CreateMap<TaskDto, TaskViewModel>();
}
private TaskListController controller;
}
OnApplicationStartup方法是一个WPF内部事件处理器,用它来完成一些事情的初始化。尽管不同类型的应用程序的入口代码会有所不同,但是它始终是一个放置依赖注入代码的好地方。
目标是创建一个TaskListView
类实例,因为这个视图类是整个应用程序解决方案的解析根
。
为了创建TaskListView
类,你首先要需要一个TaskListController
实例。而后者又需要一个ITaskService
接口实例和一个IObjectMapper
接口实例,所以,你又得先初始化这两个接口的实例,此时你就需要提供这个接口的实现了。而ITaskService
接口的实现类~又需要一个ISettings
接口的实现,所以你需要先提供一个ApplicationSettings
类的实例。ApplicationSettings
类本
身则是.NET Framework中的ConfigurationManager
类的一个适配器。
任务列表应用程序由一组接口、接口的实现以及相关的依赖构成 |
---|
每个类都依赖一个或多个可能也需要依赖的其他类。诸如MapperAutoMapper
类和ApplicationSettings
类之类的适配器实现很常见,它们通常只满足接口需要的依赖,但实际上依然是委托其他类完成实际工作。即使不是适配器的类,也会委托自己的一些依赖来做一些工作,比如TaskServcieAdo
类,它实际上是使用ADO.NET直接获取数据。
ITaskService
接口的其他实现可以从其他地方获取数据,比如,可以实现一个主要委托NHibernate
完成实际动作的TaskServiceNHibernate
类。此外,也可以实现一个依赖Microsoft Outlook插件框架的从Outlook内置的任务清单中读取任务数据的TaskServcieOutlook
类。再强调一遍,只要符合接口的任何东西都可以是任务的数据源,因为接口本身从不会把自己和任何具体的实现技术绑定在一起。
穷人的依赖注入会比较冗长。当该应用程序要扩展支持增加新任务、编辑任务以及可能的任务提醒功能时,你会很容易预见,为依赖注入构造各种实例的代码会快速增长,慢慢地,它们就会变得不是那么容易理解了。尽管如此,穷人的依赖注入方式仍然很灵活。无论你要构造多么复
杂的对象图,构造的方式都是清清楚楚的。因为方式只有一种:手动创建任何需要的实例,然后把它们传递给聚合它们功能的类,重复这个动作直到成功实例化应用程序的解析根。在这里,你可以为要实例化的类所依赖的接口应用任何意图的修饰器,也就是说,穷人的依赖注入允许你去
随意定制要构建的对象图。
2. 方法注入
重构的TaskListController
类,ITaskService
接口的GetAllTasks
方法参数可以接受ISettings
接口实例的注入。这需要改动ITaskService
接口的方法签名。
任务服务实例是从方法的参数中获取设置实例,而不是从构造函数的参数中获取
public class TaskListController : INotifyPropertyChanged
{
public TaskListController(ITaskService taskService,
IObjectMapper mapper,
ISettings settings)
{
this.taskService = taskService;
this.mapper = mapper;
this.settings = settings;
}
public void OnLoad()
{
var taskDtos = taskService.GetAllTasks(settings);
AllTasks = new
ObservableCollection<TaskViewModel>(mapper.Map<IEnumerable<TaskViewModel>>(taskDtos));
}
}
如果只有该方法需要这个依赖时,从方法参数注入依赖会很有用。从构造函数注入依赖表明类中的多数行为需要该依赖项,但是如果只有少部分方法需要某个依赖,从各个方法参数注入该依赖会更好。方法注入方式也有缺点,那就是,用户在调用方法前必须要先准备好依赖的实例。客户端可以通过构造函数或者方法参数沿着调用栈一直将依赖实例传递给需要使用该依赖的目标类。
3. 属性注入
重构的TaskListController
类。这里的ITaskService
接口改用属性Settings
来注入依赖。再说一次,要切记,需要同时改动接口和实现来支持相应的属性。
通过属性来完成依赖注入的动作
public class TaskListController : INotifyPropertyChanged
{
public TaskListController(ITaskService taskService,
IObjectMapper mapper,
ISettings settings)
{
this.taskService = taskService;
this.mapper = mapper;
this.settings = settings;
}
public void OnLoad()
{
taskService.Settings = settings;
var taskDtos = taskService.GetAllTasks();
AllTasks = new
ObservableCollection<TaskViewModel>(mapper.Map<IEnumerable<TaskViewModel>>(taskDtos));
}
}
这种方式的好处是可以在运行时改变属性实例值。从构造函数注入的依赖实例在类的整个生命周期内都可以使用,而从属性注入的依赖实例还能从类生命周期的某个中间点开始起作用。
控制反转
场景:开发中的类委托某些抽象完成动作,而这些被委托的抽象又被其他的类实现,这些类又会去委托其他一些抽象完成某些动作。最终,在依赖链终结的地方,都是一些小且直接的类,它们已经不需要任何依赖了。要构造有依赖项的类,首先要构造并注入这些依赖项。你已经知道如何通过手动构造类实例并把它们传递给构造函数的方式来实现依赖注入的效果。尽管这种方式已经可以让你任意替换或修饰依赖的实现,但是构造的实例对象图依然是静态的,也就是说,编译时就已经确定了的。控制反转(Inversion of Control,IoC)允许你将构建对象图的动作推迟到运行时。
控制反转的概念通常都是在控制反转容器(container)的上下文中出现。控制反转容器组成的系统能够将应用程序使用的接口和它的实现类关联起来,并能在获取实例的同时解析所有相关的依赖。
应用程序入口代码中使用了Unity控制反转容器。代码的第一步就是初始化得到一个UnityContainer
实例。注意,示例代码中这样实例化控制反转容器是在直接实例化基础组件,在后期想替换为其他容器会比较困难。
没有使用手动构造实现的实例,而是通过使用控制反转容器来建立类和接口的映射关系
public partial class App : Application
{
private IUnityContainer container;
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
container = new UnityContainer();
container.RegisterType<ISettings, ApplicationSettings>();
container.RegisterType<IObjectMapper, MapperAutoMapper>();
container.RegisterType<ITaskService, TaskServiceAdo>();
container.RegisterType<TaskListController>();
container.RegisterType<TaskListView>();
MainWindow = container.Resolve<TaskListView>();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
private void CreateMappings()
{
AutoMapper.Mapper.CreateMap<TaskDto, TaskViewModel>();
}
}
在创建好Unity容器后,你需要告诉该容器应用程序生命周期内每个接口对应的具体实现类是什么。
Unity遇到任何接口时,它都会知道需要去解析哪个实现。如果你没有为某个接口指定对应的实现类,Unity会提醒你该接口无法实例化。
在完成接口和对应实现类的关系注册后,你需要获得一个应用程序的解析跟,也就是TaskListView
类的实例。Unity
容器的Resolve
方法会检查TaskListView
类的构造函数,然后尝试去实例化构造函数要注入的依赖项,如此反复,直到完全实例化整个依赖链上的所有依赖项的实例后,Resolve
方法会成功实例化TaskListView
类的实例。这和穷人的依赖注入方式的手动构造过程完全一样,不同的只是后者需要你去手动检查构造函数并直接实例化你看到的依赖项类。
1. 注册、解析、释放模式
尽管每个控制反转容器的实现不完全相同,但是都符合下面这个通用的接口
public interface IContainer : IDisposable
{
void Register<TInterface, TImplementation>()
where TImplementation : TInterface;
TImplementation Resolve<TInterface>();
void Release();
}
三个方法的目的解释:
Register
:应用程序初始化会首先调用该方法。而且该方法会被多次调用以注册很多不同的接口及其实现之间的映射关系。这里的where子句用来强制TImplementation
类型必须实现它所继承的TInterface
接口。该方法还支持注册某个已经构造好的实例和一个没有指定接口的类型的映射关系,这样做,可以注册该类型和这个实例所实现的所有接口之间的映射关系。Resolve
:应用程序运行时会调用该方法。通常一组特殊的类会被自动解析为对象图中的顶层对象。比如,使用模型—视图—控制器(MVC)模式的ASP.NET应用程序中的控制器对象,使用视图模型优先模式的WPF应用程序中的视图模型对象,以及使用模型—视图—表示器(MVP)的Windows Form应用程序中的视图对象。也就是说,你不应该在你的应用程序类中对这些顶层对象(控制器、视图、表示器、服务、域、业务逻辑或数据访问等)调用Resolve
方法。Release
:应用程序生命周期中,这些类的实例不再需要时,就可以释放它们占有的资源了。这很有可能发生在应用程序结束时,但也有可能发生在应用程序运行时的某些恰当时机。比如,在网络应用场景中,通常情况下,资源只对单次请求(per-request)有效。因此,每次请求后都会调用Release方法。有关对象生命周期的问题会在后面更详细地讨论。Dispose
:如上面示例代码中所示,大多数控制反转容器也都会实现IDisposable
接口。应用程序在关闭的时候会调用该方法。Dispose
方法和Release
方法不一样,它会清除容器内部的字典,这样它不再带有映射关系的注册信息,所以也就无法解析任何依赖了。
如下代码,第一个控制反转容器示例进行重构,重构后,所有对容器的调用都被封装在一个类内。这样做就可以把冗长的注册代码从WPF应用程序的后置代码中隔离开.
启动事件处理器委托配置类来完成容器相关的工作
public partial class App : Application
{
private IocConfiguration ioc;
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
ioc = new IocConfiguration();
ioc.Register();
MainWindow = ioc.Resolve();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
private void OnApplicationExit(object sender, ExitEventArgs e)
{
ioc.Release();
}
private void CreateMappings()
{
AutoMapper.Mapper.CreateMap<TaskDto, TaskViewModel>();
}
}
示例中的程序入口代码现在变得更简单了,所有控制反转容器注册动作都封装在一个专门的类中。如下代码展示了任务列表应用程序中使用的IocConfiguration
类。当程序退出时,你可以在相应事件的处理器方法中调用该类的Release
方法。
下面的配置类具有针对注册、解析和释放模式的全部三个阶段的方法
public class IocConfiguration
{
private readonly IUnityContainer container;
public IocConfiguration()
{
container = new UnityContainer();
}
public void Register()
{
container.RegisterType<ISettings, ApplicationSettings>();
container.RegisterType<IObjectMapper, MapperAutoMapper>();
container.RegisterType<ITaskService, TaskServiceAdo>();
container.RegisterType<TaskListController>();
container.RegisterType<TaskListView>();
}
public Window Resolve()
{
return container.Resolve<TaskListView>();
}
public void Release()
{
container.Dispose();
}
}
示例中的Register方法和重构前应用程序入口的代码一样。但是,随着注册需求的增长,与在入口代码中直接和容器实例交互相比,将代码重构为多个方法能够让代码结构和意图更加清晰。
Resolve
方法会返回一个通用的Window
类对象,而Window
类对象是WPF应用程序的公共解析根。在这里,返回的是TaskListView
类实例,因为它是这个程序的主窗口。
2. 命令式与声明式注册
所有的注册代码都是命令式地从一个容器对象上调用方法。
命令式注册方式:
优势是:易读,比较简洁,编译时检查问题的代价非常小(比如防止代码输入错误等)。
劣势是:注册过程的实现在编译时就已经固定了。如果你想替换现有实现,就必须要直接修改源代码并重新编译。
如果通过XML配置进行声明式注册,你就不再需要重新编译,只需要应用程序重新加载更新的配置即可。
应用程序配置文件中的某个节可以描述接口应该如何映射到实现
<configuration>
<configSections>
<section name="unity"
type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection,Microsoft.Practices.Unity.Configuration" />
</configSections>
<appSettings>
<add key="TaskDatabaseConnectionString"
value="Data Source=(local);InitialCatalog=TaskDatabase;Integrated Security=True;Application Name=Task List Editor" />
</appSettings>
<unity xmlns="http://schemas.microsoft.com/practices/2010/unity">
<typeAliases>
<typeAlias alias="ISettings"
type="ServiceInterfaces.ISettings, ServiceInterfaces"/>
<typeAlias alias="ApplicationSettings"
type="UI.ApplicationSettings, UI" />
<typeAlias alias="IObjectMapper"
type="ServiceInterfaces.IObjectMapper,ServiceInterfaces" />
<typeAlias alias="MapperAutoMapper"
type="UI.MapperAutoMapper, UI" />
<typeAlias alias="ITaskService"
type="ServiceInterfaces.ITaskService,ServiceInterfaces" />
<typeAlias alias="TaskServiceAdo" type="ServiceImplementations.TaskServiceAdo,ServiceImplementations"/>
<typeAlias alias="TaskListController"
type="Controllers.TaskListController,Controllers" />
<typeAlias alias="TaskListView" type="UI.TaskListView, UI" />
</typeAliases>
<container>
<register type="ISettings" mapTo="ApplicationSettings" />
<register type="IObjectMapper" mapTo="MapperAutoMapper" />
<register type="ITaskService" mapTo="TaskServiceAdo" />
</container>
</unity>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
</startup>
</configuration>
示例XML展示了WPF任务列表应用程序的配置文件内容。为Unity增加的配置节包括typeAlias
和container
元素。前者用于为长的类型名称指定简短的别名,完整的类型名称需要包括程序集限定信息,这样Unity在运行时才可以找到指定的类型名。指定类型的别名后,container
元素内会执行与Register
方法一样的动作:建立接口和相应实现之间的映射关系。
现在注册阶段要做的只是把配置文件中的相关节传递给容器对象
public partial class App : Application
{
private IUnityContainer container;
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
var section = (UnityConfigurationSection)ConfigurationManager.GetSection("unity");
container = new UnityContainer().LoadConfiguration(section);
MainWindow = container.Resolve<TaskListView>();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
private void CreateMappings()
{
AutoMapper.Mapper.CreateMap<TaskDto, TaskViewModel>();
}
}
示例中的改动只有两行。首先你要使用ConfigurationManager
类从配置文件加载unity节数据。节数据会被转换为UnityConfigurationSection
类的实例,这样就可以把它传递给新的UnityContainer
类实例的LoadConfiguration
方法。完成这些后,就可以像前面一样,使用容器来解析应用程序的主窗口了。
尽管声明式的注册能将接口和相应实现的映射动作推迟到配置时,但它也有一些明显的缺陷,在很多情况下也不适合使用。它的最大缺陷就是太繁琐。当前示例已经很小了,但是依然有那么多类型需要定义别名和映射。某些情况下,需要注册的类型数目会是示例代码的好几倍,甚至更多,相应的XML配置文件也会变得非常大。XML文件中的别名定义和关系映射节中的输入错误直到运行时才能被发现和捕获,而命令式地注册代码能在编译时就检查到对应的问题。
声明式的注册不够好的另外一个显著原因是大多数控制反转容器都提供了很丰富的注册方式。比如lambda工厂,它会在解析接口时调用注册时提供的lambda方法。而这些高级的注册方式在声明式的XML配置文件中是无法做到的。
3. 对象的生命周期
某些对象可能会比其他对象有更长的生命周期。当然,在.NET托管语言的上下文中,没有能直接销毁对象的方法,但是如果对象实现了IDispose
接口,你就可以通过调用该接口的Dispose
方法来释放该对象占有的相关资源。
有些资源的生命周期需要谨慎管理
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
container = new UnityContainer();
container.RegisterType<ISettings, ApplicationSettings>();
container.RegisterType<IObjectMapper, MapperAutoMapper>();
container.RegisterType<ITaskService, TaskServiceAdo>(new InjectionFactory(c => new
TaskServiceAdo(new
SqlConnection(c.Resolve<ISettings>().GetSetting("TaskDatabaseConnectionString")))));
container.RegisterType<TaskListController>();
container.RegisterType<TaskListView>();
MainWindow = container.Resolve<TaskListView>();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
// . . .
public class TaskServiceAdo : ITaskService
{
private readonly IDbConnection connection;
public TaskServiceAdo(IDbConnection connection)
{
this.connection = connection;
}
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
using (connection)
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "[dbo].[get_all_tasks]";
using (var reader =
command.ExecuteReader(CommandBehavior.CloseConnection))
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
}
return allTasks;
}
}
示例中应用程序入口代码处的第一个改动是使用了一个注入工厂来创建任务服务。这个lambda表达式通过容器解析参数后返回了一个新的服务实例。原来对ISetting
接口的GetSettings
方法的调用也移到了lambda表达式内,用来获得连接字符串。这个字符串会传递给SqlConnection
类的构造函数,而创建好的SqlConnection
实例则会传递给TaskServiceAdo
类的构造函数。
GetAllTasks
方法中的using(connection)
语句是有问题的。using
语句结束前会确保调用SqlConnection
类的Dispose
方法。这样,在调用GetAllTasks
方法后,连接就已经变得无效了,因为它占有的资源已经被释放了。想要使用连接,你能做的只能是再次调用GetAllTasks
方法。
假设TaskServcieAdo
类也实现了IDisposable
接口,那么在它的Dispose
方法中再去调用连接实例的Dispose
方法会如何?
服务实现了
IDisposable
接口,因为它可以去释放连接的资源
public class TaskServiceAdo : ITaskService, IDisposable
{
public TaskServiceAdo(IDbConnection connection)
{
this.connection = connection;
}
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
connection.Open();
try
{
using (var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;command.CommandText = "[dbo].[get_all_tasks]";
using (var reader =
command.ExecuteReader(CommandBehavior.CloseConnection))
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
}
finally
{
connection.Close();
}
return allTasks;
}
public void Dispose()
{
connection.Dispose();
}
}
示例中应用程序入口代码处的第一个改动是使用了一个注入工厂来创建任务服务。这个lambda表达式通过容器解析参数后返回了一个新的服务实例。原来对ISetting
接口的GetSettings
方法的调用也移到了lambda表达式内,用来获得连接字符串。这个字符串会传递给SqlConnection
类的构造函数,而创建好的SqlConnection
实例则会传递给TaskServiceAdo
类的构造函数。
GetAllTasks
方法中的using(connection)
语句是有问题的。using
语句结束前会确保调用SqlConnection
类的Dispose
方法。这样,在调用GetAllTasks
方法后,连接就已经变得无效了,因为它占有的资源已经被释放了。想要使用连接,你能做的只能是再次调用GetAllTasks
方法。
服务实现了
IDisposable
接口,因为它可以去释放连接的资源
public class TaskServiceAdo : ITaskService, IDisposable
{
public TaskServiceAdo(IDbConnection connection)
{
this.connection = connection;
}
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
connection.Open();
try
{
using (var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "[dbo].[get_all_tasks]";
using (var reader =
command.ExecuteReader(CommandBehavior.CloseConnection))
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
}
finally
{
connection.Close();
}
return allTasks;
}
public void Dispose()
{
connection.Dispose();
}
}
上面的示例并不是在GetAllTasks
方法中释放连接,而是在释放服务本身时才释放它。这就牵扯到何时释放任务服务这个重要的问题。需要在构造函数注入ITaskService
接口实例的所有客户端类型也都要实现IDisposable
接口吗?谁来释放这些对象?最终,你总是需要在某些地方调用Dispose
方法.
如果一个类通过构造函数得到了一个依赖项,它就不应该手动释放该依赖项的资源。这一点非常重要。因为该类无法确保该依赖项实例是否也同时提供给了其他类,因此手动释放它的资源会很可能会影响其他使用这个依赖的类。
使用依赖注入时管理对象生命周期问题的方式和原始服务实现的方式很接近:
连接工厂
工厂模式就是一种通过委托一个专门用来创建对象的类来替代手动实例化对象过程的方式。
如下代码展示了可能的连接工厂接口定义。这个接口中的CreateConnection
方法返回的是更加通用的IDbConnection
接口实例,而不是要求所有客户端都要使用的SqlConnection
类。
连接工厂接口看起来很简单
public interface IConnectionFactory
{
IDbConnection CreateConnection();
}
可以把这个接口的实例注入到任务服务中,然后通过它来获取连接,而不再需要手动创建连接,这样也保持了模拟该任务服务实现的可测性。
依赖注入可以与工厂模式协同工作
public class TaskServiceAdo : ITaskService
{
private readonly IConnectionFactory connectionFactory;
public TaskServiceAdo(IConnectionFactory connectionFactory)
{
this.connectionFactory = connectionFactory;
}
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
using(var connection = connectionFactory.CreateConnection())
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "[dbo].[get_all_tasks]";
using (var reader =
command.ExecuteReader(CommandBehavior.CloseConnection))
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
}
return allTasks;
}
}
注意,CreateConnection
方法返回的连接实例会在using
语句块结束时被释放,因为工厂 生成的IDbConnection
接口实例都实现了IDisposable
接口。通过接口继承,可以让单个实现类同时满足多个接口的定义。
然而,是否每个实现一定需要实现每个接口?有时候是的,但是考虑到一个接口会有很多种实现,尤其是在原始版本后过了很长时间,这需要做一个大胆的假定。但是对于IDisposable
接口而言,很难保证每个类都实现了它。
负责人模式
你可以只在真正需要释放的类上实现IDisposable
接口,而不是劳心费力地要求所有实现都实现它。但是这也会产生一个问题。如果工厂方法返回的结果(也就是你的接口实例)并没有实现IDisposable
接口,你就无法使用using语句块来释放它。这种情况下,你必须使用负责人模式
如下代码中使用try/finally
语句块代替了using
语句块,同时,你可以在运行时检查实例对象是否实现了IDisposable
接口。
负责人模式可以确保正确地释放对象占有的资源
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
var connection = connectionFactory.CreateConnection();
try
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "[dbo].[get_all_tasks]";
using (var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
}
finally
{
if(connection is IDisposable)
{
var disposableConnection = connection as IDisposable;
disposableConnection.Dispose();
}
}
return allTasks;
}
示例中,只有实现了IDisposable
接口的连接实例才会调用Dispose方法。无论工厂方法返回的结果是否实现了IDisposable
, GetAllTasks
方法都可以正常工作。对于实现了IDisposable
接口的实例,它所占有的资源就会被正确释放。
负责人模式会明确地释放实现了IDisposable
接口的实例对象,从而有效地忽略那些没有实现IDisposable
接口的对象。但是,SOLID代码通常会有很多修饰器存在,它们会逐层包装以提供额外的功能。这种情况下,如果顶层对象实现了IDisposable
接口,负责人模式是可以正常工作的。但是如果外部修饰器对象没有实现IDisposable
接口,而内层对象实现了IDisposable
时,负责人模式就没有办法正确释放这些内层的实例了。此时,你必须应用工厂隔离模式。
工厂隔离模式
这种模式能够明确地释放整个复杂的对象图,而SOLID代码通常会形成这样的对象图。这个模式的名称来源于图书馆常用的带手套的箱子。这些玻璃或金属的箱子会自带手套以确保人们对箱内内容的操作是安全的。类似地,工厂隔离模式能够保证安全访问对象的实例,而且这些实例会在使用后被正确地释放。
只有在接口没有实现IDisposable
时才需要应用工厂隔离模式。要求所有类都实现IDisposable
接口的Dispose
方法是不现实的,也是不必要的。相反,IDisposable
应该被看作实现细节并由各个类自己做主是否实现它。这就是负责人模式和工厂隔离模式的应用场景。
前面的示例都在围绕着IDbConnection
接口实例的生命周期进行讲解,但该接口实际上已经继承了IDisposable
接口。那么,如果我们假设这个接口并没有扩展继承IDisposable
接口,从客户端代码角度看到的工厂隔离模式代码就会如代码如下所示。
一个使用工厂隔离模式的客户端代码示例
public IEnumerable<TaskDto> GetAllTasks()
{
var allTasks = new List<TaskDto>();
connectionFactory.With(connection =>
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "[dbo].[get_all_tasks]";
using (var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
{
while (reader.Read())
{
allTasks.Add(
new TaskDto
{
ID = reader.GetInt32(IDIndex),
Description = reader.GetString(DescriptionIndex),
Priority = reader.GetString(PriorityIndex),
DueDate = reader.GetDateTime(DueDateIndex),
Completed = reader.GetBoolean(CompletedIndex)
}
);
}
}
}
});
return allTasks;
}
工厂隔离模式并没有使用常见的返回工厂产品的实例的Create
方法,而是使用With
方法,该方法可以接受一个以工厂产品为参数的lambda方法。
这样做带来的好处是:工厂方法返回实例的生命周期会显式地由lambda方法决定。这会让无法控制对象生命周期的客户端代码变得非常简练
创建一个隔离工厂很简单
public class IsolationConnectionFactory : IConnectionIsolationFactory
{
public void With(Action<IDbConnection> do)
{
using(var connection = CreateConnection())
{
do(connection);
}
}
}
其中的With
方法能够创建带有大量修饰器、适配器和组合(也是SOLID建议的)的复杂对象图,而且可以管理这些对象的生命周期,客户端代码无需操心任何资源释放事宜,只需要简单地使用这些对象即可。
注意,如果把lambda方法范围内的实例对象赋值给一个有更长生命周期的变量,那么工厂隔离模式就会失效,所以并不鼓励在客户端代码中做这样的赋值。
比较复杂的注入
有两个这样的模式特别值得注意。第一个是服务定位器反模式,不幸的是,它也很常见。它在很多框架和库中都有应用,有时候,它也是唯一能提供依赖注入钩子的方式。比服务定位器更糟糕的一个反模式是非法注入(Illegitimate Injection),它的名称并没有充分表明它的副作用。它有时会使用依赖注入的灰色地带,能够在不恰当地提供依赖的情况下构建服务、控制器和其他一些类似的实体对象。
当你在使用依赖注入时,不同类型的应用程序会需要不同的设置.
在一些高级场景中,无论是通过穷人的依赖注入手动组合类型,还是使用一个控制反转容器的单个注册类型,这两种方式都太繁琐且费时费力。通过一个或多个约定推迟注册的过程,你能够消除很多没用的样板代码,但同时也能提供一些手动的注册来处理那些不满足约定的边界情况。
服务定位器反模式
服务定位器看起来与控制反转容器很相似,这也正是它们经常不会被怀疑给代码造成破坏的原因。
IServiceLocater
接口看起来就像是另外一种形式的控制反转容器
public interface IServiceLocator : IServiceProvider
{
object GetInstance(Type serviceType);
object GetInstance(Type serviceType, string key);
IEnumerable<object> GetAllInstances(Type serviceType);
TService GetInstance<TService>();
TService GetInstance<TService>(string key);
IEnumerable<TService> GetAllInstances<TService>();
}
其中诸如TService GetInstance<TService>()
之类的方法能够与IUnityContainer接口中的定义直接对应起来,只是后者使用的方法名为Resolve。问题出在服务定位器的使用方式上,如下代码清单展示的静态ServiceLocator类暴露了这个问题。
这个静态类就是将服务定位器归类为反模式的根本原因
/// <summary>
/// This class provides the ambient container for this application. If your
/// framework defines such an ambient container, use ServiceLocator.Current
/// to get it.
/// </summary>
public static class ServiceLocator
{
private static ServiceLocatorProvider currentProvider;
public static IServiceLocator Current
{
get { return currentProvider(); }
}
public static void SetLocatorProvider(ServiceLocatorProvider newProvider)
{
currentProvider = newProvider;
}
}
我在示例中保留的摘要注释直接点出了问题所在。环境容器(ambient container)的概念已经透露了有一个容器存在的细节信息。尽管把具体的服务定位器实现隐藏在接口之后是正确的,但是问题在于它在任意类型内而不只是在组合根内暴露了服务定位器或者容器存在的信息。如下代码显示了重写TaskListController
以使用ServiceLocator
时TaskListController
的样子。
服务定位器允许类检索任何内容,无论是否适合
public class TaskListController : INotifyPropertyChanged
{
public void OnLoad()
{
var taskService = ServiceLocator.Current.GetInstance<ITaskService>();
var taskDtos = taskService.GetAllTasks();
var mapper = ServiceLocator.Current.GetInstance<IObjectMapper>();
AllTasks = new
ObservableCollection<TaskViewModel>(mapper.Map<IEnumerable<TaskViewModel>>(taskDtos));
}
public ObservableCollection<TaskViewModel> AllTasks
{
get
{
return allTasks;
}
set
{
allTasks = value;
PropertyChanged(this, new PropertyChangedEventArgs("AllTasks"));
}
}
public event PropertyChangedEventHandler PropertyChanged = delegate { };
private ObservableCollection<TaskViewModel> allTasks;
}
这个示例没有构造函数,当然也就没有构造函数注入。相反,该类在需要的地方直接调用静态的ServiceLocator
类并返回请求的服务。记住,像这样的静态类都是天钩(skyhook),它是一种代码味道。
更糟糕的是,该类能从服务定位器检索任意对象。这样你就违背了依赖注入的“好莱坞准则”:不要调用我们,我们会调用你。相反,你是在直接要求需要的东西,而不是从其他地方传递得来的。你又如何知道这个类到底还需要什么样的依赖呢?使用服务定位器,你必须检查代码以搜索变化无常的调用,这些调用会检索某个需要的服务。你只需要看一眼构造函数或者智能感知列出的信息,就可以从构造函数注入中清楚地看到所有的依赖。
服务定位器模式并没有单元测试的问题。因为在使用它之前, 你可以设置一个IServiceLocator
接口的实现,也就是说,可以模拟服务定位器来对使用它的其他类型做单元测试。至少,服务定位器模式并没有阻止你去做单元测试。只是大量的注册接口和相应实现类映射关系的代码有些不合理,因为控制器、服务器和其他类的实现代码会被这些基础代码污染。在没有需要解决的问题时应用这种服务定位器模式会更加不合理,而构造函数注入并不需要担心这些问题。
服务定位器会直接委托
UnityContainer
实例来解析实例对象
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
container = new UnityContainer();
container.RegisterType<ISettings, ApplicationSettings>();
container.RegisterType<IObjectMapper, MapperAutoMapper>();
container.RegisterType<ITaskService, TaskServiceAdo>();
container.RegisterType<TaskListController>();
container.RegisterType<TaskListView>();
ServiceLocator.SetLocatorProvider(() => new UnityServiceLocator(container));
MainWindow = container.Resolve<TaskListView>();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
这与前面的示例看起来很像,只是没有去设置定位器提供者。但是,对Resolve方法的调用并没有真正解析对象图;也不再有任何依赖注入到TaskListView
类的构造函数中,所有的依赖都是在需要时才在该类的方法中单独获取的。
服务定位器模式是个很好的反面例子,它表面声称的东西并不符合应用后的实际效果。它声称带有默认构造函数的类没有依赖,但是显然不是这样的:它们肯定有依赖,要不然你为何要从服务定位器中获取它们。
不幸的是,有时又必须应用服务定位器反模式。在某些应用程序类型里,特别是Windows Workflow Foundation,基础库根本没有从构造函数注入的任何机会。在这些情况下,你的唯一选择就是服务定位器,它至少比完全不注入依赖要好。虽然我对反模式提出了这么多批判,但是它们肯定比完全手动构造依赖要好。毕竟,它也能够使用接口提供所有重要的扩展点,也就是说,可以获得修饰器、适配器以及其他一些类似的好处。
注入容器
与服务定位器密切相关的是在类型中注入容器的概念。同样,这把类变成了安全的关键点,通过这种方式将容器注入到类之后,就可以自由使用容器获取任何想获取的实例对象。假设有这样一个类,它的多个方法中零零散散地获取了很多服务对象实例。再假设另外一个类是从构造函数中注入了同样多的依赖对象,该类会在构造函数入口处对这些依赖对象做完整的前置条件检查并会在发现空引用时引发异常。显然,这两个类都做得太多了,如果需要那么多的依赖,就应该把它们重构为规模更小的类或者将各种依赖组织为有意义的修饰器。但是,相比较假设的第一个类,只有第二个类才能明显地暴露出这种代码味道,这样才能有机会尽早发现并消除它。
另外,从构造函数注入容器的类也必须引用容器的程序集。这会让容器基础代码扩散到整个代码库中,因为每个类都接受了注入的容器以获取它们真正需要的服务对象。
非法注入
非法注入表面看起来很像正常的,就像正确实现的依赖注入。它们也有可以注入依赖的构造函数,也是通过穷人的依赖注入或控制反转容器提供依赖对象。
但是,由于带有默认构造函数,这些对穷人的依赖注入和控制反转容器的应用已经的的确确被破坏了。
构造函数直接引用实现会直接让依赖注入的很多优势失效
public class TaskListController : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
private readonly ITaskService taskService;
private readonly IObjectMapper mapper;
private ObservableCollection<TaskViewModel> allTasks;
public TaskListController(ITaskService taskService, IObjectMapper mapper)
{
this.taskService = taskService;
this.mapper = mapper;
}
public TaskListController()
{
this.taskService = new TaskServiceAdo(new ApplicationSettings());
this.mapper = new MapperAu toMapper();
}
public void OnLoad()
{
var taskDtos = taskService.GetAllTasks();
AllTasks = new
ObservableCollection<TaskViewModel>(mapper.Map<IEnumerable<TaskViewModel>>(taskDtos));
}
public ObservableCollection<TaskViewModel> AllTasks
{
get
{
return allTasks;
}
set
{
allTasks = value;
PropertyChanged(this, new PropertyChangedEventArgs("AllTasks"));
}
}
}
这就意味着,该类必须引用实现所在的程序集,并且同时引入它的整个依赖链。这不就是随从反模式吗?尽管第一个构造函数是接受注入的接口实例,看起来很好地应用了阶梯模式和正确的依赖注入,但是第二个默认构造函数却直接破坏了它们带来的好处。
如果默认实现不再是你想要的时怎么办?该类会被修改为更喜欢的类。如果一个默认构造函数不够用,那么你想在某些场景下实现A,而在其他场景下又想实现B时,该怎么办?构造函数带来的副作用会很快让人失去耐心的。
有时这种容器注入的反模式也会被用来支持单元测试。要模拟测试类的默认实现看起来并没有依赖,它们可能就在该类的内部。类内部不应该包含任何只用于支持单元测试的代码。很常见的例子就是,先把private
方法变为internal
,然后应用InternalsVisibleToAttribute
属性来让测试程序集访问这些方法,而不是只让测试类通过public接口进行测试。实话实说,依赖注入支持单元测试的能力也存在被夸大的现象,但是这恰好说明了关键点所在:你已经通过使用接口来支持单元测试并将它们注入到了构造函数,因此模拟对象可以(也应当)通过构造函数注入到接口的类实现中。
非法注入被归类为反模式并不会因为默认构造函数的可见性而改变。无论默认构造函数是public
、protected
、private
或者internal
,事实都很清楚:你在引用它时不应该直接引用具体的实现。
组合根
应用程序中只应该有一个位置知道依赖注入的细节,这个位置就是组合根。在使用穷人的依赖注入时就是你手动构造类的地方,在使用控制反转容器时就是你注册接口和实现类间映射关系的地方。
理想情况下,组合根和应用程序的入口越近越好。这样能让你尽快配置好依赖注入。组合根提供了一个查找依赖注入配置的公认位置,它能帮你避免把对容器的依赖扩散到应用程序的其他地方。这也意味着不同种类的应用程序有着不同的组合根。
1. 解析根
与组合根密切相关的一个概念是解析根。它是要解析的目标对象图中根节点的对象类型。在前面的WPF示例中,解析根甚至可以是个单例对象,但是通常情况下,它们都是一组基于公共基类的类型。
有些情况下,你要自己手动获得解析根,但是在有些类型的应用程序已经利用了依赖注入(比如MVC模式)来处理解析根时,你需要做的只是注册接口和类的映射关系。
2. ASP.NET MVC
MVC模式的工程已经很好地通过控制反转容器应用了依赖注入。这些工程已经清楚地定义了解析根和组合根,也能够轻易地扩展以支持你可能需要为控制反转容器集成的任何库。
MVC应用程序的解析根就是控制器。所有来自浏览器的请求都会被路由到被称为动作(action)的控制器方法上。每当请求到来时,MVC框架会将URL映射为某个控制器名称,然后找到名称对应的类并实例化它,最后再在该实例上触发动作。图9-4中的UML时序图展示了这个交互的过程。
UML时序图展示了MVC通过工厂构造控制器的过程 |
---|
在使用控制反转容器进行依赖注入时,更确切地讲,实例化控制器的过程就是解析(resolution)控制器的过程。这就意味着,你能轻易地按照注册、解析和释放的模式,最小化对Resolve方法的调用,理想情况下,就只应该在一个地方调用该方法。
HttpApplication
的Application_Start
方法是Web应用中一个常见的组合根
public class MvcApplication : HttpApplication
{
public static UnityContainer Container;
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
AutoMapper.Mapper.CreateMap<TaskDto, TaskViewModel>();
Container = new UnityContainer();
Container.RegisterType<ISettings, ApplicationSettings>();
Container.RegisterType<IObjectMapper, MapperAutoMapper>();
Container.RegisterType<ITaskService, TaskServiceAdo>();
Container.RegisterType<TaskViewModel>();
Container.RegisterType<TaskController>();
ControllerBuilder.Current.SetControllerFactory(
new UnityControllerFactory(Container));
}
}
示例中的一些代码就是创建新MVC应用时的默认模板代码,它包括了所有MVC模式相关的初始化代码,比如注册域、过滤、路由和绑定等。这些代码都要尽早在Application_Start
方法中执行。ASP.NET应用在IIS中启动后,第一个收到的请求会触发调用Application_Start
方法。后置代码文件Global.asax
包含了HttpApplication
类的特定于应用的子类,Application_Start
方法也位于该子类的实现中。
上面的示例中,除了针对MVC的TaskController
类外,其余的服务接口和实现都是可以直接重用的。前面一节中的TaskController
类是针对WPF编写的,因此也不可以在WPF以外的上下文中重用,需要编写新的控制器类。但是,新的TaskController
类也和WPF版本的控制器类做了很多相同的工作,包括获取任务数据,使用IObjectMapper
将任务数据转换为视图友好的格式,不同点只是基于MVC控制器的基类。因为TaskController
类是继承System.Web.Mvc.Controller
类的,所以它就是应用的解析根。
TaskController
是一个解析根,它有一个需要依赖的构造函数
public class TaskController : Controller
{
private readonly ITaskService taskService;
private readonly IObjectMapper mapper;
public TaskController(ITaskService taskService, IObjectMapper mapper)
{
this.taskService = taskService;
this.mapper = mapper;
}
public ActionResult List()
{
var taskDtos = taskService.GetAllTasks();
var taskViewModels = mapper.Map<IEnumerable<TaskViewModel>>(taskDtos);
return View(taskViewModels);
}
}
List方法是该类的动作方法,会被渲染所有任务数据的同一个视图类调用。在WPF应用中,控制器首先委托ITaskService
来获取任务数据,然后使用IObjectMapper
类将这些数据转换为视图模型定义的数据类型,以供视图使用。
默认情况下,MVC控制器需要一个公共的默认构造函数,这样MVC框架才可以在调用动作方法前构造控制器实例。但是,使用依赖注入时,你需要的是能接受所需服务接口参数的构造函数。幸运的是,MVC使用工厂模式创建控制器实例,这就为你的控制器实现提供了扩展点。
MVC框架提供了很多扩展点,包括能够利用依赖注入的自定义控制器工厂
码清单9-27 MVC框架提供了很多扩展点,包括能够利用依赖注入的自定义控制器工厂
public class UnityControllerFactory : DefaultControllerFactory
{
private readonly IUnityContainer container;
public UnityControllerFactory(IUnityContainer container)
{
this.container = container;
}
protected override IController GetControllerInstance(RequestContext requestContext,
Type controllerType)
{
if (controllerType != null)
{
var controller = container.Resolve(controllerType) as IController;
if (controller == null)
{
controller = base.GetControllerInstance(requestContext, controllerType);
}
if (controller != null)
return controller;
}
requestContext.HttpContext.Response.StatusCode = 404;
return null;
}
}
注意,在这个Windows Forms应用中,你不仅可以重用服务的WPF版本实现,也可以重用TaskListViewController
类,因为它并不依赖任何特定于WPF的东西。当然,将来很可能会有些特定于WPF的依赖,因此为支持Windows Forms平台创建专门的控制器或表示器是有必要的。
这个应用的视图非常简单,后置代码中只需要给构造函数传入控制器实例并实例化数据绑定,如下代码所示。当你在使用模型—视图—表示器模式时,视图会实现一个表示器,从而能够手动委托调用以设置数据的接口。
视图使用数据绑定将获得的任务列表设置到一个数据表格控件上
public partial class TaskListView : Form
{
public TaskListView(TaskListController controller)
{
InitializeComponent();
controller.OnLoad();
this.taskListControllerBindingSource.DataSource = controller;
}
}
如果不应用已有平台来解析视图,你就必须先自己解析得到视图实例,然后再把它传递给Windows Forms应用的启动方法Application.Run
。这是在应用只有一个主视图时唯一合适的解析点,通常桌面应用都属于这种情况。此外,可以使用视图实现的控制器或表示器创建对话框和其他子窗口。
约定优于配置
通过配置来注册接口和相应实现类之间的映射关系会很费力,而且随着时间的推移,也会变得繁琐冗长。相反,你可以使用约定来减少需要编写的代码量。
约定是一组指令,用于告诉容器如何自动完成接口到相应实现类的映射。容器接受输入的指令,而不是注册。理想情况下,容器处理输入指令所得到的输出与你手动完成的注册效果一样。
使用约定可以极大简化注册阶段的代码
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
container = new UnityContainer();
container.RegisterTypes(
AllClasses.FromAssembliesInBasePath(),
WithMappings.FromMatchingInterface,
WithName.Default
);
MainWindow = container.Resolve<TaskListView>();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
单个RegisterTypes
方法完成了所有的注册过程。该方法用于给容器提供如何查找类以及将它们映射到相应接口的指令。上面示例提供给容器的指令包括以下这些:
- 注册基本路径bin目录下所有程序集包含的类。
- 把这些类映射到符合类命名约定的接口上。这里的约定(convention)是指,名为
Service
的实现类的对应接口的名称应该是IService
。 - 注册每个映射关系时使用默认值来命名映射。默认值为空代表了映射关系是未命名的。
按照这些指令,容器会枚举bin目录下的每个程序集中的每个公开的类,找到它实现的所有接口,并把它映射到其中那个符合自己命名规则(前缀为代表Interface
的I)的接口,同时并不需要为映射关系命名。不难想象,这样注册的结果要比你手动注册生成的映射关系量要大的多。然而,更重要的是如何保证正确地注册类和接口的映射关系。这也是约定注册方式引入的新问题。
不可否认,约定注册的方式的确让代码简化了很多,但也只局限在代码量更少的层次上。通过配置注册,能够很容易地知道每个接口对应的实现,而且能确保注册是正确的。
RegisterTypes
方法的第一个参数是要注册类的集合。静态AllClasses
类提供的一些辅助方法能够通过一些常见的策略获得要注册类的集合。第二个参数是一个函数,它的输入参数是第一个参数获得的实现类集合,输出的是映射得到的对应接口集合。静态WithMapping
类提供了一些辅助方法以多种策略来为每个类找到合适的接口。第三个方法是另外一个函数,它会为每个类上的映射关系返回一个名称。静态WithName
类提供了两个命名选项:总是返回空(因此映射也就是未命名的)的Default和使用类名作为映射名称的TypeName。后者允许你根据类名称获取映射到的类实例,调用的语句为Resolve<IService>("MyServiceImplementation")
。
当然,上面示例代码中的方法参数很通用,你可以使用任何符合参数签名要求的其他方法以反映你需要的约定。如下代码所示,约定注册方式的关键点就是用于查找类,建立类和接口之间的映射关系,以及为映射关系命名的约定。
约定可以按照你的需求进行定制
public partial class App : Application
{
private void OnApplicationStartup(object sender, StartupEventArgs e)
{
CreateMappings();
container = new UnityContainer();
container.RegisterTypes(
AllClasses.FromAssembliesInBasePath().Where(type =>
type.Assembly.FullName.StartsWith(MatchingAssemblyPrefix)),
UserDefinedInterfaces,
WithName.Default
);
MainWindow = container.Resolve<TaskListView>();
MainWindow.Show();
((TaskListController)MainWindow.DataContext).OnLoad();
}
private IEnumerable<Type> UserDefinedInterfaces(Type implementingType)
{
return WithMappings.FromAllInterfaces(implementingType)
.Where(iface => iface.Assembly.FullName.StartsWith(MatchingAssemblyPrefix));
}
}
这个示例不再是直接获取bin
目录下所有程序集中的所有类,而是只查找那些符合指定前缀字符串的程序集。通常情况下会使用点分隔的命名方式,这样只需要去匹配名称中的顶层命名空间即可。所以,Microsoft.Practice.Unity
是DLL名称,也是该DLL内所有类的命名空间。如果bin目录下有Microsoft.Practice.Unity
这个DLL文件(如果你在使用Unity的话就一定会有),你也许想在检索并建立映射关系时直接忽略它。一种简单的办法就是只从符合应用自己的前缀的程序集中获取类。比如,你应该使用诸如MyBusiness
或OurProject
等来代替Microsoft作为文件名称前缀
第二个参数已经被满足参数签名要求的本地方法UserDefinedInterfaces
替代。给定一个实现类Type,该方法会将映射返回到该类的一个接口集合。这里也不需要自己编写特别复杂的代码,只需要调用WithMappings.FromAllInterfaces
方法,该方法会返回指定类实现的所有接口。返回的接口集合中很可能包括你并不想要的一些接口,比如INotifyPropertyChanged
或IDataErrorInfo
等。所以,你还是应该只去检索那些符合你的命名前缀规则的程序集,这样可以确保只建立你自己的类和接口之间的映射关系。
1. 优缺点
和穷人的依赖注入以及控制反转容器进行注册类似,使用约定进行注册一样有优点也有缺点。你要写的代码量是更少了,但是同时代码也比其他方法中声明式的代码更难直接理解了。
约定在开始阶段的设置也更复杂。如果你在编写真正的SOLID代码,也不是所有类和接口之间的映射关系都是一对一的。实际上,只有一个实现(不包括用于单元测试的模拟实现)的接口的情况本身就是一个代码味道。通常情况下,一个接口都应该有多于一个的具体实现,不论它们是适配器、修饰器或是不同策略的实现等,而这种现状也会让按照约定注册变得更复杂和困难。注入类的对象图会变得更加复杂,所以很难整理出一个让类和接口相互映射的规则。在这种情况下,约定只会涵盖所需注册代码的一小部分,而不像一般情况那样涵盖所有注册代码。
简而言之,对三种方式的取舍有两个标准:价值和复杂度。价值用于衡量选项的作用和意义,从无意义到有价值的。复杂度用于衡量选项的难度,从简单的到复杂的。
三种方式位于贝尔曲线的不同位置。穷人的依赖注入方式简单但很有价值,而约定优于配置的方式虽然复杂但也很有价值。因此,它们二者之间的主要区别在于,使用约定要比手动创建类和在这些类基础上构造出的类对象图要更复杂一些。
象限图中三种依赖注入方式各有优缺点 |
---|
很有意思的是,Seemann认为手动注册的复杂度适中,但并没有实用价值。为什么他这样认为呢?主要是因为使用容器手动注册类型是弱类型化的。如果你尝试把一个类传递给要求不同参数类型的实例时,编译器会在生成时给出报错。然而,如果你给控制反转容器传入不符合要求的类时,编译器并不会报错。相反,你只有在运行时才能看到出错信息,这会让你陷入一个编写、编译、运行和测试的死循环中。不仅如此,你还需要花费很多时间和精力学习如何使用容器注册映射关系,但实际上,这样做是得不偿失的。
现在,选择看起来简单了,要么选择穷人的依赖注入,要么选择约定。如果项目很简单并且只需要少量的映射关系,此时穷人的依赖注入很合适,只需要手动构造对象即可。如果项目变得更复杂,则会需要建立很多接口和类之间的映射关系,此时应该使用约定处理大多数的注册,其余的映射关系手动处理即可。
总结
- 依赖注入是将本书其他部分结合在一起的主线。没有依赖注入,就无法清楚地将依赖从类中分解出来,也无法将它们的实现隐藏在通用的接口后。这些扩展点对于自适应代码的编写而言非常关键,也是让逐步变大变复杂的应用稳步取得进展的关键。
- 实现依赖注入有多种不同的方式,每个都有它适合的场景。不论你是在使用穷人的依赖注入或者带有少量手动映射的约定,总是使用依赖注入要比具体的实现方式更重要。
- 实际上,有些常见的对依赖注入的滥用应该被归类到代码味道或反模式中。服务定位器和非法注入就是两种常见的滥用,它们会破坏正确应用依赖注入所得到的很多好处。
- 每个应用都有一个组合根和一个解析根,二者可以帮助你理解如何利用依赖注入来组织应用中的所有类。如果注册过程是应用初始化的一个主要问题,组合根就总是应该非常接近应用的入口位置。解析根则是唯一应该解析获取的对象类型。在某些应用中,解析根只有一个实例,而在其他一些应用中,解析根类会有一组不同的子类。
- 依赖注入对SOLID代码的编写影响很大,它看似简单,实则不然。在实际应用中,强大的依赖注入经常无法得到正确的理解和应用。