分类: Java
2010-04-12 21:47:34
Inversion of Control —— 从开始到现在
Inversion of Control(IoC) —— 几乎所有流行的框架都声称自己实现了IoC。IoC究竟有什么好处?我从Tapestry-IoC框架的文档中摘录了以下几条:
l 将复杂的系统分割成很多易于测试的小片;
l 通过覆盖、取代其中的小片,就可以扩展和修改系统。
IoC果真有这么神奇吗?它背后隐藏的神秘力量是什么?
现在流行的IoC框架已经完美了吗?还有什么发展余地?
本文通过一些简单的例 子,来思考IoC的实质;再通过对比常见IoC框架的异同,思考我们如何才能把IoC框架做得更好。
无论是学习C语言、Basic语言,或者其它的编程语言,教科书往往从一个简单的控制台程序开始,来介绍这门新的语言。我们也从这 个简单的例子开始 —— 写程序完成下面的功能:
从键盘(标准输入)输入读入字符,然后打印到显示屏(标准输出)。
用Java语言实现这个功能极为简单,只需要寥寥几行代码就可以了:(程序1)
public class Copier {
public static void main(String[] args) throws IOException {
int ch;
while ((ch = System.in.read()) >= 0) {
System.out.print((char) ch);
}
}
}
(注: 这段程序也是Martin C. Robert在他的《Agile Principles, Patterns, and Practices》书中, 用来引出他的设计思想的例子,本文的很多思想来自于这本经典的参考书。)
把这段代码画成结构 图,应该是这个样子:
这段程序有问题吗?
如果你的需求永不改 变,那么这段程序毫无问题 —— 它工作得很好。
如果你的需求发生改 变,比方说,你希望从一个文件中读字符,并打印到一台打印机去,那么你不得不修改这段程序。为了和原程序保持兼容,你也许不得不把程序改成类似下面的样 子:(程序2)
public class Copier {
public enum InputDevice { STDIN, FILE }
public enum OutputDevice { STDOUT, PRINTER }
public static void main(String[] args) throws IOException {
int ch;
InputDevice in;
OutputDevice out;
try {
in = InputDevice.valueOf(args[0]);
out = OutputDevice.valueOf(args[1]);
} catch (Exception e) {
in = InputDevice.STDIN; // 默认值
out = OutputDevice.STDOUT; // 默认值
}
while ((ch = read(in)) >= 0) {
write(out, (char) ch);
}
}
private static int read(InputDevice in) {
switch (in) {
case STDIN:
return System.in.read();
case FILE:
return readFromFile();
}
}
private static void write(OutputDevice out, char ch) {
switch (out) {
case STDOUT:
System.out.print(ch);
break;
case PRINTER:
writeToPrinter(ch);
break;
}
}
}
这样,你就违反了面 向对象设计(OOD)中的第一个原则,也是最 基本的原则:Open/Closed Principle,简称OCP。这个原则的描述如 下:
面向对象设计原则:Open/Closed Principle(OCP) |
软件 实体(类、模块、函数等)应当能够被扩展(Open for Extension),而不需要被修改(Closed for Modification)。 |
违反这个原则,会导 致什么结果呢?Martin C. Robert给我们总结了以下几点:
1. Rigidity(僵硬的) —— 形容程序难以被改变
试想以后你每增加一 种“读字符”的方法,或者增加一种“打印字符”的方法,你都需要去修改这个类,以增加适当的switch case分支。不仅如此,所有用到copy功能的程序也有可能需要被修改。可见修改程序的代价是很高的。
2. Fragility(易碎的)—— 形容程序很容易受到破坏
试想你少写了一个switch case会怎样?或者,当你修改“输出到打印机”功能时,原有的“输出 到显示屏”的功能却被出乎意料地破坏了。
3. Immobility(难以移植的) —— 形容程序难以在不同的环境中被重用
试想你是一个copy程序的用户,但是你只关心从键盘输入,从显示屏输出的功能。然而系统却强迫你带上打印机的驱动程序 —— 如 果你没有,系统可能会失败。随着switch case的增多,这种情况会更严重。
改进copy程序,使之符合OCP原则,有很多种方法。
早先的面向对象的资料 会告诉你:利用继承。现在我们可以把这种方法称为“Template pattern”设计模式。程序可以改成下面的样子:
(程序3)
public abstract class Copier {
public void copy() throws IOException {
int ch;
while ((ch = read()) >= 0) {
write((char) ch);
}
}
protected abstract int read() throws IOException;
protected abstract void write(char ch) throws IOException;
}
public class KeyboardToDisplayCopier extends Copier {
@Override
protected int read() throws IOException {
return System.in.read();
}
@Override
protected void write(char ch) throws IOException {
System.out.print(ch);
}
}
public class FileToPrinterCopier extends Copier {
@Override
protected int read() throws IOException {
return readFromFile();
}
@Override
protected void write(char ch) throws IOException {
writeToPrinter(ch);
}
}
该程序结构可以表示为如下类图:
通过抽象方法,上述程 序做到了OCP原则:Copier类可以被扩展,然而Copier类本身不需要被修改。前述所有的问题都迎刃而解:
1. 解决了问题:Rigidity(僵硬的) —— 形式程序难以被改变
增加一种“读”或 “写”的方式很容易 —— 只要从Copier类扩展就行了。由于Copier类本身不需要被修改,所以现有的所有应用都不需要改变。因此修改程序的代价是很低的。
2. 解决了问题:Fragility(易碎的)—— 形容程序很容易受到破坏
增加一种“读”或 “写”的方式并不会影响其它的程序。例如,增加或修改FileToPrinterCopier,不太可能影响到使用KeyboardToDisplayCopier的用户。
3. 解决了问题:Immobility(难以移植的) —— 形容程序难以在不同的环境中被重用
如果你只关心从键盘 输入和从显示屏输出,那么没有人会强迫你同时依赖FileToPrinterCopier。因此,Copier类可以在不同的环境中被重用。
Template pattern是很有效的一种设计模式。JUnit就使用了template pattern来创建单元测试。然而这种模式有一个问题,就是所谓的“类型爆炸”的问题。
试想,我现在要从文 件中读字符,却想要输出到显示屏上;或者从键盘读字符,输出到打印机上。我就需要创建两个新的子类:FileToDisplayCopier和KeyboardToPrinterCopier。假如有n种输入设备,m种输出设备,那么,我可能需要n*m种Copier的子类。这显然是很不经济的做法。
为此,人们总结出一个 原则:
面向对象设计原则:组合胜于继承 |
尽量 用“组合”而不是“继承”来扩展新功能(见《Thinking in Java》) |
很好,我们可以把程 序改成下面的样子:(程序4)
public class Copier {
private KeyboardReader keyboard = new KeyboardReader();
private DisplayWriter display = new DisplayWriter();
public void copy() throws IOException {
int ch;
while ((ch = keyboard.read()) >= 0) {
display.write((char) ch);
}
}
}
public class KeyboardReader {
public int read() throws IOException {
return System.in.read();
}
}
public class DisplayWriter {
protected void write(char ch) throws IOException {
System.out.print(ch);
}
}
这个程序的类图可以表 现为:
这个程序成功运用了组 合,却回到了最初的困境:我们怎样增加新的“读”、“写”功能(例如从文件读,输出到打印机)?你会发现,一旦你要去修改这个程序,程序就会变得Rigidity、Fragility和Immobility。为什么?因为它违反了OCP原则 —— 它做不到在不修改程序的情况下,扩展出新的 “读”、“写”方法。
如何既能运用“组合”的方式,又能够避免破坏OCP原则呢?这里我们可以引进另一个重要的面向对象的设计原则:Dependency Inversion Principle,简称DIP。
面向对象设计原则:Dependency Inversion Principle(DIP) |
高层 模块不能依赖低层模块,而是大家都依赖于抽象; 抽象 不能依赖实现,而是实现依赖抽象。 |
假如违反了DIP原则,那么,
1. 高层模块依赖低层模块,例如:Copier依赖KeyboardReader,那么对KeyboardReader的修改,或者增加一个新的“读”方式(如FileReader),那么,高层模块(Copier)必须被修改。这违反了OCP。
2. 抽象和实现的关系也是类似的。抽象是较“高”层的模块,而实现是“低”层的模块。抽象依赖实现,会给 增加新的实现和修改现有实现带来困难。这同样违反了OCP。
3. 违反DIP,继而违反OCP,就会带来一系列的苦果:Rigidity、Fragility和Immobility。
贯彻DIP原则的方法,就是使用“接口模式”,将程序改进成这样:(程序5)
public class Copier {
private Reader reader;
private Writer writer;
public Copier(Reader reader, Writer writer) {
this.reader = reader;
this.writer = writer;
}
public void copy() throws IOException {
int ch;
while ((ch = reader.read()) >= 0) {
writer.write((char) ch);
}
}
}
public interface Reader {
int read() throws IOException;
}
public interface Writer {
void write(char ch) throws IOException;
}
public class KeyboardReader implements Reader {
public int read() throws IOException {
return System.in.read();
}
}
public class DisplayWriter implements Writer {
public void write(char ch) throws IOException {
System.out.print(ch);
}
}
这个程序结构可以表现 为类图:
DIP是依赖反置(Dependency Inversion Principle)的缩写。那么,为什么称之为“反”的呢?其实,这个“正”与“反”的依赖关系,都是相对于模块而言 的,而不是指特定的类或接口。一个模块,往往是一个开发的单元。划分模块是面向对象开发过程中的一个重要环节。从物理上看,一个模块往往被打包成一个jar包。从责任上看,往往一个模块由一个(或一 组)程序员来开发和测试。
在没有使用DIP的例子中(程序4),Copier模块(上层模块)正向依赖包含Reader和Writer实现的模块(下层模块)。使用了DIP以后(程序5),由于我们往往把接口(Reader、Writer)和调用接口的客户端(Copier)放在同一模块,因此,实现Reader和Writer接口模块(下层模块)就“反向”依赖了上层 模块。如图:
“反向”还体现在开发顺序上。在非DIP的例子中,下层模块必须先于上层模块完工, 否则上层模块将无法编译。同时,如果要测试上层模块,必须和下层模块绑在一起测试 —— 这增加了测试的难度。在DIP的例子中,上层模块可以先于下层模块完工。 在测试的时候,我们只需要对接口进行“仿冒”(Mock)就可以了。
因此,遵循DIP原则,
1. 增加、修改下层模块,丝毫不会影响到上层模块,也不需要修改上层模块。既满足了OCP原则,也避免了Rigidity、Fragility的问题。
2. 使用上层模块时,可以选择任何一种下层的实现。例如,当你只关心“从键盘输入,输出到显示屏”的时 候,你无需带上“从文件输入,输出到打印机”的模块。这就解决了Immobility的问题。这种“可移植”的特性方便了单元测 试,使你只需要提供Mock对象就可以测试上层模块。
接口模式是对DIP原则的一种实现,它是许多其它模式的基础,只 不过接口所代表的抽象的内容不同罢了。
概念:耦合(Coupling)和内聚(Cohesion) |
耦合 是指:一个程序模块对其它模块、环境、角色的依赖。 内 聚是指:单一模块的职责单一性和内部结构的相关性。 一 般来说,一个好的程序结构,应该是高内聚、低耦合的。 |
对于上述晦涩的定 义,我们不必太认真。我们只需要对一个好的程序结构有如下直观的认知就可以了:
1. 每个模块只做好一件事(高内聚)。
不要在一个模块中做所 有的事。(程序2)中的switch case在一个方法中做了所有的事,这样的程序内聚度非常低。
2. 每个模块对 其它的模块的细节了解得越少越好(低耦合)。
一般认为,依赖一个类比依赖一个接口,需要了解更多的信息。继承一个类比组合一个对象,需要了解更多 的信息。在(程序5)中,对接口的依赖,将模 块之间的相关性降到了最小。
从前面的例子可以看出,之所以符合OCP和DIP的程序会比较容易扩展、修改,正是因为这样的程序的耦合度比较小、内聚度比较高。
但是,话要说回来 —— 高 内聚、低耦合不是绝对的原则。因为要做到这些,必然会导致系统的复杂性增加。正如(程序1)所示,如果需求不更改,那么(程序1)这样的程序没有任何问题。
注意 |
高内 聚、低耦合不是绝对的要求。 变更 是促使程序进行重构,以降低其耦合度、提高其内聚度的唯一原因。 |
运用DIP原则,Copier模块和下层的Reader、Writer的实现类(Concrete Class)解耦了。然而不管怎样解耦,总要有人把这些分散的部分组装起来才能工作。
我们可以用下面的程序 来组装对象(程序6):
public class Copier {
private Reader reader;
private Writer writer;
public static void main(String[] args) throws IOException {
Copier copier = new Copier(new KeyboardReader(), new DisplayWriter());
copier.copy();
}
……
}
在这个程序中,我们 发现,通过DIP消除的依赖关系又回来了!
怎样避免Copier依赖KeyboardReader和DisplayWriter类呢?有人想到了用类型反射:
public class Copier {
private Reader reader;
private Writer writer;
public static void main(String[] args) throws Exception {
Class readerClass = Class.forName("KeyboardReader");
Class writerClass = Class.forName("DisplayWriter");
Copier copier = new Copier((Reader) readerClass.newInstance(),
(Writer) writerClass.newInstance());
copier.copy();
}
……
}
这样,Copier类在字面上确实上不依赖KeyboardReader和DisplayWriter类了。然而实质上,这只是把依赖检查从编译时刻移到了运行时刻。要做到真正的解耦,我们需要把创建reader和writer的逻辑移到Copier类之外。
很容易想到,我们可以 把Reader和Writer的实现类放到配置文件中,类似下面的样子:
reader.class = KeyboardReader
writer.class = DisplayWriter
然后,修改程序如下:
public class Copier {
private Reader reader;
private Writer writer;
public static void main(String[] args) throws Exception {
Copier copier = new Copier((Reader) BeanFactory.getBean("reader"),
(Writer) BeanFactory.getBean("writer"));
copier.copy();
}
……
}
public class BeanFactory {
public static Object getBean(String name) {
String className = Config.getProperty(name + ".class");
Class clazz = Class.forName(className);
return clazz.newInstance();
}
}
程序的结构可以表现成下图:
Martin Fowler把这种模式称作Service Locator。在我们目前的框架中,Service Manager也是使用这种模式。不管怎么称呼,实质都没有改变 —— 使用这种技术 (或类似的技术),我们就可以保持Copier和Reader、Writer实现类之间的解耦,从而确保系统的易测试、易维护和扩展性。这些特性都是拜前面所说的DIP及其它面向对象的设计原则所赐。
这种模式有什么问题 呢?最大的问题就是Copier必须依赖BeanFactory(或者Service Locator或其它名称)的引用。由于BeanFactory是属于框架的,因此这种耦合并不会增加应用程序模块之间的耦合性。在许多情况下,模块对框架的这种 依赖性并不会带来什么问题。然而,如果我们希望我们的模块能够工作在许多不同的环境下,而不仅仅是工作于指定框架下,那么这种对框架的依赖性可能成为一种 阻碍。
再进一步,如果我们不 仅把创建reader和writer的逻辑,而且把创建copier的逻辑全部移到外部框架来做,会怎样呢?很显然,这个外部框架要复杂一点 —— 因为它不仅 要了解如何创建一个对象的知识,还必须要了解对象之间的依赖关系,以便把它们组装起来。
对比前面ServiceLocator模式,ServiceLocator只需要知道如何创建一个对象,因此只需要用一个简单的properties文件就可以把ServiceLocator配置好。但现在不同了,我们可能需要用复杂得多的配置文件(或等效的其它技术,如annotation)来表达对象之间的依赖关系。例如,我们可以用下面的Spring配置文件来组装上述对象:
调用copier的程序如下:
public class Copier {
……
public static void main(String[] args) throws Exception {
Copier copier = (Copier) getBeanFactory().getBean("copier"); // BeanFactory即Spring容 器
copier.copy();
}
……
}
下图展示了在这种框架 下,Copier程序的类结构。
和上一个例子比较,我 们发现原本是Copier对BeanFactory的依赖,变成了BeanFactory对Copier的依赖。更一般地讲,是程序模块依赖框架,变成了框架依赖程序模块。这就是所谓的“反向控制”(Inversion of Control,简称IoC)了。支持IoC的框架,除了Spring,还有HiveMind、PicoContainer,以及新近出来的Tapestry-IOC、Google Guice等。
其实,正如Martin Fowler所说的,IoC反向控制是一切框架的特质,如同汽车有车轮一样。有很多控制可以被“反”转,例如:
l 在老式的用户交互应用中,程序控制着所有的过程:输入姓名、输入年龄、处理数据等。在现在的GUI交互式应用中,控制由程序转向框架,程序只需要响应事件即可。
l 基于M-V-C的WEB应用框架也是如此:一个controller会接管用户的请求,并在适当的时候(反向)调用应用的action或类似的模块。
l 前面所说的DIP原则,也是一种反向控制:由上层模块依赖下层模块,反转成下层依赖上层。
所有这些,都是Inversion of Control,无非是“Control”所代表的内容有所不同。在本例中,“Control”是指模块取得服务的方式,或者是模块对框架的依赖性。所以为了明确概念,Martin为IoC另取了一个更确切的名字:Dependency Injection(依赖注入,简称DI)。如果不加说明,现在我们说IoC,都是指代DI。
概念:反向控制(IoC)和依赖注入(DI) |
一般 来说,IoC和DI代表同一个意思,就是: 模块 不依赖框架,而是框架反向依赖模块; 模块 不主动去取得它所依赖的服务,而是由框架主动将它所需要的服务“注入”到模块中。 |
和Service Locator模式类似,IoC模式也做到了对应用程序模块的解耦,并享受到因为解耦所带来的一系列好处:易测试、易扩展、易维护 等。和Service Locator唯一的 差别是,IoC模式不要求程序模块依赖框架, 这使得程序模块可以被简化成一系列极简单的Java Bean(Plain Old Java Bean,简称POJO)。这也许是IoC框架被称为“轻量级”框架的原因 —— 并非框架本身 很轻量(事实上IoC框架很复杂),而是程序 模块可以被简化。
除此之外,和很多人认为的相反,IoC能做的,Service Locator也能做,例如:AOP、测试等。
观点 |
IoC框架的强大性并非如人所想象的,它可以使模块更小、更易测试、更易维护。事实上,这些特性在非IoC的框架(如Service Locator)上也同样存在。 使模块更小、更易测试、更易维护,是遵循DIP等面向对象编程原则的功劳。 IoC的强大在于它的“轻量性”,即它不要求模块依赖框架,因而可以把程序写得更简单。 |
事物的另一方面是,IoC框架在简化了程序编写的同时,大大复杂化了模块的配置。各IoC框架的主要不同点,几乎就在于如何配置模块,如何让框架知晓模块之间的依赖关系。当配置的复杂程序 达到一定程度时,其配置的难度、维护的复杂度会不亚于直接写一段程序。IoC框架的最大问题也在于此。后面我们将会详细讨论现有IoC框架的一些问题。
我们习惯于称呼可重用的模块为“服务”。
遵循良好设计的服务, 应该是“层次化”的。我们通过两个具体例子来说明。
相关源代码位于:
l
l
Session服务扩展了Java Servlet中的HttpSession的功能,在其下实现了一套可扩展的SessionStore机制,使我们可以控制如何保存session中的对象。例如,我们可以将session对象保存到cookie中,或者将session对象保存到专用的session server中。
整个服务的静态结构可以表现成下面的类图:
从图中,我们可以发现 整个服务分为如下几个层次:
1. 第一层:Java Servlet API。这一层是由应用服务器实现的。我们的服务将从这里扩展。
2. 第二层:Request Context机制。Request Context对标准的Java Servlet request和response进行包装。通过包装request和response,实现新的功能,或者覆盖应用服务器的原有功能。Session服务建立在Request Context机制之上,但是Request Context机制不是专为Session设计的。在Request Context基础上,我们还实现了很多其它功能,例如:buffer、URL rewrite等(未在图中表示)。
3. 第三层:Session Request Context接口定义。
4. 第四层:Session Request Context的实现。该层实现了Session机制,并定义了两个新的接口:IDBroker、SessionStore。前者用于生成唯一的session ID,后者用于定义保存session对象的机制。
5. 第五层(1):实现ID broker。
6. 第五层(2):还实现了基于cookie的session store。在这里,我们又定义了一个新的接口:CookieEncoder,用于将对象转换成cookie可以接受的字符串。
7. 第六层:实现了cookie encoder。目前有两种实现:基于Java序列化机制的实现,和基于Xstream的实现。
相关源代码位于:
l
Cache服务实现了两个功能:1) 缓存对象的机制 2) 在进程间同步对象的机制。如图:
类似session机制,Cache服务也被分成了几个层次:
1. 第一层(1):定义了Cache接口
2. 第一层(2):定义了sync相关的接口。
3. 第二层(1):实现了Cache接口。其中,Cache接口有两种实现,第一种为BaseCache,在底层通过调用CacheStore接口,来实现cache功能。第二种为SynchronizableCache,利用composite设计模式(类似JUnit),将Cache接口和sync机制连接在一起。
4. 第二层(2):实现了sync机制。SynchronizationManager利用JGroup实现了sync机制。
5. 第三层:实现了cache store。我们实现了基于内存、OSCache和TBStore的cache存储机制。
我们已经举了两个例 子:Session和Cache服务。观察这俩个服务的类图,它们有什么值得注意的地方呢?
首先,这两个服务内部,都被设计成一个个职责清晰的“模块”。在图中,我把模块表现为一个package。而事实上,它们不一定代表Java Package,只是代表一个逻辑上的内聚模块。
l 每一个模块都有一个清晰的界线。
l 每一个模块或者定义了一个或多个接口供下层模块扩展,或者实现一个或多个接口供其它模块使用,或者兼 而有之。
l 许多模块内部的结构都相当复杂,但是模块之间的关系却非常简单。
这两个服务都是遵循面 向对象的设计原则进行设计的。最基本的面向对象的设计原则,就是前面所述的OCP和DIP原则。
l 多数模块可被扩展, 但是在扩展的时候,不需要改动模块的代码。(OCP原则)
l 高层模块不依赖低层模块,而是低层模块依赖高层模块中的抽象接口。(DIP原则)
l 每一个“层次”(也 可被称为“模块”),专心做好一件事。(高内聚)
l 模块之间界线清晰, 可替换、可组合。(低耦合)
怎样才能把这些设计良好的类、接口组装在一起,却不引进新的耦合呢?如前所述,这正是IoC框架的用武之地!
此外,从这两个例子中,我们还可以看到三种不同的角色:
1. 服务提供者
服务提供者的职责就 是为客户提供实现某一功能的服务(或模块)。
2. 服务实现者
服务实现者的职责就是 在服务提供者所提供的扩展点上,添加新的功能。
3. 服务使用者
服务使用者就是使用某 个服务的人。
需要注意的是,在上述多层次的设计中,这三种角色是相对而言的。
l 某一层模块,可以扩展上一层模块,对于上一层模块而言,它的创建人就是“服务实现者”。例如:Session Request Context Impl模块的创建人相对于上层Session Request Context模块,就是服务实现者。
l 模块也可以提供新的扩展点供下一层模块扩展或被其它模块引用,对下一层模块或引用它的模块而言,它的 创建人就是“服务提供者”。例如:Session Request Impl模块的创建人相对于下层Cookie Store Impl,就是服务提供者。
l 模块也可以引用其它 的模块(但应该避免引用它的上层模块,这样会引起层次的混乱,从而增加耦合度),这样,它的创建人对被引用模块而言,就是“服务使用者”。例如:SynchronizableCache引用了另一个模块(sync)的CacheEventFirer,那么它的创建人就是sync模块的服务使用者。
三种角色的关系如图所 示:
需要补充的是,当我们 说到“层次”这个词时,是指着模块的扩展关系来说的,下层扩展上层,层层细化。层次不是指使用关系。因此服务的使用者和服务提供者不构成上下层关系。例 如,在cache的例子中,cache impl模块会“使用”(监听和聚合)到sync模块,但它们是“使用关系”,而不是“扩展”关系,因此,虽然在图上把它们画成上下两层,但它们却 不构成上下层关系。
模块不能引用上层的规则也是指着扩展关系来说的。在cache的例子中,既然cache impl模块和sync模块不构成上下层关系,那么,cache impl引用sync模块是合理的。
和“模块”不同,模块 是指程序代码块,而“角色”是参与创建、使用、扩展模块的“人”。角色之间也会有耦合吗?是的。虽然“角色耦合”这个词是我发明的,但是这种耦合关系却不 依赖于这个词汇的存在而存在。我们给它一个名称,是为了更好地解释它。
概念:角色的耦合 |
是 指:创建、使用、扩展模块的人之间的约定。约定的表现形式可以是程序接口、配置文件、文档等。 |
1. 服务的使用 者和提供者之间的耦合
要使用一个他人提供的服务,我们往往需要了解很多东西:
l 如何创建、结束这个服务?(服务的生命周期)
l 我们可以利用服务的哪些接口(或类)?反过来,哪些类属于服务内部的,我们不应当依赖?
l 这个服务有哪些用法、配置?这个服务使用了哪些默认值?
l 使用时有何要求和限制?
以上这些内容,已经超 越了“程序”的范畴,因此也超越了程序编译器所能检查的范围。我们需要借助其它的手段来描述这些关系。文档、XML DTD/Schema、示范代码等都是描写这种耦合关系的手段。
尽管这些关系超越了 程序的范畴,但它们同样会带来和程序耦合类似的问题。比如,一个服务将它原本的配置改变成不兼容的形式,那么很多使用它的程序就会失败。再比如,错误地依 赖了服务的某些未公开的特性,而这些特性一旦在将来的版本中被打破,那么我们的程序也将出错。
服务的使用者和提供者 之间的耦合应该符合如下要求:
l 耦合的方向:提供者不能依赖使用者,而是使用者依赖提供者。
l 耦合的多少:使用者对提供者的细节了解得越少越好。
l 耦合的规范:最好的耦合方式是可以由机器检查的。例如XML DTD/Schema就是一种很好的表达方式。相对而言,文档和代码示例 就不容易掌握。
对于前面所举的多层次服务的例子,一个服务好像海面上的冰山,它的使用者“从上向下”看,只能看到水 面上的那部分,对于水面下的部分,它不能够也不应该去关注。
2. 服务实现者 和服务提供者之间的耦合
如果说服务使用者是“从上到下”看待服务,那么服务实现者就是“从下到上”地看服务。
服务实现者扩展的方 式和内容是由服务提供者预先设想的一种约定。我们称这种约定为“扩展点”(Extenstion Point)。扩展点包括很多内容:
l 实现哪个(或哪些) 接口?根据接口分隔原则ISP,接口要尽量 小,因此扩展一个服务使用多个接口是很正常的。
l 可以使用哪些对象和 资源?对于实现者而言,如前所述,一个最基本的限制就是实现者不应该依赖其上层模块。但服务提供者可以为实现者提供一些内部的对象和资源,而这些对象和资 源是不对普通服务使用者开放的。
l 服务将如何创建、销毁扩展的对象?(生命周期)
l 如何配置?
l 服务对实现者有何要 求和限制?
同样,扩展点所包含的内容仍然超出了代码的范畴。服务的实现者和提供者之间的耦合应该符合如下要求:
l 耦合的方向:提供者不能依赖实现者,而是实现者依赖提供者。
l 耦合的多少:实现者对提供者的细节了解得越少越好。
l 耦合的规范:最好的耦合方式是可以由机器检查的。例如XML DTD/Schema就是一种很好的表达方式。相对而言,文档和代码示例 就不容易掌握。
作为一名开发者,我是 服务的使用者,还是提供者,又或是实现者呢?这个要看情况,有时你可能是一个使用者,有时你可能是实现者,有时兼而有之。下面举几个例子:
我们曾经说过,几乎任 何框架都是IoC的(广义,不单指DI)。以Web框架为例(Struts、Webwork、Tapestry、Spring MVC等等),你不需要亲手接管从请求到响应的所有过程 —— 框架已经帮你 接管了。你要做的是“扩展”框架,以便在适当的时候,处理由框架传递过来的控制。这就是广义的“反向控制(IoC)”。
因此,当你利用一个现 成的Web框架来编写Web应用时,你是站在服务的实现者的角度来看待框架的。一个优秀的框架设计应该是这样的:
1. 使你仅仅关 注和你业务相关的扩展点(如Action), 你甚至不需要了解整个框架服务的全貌。
2. 当你的需求增加时,总有一个扩展点可以用来满足你的需求。以Web框架为例,当你需要增加用户验证和授权的功能时,你可以扩展框架中的某个扩展点(如Pipeline、Interceptor等),来增加这些功能。
框架是被动调用的典型例子。但有时你需要主动调用一个服务。例如,在你的某个Action中,需要调用邮件服务来发送邮件。而邮件服务是和Web框架不相关的独立服务。这时,你允当的角色就是服务的使用者。
我们也会创建一个新的 服务,供别人使用;或者写一个新的框架供别人扩展。这时,你允当的角色就是服务的提供者。
也许你作为应用的开发 者,会同时担任一个或多个角色,但不能因此而把角色混淆。
作为服务的提供者,你应当清楚地定义出,你的使用者如何使用你的服务(参见中对于服务使用者和服务提供者之间耦合的描述);还应该清楚地定义出,你的实现者可以在哪些地方、以 何种方式扩展你的服务(参见中对于服务实现者和服务提供者之间耦合的描述)。
作为服务的使用者和实 现者,应按照服务提供者原有的意图,正确地使用和扩展服务。
一个好的服务框架,应该有助于分离这些角色,并清晰地定义角色之间的种种约定。
常见的IoC框架,例如:Spring、HiveMind、PicoContainer,它们是否已经完美无缺了?
在IoC轻量级框架中,Spring无疑是最具知名度和最成功的框架。
l Spring框架本身实现了分层次的设计。我们可以使用它的全部,也可以只使用它的一部分。(分层的好处之一)
l Spring用一种简单而统一的方法,来管理所有的对象。管理的范畴包括:
n 对象的生命周期 —— 创建、销毁、初始化
n 对象的装配 —— 基于构造函数、setter的依赖注入
n AOP
l Spring在核心之外,构建了大量和具体应用技术相关的服务,例如:
n WEB框架 —— Spring MVC、和其它WEB框架整合方案
n DAO框架 —— Hibernate、iBatis、JDO、JDBC……
n 事务框架 —— 各 种类型的Transaction
n Portlet
n ……
Spring兴起于EJB的风头浪尖。正当人们沉迷于EJB所鼓吹的种种好处,却不得不忍受EJB的复杂和低效时,Spring的作者Rod Johnson推出了一本书:《J2EE without EJB》。该书以大量篇幅讲述了EJB的缺陷,并提出了能解决同样问题的轻量级方案。此书一推出,立即拓宽了人们的思路 —— 原来不用EJB也能开发企业级应用。从此,Spring迅速流行起来。很快,人们发现,除了取代EJB以外,用Spring做其它事也很方便 —— WEB、Workflow、Security…… —— 凡你想得到的事,都可以用Spring来做。
但是随着Spring应用的增加,人们也逐渐发现了Spring的一些问题。最大的一个问题便是配置文件“过载”。在一个大型应用中,Spring的配置文件极为繁杂,难以维护。由此带来一系列的问题,如难以调试和查错、难以理解、难以升级系 统。为什么会产生这种现象呢?Spring不 是“轻量”的吗?
Spring的核心是一个容器,容器中管理着许多的对象。这些对象不一定要实现某个接口 —— Spring对它们的要求很“低” —— 只要求它们是一个POJO(Plain Old Java Bean)即可。为了强化这个概念,Spring称所有受管理的对象为beans,而这个容器则被称为BeanFactory。
如图所示:
l 在Spring里面,所有的beans都是平级的、可以相互注入。例如:把A注入到B,再把B注入到C。
l 在Spring里面,所有的beans都是通用的POJO。配置和注入一般是通过property设置到bean中去的。
Spring的这种方案确实很简单易懂,但也产生了一些问题,而这些问题归根结底是一个问题:角色的耦合。举例说 明之。(注:在Spring中,难以明确区分 服务的使用者或是实现者,故统称为“用户”。)
1. 用户过度依赖服务提供者
这是最常见的一种耦 合,表现为用户对于服务的实现细节了解太多。比如:
……
你看,为了使用一个 服务,我必须了解如下细节:
l Bean的实现类是什么?(MyBeanClass、OtherBeanClass)
l Bean的生命周期是什么?(Singleton?Prototype?如何初始化?如何销毁?一样也不要漏掉!)
l Bean有哪些property可配置?(referenceProperty、stringProperty、mapProperty、……)
l 哪些properties是必须配置的,哪些是可选的?
l 每个property有什么要求?(例如:referenceProperty要求接收一个OtherBean对象、stringProperty可选的值为:value1、value2等)
l 它依赖哪些其它的Bean?(MyBeanClass依赖OtherBeanClass,必须把OtherBeanClass注入到MyBeanClass.referenceProperty属性中)
从本质上讲,写这样的 配置文件,其对于服务代码的耦合度并不逊于完成同样功能的一段程序对于服务代码的耦合度。耦合所带来的负面效果也是相当的:当服务被改变时,这段配置有可 能不能工作。有人说,当服务改变时,我可以修改配置,至少我不需要重新编译代码。是的,但是如果你的应用程序中有许多这样的配置、如果你的服务被大量应用 使用时,我如何保证所有的修改都正确、不会漏掉什么?在程序代码的世界里,你至少有编译器可以帮你检查,在这里除了多写一些单元测试以外,可能没有别的方 法来帮助你了。
Spring有两种方法可以减轻这种耦合的危害:1) Auto-wire;2) 基于XML Schema的配置。
Auto-wire技术可以减少对properties的配置,但是它也造成了对于被注入对象的过多假设(例如假设被注入对象的ID必须和property setter方法同名),和不确定性(我不清楚哪些对象被注进来了,它们都正确吗?)。这给配置人员造成不少困 惑。
基于XML Schema技术来 配置是Spring 2.0的新功能。通过这 种方法,你可以把没有意义的bean的定义, 转化为更简洁更有意义的XML配置,从而部分 解决了“如何配置一个bean的property的问题”。然而,它还是没有解决根本的问题,因为:1) 它仍然是一个基于bean的配置,只是改变了形式;2) 正如Spring自己的文档所说,这种形式只适合于infrastructure or integration beans,例如AOP、事务、集合、和第三方包的整合。它并不适用于任何类型的beans;3) 定义一个schema并不是很简单易行。
2. 服务提供者依赖于用户
也许Spring的设计者也意识到让用户来依赖服务提供者,有时是不可行的。
举个例子:Spring有一个MessageSource的功能来实现国际化的功能。MessageSource被如此广泛地使用,甚至Spring容器自身也在使用它,以至于我们根本就不应该让用户来关心“有哪些bean用到了MessageSource”。换句话说,用户无法依赖服务提供者。既然如此,只好反过来:让服务的提供者依赖用户。Spring是这样实现的:
在AbstractApplicationContext类的初始化过程中,调用getBean(“messageSource”),从容器中取得ID为“messageSource”的bean。
这样,耦合关系就变成 了服务提供者依赖用户了。服务提供者(AbstractApplicationContext)对用户作了如下的假设:
l 假设在容器中只有一 个MessageSource bean。
l 假设bean的ID为“messageSource”。
一旦这种假设不成立,系统就不能正常工作。
再举一个例子。Spring MVC是一个基于Spring实现的WEB框架。它的基本结构是一套设计优良的层次状结构:
由于Spring希望能够尽可能简化用户使用Spring MVC的方法,减少配置,提出了Convension over Configuration的概念,即约定胜于配置。怎么约定呢?以HandlerMapping为例。
DispatcherServlet必须拥有一系列(1..*个)的HandlerMapping,而HandlerMapping是定义在Spring容器中的。为了简单,由于DispatcherServlet很复杂,当然不能够让用户去关心DispatcherServlet的结构,只能让DispatcherServlet主动到容器中搜集所有的实现了HandlerMapping接口的beans(利用ListableBeanFactory接口)。用户只要在容器中定义一个HandlerMapping,就能自动生效了。
然而,在这种情况下, 作为服务方的DispatcherServlet,仍然对于它的用户作了过多的假设:
l 假设容器中所有实现HandlerMapping接口的对象都为我所用。
假如我需要定义两个DispatcherServlet,各自有各自的HandlerMapping。那么我没有什么方法可以告诉Spring,哪些HandlerMapping是属于某个特定DispatcherServlet的。假如我们拿到一套这样的系统,我们有什么方法可以知道应该提供哪些beans、应该实现哪些接口呢?
此外,在这种依赖关系 中,我们发现Spring回到了非“IoC”的时代。IoC是轻量化的 —— 应用模块不依赖于框架,从而简化模块的开发和测试。而现在,我们看DispatcherServlet,它必须依赖Spring容器,并调用容器来主动取得其它对象。这回到了ServiceLocator的模式。这样的系统不能离开Spring运行和测试,从而违背了Spring轻量级框架的初衷。
抛开Spring自己提供的功能不管,在我们实际应用开发中,我们也常需要主动调用(依赖)Spring容器,来取得其它beans,而不是使用“注入”的功能。Spring提供了BeanFactoryAware、ApplicationContextAware等接口,向你的bean注入Spring容器本身。然而使用这些接口实在是迫不得已 —— 它使你的模块 依赖框架,从而不再“轻量”。
仔细想来,现在的情景仿佛回到了我们最早的例子:Copier直接调用(依赖)KeyboardReader和DisplayWriter。
不同的是,现在不是程 序上的依赖,而是角色的依赖。但它们造成的后果是类似的。
有没有办法既让服务的用户不太关心服务本身的细节,又避免让服务提供者来依赖服务的用户呢?有办 法!办法完全类似于Copier例子的解决方 案:DIP和抽象。
对于程序而言,抽象 可以是一个接口或是抽象类,那么,对于角色的耦合 —— 角色之间的约定应该怎么抽象呢?这个抽象就是:扩展点(Extension Point)。我们可以定义一个扩展点,来告诉服务的用户,哪些是他需 要关心的。
经过改进以后,DispatcherServlet和HandlerMapping之间的关系变成如上图所示:
l 用户不需要了解DispatcherServlet服务的细节,例如:实现类是什么?如何初始化?有哪些properties?等问题。
l 用户甚至不需要了解 它谁使用了这个扩展点。
l 用户需要了解的是扩展点所定义的内容。
l 作为服务提供方的DispatcherServlet,它也不需要对服务的用户做无端的假设。它只要把对使用者的要求,定义在扩展点中即可。
扩展点就是一个可被 机器检查的角色间的约定。这不是DIP原则在 角色的耦合上的应用吗?
3. 多层次服务 的问题
一个稍微复杂的服务,如果设计良好,应该是层次化的。每个层次至少代表一个模块。
再次审视Spring MVC。Spring MVC设计优良,类之间层次分明(参见前面的图)。以Spring MVC的核心组件Controller为例。Spring的优良设计体现在,它并没有直接引用Controller,而是通过一个SimpleControllerHandlerAdapter适配器来引用它。这样,就留给其它实现者一个余地:只要开发一个适配器,就可以适应任何其它形式的 处理器。例如:我应该可以写一个StrutsActionHandlerAdapter来处理Struts的Action。
然而,Spring容器只支持beans,而所有beans都是平级的。这意味着:
l Beans是无法划分层次的。假如我把所有层次中的类全都配在Spring容器中,那么除非查看源码或文档,否则你没有办法看清beans之间的设计思路。
l Beans之间的依赖是无法约束的,而层次化的设计要求层级之间有一定的约束,例如:下层不能依赖上层。层次之 间还有一些可见性的约束 —— 有些内部对象不希望给外部层次知晓。
怎么解决这些问题呢? 看看Spring MVC是怎样解决的吧。
Spring MVC显然希望把用户的麻烦降到最低,因此它做的第一件事是:设置默认值。例如:HandlerAdapter的默认实现包括SimpleControllerHandlerAdapter;HandlerMapping的默认实现是BeanNameUrlHandlerMapping。这样大多数用户不需要理解上层Adapter的细节,直接写一个类实现Controller接口并放到Spring容器中,就可以用了。而BeanNameUrlHandlerMapping是这样一种mapping:将URL直接映射到同名的controller。例如:URL为“/test”,并且你也定义了一个controller:
那么,DispatcherServlet就会去找到并调用这个Controller(其实是通过SimpleControllerHandlerAdapter间接调用)。
用户的工作在这里被减 到了最少,剩下的事全是由Spring MVC的 默认值来完成的。从Spring MVC的类 图来看,用户关心的内容被尽可能限制在比较低的层次,而上层的复杂性被掩盖了。
假如你希望突破默认 值,而使用其它的方案,那么配置就会变得复杂起来。
另外一个问题是,Spring MVC如何创建默认的方案?不需要用户的配置,Spring MVC就会自动创建SimpleControllerHandlerAdapter、BeanNameUrlHandlerMapping等原来需要由用户创建的beans。但因为这些beans并不在Spring容器里,所以Spring MVC无法用IoC“注入”的方式来创建这些beans。创建这些beans的方法属于“另一个世界” —— Spring MVC用了一 个properties文件来定义和配置这些 对象。
这实际上是一个普遍的问题:利用Spring来开发服务时,如何在对用户保持简单的情况下,又保持良好的但可能会比较复杂的类层次结构?大部分作 者都采用了类似Spring MVC的做法 —— 在 “另一个世界”里创建和维护用户不需要关心的对象,只留出一个相对“扁平”的层次给用户。
这导致了两个结果:
l 第一个结果:由于服务本身的复杂性都被封装在“另一个世界”里,因此服务本身的设计不能利用Spring IoC的便利。
l 第二个结果:我只能在Spring中使用相对“扁平”的类层次结构,否则就会给用户的使用带来麻烦。
第一个结果导致我们难 以利用Spring IoC的便利来开发可重 用的服务。
第二个结果导致Spring只能适用于(或者说擅长于)结构较扁平的服务。一般企业应用的最底层模块,就属于此列,比如:Web层的Action、业务层的业务逻辑模块、数据访问层的DAO等,都是属于层次很少的扁平结构。事实证明,Spring最成功的地方也在这些领域。