EaBIM一直以来积极响应国家“十二五”推进建筑业信息化的号召,对建筑领域的信息技术开展深入技术交流和探讨!致力于打造“BIM-建筑师-生态技术”三位一体综合资源交流共享平台,希望为BIM与可持续设计理念及技术的普及做出微小的贡献!!!

萧闫子 发表于 2014-1-10 09:54:05

如何设计优秀的API

摘要:
    本文以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);
后者的方式才是正确的。模块的开发者很乐意看到这样的更新,因为这样的更新使他们开发的类更有弹性,而且这种更新很简单并不复杂。当然,这种更新应该作为新版本的闪光点而被说明。
设计实践 (Design Practices)
    现在我们来谈谈Java的设计实践与设计模式,这两者有助于开发者和维护者的工作符合前几个章节所提到的准则,用户体验佳。
不要暴露过度 (Do not expose more than we want)
    显而易见,API暴露的内部实现越少,将来的弹性就更好。有不少窍门可以用来隐藏内部实现,但是不影响到其API的功能。这一节我们就来谈谈这些窍门。
方法优于字段 (Method is better than Field)
    最好用方法(getters 和 setters)来访问字段,而不要直接暴露字段。这样做的原因之一是:调用方法可以做很多额外的事情,比如限制字段为只读或者只写。使用getters,可以进行例行的初始化,同步访问,以及利用某种算法对数值进行组织。另一方面,setters可以对字段的赋值正确与否进行检查,还可以在字段的数值改变时通知相应的监听器。
    使用方法的另一个原因在于Java虚拟机规范。该规范允许将一个方法从子类移到父类中而不破坏二进制级别上的兼容性。因此,一个最初像如下形式引入的方法在新版本中可以被删除:
    Dimension javax.swing.JComponent.getPreferredSize (Dimension d)
在新版本中,它被移到

    Dimension java.awt.Component.getPreferredSize (Dimension d)
JComponent 是 Component 的子类(以上真实发生在JDK 1.2版本中)。但是类似的操作对字段是禁止的。一旦在一个类中定义了某个字段,该字段就永远不应该被挪动位置,以保证二进制级别上的兼容性。这也是最好把字段定义为私有属性的原因。
工厂优于构造器 (Factory is better than Constructor)
    导出工厂方法比导出构造器更有弹性。一旦构造器作为API的一部分,那么它可以保证生成的实例是而且仅仅是对应类的实例,而不是其子类的实例。另外,每次调用构造器的时候都会生成一个新的实例。与之相对应的工厂方法 (通常工厂方法实现成一个静态方法,该方法的参数与构造器的一模一样,也返回构造器所在的类的实例) 有诸多不同:首先,工厂方法并不是简单返回指定的类的实例,而是使用了多态 (polymophism),另一个优势在于工厂方法可以缓存实例。构造器每次都生成新的实例,而工厂方法可以缓存之前生成的实例来进行重用,这样可以节省内存。另一个原因是:调用工厂方法可以进行合适的同步,而构造器不能。以上这些便是选择工厂方法要优于构造器的原因。
所有的API都应该定义为Final属性 (Make Everything Final)
    很多情况下,人们都没有考虑过子类化 (subclassing) 的问题,在设计时也没有进行保护。如果你在开发一个API,但是你不希望别人进行子类化你的接口 (可以参考 API vs. SPI 一节),那么最好显式禁止子类化。
    最简单的办法是把你的类声明成Final类型的。其他的办法包括:把构造器声明为非公有类型的 (对应的工厂方法也应该这样处理),或者把所有 (至少大多数的) 方法声明为Final或者私有类型的。
    当然这样做只在类级别上有效,如果你开发的是接口,那么就不能阻止在虚拟机级别上对该接口进行外部实现,你只能要求制定Java规范的人不要这样做。
只赋予友元代码(friend code)访问权限
    另一个可以防止“暴露过度”的很有用的技术是:只给友元代码以访问特定功能的权限 (例如,实例化某个类或者调用某个方法)。
