API的设计与实现

关于API的设计与实现

API的设计是软件开发中一个独特的领域。最主要的特殊点在于API是供开发者使用的界面,即Application Programmer Interfaces。类似于用户可以直接使用到的GUI的作用一样。所以相对于依据软件设计的原则,考虑用户的”体验”会更加重要。

许多著名的工具和库的作者都写过相关的著作,详细的论述他们在API上的设计与实现要点。下面的论述,就是从这些前人的工作成果中总结而来。以下先列出参考资料:

关于API

狭义上API可能只是一个动态库(共享库)提供功能的接口定义。广义上API分为public API,以及internal API之分。既有整体软件系统对外输出的接口(包括与设备通讯的接口),也有系统内一个底层模块提供给上层模块使用的接口定义。

API看似简单的名词,却代表着重要的架构设计。从架构设计的角度来看(所谓的组成论),软件系统就是模块和接口。模块(层次/组件)决定分工,接口决定交互。API就是接口的定义。模块间并不需要关心其它模块的实现,只需要了解如何进行协作即可。这样将复杂度分散到各个模块之中,使得整体系统更为可控。而API的本质,就是提供给模块开发者使用的接口,是给”人(Programmer)”用的。API的设计任务的核心就是保证使用者以较低的成本,正确的使用接口,驱动模块完成他们的业务。对于Public API,最大的设计挑战则是如何把API一次就做对!

附1的作者在书中提到了一个”无绪(cluelessness)”的概念,即API的使用者不需要对API的内在逻辑有了解,可以只依据API的定义来使用API。更直白一点就是傻瓜式的API。


什么是好的API

对于一般的开发任务,常常思考的是保证功能的正确性和设计的完美,可以不断尝试做创新和重构。但这些原则放到API设计上就不一定正确了,反而需要有些保守。先看一下KDE/Qt开发者总结出来的好API标准:

容易学习和记忆

(Easy to learn and memorize)
这包括了命名,模式的使用,最关键是对于经验式编程的包容。所谓经验式编程是指开发者常常不会认真读完接口的文档(如果提供的话),而是根据思维的连续性,以过往的经验来预先假定API的功能。比如,如果如下两个类都有相同方法:

void Widget::SetSize(int width, int height);
void View::SetSize(int width, int height);

另一个类,逻辑上会自然的认为是View的子类,但却提供如下的方法,就会让人捉摸不透了:

void Button::Layout(int width, int height);

从经验式编程的角度,使用Button::SetSize()是非常自然的事,程序员很可能不会认真核实这个Button竟然没有提供这个方法。
作为API设计者,不能假定使用者都会认真的看完所有的文档,而是要尽量做到两点:

  • 保持与普遍认知一致的设计。
  • 保持设计概念上的一致性(Consistency)。

那些被公认的行为和命名就非常重要,千万不要做太多创新。请遵守最小惊喜原则。

简洁清晰的语义

这样有助于理解,也很难被误用。当一个API无法满足所有的需求时,不要尝试为了一些极小场景来影响到一般的场景,可以另分一个独立的路径。这样的情况,往往反应在函数的参数上。比如这样的API(来自Win32), 你必须每次都要对着文档来调用了:

HWND CreateWindow(LPCTSTR lpClassName, LPCTSTR lpWindowName, DWORD dwStyle, int x, int y, int nWidth, int nHeight, HWND hWndParent, HMENU hMenu, HINSTANCE hInstance, LPVOID lpParam);

另外在附2里举了一个输出如下HTML文本的例子:

the <b>goto <u>label</b></u> statement

以C++的实现可以为:

stream.writeCharacters("the ");
stream.writeStartElement("b");
stream.writeCharacters("goto ");
stream.writeStartElement("i");
stream.writeCharacters("label");
stream.writeEndElement("i");
stream.writeEndElement("b");
stream.writeCharacters(" statement");

很显然,这里Element的Start与End需要开发者自己处理。如果想要编译器来帮助检查,让开发者少犯错,则代码可以变为:

stream.write(Text("the ")
        + Element("b", Text("goto ") + Element("u", "label"))
        + Text(" statement"));

容易扩展及保证向后兼容

之前的资料都是分散的谈到两者的,我将它们合并在这里,因为它们都是API演变所必须考虑的。
随着需求变化,API的演变是必须的,不可能存在一成不变的API。但是作为稳定的API则是对使用者的承诺,不单单是技术上。稳定的概念不是不变,而是指变化的成本要尽可能的低。
如果新增一个API会导致之前的代码无法编译,或者程序无法正常执行,都会影响使用者对API的信任。

