翻译时间:31-08-2007
译者:Cubie Zhou
摘要:
本文以NetBeans架构下API的设计为背景,描述了如何设计优秀的API(Application Programming Interface)。
目录:
1. 为何要有API?
2. 什么是API?
3. 面向用例(Use Case Oriented)的重要性
4. API的生命周期
5. 投资保值(Preversation of Investments)
6. 设计实践:
1. 不要暴露过度(Do not expose more than you want)
. 方法(Method)优于字段(Field)
. 工厂(Factory)优于构造器(Constructor)
. 所有的API应该定义为Final属性
. 只赋予友元代码(friend code)访问权限
2. 将Client API 与 Provider API(SPI) 分离
3. 组件间的查找与通信
4. 接口(Interface) vs. 抽象类(Abstract Class)
5. 将Client API 与 SPI 分离的学习示例
6. 玩NetBeans核心开发团队开发的游戏来提高API的设计水平
为何要有API
API的全称是应用程序编程接口(Application Programming Interface).在描述和建议如何实现API之前,没有理由不分析一下这个名字的意义。
“接口”(interface)这个词表明API介于至少两个客体之间。举个例子来说,某个应用程序内部的数据结构对该应用程序是透明的,而其他的应用程序只能通过外部调用间接使用该数据结构。再举个例子,某个程序员或者开发团队开发了一个应用程序以及相应的API,而其他的程序员使用这些API。我们可以看出,在这两个例子中都存在彼此独立的双方——一方独立地进行编译,一方由完全不同的开发团队,根据他们自己的进度安排,目标和需要,进行开发。
正是“分离”(separation)一词精确地暗示了设计和维护API的准则。如果不采用“分离”的思想,整个产品由一个高度耦合的团队进行开发,一次build, 那么就没有必要引入API和撰写本文了。但是在现实世界中,产品是由彼此独立的工程(Project)组合起来的,每个工程由不同的团队来开发,他们没有必要彼此认识。虽然他们有完全不同的进度安排,独立地build各自的工程,但是他们可以相互交流。Stable Contract就是用来达到这种交流的一种手段。
例子:虽然Mandrake和Redhat是Linux版本的生产商,但是这些Linux版本实际上是由成千上万个的独立的开源工程组成的。这些版本的生产商并不干预这些开源工程的开发者的开发工作,仅仅在给定的时间,提取这些工程中稳定可用的部分,整合后生成发行版本。
什么是API
由于API允许在开发团队和应用程序之间进行交互,它使开发过程变成了分离的,分布式的活动,所以要解释什么是API就要涵盖影响这种活动的方方面面。
. 方法和字段的签名(method and field signatures)
应用程序之间的相互通常是通过如下的方式展现的:函数调用以及数据结构的传递。如果方法的名字,参数,或者交互用的数据结构改变了,那么整个项目通常不能够链接成功,正确运行。
. 字段及其内容(fields and their content)
许多应用程序都要读取各种各样的文件。它们的内容会影响它们的行为。设想一个应用程序,在调用它之前,需要有另一个程序来读取它的配置文件并以此来修改它的内容。如果文件格式改变了或者文件被完全忽略了,那么这两个应用程序之间的交互就断开了。
. 环境变量(enviroment varibals)
例如,CVS会受变量CVSEDITOR的影响。
. 协议(protocols)
为如下的操作建立API给其他应用程序使用:打开一个Socket用来解析数据流;把读取的数据放入剪贴板中;拖放操作……
. 行为(behaviour)
有点难掌握但是对于“分离”非常重要的一点是动态行为:程序流如何,执行序列是怎样的,哪些锁在调用期间要保持,在哪些线程里调用可以发生,等等。
. L10N 消息(L10N message)
通常进行本地化成某种语言工作的人并不是代码的作者,所以他们必须使用同样的关键词(NbBundle.getMessage (“CTL_SomeKey”))。代码的作者和翻译者之间应该达成契约(对API进行排序)。
特别要注意某些API,它们和分布式开发活动有关,其他代码可能依赖它们。只有认识了自己应用程序的这些方面,开发才不会影响到参与合作的其他的应用程序。
面向用例的重要性
很多时候不难评判一个程序是好程序还是坏程序——如果它没有做任何有用的事情就崩溃了,那么它就是一个坏程序。如果程序不能编译,那么更糟。但是如果它可以运行,也可以完成预定的工作,只是有时候会崩溃,那么不能说它是一个好程序,而只能说它算不上是一个坏程序,最终好坏与否这取决于评估者的感觉。和主观感受有关系。对于设计的评判同样如此,无论是UI的设计还是API的设计。
另一方面,工程(至少应该)由工程师来完成,工程中很重要的一个方面是可度量性(measurability)。所以设计的最终目标是使其可度量,排除主观上的东西,定义出可以度量设计质量的需求集合来。当然,定义需求集合的时候需要主观意见,但是一旦需求被文档化,工程师就是纯粹的工程师了,用纯粹的科学方法来度量哪些需求可以被满足。
正如上面好程序/坏程序的例子所展示的那样,用户的主观感受是很重要的。对于设计也是如此。但是对于API来说,由于它是应用程序内部实现与该应用程序功能使用者之间的接口,所以这种主观感受来自使用API的程序员。他会评判设计好坏与否。当然,这种评判因人而异,这取决于学习设计与使用API期间获得的经验。
越能让API的使用者减少所需要的工作量,这样的设计越能得到高的评价。程序员更多关注的是学习API的时间,完成工作所需要的代码量以及API的稳定性。要设计好的API就要平衡这些相互矛盾的需求。
通常为了赢得更多的使用者,更好地提高使用者的开发效率,要对API的设计进行优化。一般说来,API使用者的数目远远大于API实现者的数目,如果能简化API使用者开发的话,即便是API的实现复杂一点也可以接受的。为了更好地表达使用者的需要,理解使用者的需求是很有必要的。如果设计出来的API能简化普通任务的实现,那么它就是一个好的API。
这就是为什么在API设计的初期阶段要调查和收集用例的原因。一旦这些用例文档化了,就可以对API的每个方面进行评估,确认设计。虽然用例在实际中不可能用来评判设计质量,但是至少可以很容易地检查设计有没有满足这些用例。
一个用例一旦被支持,就应该一直被支持下去。
API的生命周期
API的形成似乎有两种方式:一种是自然形成的(spontaneously),另一种是人为设计的(by design)。
. 自然形成的(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)进行联系。版本更迭允许非兼容的更改,但是应该尽量少,并且这样的更改应该是非基础性的,除此之外,还应该通过邮件进行公开声明。
. 稳定的(Stable)API —— 是指那些已经完成而且维护人员打算永久支持,决不进行非兼容性更改的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)
即便是迄今为止最大的更改(NetBeans 4.0 classpath的改变),仍然允许用户可以使用用老版本开发的某个模块,而不出什么问题。在这种情况下,用户唯一要做的事情就是重新设置文件系统的根目录,来匹配新的classpath。
另一方面,API是人开发出来的,即使是最好的API,在未来的某一天一定也会发现有错误。举个例子来说, 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);
后者的方式才是正确的。模块的开发者很乐意看到这样的更新,因为这样的更新使他们开发的类更有弹性,而且这种更新很简单并不复杂。当然,这种更新应该作为新版本的闪光点而被说明。