默认情况下,Java要求互为友元的类必须在同一个包中。如果你想把某个功能共享给同一个包中的其他类,马么你可以给构造器,字段或者方法加上package-private修饰符,这样的话,只有友元可以进行访问。
但是有的时候,更有用的方法是将友元集合扩展到更广的类范围中 —— 比如,有人把API的纯定义放在一个包中,而其实现放在另一个包中。这种情况下,下面的方法非常有用。假设有一个类item (顺便说一下,你可以直接从CVS上check out源代码):
public final class api.Item {
       /** Friend only constructor */
       Item (int value) {
            this.value = value;
       }
       /** API methods (s) */
       public int getValue () {
          return value;
       }
       /** Friend only method */
       final void addListener (Listener l) {
             // some impl
       }
}
以上只是item的部分代码,但是已经可以防止友元(这些友元类不仅仅只在 api 包中)之外的类对其进行实例化或者监听事件了。接下来的代码在非api包中定义了一个Accessor:
public abstract class impl.Accessor {
   public static Accessor DEFAULT;            

   static {
      // invokes static initializer of Item.class
      // that will assign value to the DEFAULT field above
      Class c =api.Item.class;
      try {
         Class.forName (c.getName (), true, c.getClassLoader ());
      } catch (ClassNotFoundException ex) {
               assert false : ex;
      }

      assert DEFAULT != null : “The DEFAULT field must be initialized”;
}
/**Accessor to constructor Item */
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 {
   public Item newItem (int value) {
       return new Item (value);
   }
   public void addListener (Item item, Listener l) {
      item.addListener (l);
   }
}
    为Item类添加一个静态的初始化器 (initializer),这个初始化器为首次接触api.Item的人的注册了一个默认的实例:
public final class Item {
       static {
                impl.Accessor.DEFAULT = new api.AccessorImpl ();
       }
      // the rest of the Item class as shown above
}
    现在友元代码就可以从任意一个包,利用Accessor来调用隐藏的功能了:
Api.Item item = impl.Accessor.DEFAULT.newItem (10);
Impl.Accessor.DEFAULT.addListener (item, this);
    请注意:在NetBeans中有一个很有用的做法:把指定的具有公有访问权限的包全部列在模块清单 (module 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语言中的表达
    这一节我们来讨论一下API在以下两种语言中的实现:面向过程的C语言和面向对象的Java语言。
    C语言很适合来表达(客户)API。只需要写出对应的方法实现,并在头文件里声明,其他人就可以编译它们了:

    void xmms_pause();
    void xmms_add_to_playlist(char *file);
    用Java来表达也没有很大不同:

    Class XMMS {
   public void pause();
   public void addToPlaylist(String file);
}
    但是使用后者会有更多的选择:可以把上面的那些方法声明成静态方法,实例方法,抽象方法,或者final方法,等等。但是总的来说,C语言和Java语言在处理client API方面很相似。但是在实现SPI方面却大相径庭。
    为了用C语言开发XMMS的插件,必须从实现回放功能的方法开始。插件必须定义:
void my_playback(char *data) {
   // do the playback
}
播放器必须提供注册方法,例如:
void xmms_register_playback((void)(f*)(char*));
    插件可以利用上述方法来进行注册。Xmms_register_playback(my_playback) 和回放函数将在需要的时候被XMMS调用。用Java语言的话,要在一开始的时候定义回放功能的接口:
Interface XMMS.Playback {
    public void playback(byte[] data);
}
    接下来,插件必须实现MyPlayback implements 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也不例外。所以最好在一开始的时候就为它们的进化做好准备,避免在将来让棘手的错误浮出台面。
    既然API为客户提供某种功能,那么扩展其功能也不应该有任何问题。但是扩展功能不能影响客户既有的体验 —— 用户可以选择不使用扩展功能。
    对于SPI,情况完全相反。如果在接口中添加一个新方法,那么在以前程序中实现该接口的地方全部得重写。因为这个新方法在以前的程序中并没有被实现。另一方面,停止对SPI中某个方法的调用(实际上是把该方法从SPI中删除)是可以的,不应该对程序产生影响。当然前提是:程序不再需要该方法提供的功能。
综上所述,进化依赖于接口的类型:能扩展其功能但不能删减已有功能;可以删减功能但是不能扩展其功能。在一开始设计接口的时候,就要搞清楚哪些应该被设计成API,用来给用户调用的;哪些应该被设计成SPI,用来扩展已有功能的。最忌讳的是把API和SPI放在同一个类中。这样的话,就不能对其进行进化了 —— 由于SPI的存在,增加方法是被禁止的;由于API的存在,删除方法也是被禁止的。
示例(Example)
    我们选择Data System 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) {
   o.open();
}
    但是上面的示例代码有个问题:客户API(client API)和很多只给子类(这些子类在Java规范中是protected类型的)用的方法混在一起了。这样的混合不光是没有意义的,而且使该客户API在将来无法得到扩展。此外,在这种情况下,不仅API和SPI会相互冲突,给进化带来困难,而且API和SPI之间的执行流程会导致很多程序流程相互冲突 —— 死锁。
这就是在New Data Systems 接口设计时,DataObject只保留API的原因。它被声明为final类型,完全受实现方控制。而另一个SPI提供真正的操作:
Interface DataObjectOperator {
   // delegated to from DataObject.move(DataFolder df)
   public void move(DataObject obj, DataFolder target);
   // delegate to from DataObject.rename(String name)
   public void rename(DataObject obj, String name)
   // delegate to from DataObject.getCookie(Class clazz)
   public Object getCookie(DataObject obj, Class clazz);
   // etc.
}
    将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)会调用该方法。
    New Data Systems可以作为优秀设计的范本:对SPI实现来说好的东西,不一定对客户API也是好的;要给客户API进化的机会,而且对SPI实现的限制要尽可能的少。
    如果你还不信服的话,再举一个例子:AntArtifact 是一个抽象类而不是一个接口,所以可以为“客户”(client)增加一些final方法,比如getArtifactFile和getScriptFile,缺省情况下使用getID。目前为止看起来一切都没有问题。当然,为了支持多种artifact和属性,以后必须扩展SPI。增加对属性向下兼容性的支持很容易,但是增加对多种artifact的支持却很麻烦:我们必须废弃老版本的单个artifact的getter,引入新的getter,而且这种改变要保持对老版本实现的兼容。如果有一个final类型的类AntArtifact类,它有一个工厂方法来接收SPI接口AntArtifactImpl(或者是类似的接口)的话,那么就可以简化对多种artifact支持的处理。因为在这种情况下,我们可以创建一个新的SPI接口和一个新的工厂方法。

