How To Design A Good API and Why it Matters 这篇 talk 算是把我心里那些“模模糊糊觉得不对劲”的地方,一条条拆开给我看。 它没教你什么高深框架,只是在反复追问几个朴素的问题:这个类、这个方法到底是干嘛的?别人第一次用,会不会踩我已经踩过的坑? 几年后再回头看,这个 API 还撑得住新的需求吗?

我希望把这篇笔记作为一把尺子,对照 Bloch 的经验,一边给未来的自己留下一点提醒。

好 API 长什么样:先有一把尺子

在我自己的理解里,一套 API 想称得上“好”,至少要满足几个直觉:打开 IDE,扫一眼类和方法名,大概知道能干嘛, 写两行调用代码,不翻文档也能大致用对。一旦用错,问题会尽早暴露,而不是三天后在日志里留下一个莫名其妙的 NPE。 过几年回头看,还能在不破坏老调用方的前提下加点新东西。

这套标准听上去有点虚,但用在具体代码上反而很管用。每次准备把某个接口、某个模块真正“对外”时,我现在都会停一下, 拿这几个问题给自己过一遍:别人看到它,会觉得顺手吗?会不会一眼看不懂?有没有那种“看名字以为是 A,实际上悄悄做了 B” 的行为? 如果答案都挺心虚,那就说明设计还没成熟。

通用设计原则:从“只做一件事”开始

整场 talk 里我印象最深的一条,是他反复强调只做一件事,并把它做好。 能不能用一句话说清楚这个类、这个接口是干嘛的?能不能给它起一个自然、不别扭的名字? 如果解释和命名都很吃力,那多半是你把几件事情硬揉在了一起。

日常开发里最典型的,就是各种 工具类某某Service。 配置也在里面,网络请求也在里面,日志也在里面,数据转换也在里面。 久而久之,它就变成一个谁都不敢动、谁都能往里塞东西的黑箱。

紧接着是那句很有名的尽量小,但不要小过头。这里的,一方面指暴露出去的类和方法要克制, 另一方面指 API 引入的“概念重量”要小,也就是调用者不用背太多术语就能干活。 至于“不要小过头”,则是提醒别偷懒。所有东西塞进一个 doEverything(config) 里, 表面好像只有一个入口,实际上是把复杂度从 API 挪到了配置和调用方,长期看更难维护。

还有一个容易忽略却很致命的点,是实现细节不要漏到 API 里。 比如在异常里抛出底层数据库的错误,在返回值里暴露具体容器实现,甚至在命名里直接写上协议和版本号。 当时可能觉得方便,反正能用,但一旦调用方依赖了这些信息,你以后想换实现,就要背上各种奇怪的兼容包袱。 Bloch 的态度很简单:API 是你对外立下的承诺,实现是背后可以随时重写的部分,两者最好彻底解耦。

命名和文档其实也被他放在了很高的位置。他把 API 比作一门“小语言”,类名、方法名、参数名就是这门语言里的词汇和语法。 你希望调用代码读起来像一句正常的话,而不是一串难解的咒语。这意味着,同一个概念在整个项目里应该只用一个词来指代,相似操作的命名风格要统一。 文档写的也不只是翻译代码,而是你对调用方的正式承诺:在什么前提下可以调用,会做什么,不会做什么,失败时会发生什么。 没有这层说明,所谓好设计基本没机会真正流通起来。

性能在 talk 里也出现了,但位置比较克制。他承认,有些接口一开始的设计就会把性能死死锁住,比如强制要求每次都构造一个巨大的可变对象, 或者为了方便直接复制大量数据。但他更担心另一种情况:为了某个当下的微优化,把接口搞得又绕又丑。 实现可以重写,硬件会升级,而 API 一旦对外公开,就得负责很多年。先把概念和边界设计对,再在实现层面做性能文章,这基本成了他给的默认顺序。

还有一个让我很认同的提醒,是和平台和平共处。在 Java 世界,就尽量写得像 JDK 自家人,在 Python 世界,就别写出一个Java 风味的 SDK。 对调用方来说,这是上手成本的大头:你是用和他们熟悉的生态同一套语气说话,还是强行让他们再学一门小众方言。

类的设计:状态和继承怎么拿捏

讲完通用原则,Bloch 开始往下拆到“类”的层面。这里他先抛出一个非常直接的建议:能写成不可变类,就不要写成可变类。 不可变对象在多线程场景下天然安全,很适合缓存和共享,调用方也不需要担心事情在背后悄悄被改。 如果确实需要可变,那就尽量把状态压缩到最少,并且清楚地写明状态变化的合法路径,否则调用顺序一乱,你自己都很难 debug。

继承是另一块大雷区。Bloch 的态度可以概括成两句话:只有在语义上真的是一类的情况下才用继承,否则宁可用组合。 要么专门为继承设计和写文档,要么干脆禁止继承。问题在于,一旦开放了继承,子类就很容易对父类的内部实现产生各种隐形依赖。 父类内部稍微改一点实现,子类就可能出现一些匪夷所思的行为。如果你没有时间写一份清晰的继承指南,说明哪些方法会互相调用、 哪些可以安全 override,那最保险的做法其实就是把类标成 final,不让人继承,比放出一个危险的 extension point 要友善得多。

方法与参数:日常最容易栽的地方

方法这一层,是整场 talk 里最接近日常写代码手感的部分。Bloch 不希望调用方为了用你这个库,每次都从头写一大段样板代码。 如果打开连接、处理分页、关闭资源这些事情,每个使用者都要亲自打一遍,那说明这些逻辑本该被封装进你的 API 里。 库应该替调用方干掉那些机械重复的部分,让大家把精力放在真正的业务逻辑上。

然后是两个我很喜欢放在一起想的原则:最小惊讶和 Fail Fast。所谓最小惊讶,就是方法的行为不要和名字、直觉对着干。 一个叫 getSomething 的方法如果顺手改了内部状态,那就是不折不扣的坑。 Fail Fast 则强调错误要尽早暴露,能在编译期发现的问题就不要留到运行期,运行期的错误也要尽量在第一次错误调用时就抛出来, 而不是在内部留一个半残的状态,等后面某个操作再爆炸。

重载在 Java 里看起来是个语法糖,但在他嘴里却更像一个危险品。重载一多,自动类型提升、装箱拆箱全掺合进来, 调用方很难一眼看出自己到底调用的是哪个版本。如果不同重载的语义差得很远,就更容易出事。很多情况下,老老实实起两个意义明确的方法名, 比在一个名字下堆一堆重载要健康得多。

参数和返回值的设计,是另一块值得用心的地方。输入形参尽量用接口类型而不是具体类,比如声明接受 List 而不是 ArrayList,可以给调用方更多自由。 但同时,能用专门类型就不要都用 String 做万能胶,金额就别用浮点数,实在需要精确小数就用十进制类型。 参数太多本身就是一个信号,三个以内通常最舒服,再多就该考虑拆方法、引入参数对象或者 Builder。 还有一个很小但很实用的细节:在整个 API 里保持参数顺序的一致,比如永远都是 (key, value),而不是有时候反过来。 至于返回集合时,尽量给空集合而不是 null,可以直接帮调用方省掉一大堆防御式的判空代码。

最后还有一点很多人会忽略:不要只暴露字符串形式的数据。很多库只在 toString() 或日志里输出了大量有用信息,却没有提供相应的结构化访问接口。 调用方没有别的办法,只能去解析那串字符串。一旦你哪天改了格式,整片下游代码就会跟着一起炸。 只要你觉得某个信息值得在字符串里展示给人看,就基本也应该有对应的 getter 或结构化 API 给程序用。