有道无术,术尚可求,有术无道,止于术。
原文地址
作者:Martin Fowler
发布时间:2004 年 1 月 23 日
前言
在 Java
社区,出现了一批轻量级容器,它们将来自不同项目的组件组装成一个有机的应用程序。这些容器背后有一个共同的模式,定义了它们如何进行组件装配,这个概念通常被统称为控制反转(Inversion of Control
)。
在本文中,我将深入探讨这个模式如何工作,并称呼为依赖注入(Dependency Injection
),并将其与服务定位器(Service Locator
)模式进行对比。选择它们之间的方式并不如将配置与使用分离原则那样重要。
企业 Java
世界中的有趣现象之一,就是在构建替代主流 J2EE
技术方面的巨大活跃,许多工作都发生在开源社区。这其中有很大一部分是对主流 J2EE
庞大复杂性的反应,但同样也有许多是探索替代方案并提出创造性想法的结果。
一个常见的问题是如何将不同的元素连接起来:当这些元素是由不同的团队构建的,且彼此知之甚少时,如何将这个 Web
控制器架构与那个数据库接口结合在一起?许多框架都试图解决这个问题,其中一些框架还进一步发展,提供了一种通用能力,用于组装来自不同层的组件。这些通常被称为轻量级容器,示例包括 PicoContainer
和 Spring
。
这些容器背后有许多有趣的设计原则,超越了这些具体的容器和 Java
平台本身。在这里,我想开始探讨其中的一些原则。尽管我使用的示例是 Java
,但像我大多数的写作一样,这些原则同样适用于其他面向对象环境,特别是 .NET
。
组件和服务
关于将元素连接在一起的问题,几乎立刻引出了围绕服务和组件这两个术语的复杂术语问题。你可以轻松找到大量长篇且相互矛盾的文章,讨论这些术语的定义。在这里,我将分享我当前对这些被过度使用的术语的理解。
我使用组件来指代一个软件模块,它是为了被外部应用程序使用而设计的,且在使用时不需要做任何修改,且该应用程序与组件的开发者无关。这里的没有修改指的是,使用该组件的应用程序不会改变组件的源代码,尽管它们可以通过组件开发者允许的方式来扩展组件的行为。
服务与组件类似,都是被外部应用程序使用。主要区别在于,我期望组件是本地使用的(可以理解为 jar
文件、程序集、dll
或源代码导入)。而服务则是通过某种远程接口来使用的,无论是同步的还是异步的(例如:Web
服务、消息系统、RPC
或 Socket
)。
在本文中,我主要使用服务这个术语,但相同的逻辑也适用于本地组件。事实上,通常你需要某种本地组件框架来便捷地访问远程服务。然而,组件或服这样写既费力又冗长,而且现在服务这一术语更为流行。
一个简单的示例
为了让这些概念更具可操作性,我将通过一个示例来说明。就像我所有的示例一样,这是一个非常简单的例子,虽然足够小,几乎不真实,但需求足够清晰,让你能够理解发生了什么,而不会陷入实际示例的复杂性中。
在这个示例中,我写了一个组件,用于提供由特定导演执导的电影列表。这个极其有用的功能是通过一个方法实现的。
class MovieLister {
public Movie[] moviesDirectedBy(String arg) {
List allMovies = finder.findAll();
for (Iterator it = allMovies.iterator(); it.hasNext();) {
Movie movie = (Movie) it.next();
if (!movie.getDirector().equals(arg)) it.remove();
}
return (Movie[]) allMovies.toArray(new Movie[allMovies.size()]);
}
}
这个函数的实现极其简单,它要求一个 finder
对象(稍后我们会详细讲到)返回它知道的所有电影。然后,它遍历这个列表,返回由特定导演执导的电影。这个实现的简单性,我暂时不会修正,因为它仅仅是为了展示本文的核心要点。
本文的真正重点是这个 finder
对象,或者更具体地说,是如何将 lister
对象与特定的 finder
对象连接起来。为什么这是一个有趣的问题呢?因为我希望我的 moviesDirectedBy
方法能够完全独立于所有电影存储的方式。所以,方法所做的只是引用一个 finder
,而这个 finder
只需要知道如何响应 findAll
方法。我通过为 finder
定义一个接口来实现这一点。
public interface MovieFinder {
List findAll();
}
现在,所有这些都已经非常解耦了,但在某个时刻,我必须创建一个具体的类来实际提供电影数据。在这个例子中,我把这个代码放到了 lister
类的构造函数中。
class MovieLister {
private MovieFinder finder;
public MovieLister() {
finder = new ColonDelimitedMovieFinder("movies1.txt");
}
}
这个实现类的名称来源于我从一个以冒号分隔的文本文件中获取电影列表。我就不详细介绍这些细节了,毕竟重点只是有一个实现存在。
现在,如果我只是自己使用这个类,那一切都很顺利。但如果我的朋友们也渴望这个极好的功能,并想要复制我的程序呢?如果他们也将电影列表存储在一个名为 “movies1.txt
” 的冒号分隔文本文件中,那一切都非常好。如果他们使用的是不同名称的电影文件, 那么可以轻松地将文件名放入配置文件中。
但如果他们有一种完全不同的存储电影列表的方式:比如 SQL
数据库、XML
文件、Web
服务,或者只是另一种格式的文本文件呢?在这种情况下,我们需要一个不同的类来获取这些数据。现在,由于我已经定义了一个 MovieFinder
接口,这并不会改变我的 moviesDirectedBy
方法。但我仍然需要某种方式,将正确的 finder
实现类的实例引入进来。
图1:Lister 类中的简单创建依赖关系
图1
展示了这种情况的依赖关系。MovieLister
类依赖于 MovieFinder
接口及其实现。我们更希望它仅依赖于接口,但问题是,如何创建一个可以使用的实例呢?
在我的书《P of EAA
》中,我们将这种情况描述为一个插件。MovieFinder
的实现类并不是在编译时与程序链接的,因为我并不知道我的朋友们会使用什么样的实现。相反,我们希望 MovieLister
能够与任何实现类一起工作,并且这种实现类能够在某个稍后的时刻插件式地加入进来,由我们之外的某人来负责。问题在于,如何建立这种连接,使得 MovieLister
类对实现类保持无知,但仍然能够与一个实例进行交互并完成工作。
将这个概念扩展到一个真实系统中,我们可能会有多个这样的服务和组件。在每个情况下,我们可以通过接口来抽象与这些组件的交互(如果组件没有设计接口,我们可以使用适配器)。但是,如果我们希望以不同的方式部署这个系统,我们需要使用插件来处理与这些服务的交互,以便在不同的部署环境中使用不同的实现。
因此,核心问题是,如何将这些插件组装成一个应用程序?这是新一代轻量级容器所面临的主要问题之一,普遍采用的解决方案是使用控制反转(Inversion of Control, IoC
)。
控制反转
当这些容器谈论它们如何因为实现了控制反转而变得非常有用时,我常常感到困惑。控制反转是框架的一个常见特性,所以说这些轻量级容器之所以特别,是因为它们使用了控制反转,就像说我的车因为有轮子而特别一样。
问题是:它们到底在反转控制的哪个方面? 我第一次遇到控制反转时,是在用户界面的主控制部分。早期的用户界面由应用程序控制。你会有一系列的命令,比如输入姓名、输入地址,你的程序会驱动提示并获取每个命令的响应。随着图形用户界面的出现,UI
框架会包含这个主循环,而你的程序则提供事件处理器来处理屏幕上各个字段的交互。程序的主控制权发生了反转,从你这边转移到了框架。
对于这种新型容器,控制反转的核心在于它们如何查找插件的实现。在我最初的例子中,lister
通过直接实例化来查找 finder
的实现,这样就使得 finder
不再是一个插件。这些容器采用的方法是,确保任何使用插件的代码都遵循某种约定,以便一个单独的组装模块可以将插件的实现注入到 lister
中。
因此,我认为我们需要给这种模式起一个更具体的名字。控制反转是一个过于通用的术语,因此很多人觉得它很混乱。经过与许多 IoC
(控制反转)倡导者的讨论后,我们最终决定将其命名为依赖注入(Dependency Injection
)。
我将开始讨论依赖注入的各种形式,但我现在先指出,这并不是从应用程序类到插件实现移除依赖关系的唯一方法。你还可以使用另一个模式来做到这一点,那就是服务定位器(Service Locator
)。我将在讲解完依赖注入后,讨论服务定位器。
依赖注入的方式
依赖注入(Dependency Injection, DI
)的基本思想是,通过一个独立的对象–组装器(assembler
),将 lister
类中的字段填充为适当的 finder
接口实现,从而形成一个依赖关系图,如图 2
所示。
图2:依赖注入器的依赖关系
依赖注入有三种主要形式。我在这里使用的名称是构造器注入(Constructor Injection
)、Setter
注入(Setter Injection
)和接口注入(Interface Injection
)。如果你阅读当前关于控制反转的讨论,你会听到这些被称为:type 1 IoC
(接口注入)、type 2 IoC
(设置器注入)和 type 3 IoC
(构造器注入)。我发现数字名称比较难记,因此我在这里使用了这些名称。
使用 PicoContainer 的构造器注入
我将首先展示如何使用一个轻量级容器 PicoContainer
来进行依赖注入。之所以从这里开始,主要是因为我在 Thoughtworks
的几个同事在 PicoContainer
的开发中非常活跃(是的,这算是一种公司内的裙带关系)。
PicoContainer
使用构造器来决定如何将 finder
实现注入到 lister
类中。为了使这个工作正常进行,movie lister
类需要声明一个构造器,包含它所需注入的所有内容。
class MovieLister {
private MovieFinder finder;
public MovieLister(MovieFinder finder) {
this.finder = finder;
}
}
finder
本身也将由 PicoContainer
管理,因此容器会将文本文件的文件名注入到 finder
中。
class ColonMovieFinder {
private String filename;
public ColonMovieFinder(String filename) {
this.filename = filename;
}
}
接下来,PicoContainer
需要被告知,应该将哪个实现类与每个接口关联,以及要注入到 finder
中的字符串。
private MutablePicoContainer configureContainer() {
MutablePicoContainer pico = new DefaultPicoContainer();
Parameter[] finderParams = {new ConstantParameter("movies1.txt")};
pico.registerComponentImplementation(MovieFinder.class, ColonMovieFinder.class, finderParams);
pico.registerComponentImplementation(MovieLister.class);
return pico;
}
这段配置代码通常会在一个不同的类中进行设置。在我们的示例中,每个使用 lister
的朋友可能会在他们自己的某个设置类中编写适当的配置代码。当然,通常会将这些配置数据保存在单独的配置文件中。你可以编写一个类来读取配置文件并适当地设置容器。虽然 PicoContainer
本身并不包含这个功能,但有一个相关的项目——NanoContainer
,它提供了适当的封装,使你可以使用 XML
配置文件。这样的 NanoContainer
会解析 XML
文件,然后配置底层的 PicoContainer
。该项目的理念是将配置文件的格式与底层机制分开。
为了使用容器,你可以编写类似如下的代码:
public void testWithPico() {
MutablePicoContainer pico = configureContainer();
MovieLister lister = (MovieLister) pico.getComponentInstance(MovieLister.class);
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
然在这个例子中,我使用了构造器注入,但 PicoContainer
同样支持设置器注入,不过其开发者更倾向于使用构造器注入。
使用 Spring 的 Setter 注入
Spring
框架是一个广泛应用于企业级 Java
开发的框架。它包含了事务、持久化框架、Web
应用开发和 JDBC
等抽象层。与 PicoContainer
一样,Spring
支持构造器注入和 Setter
注入,但其开发者通常更倾向于使用 Setter
注入。
为了使我的 MovieLister
能够接受注入,我为这个服务定义了一个 Setter
方法:
class MovieLister {
private MovieFinder finder;
public void setFinder(MovieFinder finder) {
this.finder = finder;
}
}
类似地,我为文件名定义了一个 Setter
:
class ColonMovieFinder {
private String filename;
public void setFilename(String filename) {
this.filename = filename;
}
}
第三步是配置文件,Spring
支持通过 XML
文件和代码配置,但通常推荐使用 XML
文件进行配置。
<beans>
<bean id="MovieLister" class="spring.MovieLister">
<property name="finder">
<ref local="MovieFinder"/>
</property>
</bean>
<bean id="MovieFinder" class="spring.ColonMovieFinder">
<property name="filename">
<value>movies1.txt</value>
</property>
</bean>
</beans>
然后,测试代码看起来如下:
public void testWithSpring() throws Exception {
ApplicationContext ctx = new FileSystemXmlApplicationContext("spring.xml");
MovieLister lister = (MovieLister) ctx.getBean("MovieLister");
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
接口注入(Interface Injection)
第三种注入技术是通过定义并使用接口来进行注入。Avalon
是一个使用这种技术的框架示例。我稍后会详细讨论它,但在这个例子中,我将通过一些简单的示例代码来展示这种技术。
使用这种技术时,我首先定义一个接口,用于通过该接口进行注入。以下是用于将电影查找器注入到对象中的接口。
public interface InjectFinder {
void injectFinder(MovieFinder finder);
}
这个接口是由提供 MovieFinder
接口的类定义的。任何需要使用查找器的类都需要实现这个接口,比如电影列表器。
class MovieLister implements InjectFinder {
private MovieFinder finder;
public void injectFinder(MovieFinder finder) {
this.finder = finder;
}
}
我使用类似的方法将文件名注入到查找器实现中。
public interface InjectFinderFilename {
void injectFilename(String filename);
}
class ColonMovieFinder implements MovieFinder, InjectFinderFilename {
private String filename;
public void injectFilename(String filename) {
this.filename = filename;
}
}
接下来,像往常一样,我需要一些配置代码来连接实现。为了简化,我将在代码中完成这个配置。
class Tester {
private Container container;
private void configureContainer() {
container = new Container();
registerComponents();
registerInjectors();
container.start();
}
}
此配置分为两个阶段,通过查找键注册组件与其他示例类似。
class Tester {
private void registerComponents() {
container.registerComponent("MovieLister", MovieLister.class);
container.registerComponent("MovieFinder", ColonMovieFinder.class);
}
}
一个新的步骤是注册将注入依赖组件的注入器。每个注入接口都需要一些代码来注入依赖对象。这里,我通过将注入器对象注册到容器中来做到这一点。每个注入器对象都实现了注入器接口。
class Tester {
private void registerInjectors() {
container.registerInjector(InjectFinder.class, container.lookup("MovieFinder"));
container.registerInjector(InjectFinderFilename.class, new FinderFilenameInjector());
}
}
public interface Injector {
void inject(Object target);
}
当依赖的类是为此容器编写时,组件实现注入器接口本身是合理的,就像我在电影查找器中所做的那样。对于像字符串这样的通用类,我会在配置代码中使用内部类来实现。
class ColonMovieFinder implements Injector...
public void inject(Object target) {
((InjectFinder) target).injectFinder(this);
}
class Tester...
public static class FinderFilenameInjector implements Injector {
public void inject(Object target) {
((InjectFinderFilename)target).injectFilename("movies1.txt");
}
}
使用容器测试:
class Tester…
public void testIface() {
configureContainer();
MovieLister lister = (MovieLister)container.lookup("MovieLister");
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
容器使用声明的注入接口来确定依赖关系,并通过注入器注入正确的依赖对象。(这里我实现的具体容器实现方式对这个技术并不重要,而且我不会展示它,因为你只会笑。)
使用服务定位器
依赖注入的关键好处在于,它消除了 MovieLister
类对具体 MovieFinder
实现的依赖。这使得我可以将 MovieLister
传递给朋友们,他们可以根据自己的环境插入一个合适的实现。依赖注入并不是唯一打破这种依赖关系的方法,另一种方法是使用服务定位器。
服务定位器的基本思想是拥有一个对象,它知道如何获取应用程序可能需要的所有服务。因此,这个应用程序的服务定位器将提供一个方法,当需要时返回一个电影查找器。当然,这只是稍微转移了负担,我们仍然需要将定位器注入到 MovieLister
中,从而导致图 3
所示的依赖关系。
图3:服务定位器的依赖关系
在这种情况下,我将使用 ServiceLocator
作为单例注册表。然后,MovieLister
可以在实例化时使用它来获取 MovieFinder
。
class MovieLister...
MovieFinder finder = ServiceLocator.movieFinder();
class ServiceLocator...
public static MovieFinder movieFinder() {
return soleInstance.movieFinder;
}
private static ServiceLocator soleInstance;
private MovieFinder movieFinder;
与依赖注入方法一样,我们需要配置服务定位器。这里我是在代码中进行配置,但使用机制从配置文件中读取适当的数据并不困难。
class Tester...
private void configure() {
ServiceLocator.load(new ServiceLocator(new ColonMovieFinder("movies1.txt")));
}
class ServiceLocator...
public static void load(ServiceLocator arg) {
soleInstance = arg;
}
public ServiceLocator(MovieFinder movieFinder) {
this.movieFinder = movieFinder;
}
这是测试代码:
class Tester...
public void testSimple() {
configure();
MovieLister lister = new MovieLister();
Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
我常常听到这样的抱怨:这些类型的服务定位器不好,因为它们不可测试,因为你不能替换它们的实现。确实,你可以设计得很糟糕,导致这种麻烦,但你并不一定需要这么做。在这种情况下,服务定位器实例只是一个简单的数据容器。我可以很容易地创建一个使用测试实现的服务定位器。
对于更复杂的定位器,我可以对子类化 ServiceLocator
,并将该子类传递到注册表的类变量中。我可以将静态方法更改为调用实例上的方法,而不是直接访问实例变量。我还可以通过使用线程特定存储提供线程特定的定位器。所有这些都可以在不改变服务定位器客户端的情况下完成。
一种理解方式是,服务定位器是一个注册表,而不是单例模式。单例模式提供了一种实现注册表的简单方法,但这种实现决策是可以轻松改变的。
使用分隔接口作为定位器
上面简单的方法有一个问题,即 MovieLister
依赖于整个服务定位器类,即使它只使用了其中一个服务。我们可以通过使用角色接口来减少这种依赖。这样,MovieLister
就可以仅声明它所需要的接口部分,而不是依赖整个服务定位器接口。
在这种情况下,MovieLister
的提供者还需要提供一个它需要的定位器接口,用于获取 MovieFinder
。
public interface MovieFinderLocator {
public MovieFinder movieFinder();
}
然后,定位器需要实现这个接口,以提供对 MovieFinde
r 的访问。
MovieFinderLocator locator = ServiceLocator.locator();
MovieFinder finder = locator.movieFinder();
public static ServiceLocator locator() {
return soleInstance;
}
public MovieFinder movieFinder() {
return movieFinder;
}
private static ServiceLocator soleInstance;
private MovieFinder movieFinder;
你会注意到,由于我们希望使用接口,我们不能再通过静态方法直接访问服务。我们必须使用类来获取一个定位器实例,然后通过它来获取我们需要的服务。
动态服务定位器
上面的示例是静态的,因为服务定位器类为你需要的每个服务提供了方法。这并不是唯一的方式,你还可以创建一个动态服务定位器,它允许你在运行时将任何服务存储进去,并根据需要选择服务。
在这种情况下,服务定位器使用一个 Map
来代替每个服务的字段,并提供通用方法来获取和加载服务。
class ServiceLocator {
private static ServiceLocator soleInstance;
public static void load(ServiceLocator arg) {
soleInstance = arg;
}
private Map<String, Object> services = new HashMap<>();
public static Object getService(String key) {
return soleInstance.services.get(key);
}
public void loadService(String key, Object service) {
services.put(key, service);
}
}
配置服务时需要用适当的键加载服务。
class Tester {
private void configure() {
ServiceLocator locator = new ServiceLocator();
locator.loadService("MovieFinder", new ColonMovieFinder("movies1.txt"));
ServiceLocator.load(locator);
}
}
使用服务时,通过相同的键字符串来获取服务。
class MovieLister {
MovieFinder finder = (MovieFinder) ServiceLocator.getService("MovieFinder");
}
总体来说,我不太喜欢这种方法。虽然它确实灵活,但它不够显式。通过文本键来查找服务的方式不如显式的方法直观。显式的方法更容易通过查看接口定义来找出服务的位置。
使用定位器和注入结合的 Avalon 框架
依赖注入(Dependency Injection
)和服务定位器(Service Locator
)并不一定是互相排斥的概念。一个很好的例子是 Avalon
框架。Avalon
使用了服务定位器,但同时使用注入来告知组件在哪里可以找到这个定位器。
Berin Loritsch
给我发送了一个使用 Avalon
的简单版本,基于我之前的例子:
public class MyMovieLister implements MovieLister, Serviceable {
private MovieFinder finder;
public void service(ServiceManager manager) throws ServiceException {
finder = (MovieFinder) manager.lookup("finder");
}
}
在这个例子中,service
方法是接口注入的一个例子,允许容器将一个服务管理器注入到 MyMovieLister
中。服务管理器则是服务定位器的一个例子。在这个例子中,MovieLister
并没有将管理器存储在字段中,而是立即使用它来查找 finder 服务,并且将查找结果存储下来。
选择使用哪种选项
到目前为止,我一直在专注于解释我如何看待这些模式及其变种。现在我可以开始讨论它们的优缺点,以帮助确定在何时何地使用哪些模式。
服务定位器 VS 依赖注入
最根本的选择是在服务定位器(Service Locator
)和依赖注入(Dependency Injection
)之间进行选择。第一个要点是,两种实现方式都提供了在简单示例中缺失的基本解耦,在这两种情况下,应用程序代码与服务接口的具体实现是独立的。两者之间的关键区别在于,服务实现是如何被提供给应用程序类的。使用服务定位器时,应用程序类通过消息显式地请求定位器提供服务;而使用依赖注入时,没有显式请求,服务会自动出现在应用程序类中,因此实现了控制反转。
控制反转是框架的常见特性,但它带来了一定的代价。控制反转通常难以理解,并且在调试时可能会遇到问题。所以总体来说,除非我确实需要它,否则我倾向于避免使用它。这并不是说它是坏的,而是我认为它需要在比更直接的替代方案更复杂的情况下才能证明其价值。
关键区别在于,使用服务定位器时,每个服务的使用者都依赖于定位器。定位器可以隐藏对其他实现的依赖,但你仍然需要看到定位器。因此,选择服务定位器还是依赖注入,取决于这个依赖是否会成为问题。
使用依赖注入有助于更容易地查看组件的依赖关系。通过依赖注入,你可以直接查看注入机制,比如构造函数,来了解依赖关系。而使用服务定位器时,你必须在源代码中搜索定位器的调用。现代 IDE 提供了查找引用的功能,虽然这使得查找变得更容易,但它仍然不如直接查看构造函数或设置方法来得直观。
很多情况下,这取决于服务使用者的性质。如果你正在构建一个包含多个使用服务的类的应用程序,那么应用程序类对定位器的依赖并不是大问题。以我给朋友们提供电影列表的例子来说,使用服务定位器工作得很好。他们只需要配置定位器来连接正确的服务实现,可能通过一些配置代码或配置文件。在这种场景下,我并不觉得依赖注入的控制反转提供了什么特别吸引人的优势。
区别出现在,如果列表器是我提供给其他人写的应用程序中的组件。在这种情况下,我并不清楚我的客户将使用什么样的服务定位器的 API
。每个客户可能都有自己不兼容的服务定位器。为了解决这个问题,我可以使用分离的接口。每个客户可以编写一个适配器,将我的接口与他们的定位器匹配,但无论如何,我仍然需要看到第一个定位器,以查找我的具体接口。一旦适配器出现,直接连接到定位器的简洁性就开始逐渐丧失。
由于在使用注入器时,组件不会依赖于注入器,因此一旦组件配置完成,它无法从注入器获取更多的服务。
人们偏好依赖注入的一个常见理由是它使得测试更加容易。这里的观点是,在进行测试时,你需要能够轻松地将真实的服务实现替换为存根或模拟对象。然而,依赖注入和服务定位器在这方面没有本质区别:两者都非常适合于使用存根。我怀疑这种观点来自于那些没有努力确保他们的服务定位器可以轻松替换的项目。这就是持续测试发挥作用的地方,如果你无法轻松地存根服务进行测试,这就意味着你的设计存在严重问题。
当然,组件环境的干扰性越强,这个测试问题就越严重,比如 Java
的 EJ
B 框架。我的观点是,这类框架应该尽量减少对应用程序代码的影响,特别是不要做出那些会拖慢编辑执行周期的操作。使用插件来替代重量级组件在很大程度上有助于这个过程,这对像测试驱动开发(TDD
)这样的实践至关重要。
所以,主要问题是针对那些编写代码并希望这些代码能在写作者无法控制的应用程序中使用的开发者。在这些情况下,即使是对服务定位器的最小假设也是一个问题。
构造函数注入 VS setter 注入
对于服务组合,你总是需要一些约定来将各个部分连接起来。注入的优势主要在于它需要非常简单的约定,至少对于构造函数和 setter
注入来说是这样。你不需要在组件中做什么特别的操作,并且对于注入器来说,配置一切都非常直接。
接口注入则更加具有侵入性,因为你需要写大量的接口来整理这些东西。对于容器所需的一小部分接口,比如 Avalon
的方法,这还不算太糟糕。但如果需要组装很多组件和依赖关系,那么这就会是一个非常繁琐的工作,这也是当前轻量级容器大多采用 setter
和构造函数注入的原因。
构造函数注入与 setter
注入之间的选择很有趣,因为它反映了面向对象编程中的一个更普遍的问题:应该在构造函数中填充字段,还是通过 setter
方法来填充。
我长期以来的默认做法是尽可能在构造时创建有效的对象。这条建议可以追溯到 Kent Beck
的《Smalltalk
最佳实践模式》:构造方法和构造参数方法。带有参数的构造函数能够清晰地说明如何在一个明显的位置创建有效的对象。如果有多种方式可以做到这一点,可以创建多个构造函数,显示不同的组合。
构造函数初始化的另一个优势是,它可以通过简单地不提供 setter
来明确隐藏任何不可变的字段。我认为这点很重要,如果某个东西不应该被修改,那么没有 setter
能很好地传达这一点。如果你使用 setter
来进行初始化,这可能会变得麻烦。(实际上,在这种情况下,我倾向于避免使用通常的 setter
约定,我更喜欢使用类似 initFoo
的方法,来强调这应该是对象“出生”时才做的事。)
但在任何情况下都存在例外。如果你有很多构造函数参数,代码可能会显得混乱,尤其是在没有关键字参数的语言中。的确,构造函数参数过多通常是一个过度复杂对象的标志,可能应该拆分,但有时候这也是你所需要的。
如果你有多种方式构造一个有效的对象,通过构造函数来表现这一点可能会很困难,因为构造函数只能通过参数的数量和类型来变化。这时,工厂方法(Factory Methods
)就派上用场了,它们可以使用私有构造函数和 setter
方法的组合来实现工作。经典的组件组装工厂方法的问题在于,它们通常被视为静态方法,而接口中不能包含静态方法。你可以创建一个工厂类,但这又变成了另一个服务实例。工厂服务通常是一个不错的策略,但你仍然需要使用这里提到的某种技术来实例化工厂。
构造函数在处理简单参数(如字符串)时也有缺点。使用 sette
r 注入时,你可以给每个 setter
起个名字,来指明字符串的作用。而构造函数则只能依赖参数的位置,这更难理解。
如果你有多个构造函数和继承关系,那么事情可能会变得特别尴尬。为了初始化一切,你必须提供构造函数来转发到每个父类构造函数,同时还要加入自己的参数。这可能会导致构造函数的大量爆炸。
尽管有这些缺点,我的偏好是从构造函数注入开始,但一旦我上面提到的问题开始成为困扰时,我会准备切换到 setter
注入。
这个问题引发了各个团队之间大量的辩论,这些团队将依赖注入器作为其框架的一部分。然而,似乎大多数构建这些框架的人已经意识到,支持这两种机制很重要,即使其中有一种机制更为偏好。
代码与配置文件
一个与之相关但经常被混淆的问题是,是否使用配置文件或代码来在 API
中进行服务的组装。对于大多数可能部署到多个地方的应用程序,通常使用单独的配置文件更为合理。几乎所有时候,这个配置文件都是一个 XML
文件,这是有道理的。然而,也有一些情况,使用程序代码进行组装更为简单。一个例子是,如果你有一个简单的应用程序,它的部署变化不大。在这种情况下,使用一些代码可能比使用单独的 XML
文件更清晰。
一个对立的例子是,组装过程非常复杂,涉及到条件步骤。一旦你接近编程语言的程度,XML
开始表现不佳,这时最好使用一种真正的编程语言,它具备编写清晰程序所需的所有语法。你可以编写一个构建器类来完成组装。如果你有不同的构建器场景,可以提供多个构建器类,并使用一个简单的配置文件来选择其中之一。
我常常认为,人们过于急于定义配置文件。实际上,编程语言通常提供了一种直接而强大的配置机制。现代语言可以轻松编译小型的组装器,这些组装器可以用于为大型系统组装插件。如果编译过程麻烦,那么脚本语言也能很好地解决这个问题。
常常有人说配置文件不应该使用编程语言,因为它们需要由非程序员编辑。但是这种情况究竟有多常见呢?人们真的指望非程序员去修改复杂服务器端应用程序的事务隔离级别吗?非编程语言的配置文件仅在它们简单时有效。如果它们变得复杂,那么就该考虑使用适当的编程语言了。
目前我们在 Java
世界中看到的一个现象是,配置文件的喧嚣,每个组件都有自己独立的配置文件,而且这些文件之间是不同的。如果你使用了十几个这样的组件,那么你很容易就会有十几个配置文件需要保持同步。
我的建议是,始终提供一种通过编程接口轻松进行所有配置的方式,然后将配置文件作为一个可选功能来处理。你可以轻松地构建配置文件处理功能,以便使用编程接口。如果你正在编写一个组件,你可以让用户自己决定是否使用编程接口、你的配置文件格式,或者编写自定义的配置文件格式并将其与编程接口集成。
配置与使用的分离
所有这些的关键问题是确保服务的配置与其使用是分离的。实际上,这是一个基本的设计原则,与接口与实现的分离相一致。这在面向对象编程中尤为明显,当条件逻辑决定实例化哪个类时,未来对该条件的评估通过多态性来完成,而不是通过重复的条件代码。
如果在单一代码库中这种分离是有用的,那么当你使用外部元素(如组件和服务)时,这种分离尤为重要。第一个问题是,你是否希望将实现类的选择推迟到特定的部署阶段。如果是这样,你需要使用某种插件实现。一旦你使用了插件,那么确保插件的组装与应用程序的其他部分分离,就变得至关重要,这样你可以轻松地为不同的部署替换不同的配置。如何实现这一点并不重要,次要的是,配置机制可以配置服务定位器,或者使用注入直接配置对象。
一些进一步的问题
在本文中,我集中讨论了使用依赖注入(Dependency Injection
)和服务定位器(Service Locator
)进行服务配置的基本问题。还有一些相关的议题同样值得关注,但由于时间原因,我还没有深入探讨。特别是生命周期行为的问题。一些组件有明确的生命周期事件,例如停止和启动。另外一个问题是,越来越多的人开始关注如何在这些容器中使用面向方面的思想。虽然我在文章中并没有考虑这些内容,但我希望能够写更多相关的文章,要么通过扩展本文,要么写一篇新的文章。
你可以通过查看专注于轻量级容器的网站,了解更多这些思想的内容。从 PicoContainer
和 Spring
的官方网站浏览,可以让你进入更多相关讨论,并开始了解一些进一步的问题。
结语
当前轻量级容器的迅猛发展有一个共同的基础模式,即它们如何进行服务组装–依赖注入模式。依赖注入是服务定位器的一个有用替代方案。在构建应用类时,这两者大致是等价的,但我认为服务定位器略微占优,因为它的行为更加直观。然而,如果你在构建可以在多个应用中使用的类,那么依赖注入是更好的选择。
如果你使用依赖注入,你可以选择多种风格。我建议你使用构造器注入,除非你遇到该方法的具体问题,这时可以转为使用 setter
注入。如果你选择构建或获取一个容器,建议寻找一个支持构造器注入和 setter
注入的容器。
服务定位器与依赖注入之间的选择,其实比应用程序内服务配置和服务使用分离的原则要次要。
致谢
衷心感谢许多帮助我完成这篇文章的人。Rod Johnson
、Paul Hammant
、Joe Walnes
、Aslak Hellesøy
、Jon Tirsén
和 Bill Caputo
帮助我理解这些概念,并对文章的早期草稿进行了评论。
Berin Loritsch
和 Hamilton Verissimo de Oliveira
提供了关于 Avalon
如何适应的非常有用的建议。Dave W Smith
一直不断地询问我最初接口注入配置代码的问题,这促使我意识到这个设计的缺陷。Gerry Lowry
给我发来了很多拼写修正,足够让我把他列入感谢名。