接口 vs. 抽象类(Interfaces vs. Abstract Classes)
    喜欢使用纯接口的人与喜欢使用抽象类的人似乎永远都会相互争论。像这样的争论每隔几个月就会发生,无休无止。因为人们都趋向于固执己见。通常像这样的争论是在背景都不一样的情况下发生的 —— 用例或者需求都不相同。下面我们从用例的角度来看这个问题。
使用接口的好处(The Advantages of Interfaces)
    最显而易见的一点是类型的使用。如果是用抽象类来实现的话,是不允许多继承的。这个问题仅仅在以下的情况中才会显得尖锐突出:当类型很巨大,或者为了提高开发者的工作效率,在子类中重用父类的实现的时候。我们可以称这样的它们为“支撑类(support class)”,在支撑类中有一个子类,它重用了某个父类的实现。
    使用接口的第二个好处是:可以将API与它的实现有效地分离。但是抽象类也有这个功能,但是必须由抽象类自己来保证这种分离,而用接口的话,这种分离是由编译器来保证的。
使用抽象类的好处(The Advantages of Abstract Classes)
    人们喜欢使用抽象类最主要的原因是它的进化能力 —— 它可以增加一个有缺省实现的新方法而不影响已有的客户和实现方(在这里我们谈的是运行期的兼容性,而不是编译期的兼容性)。接口不具备这种能力,所以必须引入另一个接口来提供扩展功能,如:interface BuildTargetDependencyEx extends BuildTargetDependency。这种情况下,原始的接口仍然有效,新的接口也可用。
    抽象类另一个很有用的特性在于它的限制访问权限的能力。公共接口中的方法都是公有类型的,所有人都可以实现该接口。但是在现实情况中,通常应该进行限制。接口缺少这种限制能力。
    其次,抽象类可以有静态工厂方法。当然,对于接口,可以使用工厂方法来创建不同的类,但是,类其实才是容纳返回实例对象的工厂方法最合理也是最自然的地方。
用例(Use cases)
    现在让我们举一些现实世界中的例子,来谈谈接口和抽象类哪个好一些,并且阐明其原因。
TopManager
    TopManager可以说是NetBeans开源API中的老资格了。它被设计成连接org.openide.* 包和这些包在org.netbeans.core里的实现的纽带。该manager(由core提供)只有一个实例,并且该API的客户不应该扩展或者实现它。