能够鼓励编写可读性代码

还是前面强调的,API是给程序员用的,所以本身的命名必须具备可读性。同时,它还要设计成引导使用者写出更具可读性的代码。附2里举了如下的例子。
在Qt3中,Slider的建构函数允许用户指定多个参数:

slider = new QSlider(8, 128, 1, 6, Qt::Vertical, 0, "volume");

而在Qt4,则需要这样做:

slider = new QSlider(Qt::Vertical);
slider->setRange(8, 128);
slider->setValue(6);
slider->setObjectName("volume");

显然后者更具可读性。

这里还是有争议的。既不能为单独的追求可读性而将相关的东西分离开来,也不能为了简化代码,而将不同的内容合在一起。

简洁

这一点对于第一条特别重要。一个不断膨胀,十分臃肿的API必然会产生各种理解和使用上困扰,特别是当多个API存在功能重叠的情况时。举一个会带来理解上困扰的例子:
void View::SetSize(int width, int height);
void View::SetWidth(int width);
void View::SetHeight(int height);
后两者明显是前者的两个子任务,却因为某些特别的原因被公开出来。就会出来到底是调用SetSize(),还是根据变化调用对应的SetWidth()或SetHeight()呢?

完整

如果需要提供的功能就要提供,一个接口类应当具备的函数(包括setters/getters)也应当在这个类中提供。


API的设计实现

关于API的设计实现,不同的背景,不同的需求会有不同的描述了。我这里概括了一些他们间相通的要点。

工厂方法优于建构函数

如果公开一个构造函数,那么创建的对象一定是类的实例。而工厂方法更具灵活性,虽然参数完全相同,但可以返回一个子类的实例。同时更利于实现单例或者缓存对象实例。
在Chromium一些模块的接口上,常常可以看到这类的应用。

常量修饰符

常量修饰符,有助于限定不必要的修改动作,也是一种行为约定。无论是对参数,函数,或是返回值,都可以视需要添加常量修饰符。

基于属性的API

相对于在建构时传入一串参数的接口类,不如在建构后再以setter设置其它参数的方式。其区别在于后者更利于编写可读性的代码。在上面关于可读性代码中已举过例子,这里不再赘述。
要点是各个属性需要做到正交,且与顺序无关。

Virtual APIs

对于是否需要提供虚函数形式的API,也是一直有争论。这里并不是讨论接口类(纯虚类)的定义,接口类的定义的必要性是明确的,不需要额外讨论。
原则上对虚函数作为API是限制使用的,原因是继承下的override可能会导致接口的行为变得不符预期,因为子类的行为无法确定。
但在一些场景下确实有必要为使用者提供一定的扩展性,就可以提供虚函数,以便使用者可以通过继承改变原来的行为。

布尔值参数

以整型数据代替Enum的作法类似,关键在于使用者的理解。
可以改进的做法包括,分成不同的函数实现,或者以枚举变量代替。
示例:

widget->repaint();
widget->repaint(true);
widget->repaint(false);

分开函数的方式:

widget->repaint();
widget->repaintWithoutErasing();

使用整数代替格枚举变量时也是相同的问题。

异常处理

在附5中作者详细说明了关于API中的异常处理。我的总结是只抛必须抛的异常,绝不能自作聪明的默默处理。API的代码应当最真实的反应出执行中的问题,更不能用聪明的代码做某些特别处理。其背后的原因是这样做会使得API的行为与预期会发生偏差,违背了最小惊喜原则。

命名

