专栏首页Java研发军团如何设计优秀的API(三)

如何设计优秀的API(三)

阅读本文需要5分钟

引言

此文章翻译来自国外的一本叫做《How to design API》的书籍,如果还没有没有看过前两张的朋友可以先看看前两章文章,如何设计优秀的API(一)如何设计优秀的API(二)

接口 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的设计水平。

END

本文分享自微信公众号 - Java研发军团(ityuancheng),作者:dcc939705214

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-03-14

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 如何设计优秀的API(一)

    1. 不要暴露过度(Do not expose more than you want)

    用户5224393
  • Java之接口详解

    接口(英文:Interface),就是比“抽象类”还“抽象”的“抽象类”,可以更加规范的对子类进行约束。全面地专业地实现了,规范和具体实现的分离。

    用户5224393
  • 如何设计优秀的API(二)

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

    用户5224393
  • Spring Boot从零入门6_Swagger2生成生产环境中REST API文档

    在如今前后端分离开发的模式下,前端调用后端提供的API去实现数据的展示或者相关的数据操作,保证及时更新和完整的REST API文档将会大大地提高两边的工作效率,...

    别打名名
  • 011 抽象类和接口的区别

    1、抽象类和接口都不能直接实例化,如果要实例化,抽象类变量必须指向实现所有抽象方法的子类对象,接口变量必须指向实现所有接口方法的类对象。 2、抽象类要被子类继承...

    nnngu
  • 如何调试Python 程序的内存泄露问题

    如果大家在 Linux 或者 macOS 下面运行一段可能导致内存泄露的程序,那么你可能会看到下面这样的情况:

    青南
  • 芋道 Spring Boot API 接口文档 Swagger 入门

    目前,大多数系统都采用前后端分离。在享受前后端分离的好处的同时,接口联调往往成为团队效率的瓶颈,甚至产生前后端的矛盾。简单归结来说,有几方面的原因:

    芋道源码
  • 使用Swagger记录ASP.NET Web API

    原文地址:https://dzone.com/articles/documenting-a-aspnet-web-api-with-swagger

    恒恒
  • 如何通过HTML做外链跳转

    半夜喝可乐
  • Selenium处理单选项下拉框列表

    UI自动化测试中,经常会遇到下拉框列表选项,常见的下拉框列表有:单选项下拉框,多选项下拉框。

    Altumn

扫码关注云+社区

领取腾讯云代金券