分析表明:TopManager是为客户提供一系列有用的方法,但是对这些方的实现有完全控制权的典型案例。客户应该把精力放在对该API的使用上面,动态地去发现其实现(该API在编译单元openide里,而其实现在另一个编译单元core里)。
在这种情况下,和抽象类相比,使用接口没有任何优势。抽象类可以有工厂方法,可以增加新方法,可以有效地将API与其实现相分离,可以防止除默认实例之外的实例化的发生。如果你也面临类似的情况,最好使用抽象类。
    再举一个例子来说明使用接口的后果:让我们把目光放在和TopManager处于同一个包中的Places接口上面。实际上,该接口和TopManager一样,也只允许一个实例存在。该实例可以通过工厂方法TopManager.getDefault().getPlaces()进行访问。而且,该接口的所有方法都可以是TopManager的一部分。我们仅仅想在逻辑上将该接口与其实现分开,而且我们是使用接口来达到这个目的。结果,新版本的应该很有用的“places”被创建以后,我们将不敢为它添加新方法。一旦我们创建了这样的Places2接口之后会产生严重的后果,所以使用Places接口的用户越来越少,现在几乎被丢弃不用了。
Cookies
    Cookie是一种编码模式,它允许任何对象提供某种特性(这种特性称为cookie)给调用者:
OpenCookie opencookie = (OpenCookie)anObject.getCookie(OpenCookie.class);
if(openCookie != null) {
   opneCookie.open();
}
    那么OpenCookie应该被设计成接口还是抽象类呢?简单的分析表明:存在很多的客户,API的用户以及很多经常想同时提供多个Cookie的服务提供者。此外,cookie自身只有一个open方法。以上者所有的一切都表明Cookie应该被设计成接口。这样的话,我们就有多继承能力,而且不用害怕接口的功能扩展问题 —— 因为该接口只有一个方法。除此之外,也没有必要提供工厂方法,没有必要担心子类化问题。综上所述,设计成接口是正确的选择。
    类似的,还有何多其它cookie的例子 —— InstanceCookie。它也是一个接口,在以前的老版本里有三个方法。但是在发布了几个版本之后,我们意识到有必要改善该接口的性能,所以我们不得不引入一个子类InstanceCookie.Of extending InstanceCookie,并且为它增加了一个instanceOf方法。当然,这样的更改没有问题,但是给使用该接口的用户带来了不少麻烦。每个使用该API的用户都必须如下编码:
Boolean doIAccept;
InstanceCookie ic = (InstanceCookie)obj.getCookie(InstanceCookie.class);
if(ic instanceOf InstanceCookie.Of) {
   doIAccept = ((InstanceCookie.Of)ic).instanceOf(myRequiredClass);
} else {
   doIAccept = ic != null &&
      myRequiredClass.isAssighnableFrom(ic.instanceClass());
}
    以上的代码看起来并不简单,而且这样的代码遍布了整个代码库。但是我们给这个cookie增加新方法的时候是多么简单啊:
Boolean isInstanceOf(Class c) {
   return c.isAssighnableFrom(instanceClass());
}
    但是Java并不允许接口中存在方法的缺省实现。我们应该换用抽象类吗?不,我们不应该这样做,当前的用例和OpenCookie类似,但是得用到一个技巧:
我们并不把那三个方法放进该接口,取而代之的是仅仅增加一个返回包含所有必要信息的类的方法:
Interface InstanceCookie {
   public Info instanceInfo();

   public static class Info extends Object {
      public String instanceName();
      public Class instanceClass();
      public Object instanceCreate();
   }
}
    以上的解决方案似乎是完美的。客户有简单的API可以使用,服务提供者可以实现而不是扩展这个接口。instanceInfo方法可以实例化info,实例化方式可以是:使用构造器,使用工厂方法,或者是使用子类化。这样的话,在InstanceCookie中增加instanceOf方法就一点问题也没有了。InstanceCookie.Info是一个类,它可以由一个有缺省实现的方法来进行扩展。
当然为了使这样增加方法的处理是安全的,最好把这个类声明成final类型,并且为InstanceCookie的实现方提供工厂方法。这样的工厂方法可以有两种:一种很简单,比方说给instanceName,instanceClass和instanceCreate方法准备好返回值;另一种会使用另一个接口,该接口中的方法会来处理像info.instanceCreate这样的方法调用。具体采用哪一种取决于API用户的需求。
    请注意:Java监听器采用了类似的模式。每个监听器都是一个接口,它有固定数目的方法。但是每个方法都对应一个EventObject,EventObject是一个类。如果有必要的话,可以为该类增加一个新方法。
