API(应用程序编程接口)是一种规范,定义了不同软件组件之间如何进行交互。API 描述了一组操作、输入和输出,这些操作独立于实现,使得开发人员可以访问其他程序、库或框架的功能,而无需了解其底层实现细节。API 是一种在软件系统中实现模块化和解耦的方式。通过定义清晰的边界和接口,API 使得各个组件或模块可以独立地开发、测试和维护,同时保证它们之间的互操作性。
公共 API 是为其他开发者或系统设计的,它们是公开可用的,并提供了一种标准化的方式来访问特定功能。在 Java 语言中,与公共 API 相关的概念包括 public 和 protected 修饰符。public 修饰符表示类、方法或属性对外部可见。而 protected 成员只对于同一包内的类和子类可见,但也算是公共 API 的一部分。
Library与Framework中的API
不知你是否清楚library和framework之间的区别(主要区别就在于主动和被动的关系)。我们可以从通过 API 的角度来看,我们的代码与库(Library)和框架(Framework)之间的关系如下:
对于库(Library):
当我们使用库时,我们的代码会主动调用库中提供的 API。这意味着我们的代码对库的 API 进行调用,以实现某种功能或执行特定任务。在这种情况下,我们的代码负责驱动程序流程,通过调用库的 API 来执行所需的操作。这种关系类似于我们的代码是客户端,而库是服务端。
对于框架(Framework):
当我们使用框架时,框架通常会提供一组 API,我们需要遵循框架的规定,实现这些 API。在这种情况下,框架负责驱动程序流程,而我们的代码则负责响应框架的请求。这种关系类似于框架是客户端,而我们的代码是服务端。这种模式通常被称为“好莱坞原则”(Hollywood Principle),意为“不要给我们打电话,我们会给你打电话”,即框架会在适当的时候调用我们的代码。
API的发展历史
- 20世纪50年代至60年代 - 一些简单的数学运算api:当时的整个库可能只包含10到20个函数。
- 20世纪70年代 - malloc, bsearch, qsort, rnd, I/O, 系统调用,格式化,早期数据库:这个时期,API 开始涉及内存管理、二分查找、快速排序、随机数生成、输入输出操作、系统调用、格式化以及早期数据库。
- 20世纪80年代 - 图形用户界面(GUIs),桌面出版,关系数据库:这个时期,API 开始支持图形用户界面、桌面出版技术和关系数据库等领域。
- 20世纪90年代 - 网络,多线程:API 进一步扩展,涉及网络编程和多线程技术。
- 21世纪00年代 - 数据结构,高级抽象,Web API:社交媒体,云基础设施:这个时期,API 开始包含数据结构、高级抽象以及 Web API,涉及社交媒体和云计算基础设施等领域。(从此再无手写数据结构!)
- 21世纪10年代 - 机器学习,物联网(IOT),几乎所有领域:这个时期,API 的能力得到了前所未有的扩展,包括机器学习、物联网等几乎所有现代技术领域。(以至于现在做一个机器学习程序或者手机移动应用基本就是在调API了哈哈。)
API设计原则
当我们设计公共API时,一旦发布了,那么被人使用了后就很难更改了,因此一旦发布了公共API,我们就只能向库中添加新功能,但不能执行以下操作:1从库中移除方法。2更改库中的 API 协议。3更改框架的插件接口……等一系列有可能影响之前用户使用的操作。有时我们调用某个API经常会看到告诉你这个API要Deprecation(废弃)了,提醒你换别的调用方式,但往往你也还能继续使用这个旧版,因为为了不影响在使用旧版API的用户,他们不得不继续支持。
一个好的API应该具体这些特点:易于学习和使用(即使没有文档,也易于使用),难以误用(否则有时候误用了别的api还不报错,这种bug能找半天),易于理解(一看这个api的调用就知道这是要干啥的),能满足需求的功能强大,易于发展和扩展,适合目标受众……
在设计 API 时,尽早且频繁地编写 API 使用示例非常重要,先把文档和接口一遍遍的修改和润色好,而不要一上来就先写实现,避免因修改设计而浪费已完成的实现工作。直到完全确定规范之后我们再开始编写。
Information hiding 信息隐藏
这点算是API的基本要求了,只需要告诉用户这个接口咋用,能实现什么功能即可,不要透露任何实现上的细节。我们可以通过封装的方式将类和成员设为私有,只暴露必要的公共接口。
要避免抛出特定的异常:比如一个获取用户信息的api,抛出了SQL异常,那么用户就有可能知道数据是存在哪里的,有什么字段之类的。当然可以抛出一些有指导意义、文档中提到的异常来帮助用户修改和使用。
在文档中,也要避免对实现细节的提及:如“最终使用了hashcode()方法来返回XX值”等。
当一个类实现 Serializable
接口时:该类的所有字段(包括私有字段)会被包含在序列化形式中。这可能导致一些实现细节被暴露,因为在序列化和反序列化的过程中,需要了解这些字段的类型、结构以及如何读写它们。为了避免暴露实现细节,可以采用以下方法:1.在类中显式地定义哪些字段需要序列化,哪些字段不需要序列化。可以使用 transient
关键字来标记不需要序列化的字段。2.考虑使用其他序列化方案,例如 JSON 或 XML,这些方案允许更细粒度地控制输出格式和暴露的数据。
Conceptual Weight 概念负担
概念负担指的是程序员在使用 API 时需要学习的概念数量和难度。概念负担比 API 的“物理大小”更为重要,因为它直接影响到程序员学习和使用 API 的难易程度。
概念负担可以定义为 API 中新概念的数量和难度,即 API 在用户大脑中占用的空间,需要的空间越大其实就越不好记和使用。在 API 设计过程中,有些修改可能会增加很少的概念负担可以增加很多功能,这些就是很好的,例如:
- 添加与现有方法行为一致的重载方法。
- 在已有 sin、cos 和 arcsin 的基础上添加 arccos 函数。
- 为现有接口添加新的实现。
设计 API 的目标之一是实现高功率与低负担(a high power-to-weight ratio),即用较少的概念让程序员完成更多的工作。为了实现这一目标,API 设计者应尽量减少概念负担,让 API 更易于学习和使用。
Naming 命名
Naming算是API的可用性usability当中最重要的一个因素了。对于用户来说,API is a little language,在设计 API 时,我们的主要目标是让客户端代码易读、表意明确且编写顺畅。为了达到这些目标,我们需要遵循以下原则来命名 API 组件:
- 名称应该尽量自解释self-explanatory,让使用者在看到名称时就能理解其功能。
- 利用现有知识,遵循通用的命名规范和惯例,降低学习成本。
- 名称之间要和谐,与所使用的编程语言保持一致,以实现整体的协调。
要遵循“最少惊讶原则principle of least astonishment”,让用户一看到这个API就知道它是干啥的,并且真正使用时确保 API 的行为和用户的预期一致,避免令人困惑的命名和设计。
在为 API 命名时,确保遵循以下原则:
-
保持一致性:不要为多个含义使用相同的词语,也不要为相同的含义使用多个词语。例如,不要在同一个 API 中同时使用
computeX()
和generateX()
,或者deleteX()
和removeX()
,因为它们可能会让用户感到困惑。(一般来说,deleteX()
表示从存储或数据结构中永久性地删除某个对象或数据。removeX()
表示将某个对象从集合或数据结构中移除,但不一定涉及永久性删除,总之咬文嚼字,选择最合适的然后保持一致。)除了使用的单词表示要保持一致,相似方法中的参数的顺序也要保持一致,比如这个方法中先写source再写的destination,另一个类似方法也要把source写在前面。 -
避免使用晦涩的缩写:简洁易懂的名称可以帮助用户更快地理解 API 的功能。例如,使用
Set
、PrivateKey
、Lock
、ThreadFactory
和Future<T>
这样的名称要比使用DynAnyFactoryOperations
、ENCODING_CDR_ENCAPS
和OMGVMCID
这样的名称更容易理解。 -
选择与抽象概念相关的名称:好的命名应该与 API 中的抽象概念紧密相关,这有助于用户更好地理解功能和使用方法。
一些预订成俗的规范:
- 驼峰命名:比如对于get_x() 和 getX(),一个使用了下划线来分隔动词和属性,另一个使用了驼峰式命名。在大多数编程语言中,驼峰式命名(如
getX()
)更为常见。 -
类名应使用名词:如 BigInteger、PriorityQueue;接口名应使用名词或形容词:如 Collection、Comparable。
-
非变异方法(不改变对象状态的方法)应使用名词、系动词或介词:如 size、isEmpty、plus;变异方法(改变对象状态的方法)应使用动词:如 put、add、clear。
-
力求一致性:如果 API 中有两个动词和两个名词,程序员可能会期望所有四种组合都存在。例如:addRow,removeRow,addColumn,removeColumn
总之,API的命名是一个很重要的事情,往往要花费很多时间先在命名上,这需要不断的列出备选名称,通过实际编写用例、讨论决策来选好最佳命名。
一些其他API设计的注意事项
- 为API写好文档。虽然我们的API已经有良好的命名来帮助用户理解,但我们仍然需要好好地写一个文档来帮助用户使用。好的API文档要确保覆盖以下几点:为每个公共类编写简要描述,解释其功能和用途;为每个公共方法编写详细说明,包括其功能、参数、返回值和可能抛出的异常;为每个公共字段编写描述,说明其用途和作用范围;为每个方法参数提供描述,解释其用途和预期输入;为API提供清晰的行为规范,以便使用者了解API的预期行为和限制。“we won't see the components reused without good documentation.”--D. L. Parnas.
- 参数避免太多,三个及以内就好,太多了用户很难使用。(为了缩短参数列表,可以使用拆分的方法:将一个具有多个参数的方法拆分为多个具有较少参数的方法。 或者合并参数,将相关参数封装到一个单独的对象中,然后将该对象作为方法的参数。
- 减少用户要处理的异常。1、返回空列表而不是null。比如我们写一个api说是可以返回当前下雨的所有城市列表,但是如果所有城市都没下雨,我们应该返回什么呢?null吗,不行!比null更合适的其实是一个空列表,这样即使没有城市下雨,对用户拿到列表之后的遍历操作也不会有影响。如果返回null的话,用户还要被迫单独处理返回值为null的情况,就很难受了。2、如果有更加合适的类型,避免返回字符串String类型。假设有一个API方法返回一个日期值。如果返回一个字符串类型,可能有很多种不同的日期格式,如 "2023-05-06"、"May 6, 2023" 等。这就需要调用者了解并处理这些格式,从而增加了错误的可能性和理解成本。而如果使用一个专门的日期类型(如Java中的
LocalDate
),调用者就可以更容易地处理和操作日期值,因为它们已经是更具体、更明确的结构化的数据。 - 如果发生非预期的事件,就要快速、及时且醒目地报出来。比如在下面的代码中,
Properties
类继承了HashTable
,但Properties
实例需要将字符串映射到字符串。当调用save
方法时,如果Properties
实例包含非字符串类型的键或值,将会抛出ClassCastException
。这意味着错误只在调用save
方法时才会被检测到,而不是在尝试插入非法键或值时。要解决这个问题,可以覆盖put
方法,只接受字符串类型的键和值。这样,当尝试插入非字符串类型的键或值时,可以立即抛出异常并通知用户错误,而不是在稍后的save
方法调用时抛出异常。这个例子看起来很简单,但很多时候开发人员真的考不到这种情况,有错误不及时阻止,到最后才说,导致用户体验很差。
// A Properties instance maps Strings to Strings
public class Properties extends HashTable {
public Object put(Object key, Object value);
// Throws ClassCastException if this instance
// contains any keys or values that are not Strings
public void save(OutputStream out, String comments);
}
小结
这篇文章讲了一下api的概念,发展历史以及着重介绍了一些API设计原则,只能算是抛砖引玉,在cmu甚至专门有一门课来讲解API的设计,可见这是一个十分高深复杂的问题,绝对不一篇文章所能覆盖的完,但这里所提出的一些比较常见、根本上的设计还是可以起到很好地启发作用的。