type
status
date
slug
summary
tags
category
icon
password
 
在 Java 社区,出现了一股轻量级容器的热潮,它们有助于将来自不同项目的组件组装成一个高内聚的应用程序。这样的容器有一种通用模式,即 "控制反转"(Inversion of Control)。在本文中,我将以 "依赖注入 "深入探讨这种模式的工作原理,并将其与服务定位器(Service Locator)替代方案进行对比。它们之间的选择并不重要,重要的是将配置与使用分开的原则。
在企业级 Java中,最有趣的事情之一就是在构建主流 J2EE 技术的替代品方面投入大量精力,其中大部分都是开源活动。这其中有很多是对主流 J2EE的笨重和复杂作出的回应,但也有很多是对替代方案的探索和创造性想法的提出。要解决的一个常见问题是如何将不同的元素连接在一起:如何将web-controller架构与那个database interface接口结合在一起,而这两者是由不同的团队进行构建,双方对各自的情况都知之甚少。许多框架都对这一问题进行了尝试,其中一些框架还提供了从不同层组装组件的通用功能。这些框架通常被称为轻量级容器,例如PicoContainer和Spring。
这些容器的基础是一些有趣的设计原则,它们超越了这些特定的容器的范畴,甚至超越了Java平台。在此,我想开始探讨其中的一些原则。我使用的示例是Java构建的,但就像我写的大多数文章一样,这些原则同样适用于其他 OO环境,尤其是.NET。