在命名上,附2列举的比较详细。概括如下:

  • 选择具有自解释能力的命名
    核心是从用户和领域的角度命名,而不是从自身的设计命名。比如Qt 4.2中QWorkspace实现了MDI (multiple document interface)。好在这样的命名后来被修正为QMdiArea。
  • 命名不要有歧义
    如果遇到有概念相似的API,一定要从命名上将它们区分出来。如sendEvent()表示同步的事件,而sendEventLater()则表示异步事件。
  • 保持一致性
    这一点对于前面对经验式编程的支持很重要,也被称为对称性(Symmetry)。如果set前缀代表的是setters,就不要出现以set打头,但却不是setter的情况。再比如Chromium中对setters/getters的定义以非常明确的方式独立出来。
  • 避免简写
    简写除了是某种通用的缩写外,不要随意以首字母缩写的形式定义简写。不然,读者可能对名字完全不知所云。
  • 优先使用特殊的命名,而不是通用的命名
    一个通用的名字常常包含更为普遍的职责,如果API的功能带有明确的应用场景,就应当在API上体现出来。否则一旦遇到需要一个通用API的情况,就用很多余的加上XXXXInGeneral之类的命名,而且会让用户出现难以选择适用API的情况。
  • 不要太迁就于既有的命名
    比如包装一个旧的或子功能的API的时候,常常会延用原有的API命名。其实完全没必要,更合理的做法还是从新API的功能入手,选择合适的名字。

关于向后兼容

一个模块(库)的兼容性主要包括:

  • API兼容
    主要是定义上的兼容性,即代码能否编译,以及行为的一致性。
  • ABI兼容,即二进制级的兼容。
    对于共享库就是需要有相同的符号表,包括全局的对象和定义。Linux里这类问题太多了。
  • 通讯协议的兼容
    如果有自定义协议的网络通讯,就可能存在C/S之间通讯协议的兼容性问题。
  • 存储的数据及文件格式的兼容
    如果用户升级后,发现以前的历史数据不可用了,大多数情况都是无法接受的,搞不好还要吃官司的。

保证兼容性

至于要保证哪些点的兼容性,取决于用户的规模,以及影响的程度(或者用户的承受能力)。从兼容性的角度,保证兼容性方法包括:

  • 不要丢掉任何东西
    非常悲催的现实。如果你弃用了API的某一部分(更不能改了),无论使用@Deprecated,还是在文档中反复声明,你都可能会造成使用者之前的代码失效。一定要保证之前API的完整性,除非你的兼容性规则允许你放弃,就比如像MicroSoft一样宣称将不再支持某个版本。
  • 隐藏细节
    可以使用Opaque Pointer (PIMPL)或者利用建构函数来帮助API隐藏内部的数据结构,而且让使用者只能通过提供的函数来操作数据。
  • 保证协议及数据格式的扩展性
    可以使用标准化的XML以及标准化的协议来取代自定义的格式。如果条件不允许,也记得在协议及数据格式中定义出版本,以便于后期做兼容性处理。
    预留字段也是一个常用的做法。我曾经不止一次的遇到,通过协议中的预留字段解决紧急问题的案例。
  • 实现上保证兼容性
    在实现逻辑上,特别是判断处理也要注意兼容性处理,这是一个常常犯错的地方。以某个字段flagA的处理为例:

    if (headers.flagA != 1) {
    doB();
    } else {
    doA();
    }

显然将判断条件改为headers.flagA == 1会让实现更具兼容性。否则,降级时,就是灾难了。


极端的意见有害无益

(主要参考附1)
关于API定义的评价中,漂亮或者优雅都是很主观的。我们应当设计易于使用,广为接受且富有成效的API(节自附1)。至于所定义的原则,完合取决于API自身的需求。比如因为性能的原因,一些API可能无法满足某些场景的需求,达不到完整性的要求。API的设计者不需要去满足所有人,重要的是API本身保持正向的演进。比如标准的优化流程就比较适合API的发展:
1. Make it work
2. Make it right
3. Make everything work
4. Make everything right
5. ……

转载请注明出处: http://blog.csdn.net/horkychen

进一步阅读: 避免类的膨胀 (接口类适用)

时间: 2024-08-27 12:11:28

API的设计与实现的相关文章

揭秘ZSearch2.0—基于OpenResty的API网关设计

背景 OpenResty 是一个支持lua的nginx,并且内置了一些常用的lua库.利用lua,我们就可以便捷得扩展nginx能力,甚至可以直接作为Web服务对外提供. 主页链接 由下图可以看出,我们可以在各个阶段进行干预. 设计 本文介绍的API网关设计很简单,主要有路由,过滤器,拦截器三个部分组成. 可以实现权限验证,日志记录,参数改写,限流限速等功能. 实现关键 这套方案实现并不复杂,主要是对nginx的干预要可控,对nginx主要的干预点有三个. init_worker_by_lua_

接口-移动应用中 服务端API的设计

