API的设计与实现

简介: 关于API的设计与实现API的设计是软件开发中一个独特的领域。最主要的特殊点在于API是供开发者使用的界面,即Application Programmer Interfaces。

关于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

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

目录
相关文章
|
API C# Windows
C#实现操作Windows窗口句柄:常用窗口句柄相关API、Winform中句柄属性和Process的MainWindowHandle问题【窗口句柄总结之三】
本篇主要介绍一些与窗口句柄相关的一些API,比如设置窗口状态、当前激活的窗口、窗口客户区的大小、鼠标位置、禁用控件等,以及介绍Winform中的句柄属性,便于直接获取控件或窗体句柄,以及不推荐...
3218 0
C#实现操作Windows窗口句柄:常用窗口句柄相关API、Winform中句柄属性和Process的MainWindowHandle问题【窗口句柄总结之三】
|
XML JSON 前端开发
软件测试|Spring Boot 的 RESTful API 设计与实现
软件测试|Spring Boot 的 RESTful API 设计与实现
软件测试|Spring Boot 的 RESTful API 设计与实现
|
NoSQL Java 应用服务中间件
使用ZooKeeper原生API实现分布式锁
分布式锁的引入 一个很典型的秒杀场景,或者说并发量非常高的场景下,对商品库存的操作,我用一个SpringBoot小项目模拟一下。 用到的技术知识: SpringBoot Redis ZooKeeper 我提前将库存 stock 放在redis,初始值为288:
|
SQL 安全 Java
微服务API开放授权平台的设计与实现
微服务API开放授权平台的设计与实现
微服务API开放授权平台的设计与实现
|
XML SQL JSON
RESTful API 设计指南
RESTful API 设计指南
312 0
RESTful API 设计指南
|
人工智能 数据可视化 API
【超简单API实现分割】PaddleSeg 分割之API
【超简单API实现分割】PaddleSeg 分割之API
395 0
【超简单API实现分割】PaddleSeg 分割之API
|
前端开发 JavaScript API
借助 Web Animations API 实现一个鼠标跟随偏移动画
借助 Web Animations API 实现一个鼠标跟随偏移动画
320 0
借助 Web Animations API 实现一个鼠标跟随偏移动画
|
API 微服务 Java
Netflix 实用 API 设计 (下)
Netflix 实用 API 设计 (下)
267 0
Netflix 实用 API 设计 (下)
|
SQL Java 数据库连接
Apache-DBUtils实现CRUD操作,已封装的API实现jdbc对数据库进行操作
Apache-DBUtils实现CRUD操作,已封装的API实现jdbc对数据库进行操作
202 0
Apache-DBUtils实现CRUD操作,已封装的API实现jdbc对数据库进行操作
|
SQL API 数据库
easyswoole快速实现一个网站的api接口程序
easyswoole快速实现一个网站的api接口程序
131 0
easyswoole快速实现一个网站的api接口程序