组件和服务(Components and Services

如何区分组件(Components)和服务(Services)是一个很棘手的问题。你能很容易的找到关于相关定义矛盾且·冗长的文章。针对这样的情况给出我在本文中的定义。
我用"组件"来表示一个软件的集合体,这个集合体的目的是在不做任何改动的情况下,供组件编写者无法控制的应用程序使用。我所说的"不做改动 "是指使用程序不会改动组件的源代码,尽管他们可能会通过组件编写者允许同个继承等方式组件,从而改变组件的行为。
服务与组件类似,都是提供给外部应用程序使用的。主要区别在于,我希望组件在本地使用(如 jar 文件、程序集、dll 或源代码导入)。服务将通过一些同步或异步的远程接口(如网络服务、消息系统、RPC 或套接字)远程使用。
我在本文中主要使用服务这个词汇,但同样的逻辑也可以应用于本地组件。事实上,如果需要轻松的访问远程服务你可能需要某种远程框架。不过,写 "组件或服务 "这样的文字读看起来很麻烦,而服务是一种当下正流行的用法。

一个简单的例子

为了让这一切更加具体,我将用一个运行示例来描述这一切。和我所有的例子一样,这是一个超级简单的例子;小到不真实,但希望足以让你直观地了解发生了什么,而不会陷入现实的泥潭。
在这个示例中,我正在编写一个组件,这个组件的功能是获取特定导演执导的电影列表。这样一个功能仅仅需要一个方法实现。
这个函数的实现极其简单,它通过finder找到(我们稍后会提到)所有的电影。然后,在列表中根据导演过滤,这个非常的简单,不必过分关注,它只是本文的脚手架。
本文的真正重点是这个finder对象,尤其是我们如何将MovieLister与特定的finder对象连接起来。之所以说这一点很有趣,是因为我希望moviesDirectedBy方法能够完全与电影的实际存储方式解藕达到完全独立。因此,该方法所做的只是引用一个finder对象,而查找器所做的只是知道如何实现findAll方法。我可以通过为定义接口MovieFinder来实现这一点。
现在,所有这一切都很好地解耦了,但在某些时候,我需要给出finder的一个具体实现。在这种情况下,我把代码放在了我的lister类的构造函数中。
这个实现类的具体实现不需要关心过多的细节
现在,如果这个类仅由我自己使用,这一切都没什么问题。但是,如果我的朋友们对这种奇妙的功能非常渴望,也想要使用这个程序,那该怎么办呢?如果他们也把电影列表存储在一个冒号分隔的文本文件中,文件名为 "movies1.txt",那么一切就都好办了。如果他们有一个不同的电影文件名,那我可以简单的讲名称添加到配置中。但如果他们其它不同的电影列表存储方式:SQL 数据库、XML 文件、网络服务或其他格式的文本文件呢?在这种情况下,我们需要一个不同的类来抓取这些数据。因为我已经定义了MovieFinder接口,所以这不会改变我的moviesDirectedBy方法。但我仍然需要某种方法来获取正确的finder实现的实例。
图1:在MovieLister 类与MovieFinder 实例的依赖关系
图1:在MovieLister 类与MovieFinder 实例的依赖关系
图1显示了这种情况下的依赖关系。MovieLister类既依赖于MovieFinder接口,也依赖于其实现。我们希望它只依赖于接口,但这样我们如何创建一个实例来使用呢?
在我的Patterns of Enterprise Application Architecture一书中,我们将这种情况描述为插件(Plugin)。finder的实现类并没有在编译时链接到程序中,因为我不知道它将被怎么使用。相反,我们希望我的MovieLister能与任何实现类一起工作,并且该实现类能在稍后的某个时候(Runtime)插入,而不是由我来控制。问题是,我怎样才能实现这种链接,使我MovieLister不知道具体实现类,但仍能与实例对话,完成工作。
将此扩展到实际系统中,我们可能会有几十个这样的服务和组件。在每种情况下,我们都可以通过接口与这些组件进行对话(如果组件在设计时没有考虑到接口,则可以使用适配器,从而抽象出我们对这些组件的使用。但是,如果我们希望以不同的方式部署该系统,就需要使用插件来处理与这些服务的交互,以便在不同的部署中使用不同的实现。
因此,核心问题是我们如何将这些插件组装成一个应用程序?这是新型轻量级容器面临的主要问题之一,它们普遍都使用反转控制(Inversion of Control)来解决这个问题。

控制反转(Inversion of Control

当这些容器谈论它们如何因为实现了”控制反转"而变得如此有用时,我感到非常困惑。反转控制是框架的共同特征,所以说这些轻量级容器因为使用了反转控制而很特别,就好像说我的车因为有轮子而很特别一样。
问题是:"他们反转控制的哪一方面?我第一次遇到控制权倒置时,是在用户界面的主控制方面。早期的用户界面由应用程序控制。你会有一连串的命令,如 "输入姓名"、"输入地址";你的程序会驱动这些提示,并对每个提示做出响应。对于图形用户界面(甚至是基于屏幕的用户界面),用户界面框架将包含这个主循环,而你的程序则为屏幕上的各个字段提供事件处理程序。程序的主要控制权发生了倒置,从你手中转移到了框架上。
对于这种新型容器来说,反转在于它们如何查找前文提到的插件实现。在上面的例子中,MovieLister通过直接实例化查找MovieFinder实现。这使得finder对象不再是一个插件。这些容器使用的方法是确保插件的任何用户都遵循某种约定,允许一个单独的汇编模块将实现注入到MovieLister中。
因此,我认为我们需要为这种模式取一个更具体的名称。控制反转(Inversion of Control)这个词太笼统,因此人们会觉得很困惑。因此,经过与各种IoC倡导者的大量讨论,我们最终确定将此行为称作依赖注入(Dependency Injection)。
首先,我要谈谈依赖注入的各种形式,但我要指出的是,这并不是消除从应用程序类到插件实现之间的依赖关系的唯一方法。你可以使用的另一种模式是服务定位器(Service Locator)也可以达到同样的目的,我将在解释完依赖注入后再讨论它。

依赖注入的形式

依赖注入的基本思想是建立一个单独的对象———装配器,该装配器会在列表类中的一个字段中填充适当的MovieFinder接口实现,从而形成如图2 所示的依赖关系图
图2:依赖注入的依赖关系
图2:依赖注入的依赖关系
依赖注入主要有三种方式。我将它们分别命名为构造函数注入(Constructor Injection)、setter注入(Setter Injection)和接口注入(Interface Injection)。如果你在当前关于反转控制的讨论中读到这些内容,你会听到它们被称为类型 1 IoC(接口注入)、类型 2 IoC(设定器注入)和类型 3 IoC(构造器注入)。我觉得数字名称比较难记,这里我使用名称命名。

使用PicoContainer注入构造函数

首先,我将展示如何使用一个名为PicoContainer的轻量级容器来实现这种注入。我之所以从这里开始,主要是因为我在Thoughtworks的几位同事非常积极地参与了PicoContainer的开发(是的,这是一种公司裙带关系。)
PicoContainer使用一个构造函数来决定如何将MovieFinder的实现注入到列表类中。为此,MovieLister需要声明一个构造函数,其中包含需要注入的所有内容。
MovieFinder本身也属性也由pico容器管理,因此容器会将文本文件的文件名注入其中。
然后,需要告诉pico容器哪个实现类与每个接口相关联,以及对MovieFinder对应的参数注入。
这些配置代码通常被设置在不同的类中。在我们的例子中,每个使用我的MovieLister的朋友都可以在自己的设置类中编写相应的配置代码。当然,在不同的配置文件中保存这类配置信息也很常见。你可以编写一个类来读取配置文件,并对容器进行适当设置。虽然PicoContainer本身并不包含这种功能,但有一个与之密切相关的名为NanoContainer的项目,它提供了适当的封装器,允许你使用XML配置文件。这种Nano容器将解析 XML,然后配置底层的Pico容器。该项目的理念是将配置文件格式与底层机制分开。
使用容器代码如下。
虽然在本例中我使用了构造器注入,但PicoContainer也支持设置器注入,不过一般开发人员更喜欢构造器注入。

使用spring的setter注入

Spring框架是一个适用于企业级Java开发的广泛框架。它包括事务抽象层、持久化框架、网络应用程序开发和JDBC。与 icoContainer 一样,它也支持构造器和设置器注入,一般开开发人员更倾向于setter注入--所以它是作为本示例的最佳选择。
类似的,定义一个获取文件名称的setter方法。
第三步是为文件设置配置。Spring支持通过XML文件和代码进行配置,但XML是最理想的配置方式。
使用测试如下。

接口注入

第三种注入方式是使用接口进行注入。 Avalon框架使用的就是这样一种技术。 我稍后会详细介绍,但现在我将在一些简单的示例代码中使用它。
我首先要定义一个接口,通过它来执行注入操作。通过接口注入MovieFinder对象
提供MovieFinder接口定义。 任何想要使用该接口(例如当前例子中的Movie Lister)的类都需要实现该接口。
使用类似的方法注入文件名
然后,和前文一样,我需要一些配置代码来关联实现。
该配置代码分两个步骤,通过查找键值注册组件
第二步注册注入器,以及注入器依赖的组件。 每个注入接口都需要一些代码来注入依赖的对象。 在这里,我是通过在容器中注册注入器对象来实现这一步的。 每个注入器对象都实现了Injector接口。
当依赖的类是为该容器编写的类时,组件本身实现Inject接口是合理的,就像我在MovieFinder中所做的那样。 对于通用类(如字符串),我会在配置代码中使用一个内部类。
测试代码如下
容器使用声明的注入接口来找出依赖关系,并使用注入器注入正确的依赖关系。

使用服务定位器模式(Using a Service Locator

依赖注入器的主要优点是,它消除了MovieLister类对具体的MovieFinder实现的依赖。这样可以在不同的环境插入合适的实现。依赖注入并不是打破这种依赖的唯一方法,另一种方法是使用服务定位器模式。
服务定位器的基本概念是拥有一个知道如何获取应用程序可能需要的所有服务的对象(这个服务实例可能是一个单例的)。因此,该应用程序的服务定位器会有一个方法,在需要时返回MovieFinder。当然,这只是稍微转移了一下负担,我们仍然需要将ServiceLOcator放入MovieLister中,这就产生了图3中的依赖关系
图3:服务定位器依赖关系
图3:服务定位器依赖关系
在这种情况下,我将把ServiceLocator注册为一个单例。然后,当列表实例化时,就可以使用它来获取MovieFinder。
与注入方法一样,我们必须配置服务定位器。在这里,我是用代码完成的,并从配置文件中读取相应数据。
测试代码如下
我经常听到有人抱怨说,这类服务定位器不好,因为它们无法测试,因为你无法用实现来替代它们。当然,你可以把它们设计得很糟糕,让它们陷入这种麻烦,但你不必这样做。在本例中,服务定位器实例只是一个简单数据仓库。我可以用服务的测试实现轻松创建MovieFinder。
如果需要更复杂的定位器,我可以子类化服务定位器,并将该子类传入Register类变量中。我可以更改静态方法,调用实例上的方法,而不是直接访问实例变量。我可以通过使用特定线程存储来提供特定线程的定位器。所有这些都可以在不更改服务定位器客户端的情况下完成。
可以这样认为,服务定位器是一个注册表,而不是一个单例。单例提供了一种实现注册表的简单方法,但这种实现方式很容易改变。

为定位器构建接口隔离

上述简单方法的一个问题是,尽管MovieLister只使用一种服务,但它依赖于完整的服务定位器类。我们可以通过使用特定的接口来解决这个问题。这样,MovieLister就不用使用完整的服务定位器接口,而只需声明它所需要的接口。
在这种情况下,MovieLister的提供者也会提供一个定位器接口,它需要这个接口来获取MovieFinder。
然后,需要实现这个接口,访问MovieFinder。
你会注意到,由于我们要使用接口,所以不能再通过静态方法访问服务了。我们必须使用类来获取一个定位器实例,然后使用它来获取我们需要的内容。

动态服务定位器

上面的示例是静态的,即服务定位器类为您需要的每项服务都提供了方法。这并不是唯一的方法,你还可以制作一个动态服务定位器,让你可以将所需的任何服务藏在其中,并在运行时做出选择。
在这种情况下,服务定位器使用映射来代替每个服务的字段,并提供通用方法来获取和加载服务。
配置加载如下

同时使用服务定位器和依赖注入

容器向MyMovieLister注入服务管理器。

到底使用哪一个

服务定位器 vs 依赖注入

最根本的问题是在服务定位器和依赖注入之间做出选择。首先,这两种实现方式都提供了基本的解藕功能--在这两种情况下,应用程序代码都独立于服务接口的具体实现。这两种模式的重要区别在于如何向应用程序类提供该实现。使用服务定位器时,应用程序类通过向定位器发送消息的方式明确提出请求。而注入模式则没有明确请求,服务会出现在应用程序类中,因此控制权是反向的。
控制反转是框架的一个常见功能,但也是有代价的。它往往难以理解,不便于调试。因此,总的来说,除非有必要,我更愿意避免使用它。这并不是说它不好,只是我认为它需要证明自己的合理性,而不是更直接的替代方案。
服务定位器的关键区别在于,服务的每个用户都依赖于定位器。定位器可以隐藏对其他实现的依赖,但你确实找到对应的定位器。因此,在定位器和依赖注入之间做出选择取决于这种依赖关系是否会造成问题。
使用依赖注入有助于更轻松地查看组件的依赖关系。使用依赖注入,您只需查看注入机制(如构造器),就能看到依赖关系。而使用服务定位器时,您必须在源代码中搜索对定位器的调用。具有查找引用功能在现代的IDE中变得很容易,但仍不如查看构造函数或setter来得简单。
这在很大程度上取决于服务用户的性质。如果您正在构建一个包含各种使用服务的类的应用程序,那么从应用程序类到定位器的依赖关系并不是什么大问题。
如果类似MovieLister这样的服务是我为其他人编写的应用程序提供的组件,那么区别就来了。在这种情况下,我对客户将要使用的服务定位器的应用程序接口知之甚少。每个客户可能都有自己不兼容的服务定位器。我可以通过使用隔离接口来解决部分问题。每个客户都可以编写一个适配器,将我的接口与他们的定位器相匹配,但无论如何,我仍然需要查看第一个定位器来查找我的特定接口。一旦适配器出现,这种情况使用服务定位器显然就不这么方便了。
由于注入器与组件之间不存在依赖关系,因此组件一旦配置完成,就无法从注入器中获得更多服务。
人们喜欢使用依赖注入的一个常见原因是它能让测试变得更容易。这里的重点是,要进行测试,你需要用stubs或mock轻松替换真实的服务实现。然而,依赖注入和服务定位器之间其实并没有什么区别:两者都非常适合stubs。这正是测试驱动开发的用武之地,如果你不能轻松地将服务用于测试,那么这就意味着你的设计存在严重问题。
当然,如果组件环境具有很强的侵入性,如Java的EJB框架,测试问题就会凸显。我的观点是,这类框架应尽量减少对应用程序代码的影响,尤其不应该做那些会影响执行效率的事情的事情。使用插件来替代重量级组件对这一过程有很大帮助,这对测试驱动开发等实践至关重要。
因此,最主要的问题是那些正在编写代码的人,他们希望在外部程序使用这些代码。在这种情况下,即使是对服务定位器一样可能会产生不可预知的问题。

构造器注入和setter注入

对于服务组合,您总是必须有一些约定,才能将事物连接在一起。注入的优势规则简单--至少对于构造函数和setter注入而言。你不必在你的组件中做任何奇怪的事情,就可以非常直接地获得所有配置。
接口注入则比较麻烦,因为你必须编写大量的接口,对于组装组件和依赖关系来说,这是一项繁重的工作,这也是目前的轻量级容器采用设置器和构造器注入的原因。
在setter和构造器注入之间做出选择是很有趣的事情,因为它反映了面向对象编程中一个普遍问题--是在构造器中填充属性,还是使用setter填充属性。
对于对象,我长期以来的做法是尽可能使用构造器注入。这一建议可以追溯到 Kent Beck 的《Smalltalk 最佳实践模式》:构造方法和参数。带有参数的构造函数可明确说明当前构建对象内容。如果有不止一个对象形式,那么就创建多个构造函数来展示不同的组合。
构造函数初始化的另一个优点是,它允许你通过不提供setter的情况下隐藏任何不可变的字段。我认为这一点很重要--如果某些东西不应该改变,那么setter方法就不应该存在。如果使用setter进行初始化,这就会变得很麻烦。(事实上,在这种情况下,我更倾向于不使用这个方法,我更倾向于使用类似 initFoo 这样的方法,以强调这是你只有在初始化时执行)。
但任何情况都有例外。如果你有大量的成员变量,事情就会显得一团糟,尤其是在没有关键字参数的语言中。冗长的构造函数表明这是一个很复杂的对象,应该拆分,但在某些情况下,右不得不这么做。
如果您有多个构造函数去构造一个有对象时,那么使用构造函数会比较麻烦v。这时,工厂方法(Factory Methods)就可以发挥作用了,它们可以使用私有构造器和setter的组合来实现。对于组件组装来说,经典工厂方法的问题在于它们通常被视为静态方法,而在接口上是不能使用静态方法的(现在不是了)。你可以创建一个工厂类,但这样就变成了另一个服务实例。
如果使用字符串等简单参数,构造函数也会受到影响。有了setter注入,你就可以给每个setter起一个名字,说明字符串应该做什么。而在构造函数中,你只能依赖于参数位置,不是很好理解。
如果有多个构造函数并且存在继承关系,情况就会变得特别棘手。为了初始化所有内容,你必须提供构造函数来转发给每个超类的构造函数,同时还要添加自己的参数。这可能会导致构造函数数量激增。
尽管有这些缺点,我还是倾向于从构造器注入,对于框架来说支持这两种方式非常重要。

代码方式还是配置文件

使用配置文件还是使用应用程序代码来关联服务也是一个令人困惑的问题。对于分布式部署的场景,使用单独的配置文件通常是最合理的。几乎所有情况下,配置文件都是XML文件,这也是合理的。不过,在某些情况下,使用程序代码进行组装会更方便。一种情况是,你的应用程序很简单,没有太多的部署变化。在这种情况下,一段代码可能比一个单独的XML文件更清晰。
在装配相当复杂,涉及条件步骤。一旦开始接近编程语言,XML就无法满足需求了,因此最好使用一个合适的语言编写你的罪状逻辑。如果您有不同的生成器方案,您可以提供多个生成器类,并使用一个简单的配置文件在它们之间进行选择。
我常常认为,人们急于定义配置文件。通常情况下,编程语言可以提供直接而强大的配置机制。现代语言可以很容易地编译出小型汇编程序,用于为大型系统组装插件。如果编译很麻烦,也可以使用脚本语言。
人们常说,配置文件不应该使用编程语言,因为它们需要由非编程人员编辑。但事实真的如此吗?难道人们真的指望非程序员来更改复杂服务器端应用程序的事务隔离级别吗?非语言配置文件只有在简单的情况下才能运行良好。如果配置文件变得复杂,那么就应该考虑使用适当的编程语言了。
目前,我们在Java中看到的一种情况是,配置文件杂乱无章,每个组件都有自己的配置文件,而这些配置文件又与其他人的不同。如果你使用了十几个这样的组件,你需要和几十个配置文件保持同步。
在此,我的建议是,始终提供一种通过编程接口轻松完成所有配置的方法,然后将单独的配置文件视为可选功能。你可以使用编程接口轻松构建配置文件处理程序。如果你正在编写一个组件,你可以让用户自己决定是使用编程接口、配置文件格式,还是编写他们自己的自定义配置文件格式并将其与编程接口绑定。

分离配置和使用

确保服务的配置与使用分离这一点十分重要。这是一个基本的设计原则,与接口与实现分离的原则是一致的。在面向对象程序中,当条件逻辑决定实例化哪个类,然后通过多态进行动态绑定时,就能反映出这一点。
如果说这种分离在单一的代码库中是有用,那么当你使用组件和服务等外部元素时,这种分离就显得尤为重要。第一个问题是,您是否希望将实现类的选择推迟到特定的部署环境中。如果是这样,就需要使用某种插件实现。一旦使用了插件,那么插件的组装就必须与应用程序的其他部分分开,这样就可以很容易地在不同的环境切换不同的配置。如何实现这一点是次要的。这种配置机制既可以配置服务定位器,也可以使用依赖注入。