问题描述 移动应用中 服务端API的设计 php作为服务端 Android ,苹果 作为客户端,php 提供API接口的时候 应该怎么设计才能解决新旧版本 及 安卓 和 苹果版本不一致的问题,2. 怎么设计接口才能保证功能的可扩展性 和 灵活性 求教大家 能否给一个完整的例子 或者 说说思路也可以 解决方案 API中带一个版本信息http://www.xxx.com/api/v1.0http://www.xxx.com/api/v2.0 区分android和ios,可以在api中带一个os参数来

从涂鸦到发布——理解API的设计过程(转)

英文原文:From Doodles to Delivery: An API Design Process 要想设计出可以正常运行的Web API,对基于web的应用的基本理解是一个良好的基础.但如果你的目标是创建出优秀的API,那么仅凭这一点还远远不够.设计优秀的API是一个艰难的过程,如果它恰巧是你当前的工作任务,那么你很可能会感到手足无措. 不过,优秀的设计绝对是可以实现的.本文所描述的流程将帮助你获得成功,我们将共同研究什么是优秀的设计,以及迭代式的流程如何帮助我们实现这一目标.我们还将叙

《JavaScript应用程序设计》一一2.9 方法API的设计

2.9 方法API的设计 JavaScript中的语言特性可以帮助你更好地设计方法API,包括参数命名.函数多态.链式调用.lambda等.你应该了解并掌握这些语言特性,以便在合适的时候将它们引入.当你在设计方法API时,要时刻牢记章首介绍的四项原则,这里再强调一遍:· 保持简单· 一次只做一件事情· 不要重复造轮子· 少即是多

微信小程序的Web API接口设计及常见接口实现

微信小程序给我们提供了一个很好的开发平台,可以用于展现各种数据和实现丰富的功能,通过小程序的请求Web API 平台获取JSON数据后,可以在小程序界面上进行数据的动态展示.在数据的关键 一环中,我们设计和编写Web API平台是非常重要的,通过这个我们可以实现数据的集中控制和管理,本篇随笔介绍基于Asp.NET MVC的Web API接口层的设计和常见接口代码的展示,以便展示我们常规Web API接口层的接口代码设计.参数的处理等内容. 1.Web API整体性的架构设计 我们整体性的架构设计

Web API接口设计经验总结

在Web API接口的开发过程中,我们可能会碰到各种各样的问题,我在前面两篇随笔<Web API应用架构在Winform混合框架中的应用(1)>.<Web API应用架构在Winform混合框架中的应用(2)--自定义异常结果的处理>也进行了总的介绍,在经过我的大量模块实践并成功运行后,总结了这篇随笔,希望对大家有所帮助. 1.在接口定义中确定MVC的GET或者POST方式 由于我们整个Web API平台是基于MVC的基础上进行的API开发,因此整个Web API的接口,在定义的时

API接口设计 OAuth2.0认证

OAuth2.0官网 Java代码   git clone https://github.com/bshaffer/oauth2-server-php.git   OAuth和OpenID的区别:OAuth关注的是authorization授权,即:'用户能做什么':而OpenID侧重的是authentication认证,即:'用户是谁'.OpenID是用来认证协议,OAuth是授权协议,二者是互补的 OAuth 2.0将分为两个角色: Authorization server负责获取用户的授权

API接口设计 注意问题

总结一下API接口开发过程中的注意事项 1.跨平台性 所谓跨平台是指我们的接口要能够支持不同的终端,比如Android.iOS.windowsphone以及桌面软件.网站等.如:不同的终端每页显示的记录数不同 采用通用的解决方案,比如通信协议就采用最常用的HTTP协议,如果是即时通信,可以采用开放的XMPP协议,做游戏的可以采用可靠的TCP协议,除非TCP不够用了,再采用定制的UDP协议. 数据交换采用xml或者json格式或者webservice等等.总之,要达到的目标就是让不同的端能够很方便

实际应用delegate做好api简洁设计——从commons-io排序器想开

我们已经知道,在完成一个通用功能的设计时,必然会抽象并且隔离功能级别,把最一般的功能抽象出来,放到接口里去,具体实现接口的类完成具体功能.因为所有的具体实现都有共同的接口,虽然功能实际不同,但是抽象含义相似,因此在抽象级别,其他类调用时就可以把最抽象的接口作为代理(委托)来调用,思路简单清晰. 在commons-io这个开源框架中,封装了对io的基本操作,其中org.apache.commons.io.comparator包就是经典的设计典范,comparator顾名思义就是为了提供一系列的文件