如何设计优秀的API(二)

时间:2022-06-23
本文章向大家介绍如何设计优秀的API(二),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

阅读本文需要5分钟

设计实践 (Design Practices)

现在我们来谈谈Java的设计实践与设计模式,这两者有助于开发者和维护者的工作符合前几个章节所提到的准则,用户体验佳。可以先看看如何设计优秀的API(一)

不要暴露过度 (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接口和一个新的工厂方法。

END