如何设计优秀的API
译者:周林
时间:31-08-2007
( 版权所有,未经译者同意谢绝转载 )
摘要:
Programming Interface)。
目录:
1.
2.
3. 面向用例(Use Case Oriented)的重要性
4. API的生命周期
5.
6.
为何要有API
Interface).在描述和建议如何实现API之前,没有理由不分析一下这个名字的意义。
“接口”(interface)这个词表明API介于至少两个客体之间。举个例子来说,某个应用程序内部的数据结构对该应用程序是透明的,而其他的应用程序只能通过外部调用间接使用该数据结构。再举个例子,某个程序员或者开发团队开发了一个应用程序以及相应的API,而其他的程序员使用这些API。我们可以看出,在这两个例子中都存在彼此独立的双方——一方独立地进行编译,一方由完全不同的开发团队,根据他们自己的进度安排,目标和需要,进行开发。
正是“分离”(separation)一词精确地暗示了设计和维护API的准则。如果不采用“分离”的思想,整个产品由一个高度耦合的团队进行开发,一次build,
那么就没有必要引入API和撰写本文了。但是在现实世界中,产品是由彼此独立的工程(Project)组合起来的,每个工程由不同的团队来开发,他们没有必要彼此认识。虽然他们有完全不同的进度安排,独立地build各自的工程,但是他们可以相互交流。Stable
Contract就是用来达到这种交流的一种手段。
例子:虽然Mandrake和Redhat是Linux版本的生产商,但是这些Linux版本实际上是由成千上万个的独立的开源工程组成的。这些版本的生产商并不干预这些开源工程的开发者的开发工作,仅仅在给定的时间,提取这些工程中稳定可用的部分,整合后生成发行版本。
什么是API
. 方法和字段的签名(method and field
signatures)
应用程序之间的相互通常是通过如下的方式展现的:函数调用以及数据结构的传递。如果方法的名字,参数,或者交互用的数据结构改变了,那么整个项目通常不能够链接成功,正确运行。
their
content)
许多应用程序都要读取各种各样的文件。它们的内容会影响它们的行为。设想一个应用程序,在调用它之前,需要有另一个程序来读取它的配置文件并以此来修改它的内容。如果文件格式改变了或者文件被完全忽略了,那么这两个应用程序之间的交互就断开了。
varibals)
例如,CVS会受变量CVSEDITOR的影响。
协议(protocols)
为如下的操作建立API给其他应用程序使用:打开一个Socket用来解析数据流;把读取的数据放入剪贴板中;拖放操作……
行为(behaviour)
有点难掌握但是对于“分离”非常重要的一点是动态行为:程序流如何,执行序列是怎样的,哪些锁在调用期间要保持,在哪些线程里调用可以发生,等等。
message)
通常进行本地化成某种语言工作的人并不是代码的作者,所以他们必须使用同样的关键词(NbBundle.getMessage
("CTL_SomeKey"))。代码的作者和翻译者之间应该达成契约(对API进行排序)。
特别要注意某些API,它们和分布式开发活动有关,其他代码可能依赖它们。只有认识了自己应用程序的这些方面,开发才不会影响到参与合作的其他的应用程序。
面向用例的重要性
另一方面,工程(至少应该)由工程师来完成,工程中很重要的一个方面是可度量性(measurability)。所以设计的最终目标是使其可度量,排除主观上的东西,定义出可以度量设计质量的需求集合来。当然,定义需求集合的时候需要主观意见,但是一旦需求被文档化,工程师就是纯粹的工程师了,用纯粹的科学方法来度量哪些需求可以被满足。
正如上面好程序/坏程序的例子所展示的那样,用户的主观感受是很重要的。对于设计也是如此。但是对于API来说,由于它是应用程序内部实现与该应用程序功能使用者之间的接口,所以这种主观感受来自使用API的程序员。他会评判设计好坏与否。当然,这种评判因人而异,这取决于学习设计与使用API期间获得的经验。
越能让API的使用者减少所需要的工作量,这样的设计越能得到高的评价。程序员更多关注的是学习API的时间,完成工作所需要的代码量以及API的稳定性。要设计好的API就要平衡这些相互矛盾的需求。
通常为了赢得更多的使用者,更好地提高使用者的开发效率,要对API的设计进行优化。一般说来,API使用者的数目远远大于API实现者的数目,如果能简化API使用者开发的话,即便是API的实现复杂一点也可以接受的。为了更好地表达使用者的需要,理解使用者的需求是很有必要的。如果设计出来的API能简化普通任务的实现,那么它就是一个好的API。
这就是为什么在API设计的初期阶段要调查和收集用例的原因。一旦这些用例文档化了,就可以对API的每个方面进行评估,确认设计。虽然用例在实际中不可能用来评判设计质量,但是至少可以很容易地检查设计有没有满足这些用例。
API的生命周期
.
自然形成的(spontaneously) ——
某人开发了一种功能,另一个人发觉这种功能很有用并且开始使用它。之后他们开始交流,共享他们的经验,而且很有可能发现该功能之前的设计并不是十分通用,或者说还不至于形成真正的API。为了让该功能的设计向API演进,他们开始讨论如何把该功能做得更好。几次改进之后,便会形成一种有用的,稳定的版本。
. 人为设计的(by
design) ——
在系统的两个组件之间存在某种已知的契约。经过需求采集,问题域调研,用例理解之后,某人开始着手设计和实现API。最终该API会形成一种有用的,稳定的版本。
尽管上述两种情况的出发点不同,但是它们有共同的一个特性:在API正式开始被用户使用之前,它们都需要一段时间接受反馈和评估。并不是所有的努力都会以诞生稳定的API为回报;有时最终不得不放弃之前所作的所有努力。
为了清楚地知道API的设计处于哪个阶段,它是否还在发展,它是否可以最终成为一个真正的API,以及它是否很稳定可被使用,让我们引入一个稳定性分级系统
(a
system of stability
classification)。该系统的目标是:提供一个让API实现的代码作者与需要该API功能的用户之间进行交流的途径。
. 私有性(Private)
——
私有性是一种在其组件外部不可访问的属性。在新版本中对这些属性进行修改是有一定风险的,应该尽量避免。
. 友元(Friend) API
——
这种API是为系统中某些指定组件之间的访问服务的。它可以用来解决缺乏真正稳定的API的问题。但是它仅仅只可以用在那些互为友元的组件之间。友元组件常常由同一个开发团队的人来开发。虽然每个发布版本中组件组件之间的友元关系可以改变,但是必须提前通知这些友元组件的宿主(owners
of those friend components)。系统中的其他非友元组件并不依赖该API ——
该API的开发者并没有打算让它成为一个全局通用的API。
. 开发(Under
development) ——
这里“开发”的意思是:正在为实现一个稳定的API而努力,但是还没有完成。当前状态是已经有了大体概念,大家开始着手进行开发工作,并通过邮件列表(mail
list)进行联系。版本更迭允许非兼容的更改,但是应该尽量少,并且这样的更改应该是非基础性的,除此之外,还应该通过邮件进行公开声明。
.
是指那些已经完成而且维护人员打算永久支持,决不进行非兼容性更改的API。“永久”和“决不”不是绝对意义上的;这些API可能会有更改,但是只会在某些主要版本上进行,并且这些更改必须是经过深思熟虑,不得不改的。
.
官方的(Official)API
——是指那些已经稳定的,并且被包装进NetBeans的一个官方命名空间(如:org.netbeans.api, org.netbeans.spi 或者
org.openide)的API。把一个API放进一个包中,必须向其他包声明该API是稳定的 ——
随后也是稳定的(不包括对早期的7个模块的有条件支持,这7个模块对应的代码库的名字都以/0结尾)。此外,尽量减少对官方API非兼容性的更改,即便是源代码级不兼容了,也要保持二进制级别上的兼容。
. 第三方(Third
party)接口 ——
它们是由不遵循NetBeans规则的其他组织开发的,因此很难对它们进行分类。为了不让NetBeans
API的用户受到这些接口的改变带来的非预期的影响,最好不要把它们作为NetBeans标准API的一部分。
. 标准(Standard)
——
是和上面“第三方”相似的一个概念。也是由NetBeans之外的人提供的。但是它与NetBeans相兼容(例如JSRs)。人们不希望“标准”经常性地被更改。
.
过时的(Deprecated)API ——
不久,几乎所有的API,不管它现在怎么样,最终都会被废弃。通常,对某个功能支持更好的新版本的API被开发出来后,都会取代对应的老版本的API。这种情况下,就把这个老版本的API标记为“过时的”。这个以前是稳定的,而现在被标记为“过时的”API应该继续被支持一段时间,以便用户可以从这个“过时的”API过渡到新的API。之后,这个“过时的”API会被彻底删除,而通过其他替代方式对使用该“过时的”API的客户提供支持。
在本章节开始的时候,提到了两种开发API的方式。“自然形成”式,根据上面提到的API的类别,一开始是作为私有的或者是友元的API被引入,其他人发现这样的API很有用,然后推动它向稳定的API发展。而“人为设计”式,很有可能一开始就处于“开发”(Under
development)状态,而后逐渐完善成稳定的API。
投资保值(Preversation of
Investment)
NetBeans最重要的一点是照顾到了它的合作者。模块开发者,平台扩展者,参与者以及其他相关人员,无论什么时候,都不用担心他们的成果在新版本的NetBeans上不能运行。他们的工作应该得到尊重和赞许。只要NetBeans还在成功的一天,它的合作者就可以与其他人分享经验,推动NetBeans社区的发展。因为系统的各部分之间通过公有的接口(API,
SPI,
注册位置以及已定义的功能行为)进行交互,这种使参与者投资保值的方式以向下兼容地推动这些接口的发展。NetBeans的每个新版本应该保证以前版本的所有模块可以正确运行,即使不能运行,也应该可以很容易地更新以前的源代码,来编译并使用新版本的接口。
可维护的(Maintained)与不再维护的(Unmaintained)
之前版本的模块仍然要保持可以运行的另外一个原因是:某个模块设计得非常成功,用户体验很棒,但是它没有维护了。这种情况由以下原因导致:模块开发者离开了致力于其他项目,或者创建该模块的公司不复存在了。甚至netbeans.org上的一些项目也是如此,虽然没有维护了,但是仍然很好地被用户使用。如果NetBeans发布的新版本引入了一些非兼容的更改,以至于一些模块不能正常运作的话,那么NetBeans的开发者将会被责备,并感到丢脸。这就是为什么说向前兼容是很有必要的原因:必须尊重已经开发出来的劳动成果,即使它们中的一些已经没有继续被维护了。
例如,更改API的原因之一是提高它们的性能,任何模块的开发者都会有这种考虑。这应该很容易做到,很多情况下不需要很大的工作量。但是在某些情况下,即使在发展API的过程中投入了很多的注意力,这样的更新也需要很大的工作量。如果某个人在维护一个模块,那么人们希望他所作的必要的更新,应该与当前API集合保持一致。
示例(Examples)
classpath的改变),仍然允许用户可以使用用老版本开发的某个模块,而不出什么问题。在这种情况下,用户唯一要做的事情就是重新设置文件系统的根目录,来匹配新的classpath。
Node.Cookie maker接口,它对Cookie的使用进行了限制 —— 能否使用取决于nodes packet,而nodes
packet并不是非要不可的。所以这个接口应该被删除。Node.Cookie的Node.getCookie(Class)方法被Object的Node.getCookie(Class)所取代。即使在这种改动之后,仍然可以保证老的模块可以正确运行。另一方面,以前正确的源代码不能再编译了。99%的使用Node.getCookie方法的用户会继续用如下的方式编译代码:
MyCookie c = (MyCookie)node.getCookie(MyCookie.class);
剩下的1%的用户会如下进行编译:
Node.Cookie c = node.getCookie(something);
后者的方式才是正确的。模块的开发者很乐意看到这样的更新,因为这样的更新使他们开发的类更有弹性,而且这种更新很简单并不复杂。当然,这种更新应该作为新版本的闪光点而被说明。
接口 vs. 抽象类(Interfaces vs. Abstract
Classes)
—— 用例或者需求都不相同。下面我们从用例的角度来看这个问题。
使用接口的好处(The Advantages of
Interfaces)
class)”,在支撑类中有一个子类,它重用了某个父类的实现。
使用抽象类的好处(The Advantages of Abstract
Classes)
它可以增加一个有缺省实现的新方法而不影响已有的客户和实现方(在这里我们谈的是运行期的兼容性,而不是编译期的兼容性)。接口不具备这种能力,所以必须引入另一个接口来提供扩展功能,如:interface
BuildTargetDependencyEx extends
BuildTargetDependency。这种情况下,原始的接口仍然有效,新的接口也可用。
用例(Use cases)
TopManager
包和这些包在org.netbeans.core里的实现的纽带。该manager(由core提供)只有一个实例,并且该API的客户不应该扩展或者实现它。
分析表明:TopManager是为客户提供一系列有用的方法,但是对这些方的实现有完全控制权的典型案例。客户应该把精力放在对该API的使用上面,动态地去发现其实现(该API在编译单元openide里,而其实现在另一个编译单元core里)。
在这种情况下,和抽象类相比,使用接口没有任何优势。抽象类可以有工厂方法,可以增加新方法,可以有效地将API与其实现相分离,可以防止除默认实例之外的实例化的发生。如果你也面临类似的情况,最好使用抽象类。
Cookies
OpenCookie opencookie = (OpenCookie)anObject.getCookie(OpenCookie.class);
if(openCookie != null) {
}
—— 因为该接口只有一个方法。除此之外,也没有必要提供工厂方法,没有必要担心子类化问题。综上所述,设计成接口是正确的选择。
InstanceCookie。它也是一个接口,在以前的老版本里有三个方法。但是在发布了几个版本之后,我们意识到有必要改善该接口的性能,所以我们不得不引入一个子类InstanceCookie.Of
extending
InstanceCookie,并且为它增加了一个instanceOf方法。当然,这样的更改没有问题,但是给使用该接口的用户带来了不少麻烦。每个使用该API的用户都必须如下编码:
Boolean doIAccept;
InstanceCookie ic = (InstanceCookie)obj.getCookie(InstanceCookie.class);
if(ic instanceOf InstanceCookie.Of) {
((InstanceCookie.Of)ic).instanceOf(myRequiredClass);
} else {
}
Boolean isInstanceOf(Class c) {
}
我们并不把那三个方法放进该接口,取而代之的是仅仅增加一个返回包含所有必要信息的类的方法:
Interface InstanceCookie {
}
当然为了使这样增加方法的处理是安全的,最好把这个类声明成final类型,并且为InstanceCookie的实现方提供工厂方法。这样的工厂方法可以有两种:一种很简单,比方说给instanceName,instanceClass和instanceCreate方法准备好返回值;另一种会使用另一个接口,该接口中的方法会来处理像info.instanceCreate这样的方法调用。具体采用哪一种取决于API用户的需求。
文件对象(FileObject)
API的一部分)。它的用法似乎和TopManager的例子很相似(其实不然):很少有人直接子类化FileObject(Java规范中的HttpFileSystem,Kyley和Niclas),但是使用该客户API的人却很多。直接子类化FileSystem的人也很少。由此看来,似乎应该把FileObjct和FileSystme作为抽象类,但是事实上是作为接口的。此外,有一个支撑类AbstractFileSystem,它是FileSystem的子类,用来实现FileSystem类。因为它是一个支撑类,所以它必须是一个具体的类或者至少有一个工厂方法,但是实际上它提供了五个接口(Info,
Change,List,Transfer)。这五个接口并没有在FileSystem这个客户API中暴露出来。FileSystem
API的用户可以自己实现FileSystem。事实上很多时候都是这样做的,而且还可以使用多继承。因为AbstractFileSystem实现了FileSystem这个客户API,所以任何子类化了FileSystem的用户都可以放心:他们不光实现了FileSystem,也实现了FileSystem。
CloneableEditorSupport
—— 即便是在实现代码中也只能使用API的方法,而不能hook非公有类型的方法。
接口还是抽象类?(Interface or
Classes)
从以上两个方面可以看出:对于客户API用抽象类要好一些;而对于服务提供者API来说,用接口要好一些。如果使用该API的用户仅仅只是调用它的话,那么最好就用抽象类;如果仅仅只想让用户调用它的子类的话,那么最好用接口,这样当子类化的时候,使用该API起来比较安全,简单。如果你面临的情况介于以上两者之间的话(根据“将Client API 与 Provider API(SPI)
分离”那个章节所说的,这种情况是禁止的),那么最后的抉择取决于你,但是你在下最后的决定之前,要仔细判断考量哪些是用户经常会用到的
—— 仅仅只是调用一下还是需要子类化。这样的话,你的选择才是恰当的。
将Client API 与 SPI 分离的学习示例(Case Study of client
API and SPI seperation)
messageName(),String messageModified()和String
messageOpen()这样的抽象方法。为了实现这些抽象方法,子类可以调用一些像protected final UndoRedo.Manager
getUndoRedo()这样的支撑方法,并且可以使用像protected Task
reloadDocument()这样的方法来与父类的实现进行交互。以上整个过程已经很复杂了,但是以下的事实会让其变得更加复杂:几乎所有的方法都可以在子类中被覆盖(overriden)。这使得局面变得很混乱,而且将来几乎没有办法再对其进行扩展了。
把protected类型的方法移到接口里面(Move Protected Methods Into Interface)
public interface CloneableEditorProvider {
}
EditorFactory.createEditor(CloneableEditorProvider p);
该工厂方法可以把服务提供者接口转换成所想要的客户API(这种处理很简单,不然的话,真正的API必须通过一个参数Class[]来支持多种Cookie的创建,如:OpneCookie,EditorCookie等等,这个Class[]参数用来为不同的Cookie指定不同的返回值)。从功能上讲,这相当于提供了一个包含所有应该在子类中实现的方法的类,而且它还确保任何人都不能通过把EditorCookie转换成CloneableEditorProvider来调用一些特殊的方法,因为createEditor方法必须返回一个新的对象,来提供它的功能。
发通知给实现方(Passing Notifications to
Implementation)
不能调用reloadDocument或者任何相似功能的方法。为了说明这一点,我们增强了CloneableEditorProvider接口:
public interface CloneableEditorProvider {
TooManyListenersExceptio
l);
}
private ChangeListener listener;
public void addChangeListener(ChangeListener l)
ToomanyListenersExceptio
}
实现方的回调方法(Callbacks to
Implementation)
不能通过CloneableEditorSupport.getUndoRedo来得到UndoRedo。为了支持这种功能,我们不得不对CloneableEditorProvider再做一次修改:
public interface CloneableEditorProvider {
ToomanyListenersExceptio
implementation
}
请注意:Impl类是声明为final类型的,任何从CloneableEditorProvider接口的实现方调用的方法都是CloneableEditorProvider接口里面的方法。从服务提供者到工厂的反向通信被独立出来放在CloneableEditorProvider.Impl类中。现在的CloneableEditorSupport,乍眼看来比之前的CloneableEditorSupport复杂很多,但是代码关系显得清晰多了。
可扩展的客户行为(Extensible Client
Behaviour)
EditorFactory是实现这种功能的好地方。可以提供一些同步访问和死锁等等保护吗?在EditorFactory里实现这些功能是最佳选择。
服务提供者与其实现之间的可扩展性交互(Extensible Communication
between provider and implementation)
Object {
}
可扩展的服务提供者的进化(Extensible Provider
Evolution)
Interface CloneableEditorProvider2
user */
}
EditorCookie EditorFactory.createEditor(CloneableEditorProvider2
Interface PaintProvider {
}
/** Based on a ability to paint creates new EditorCookie */
EditorCookie EditorFactory.createEditor(PaintProvider p);
玩NetBeans核心开发团队开发的游戏来提高API的设计水平(Using games to
Improve API Design Skills)
Fest的文章,来了解一下API Fest游戏。该游戏是由NetBeans核心开发团队开发出来的,玩该游戏可以提高API的设计水平。
| -- |
设计实践
(Design Practices)
现在我们来谈谈Java的设计实践与设计模式,这两者有助于开发者和维护者的工作符合前几个章节所提到的准则,用户体验佳。
不要暴露过度 (Do not expose more than
we
want)
显而易见,API暴露的内部实现越少,将来的弹性就更好。有不少窍门可以用来隐藏内部实现,但是不影响到其API的功能。这一节我们就来谈谈这些窍门。
方法优于字段 (Method is better than
Field)
setters)来访问字段,而不要直接暴露字段。这样做的原因之一是:调用方法可以做很多额外的事情,比如限制字段为只读或者只写。使用getters,可以进行例行的初始化,同步访问,以及利用某种算法对数值进行组织。另一方面,setters可以对字段的赋值正确与否进行检查,还可以在字段的数值改变时通知相应的监听器。
使用方法的另一个原因在于Java虚拟机规范。该规范允许将一个方法从子类移到父类中而不破坏二进制级别上的兼容性。因此,一个最初像如下形式引入的方法在新版本中可以被删除:
(Dimension d)
在新版本中,它被移到
d)
JComponent 是 Component 的子类(以上真实发生在JDK
1.2版本中)。但是类似的操作对字段是禁止的。一旦在一个类中定义了某个字段,该字段就永远不应该被挪动位置,以保证二进制级别上的兼容性。这也是最好把字段定义为私有属性的原因。
工厂优于构造器 (Factory is better than
Constructor)
导出工厂方法比导出构造器更有弹性。一旦构造器作为API的一部分,那么它可以保证生成的实例是而且仅仅是对应类的实例,而不是其子类的实例。另外,每次调用构造器的时候都会生成一个新的实例。与之相对应的工厂方法
(通常工厂方法实现成一个静态方法,该方法的参数与构造器的一模一样,也返回构造器所在的类的实例)
有诸多不同:首先,工厂方法并不是简单返回指定的类的实例,而是使用了多态
(polymophism),另一个优势在于工厂方法可以缓存实例。构造器每次都生成新的实例,而工厂方法可以缓存之前生成的实例来进行重用,这样可以节省内存。另一个原因是:调用工厂方法可以进行合适的同步,而构造器不能。以上这些便是选择工厂方法要优于构造器的原因。
所有的API都应该定义为Final属性
(Make Everything Final)
的问题,在设计时也没有进行保护。如果你在开发一个API,但是你不希望别人进行子类化你的接口 (可以参考 API vs. SPI
一节),那么最好显式禁止子类化。
(对应的工厂方法也应该这样处理),或者把所有 (至少大多数的) 方法声明为Final或者私有类型的。
当然这样做只在类级别上有效,如果你开发的是接口,那么就不能阻止在虚拟机级别上对该接口进行外部实现,你只能要求制定Java规范的人不要这样做。
只赋予友元代码(friend code)访问权限
(例如,实例化某个类或者调用某个方法)。
默认情况下,Java要求互为友元的类必须在同一个包中。如果你想把某个功能共享给同一个包中的其他类,马么你可以给构造器,字段或者方法加上package-private修饰符,这样的话,只有友元可以进行访问。
但是有的时候,更有用的方法是将友元集合扩展到更广的类范围中 ——
比如,有人把API的纯定义放在一个包中,而其实现放在另一个包中。这种情况下,下面的方法非常有用。假设有一个类item
(顺便说一下,你可以直接从CVS上check out源代码):
public final class api.Item {
this.value
= value;
{
some
impl
}
以上只是item的部分代码,但是已经可以防止友元(这些友元类不仅仅只在 api
包中)之外的类对其进行实例化或者监听事件了。接下来的代码在非api包中定义了一个Accessor:
public abstract class impl.Accessor {
DEFAULT;
Item.class
field above
(c.getName (),
true, c.getClassLoader ());
false : ex;
field
must be initialized”;
}
/**
public abstract Item newItem (int value);
/** Accessor to listener */
public abstract void addListener (Item item, Listener l);
}
上面的抽象方法用来访问Item类的友元功能,静态字段用来得到Accessor的实例。Accessor的具体实现是通过api包中的一个非公有的类来实现的:
final class api.AccessorImpl extends impl.Accessor {
{
}
(initializer),这个初始化器为首次接触api.Item的人的注册了一个默认的实例:
public final class Item {
impl.Accessor.DEFAULT
= new api.AccessorImpl ();
above
}
Api.Item item = impl.Accessor.DEFAULT.newItem (10);
Impl.Accessor.DEFAULT.addListener (item, this);
manifest) 里 (OpenIDE-Module-Public-Packages:
api.**)。这样做的话,可以在类加载的级别上,阻止来自impl.Accessor之外的访问。
将Client API 与 Provider API(SPI) 分离 (Separate API
for
clients from support API)
API的种类是否不止一种?如果是这样的话,如何对它进行分类?是否也要对API的使用者进行分类?他们是不是有不同的目标?本章的第一节将回答以上这些问题。然后我们将定义进化不同类型的API的时候所要遵循的约束,除此之外,我们还会介绍一些帮助用户遵循这些约束的窍门和知识。
Client API vs. Provider
API
在正式开始之前,我们应该问一个问题:谁是客户(Client),谁是服务提供者(provider)?让我们用XMMS的例子来说明。XMMS是Unix平台上的一款多媒体播放器(在其它平台上叫做Winamp)。
该播放器可以播放音频文件,在前后歌曲之间快进,还提供了一个可以增加,删除和录制歌曲的播放列表。不光普通用户可以直接使用该播放器的功能,其他的程序也可以对其功能进行访问。所以一个外部程序可以调用xmms.pause()或者xmms.addToPlaylist(filename)。在这种情况下,交互是由调用播放器API的外部程序发起的,该程序调用这些API来完成某些操作。调用结束后,控制权返回给调用者。我们把调用者称为“客户”,
而被调用的API称为“客户API”(Client API)。
另一方面,XMMS API支持第三方的插件(output
plugins)。通过这种方式,可以提供一个方法对播放器的功能进行扩展:把播放过的数据写进磁盘,网络广播,等等。在这种情况下,交互是由播放器自身发起的。在收集到了足够用来回放的数据之后,程序将定位对应的插件,把数据发送给它进行处理:plugin.playback(data)。插件在完成了回放操作之后,把控制权返回给播放器,播放器继续收集数据,进行后续的操作。那么插件是个“客户”吗?它完全不同于上一段中提到的“客户”的概念。它并没有指示XMMS做任何事情,而是增强了XMMS的功能。所以插件并不是一个“客户”。XMMS支持插件的功能称为“服务提供者接口”(Service
Provider Interface, SPI)。
API/SPI在C和Java语言中的表达
}
但是使用后者会有更多的选择:可以把上面的那些方法声明成静态方法,实例方法,抽象方法,或者final方法,等等。但是总的来说,C语言和Java语言在处理client
API方面很相似。但是在实现SPI方面却大相径庭。
void my_playback(char *data) {
}
播放器必须提供注册方法,例如:
void xmms_register_playback((void)(f*)(char*));
和回放函数将在需要的时候被XMMS调用。用Java语言的话,要在一开始的时候定义回放功能的接口:
Interface XMMS.Playback {
}
XMMS.Playback接口,并且向播放器注册实例:
XMMS.registerPlayback(new MyPlayback());
此时,播放器就可以像在上述用C语言开发的情况一样调用插件的功能了。用这两种语言写出的代码,最主要的不同,在Java课程中已经阐明过了,这里不再赘述。但是在Java中,如果声明的方法不是私有的,静态的,或者final类型的,那么该方法实际上是一个回调方法(callback),因此它是一个SPI。程序员或者教员经常不能很好地理解这一点,因为这和传统的编程经验很不一样。几乎所有的Java教科书都会在最开始的章节介绍公有的,非静态的,非final类型的方法(至少一开始讲Applet的时候就会介绍这些方法),却没有对像这样学习会带来的后果给读者以警示。如果只是开发简单的程序,像这样学习没有任何问题,但是如果是要设计API,那么在这样的学习初期所养成的编程习惯会带来恶劣的后果。
API的进化过程不同于SPI(Evolution of API
is a different
process than evolution of SPI)
进化是任何契约很自然的一部分。不管什么东西,经过时间的考验,都会过时淘汰。API和SPI也不例外。所以最好在一开始的时候就为它们的进化做好准备,避免在将来让棘手的错误浮出台面。
用户可以选择不使用扩展功能。
对于SPI,情况完全相反。如果在接口中添加一个新方法,那么在以前程序中实现该接口的地方全部得重写。因为这个新方法在以前的程序中并没有被实现。另一方面,停止对SPI中某个方法的调用(实际上是把该方法从SPI中删除)是可以的,不应该对程序产生影响。当然前提是:程序不再需要该方法提供的功能。
综上所述,进化依赖于接口的类型:能扩展其功能但不能删减已有功能;可以删减功能但是不能扩展其功能。在一开始设计接口的时候,就要搞清楚哪些应该被设计成API,用来给用户调用的;哪些应该被设计成SPI,用来扩展已有功能的。最忌讳的是把API和SPI放在同一个类中。这样的话,就不能对其进行进化了
—— 由于SPI的存在,增加方法是被禁止的;由于API的存在,删除方法也是被禁止的。
示例(Example)
API中的DataObject类作为例子。用户可以通过这个类获得文件或者文件集的逻辑表示,还可以对文件或者文件集的内容进行逻辑操作:
// locate a data object
DataObject obj = DataObject.find(fo);
// move it to different place
Obj.move(destination);
//try to open it if supported
OpenCookie o = (OpenCookie)obj.getCookie(OpenCookie.calss);
if(o != null) {
}
API)和很多只给子类(这些子类在Java规范中是protected类型的)用的方法混在一起了。这样的混合不光是没有意义的,而且使该客户API在将来无法得到扩展。此外,在这种情况下,不仅API和SPI会相互冲突,给进化带来困难,而且API和SPI之间的执行流程会导致很多程序流程相互冲突
—— 死锁。
这就是在New Data Systems
接口设计时,DataObject只保留API的原因。它被声明为final类型,完全受实现方控制。而另一个SPI提供真正的操作:
Interface DataObjectOperator {
df)
name)
clazz)
}
将API与SPI分离,并且完全控制两者之间的程序流程,我们可以对API和SPI分别进行扩展。此外,在真正的客户与服务提供者之间添加不同的pre-condition和post-condition检查。例如,可以简单地在DataObject
API中添加一个新方法 DataObject.move(DataFolder df, String
name)。该方法可以一次性地完成两个操作:移动和重命名。如果DateObjectOperator提供了新方法
moveAndRename(DataObject
obj, DataFolder df, String name)
的话,默认情况下,DataObject.move(DataFolder df, String
name)会调用该方法。
Systems可以作为优秀设计的范本:对SPI实现来说好的东西,不一定对客户API也是好的;要给客户API进化的机会,而且对SPI实现的限制要尽可能的少。
是一个抽象类而不是一个接口,所以可以为“客户”(client)增加一些final方法,比如getArtifactFile和getScriptFile,缺省情况下使用getID。目前为止看起来一切都没有问题。当然,为了支持多种artifact和属性,以后必须扩展SPI。增加对属性向下兼容性的支持很容易,但是增加对多种artifact的支持却很麻烦:我们必须废弃老版本的单个artifact的getter,引入新的getter,而且这种改变要保持对老版本实现的兼容。如果有一个final类型的类AntArtifact类,它有一个工厂方法来接收SPI接口AntArtifactImpl(或者是类似的接口)的话,那么就可以简化对多种artifact支持的处理。因为在这种情况下,我们可以创建一个新的SPI接口和一个新的工厂方法。

浙公网安备 33010602011771号