文件对象(FileObject)
    另一个来自NetBeans的例子是FileObject(filesystem 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(比如CloneableEditorSupport类就和它所实现的EditorCookie类不在同一个包中)。这样的隔离可以保证基本的设计质量,而且可以防止欺诈 —— 即便是在实现代码中也只能使用API的方法,而不能hook非公有类型的方法。
接口还是抽象类?(Interface or Classes)
    接口和抽象类哪个更好一些?很难给出一个绝对的答案。但是如果回溯到这个问题的根源上,我们会得到比较好的答案。
    首先,只有那些在设计API的人才会考虑这个问题,那些只是纯粹做开发的人没有必要考虑这个问题,他们可以根据他们的喜好来决定选择哪一个。
    其次,如果你不关心API用户的话,那就没有必要在这个问题上伤脑筋。
从以上两个方面可以看出:对于客户API用抽象类要好一些;而对于服务提供者API来说,用接口要好一些。如果使用该API的用户仅仅只是调用它的话,那么最好就用抽象类;如果仅仅只想让用户调用它的子类的话,那么最好用接口,这样当子类化的时候,使用该API起来比较安全,简单。如果你面临的情况介于以上两者之间的话(根据“将Client API 与 Provider API(SPI) 分离”那个章节所说的,这种情况是禁止的),那么最后的抉择取决于你,但是你在下最后的决定之前,要仔细判断考量哪些是用户经常会用到的 —— 仅仅只是调用一下还是需要子类化。这样的话,你的选择才是恰当的。
将Client API 与 SPI 分离的学习示例(Case Study of client API and SPI seperation)
    前面CloneableEditorSupport的例子表明:如果不用抽象类的话,很难实现支撑类。但是事实上并不是很复杂,而且可以把SPI与客户API分离,即便是将来进行扩展也会很安全,很容易。重写了CloneableEditorSupport的开发团队就是使用接口来实现的:
    CloneableEditorSupport的主要目标是实现像OpenCookie,EditCookie和EditorCookie这样的接口,而让子类去实现像String messageName(),String messageModified()和String messageOpen()这样的抽象方法。为了实现这些抽象方法,子类可以调用一些像protected final UndoRedo.Manager getUndoRedo()这样的支撑方法,并且可以使用像protected Task reloadDocument()这样的方法来与父类的实现进行交互。以上整个过程已经很复杂了,但是以下的事实会让其变得更加复杂:几乎所有的方法都可以在子类中被覆盖(overriden)。这使得局面变得很混乱,而且将来几乎没有办法再对其进行扩展了。
把protected类型的方法移到接口里面(Move Protected Methods Into Interface)
    如果把所有会在子类中被覆盖的方法隔离出来,放到一个单独的接口里面的话,情况会变得简单一些:
public interface CloneableEditorProvider {
   // methods that have to be overridden
   // in order for the functionality to work
   public String messageName();
   public String messageSave();


   // additional stuff described below
}
    再提供一个工厂方法EditorCookie EditorFactory.createEditor(CloneableEditorProvider p);
该工厂方法可以把服务提供者接口转换成所想要的客户API(这种处理很简单,不然的话,真正的API必须通过一个参数Class[]来支持多种Cookie的创建,如:OpneCookie,EditorCookie等等,这个Class[]参数用来为不同的Cookie指定不同的返回值)。从功能上讲,这相当于提供了一个包含所有应该在子类中实现的方法的类,而且它还确保任何人都不能通过把EditorCookie转换成CloneableEditorProvider来调用一些特殊的方法,因为createEditor方法必须返回一个新的对象,来提供它的功能。
发通知给实现方(Passing Notifications to Implementation)
    但是目前还不能完全模拟老版本的CloneableEditorSupport的功能 —— 不能调用reloadDocument或者任何相似功能的方法。为了说明这一点,我们增强了CloneableEditorProvider接口:
public interface CloneableEditorProvider {
   // the getter methods as in previous example
   public String messageSave();
   // the support for listeners
   public void addChangeListener(ChangeListener l) throws TooManyListenersException;
       public void removeChangeListener(ChangeListener l);
}
    现在,工厂方法不仅可以创建EditorCookie对象,还提供了监听器。因为最多只能有一个监听器,所以addChangeListener方法有抛出TooManyListenersException的签名。通常该方法用如下的简单方式来实现:
private ChangeListener listener;
public void addChangeListener(ChangeListener l)
       throws TooManyListenersException {
   if(listener != null) throw new ToomanyListenersException();
   listener = l;
}
    如果遵循JavaBeans规范的话,就没有必要为多个监听器的支持伤脑筋。无论什么时候需要重新加载文档,都可以激活listener.startChanged(ev),这样的话,监听的实现方就会知道有文档重新加载的请求来了。
实现方的回调方法(Callbacks to Implementation)
    监听器方法支持服务提供者到其实现的单向通信,但是仍然不够完美 —— 不能通过CloneableEditorSupport.getUndoRedo来得到UndoRedo。为了支持这种功能,我们不得不对CloneableEditorProvider再做一次修改:
public interface CloneableEditorProvider {
   // the getter methods as in previous example
   public String messageSave();

   // the support callbacks
   public void attach(Impl impl) throws ToomanyListenersException;
   // the class with methods for communication with the implementation
   public static final class Impl extends Object {
      public void reloadDocument();
      public UndoRedo getUndoRedo();
   }
}
    我们用一个专门的Impl类代替了之前的监听器。该Impl类包含了服务提供者可以调用的所有方法,此外新增加的attach方法用来注册Impl。
请注意:Impl类是声明为final类型的,任何从CloneableEditorProvider接口的实现方调用的方法都是CloneableEditorProvider接口里面的方法。从服务提供者到工厂的反向通信被独立出来放在CloneableEditorProvider.Impl类中。现在的CloneableEditorSupport,乍眼看来比之前的CloneableEditorSupport复杂很多,但是代码关系显得清晰多了。
可扩展的客户行为(Extensible Client Behaviour)
    可以给EditorCookie增加新的方法或者功能吗?当然可以,扩展EditorFactory就可以了。可以给客户请求做日志吗?可以, EditorFactory是实现这种功能的好地方。可以提供一些同步访问和死锁等等保护吗?在EditorFactory里实现这些功能是最佳选择。
服务提供者与其实现之间的可扩展性交互(Extensible Communication between provider and implementation)
   因为CloneableEditorProvider声明为final类型,所以我们可以给它增加一个新方法,例如:

   public static final class CloneableEditorProvider.Impl extends Object {
   public void reloadDocument();
   public UndoRedo getUndoRedo();
   public void closeDocument();
}
    事实上,Impl类可以看作是CloneableEditorProvider的客户API,这也是为什么最好把Impl设计成类的原因。
可扩展的服务提供者的进化(Extensible Provider Evolution)
    一般说来,如果CloneableEditorProvider升级了的话,EditorCookie的功能也会相应得到扩展。在最早的CloneableEditorSupport的例子里,可以增加一个新方法(protected类型的方法),该方法在CloneableEditorSupport里有一个缺省实现,但是增加一个新方法通常是很危险的(可能会使之前的程序崩溃)。在这个例子中,我们定义:
Interface CloneableEditorProvider2 extends CloneableEditorProvider {
    /** Will be called when the document is about to be closed by user */
    public Boolean canClose();
}
    此外,有可能再定义一个新的工厂方法(之所以说“有可能”是因为之前的工厂方法有可能已经够用了):

EditorCookie EditorFactory.createEditor(CloneableEditorProvider2 p);
    以上的这些做法可以提供一个新的接口来更好地实现Editor,同时可以为客户API保持相同的接口。
    再举一个这种类型的进化的经典例子:如果老版本的服务提供者接口完全错了,在新版本中修正了它,或者完全写了一个新接口:
Interface PaintProvider {
   public void piantImage(Image image);
}
/** Based on a ability to paint creates new EditorCookie */
EditorCookie EditorFactory.createEditor(PaintProvider p);
    尽管服务提供者API完全改变了,但是这些改变在工厂方法外不可见。工厂方法在客户API与新的服务提供者接口之间充当了翻译的角色。这样的做法使得进化的时候不会产生老程序崩溃的情况。真正想提供CloneableEditorProvider功能的服务提供者,可以通过直接实现CloneableEditorProvider接口来达到目的;想处理closeDocument调用的服务提供者,可以通过实现CloneableEditorProvider2接口来达到目的;而那些依赖全新绘图风格的服务提供者,可以通过实现PaintProvider来达到目的。每个上述这样的服务提供者都要显式指定它想实现哪个SPI接口,这比直接在CloneableEditorSupport里添加新方法要显得清晰得多。
玩NetBeans核心开发团队开发的游戏来提高API的设计水平(Using games to Improve API Design Skills)
    具备优秀的API设计素质对于那些致力于开发像NetBeans这样的开源框架的开发者非常重要。阅读和学习一些API设计大纲是很有帮助的,但是比起单纯学习,在模拟情景中进行设计实践要有效的多。情阅读一下有关API Fest的文章,来了解一下API Fest游戏。该游戏是由NetBeans核心开发团队开发出来的,玩该游戏可以提高API的设计水平。

译者:周林
时间:31-08-2007

页: [1]
查看完整版本: 如何设计优秀的API