原链接:How to Design a Good API and Why it Matters

作者简介:

约书亚·布洛克,曾担任Google的首席Java架构师(Chief Java Architect),《Effective Java》作者,《Java Concurrency In Practice》共同作者,2004年12月,《Java开发者杂志》(Java Developer's Journal)将他列为世界上最顶尖的四十名软件人物之一。

为什么 API 设计如此重要

  • API 可以成为公司最大的资产:
    • 客户投资巨大:购买、编写、学习
    • 停止使用 API 的代价可能会让人望而却步
    • 成功的公开API可以抓住用户
  • 也可能是公司最大的负债之一
    • 糟糕的 API 会导致无休止的电话支持
  • 公开 API 永远存在 - 只有一次机会把它写正确
  • 只要你开始编程,你就是 API 设计师
    • 好的代码是模块化的 - 每个模块都有 API
  • 有用的模块往往会被重用
    • 模块一旦被使用,就不能随意修改API
    • 优秀的可重用模块是公司资产
  • 从 API 的角度考虑可以提高代码质量

优秀 API 的特征:

  • 易于学习
  • 易于使用,即使在没有文档的情况下
  • 难以误用
  • 易于阅读和维护
  • 充分满足需求
  • 易于扩展
  • 适合使用者的视角(Appropriate to audience)

大纲:

  • 一、API 设计过程
  • 二、一般原则
  • 三、类的设计
  • 四、方法设计
  • 五、异常设计
  • 六、重构 API 设计

一、API 设计过程

1.1 收集需求- 并合理怀疑

  • 通常你会得到建议的解决方案
    • 可能存在更好的解决方案
  • 你的工作是提炼真实的需求
    • 应该使用 Use-Cases(这样做非常好)
  • 更通用的逻辑可以让工作更轻松和更有意义

1.2 从简短清单开始 - 1页就够

  • 这个阶段,敏捷胜过完整
  • 让尽可能多的人知道
    • 听取他们的意见并认真对待
  • 如果保持设计文档非常简短,修改就非常容易
  • 当你有信心时,补充更多细节
    • 这必然涉及编码

1.3 尽早编写 API

  • 在实现前开始编写 API
    • 节省那些会被你抛弃的实现
  • 在正式明确前开始
    • 节省会被你抛弃的 API 描述
  • 补充细节后持续编写
    • 避免意外
    • 代码作为示例、单元测试而存在

1.4 编写 SPI 更重要

  • Service Provider Interface(SPI)
    • 插件接口支持多种实现
    • 示例:Java Cryptography Extension (JCE)
  • 在发布之前编写多个插件
    • 如果你写一个,它很有可能不支持其他的(不好的实现)
    • 如果你写两个,它将艰难的支持更多
    • 如果你写三个,就能很好的工作(这样做非常好)
  • 威尔·贡培兹称之为“三的规则”(《二手程序销售员的自白》,培生教育出版集团,1995)

1.5 保持现实的期望(Maintain Realistic Expectations,回到现实世界?)

  • 大多数API设计都是过约束的
    • 你不可能取悦所有人
    • 结果可能是让每个人都不高兴(Aim to displease everyone equally?)
  • 期望会犯错误(Expect to make mistakes)
    • 实际使用不久就会被淘汰
    • 期望改进 API

二、一般原则

2.1 API 应该只做一件事,且把它做好

  • 功能应该易于解释
    • 如果命名很困难,这通常是个坏兆头
    • 好的名字可以驱动开发
    • 易于拆分和合并模块

2.2 API 应该尽可能小,且不能再小

  • API 应该满足它的需求
  • 当 API 不可靠时,就废弃(When in doubt leave it out)
    • 功能、类、方法、参数,等等
    • 你可以添加,但永远不能删除
  • 概念权重比主体重要
  • 找到高性价比的实现(Look for a good power-to-weight ratio)?

2.3 实现不应影响 API

  • 实现细节
    • 混淆用户
    • 禁止随便改变实现
  • 了解实现细节是什么
    • 不要过度指定方法的行为,比如 不要制定 哈希函数
    • 所有的调整参数都是可疑的
  • 不要让实现细节泄露到 API
    • 磁盘和在线格式,例外(On-disk and on-the-wire formats, exceptions?)

2.4 最小化一切的可访问性

  • 让类、成员变量尽可能私有
  • 公开的类不要有公开的类成员(常量除外)
  • 最大程度信息隐藏(系统的局部设计简化)
  • 允许模块独立使用,被理解,构建,测试和调试

2.5 命名的重要性 - API就是一种微型语言

  • 名称应该最大程度的自解释
    • 避免含糊的缩写
  • 保持一致—— 同一词表示同一事物
    • 平台上的所有 API
  • 有规律的——力求相似
  • 代码读起来应该像散文
if (car.speed() > 2 * SPEED_LIMIT) 
      generateAlert("Watch out for cops!");

2.6 文档的重要性

复用性说起来容易做起来难,做好它不仅需要良好的设计,还需要良好的文档。即使我们看到好的设计(这种情况仍然很少见),但如果没有好的文档,我们也不会看到组件的重用。

- D. L. Parnas,_软件老化。 第十六届国际软件工程会议,1994年

2.7 审慎认真的记录

  • 记录每一个类、接口、方法、构造函数、参数和异常
    • Class:实例作用是什么
    • 方法:方法和使用者间的合约
      • 先决条件、后置条件、副作用
    • 参数:标明单元(units)、组成(form)、所有权
  • 认真记录作用域(Document state space very carefully)

2.8 考虑API设计决策的性能后果

  • 错误的决策会限制性能
    • 使类型可变
    • 提供构造函数而不是静态工厂
    • 使用实现类型而不是接口
  • 不要扭曲 API 以获得性能
    • 基本的性能问题将得到解决,但头痛将永远伴随着你
    • 好的设计通常与好的性能相一致
  • API 设计决策对性能的影响是真实和永久的
    • Component.getSize() 返回 Dimension
    • Dimension 是可变的
    • 每一次 getSize 调用都需要分配 Dimension 内存
    • 导致成千上万的不必要的对象内存分配
    • 在 1.2 中增加了替代方案;不过旧的代码仍然很慢

2.9 API 必须与平台和平共处

  • 遵守惯例
    • 遵守标准命名约定
    • 避免废弃(过时)的参数和返回类型
    • 模拟核心 API 和编程语言的模式
  • 利用 API 友好的特性
    • 泛型、变量参数、枚举、默认参数
  • 了解并避免 API 缺陷与陷阱
    • Finalizers、public static final arrays(?)

三、类的设计

3.1 最小化易变性

  • 除非有充分的理由,否则类应该是不可变的
    • 优点:简单,线程安全,可重用
    • 缺点:为每个值创建对象
  • 如果易变,保证作用域足够小,定义明确
    • 明确什么时机调用哪个方法是合法的

坏的示例:Date,Calendar

好的示例:TimerTask

3.2 仅在有意义的时候使用子类

  • 子类化意味着可替代性(里氏替换,Liskov)
    • 仅当 is-a 的关系存在时才使用子类
    • 否则,使用组合
  • 为了便于实现,公共类不应该子类化其他公共类

坏的示例:Properties extends Hashtable, Stack extends Vector

好的示例:Set extends Collection

Collection是接口,HashtableVector是具体的实现。

3.3 为继承设计并文档记录它,否则禁止使用继承

  • 继承破坏封装
    • 子类对父类的实现细节敏感
  • 如果允许子类化,文档标明 作用
    • 方法如何互相调用
  • 保守政策:所有实体类都是final

坏的示例:Many concrete classes in J2SE libraries

好的示例:AbstractSet, AbstractMap

四、方法设计

4.1 不要让客户端做模块能做的任何事情

  • 减少对样板代码的需求
    • 通常通过剪切-粘贴完成
    • 丑陋,烦人,容易出错

4.2 不要违反最小惊奇原则

  • API 的使用者不应为 API 的行为感到惊讶
    • 值得付出的额外努力
    • 甚至值得降低性能

4.3 Fail Fast–错误发生后尽快报告

  • 最好是在编译时- 静态类型、泛型
  • 运行时,最好在第一个错误的方法调用是时
    • 方法的失败应该是原子的

4.4 对所有可用数据提供字符串形式的编程访问

  • 否则,客户端需要转换为字符串
    • 这很痛苦
    • 更坏的是,将字符串格式转化为真实的API

4.5 重载要小心

  • 避免不明确的重载
    • 多个重载方法实际结果一致
    • 保守:没有两个重载方法的参数数量相同
  • 仅仅因为你可以,并不意味着你应该
    • 通常更好的办法是使用不同的名字
  • 如果你一定要提供不明确的重载方法,确保他们对同样的参数有同样的行为

4.6 使用恰当的参数和返回数据类型

  • 优先选择接口而不是类作为输入
    • 能保证灵活性和性能
  • 在可能输入参数类型中,使用最具体的数据类型
    • 将错误从运行时前置到编译时
  • 如果有更好的选择类型,就不要使用字符串
    • 字符串繁琐,容易出错且运行缓慢
  • 不要对货币数值使用浮点类型
    • 二进制浮点会导致结果不够精确
  • 使用 double(64 bits)而不是float类型(32 bits)
    • 精度损失为实,性能损失可忽略不计

4.7 使用一致性的参数顺序

  • 如果参数类型相同,则尤为重要

4.8 避免长参数列表

  • 三个或更少的参数是理想的
    • 更多参数用户将不得不参考文档
  • 参数类型相同的长参数列表有害
    • 程序员很容易将参数顺序写反而犯错
    • 程序仍然可以编译,运行,但是行为异常
  • 两种缩短参数列表的方法:
    • 拆分方法
    • 创建 Helper 类以保存参数

4.9 避免异常返回值处理

  • 返回元素数为 0 的数组或集合类,而不是 null

五、异常设计

5.1 抛出异常标明异常状况

  • 不要强制客户端对控制流使用异常
  • 相应的,也不要悄悄的失败

5.2 支持未经检查的异常

  • 已检查 - 客户端必须执行恢复操作
  • 未检查 - 程序错误
  • 过度使用异常检查产生样板代码

5.3 在异常中包含故障捕获信息

  • 允许诊断,修复或恢复
  • 对于未经检查的异常,消息就足够了
  • 对于已检查的异常,提供访问器

六、重构 API 设计

6.1 Vector中的子列表操作

public class Vector {
       public int indexOf(Object elem, int index); public int 
       lastIndexOf(Object elem, int index); ...
}
  • 不够强大 - 仅支持搜索
  • 没有文档很难使用

子列表重构:

public interface List {
       List subList(int fromIndex, int toIndex); ...
}
  • 极其强大-支持所有操作
  • 使用接口可减轻概念上的负担
    • 高性价比
  • 无需文档,易于使用

6.2 线程局部变量(Thread-Local Variables)

// Broken - inappropriate use of String as capability. 
// Keys constitute a shared global namespace.
public class ThreadLocal {
       private ThreadLocal() { } // Non-instantiable
       // Sets current thread’s value for named variable.
       public static void set(String key, Object value);
       // Returns current thread’s value for named variable.
       public static Object get(String key); 
}

线程局部变量重构(1)

public class ThreadLocal {
private ThreadLocal() { } // Noninstantiable

    public static class Key { Key() { } }
    // Generates a unique, unforgeable key
    public static Key getKey() { return new Key(); }
    public static void set(Key key, Object value);
    public static Object get(Key key);
}
  • 可以工作,但需要使用样板代码
static ThreadLocal.Key serialNumberKey = ThreadLocal.getKey(); ThreadLocal.set(serialNumberKey, nextSerialNumber()); System.out.println(ThreadLocal.get(serialNumberKey));

线程局部变量重构(2)

public class ThreadLocal {
       public ThreadLocal() { } public void set(Object value); public 
       Object get();
}
  • 清除API和客户端代码中的混乱
static ThreadLocal serialNumber = new ThreadLocal(); serialNumber.set(nextSerialNumber()); System.out.println(serialNumber.get());

七、总结

  • API设计是一门高尚而有价值的技能
    • 促进了大量程序员、终端用户、公司发展
  • 这次演讲涵盖了一些启发式技术
    • 不要过分拘泥于它们,但是…
    • 没有充分的理由也不要违背它们
  • API 设计很难
    • 不是孤立的活动
    • 完美是无法实现的,但无论如何要努力

参考:

  1. Service provider interface
  2. D. L. Parnas(大卫·帕尔纳斯)
  3. 简书-API设计的一些核心原则 (2020-12-21补充)
  4. WWDC 2010 - API Design for Cocoa and Cocoa Touch