如何做好接口设计

Posted on Dec 5, 2021


在这篇文章中,我想尝试与大家探讨一下,如何做好软件的接口设计。

前言

这个话题非常的大,以我的水平,未必能胜任。但就算抛砖引玉,我也希望提出一些想法,与大家一起探讨。正所谓,真理越辩越明。

设计师,在我心目中一直是一个很神圣的职业。因为这意味着,作为设计师本人要有比使用者更加高明(好几个层次)的水平。因为设计师要设想好自己设计出来的产物在何种环境中被使用,设计要能应对各种复杂场景并且能够做好相应的处理。

当然,设计本身是一个非常大的话题。即便我们把范围缩小到软件设计,甚至缩小到软件的接口设计,也依然是一个非常大的话题。

但我还是凭自己过去的一点经验,结合自己的感受,说一些我对接口设计的思考。

按照惯例,文章的末尾列出了一些我认为有益的材料。即便我的观点对你而言毫无帮助,但如果我所列的推荐读物能对你有用,那也算是一点收益。

正式进入主题之前

在开始正文之前,先让我们开个小差,上知乎随便刷一刷:

这些问题看似与编程无关,但实际上,设计存在一些共通的地方。你设计的好与不好,使用者都能很明显的感觉到。只不过对于软件设计好与坏的心理反应无法通过一张图片直观的表达而已。

什么是好的设计

我一直很欣赏日本匠人对于设计作品细节的掌控,这意味着它们在做设计的时候,一定是经过了深入的思考反复的实验。我觉得这两点是产生好的设计最重要的基础。

我的日语水平连最基本的打招呼都不能胜任,但曾经仅凭Google Maps我就可以独自一人在日本各个城市之间,在其复杂的电车线路中完成旅行。这很大程度上就源于日本电车线路以及Google Maps产品的良好设计。

这错综复杂的线路,通过不同的颜色进行了区分。这极大的减少了使用者出错的可能性。并且,我相信这里的颜色都是经过充分思考的,不会出现相邻的颜色过于接近导致无法辨识的问题。地图和物理站台的颜色有准确的对应,避免了由于印刷色差导致红色和橙色无法分辨的结果。

只要是设计,就必定是预设提供给其他人用的,因此这里就存在一些共同的好的设计原则。

那么究竟什么是好的设计呢?

好的设计有两个重要特征,可视性(discoverability)及易通性(understanding)。

  • 可视性是指:所设计的产品能不能让用户明白怎样操作是合理的,在什么位置及如何操作。
  • 易通性是指:所有设计的意图是什么,产品的预设用途是什么,所有不同的控制和装置起到什么作用。

这么说可能不太容易理解。用人话来说就是:一个好设计,不会让用户感到困惑。所以首先得让用户知道这个产品怎么用,怎么操作才是合理的。另外,还要让用户明白你的设计意图,也就是明确地告诉用户,你设计的这个东西是干嘛用的。

什么是好的接口设计

接口设计是设计的一种具体场景,那到底什么是好的接口呢?

《API Design Patterns》 这本书中提了四个要点:

  • 可使用(Operational):这一点是毋庸置疑的,接口必须要能完成它提供的能力。可使用是最基本的能力。如果使用者的环境稍微发生了一点变化,接口就不工作了,这就与可使用相违背。
  • 富于表现力(Expressive):与可使用同样重要的是,借助接口,用户要能够清晰表达他们想要做的事情。
  • 简单(Simple):爱因斯坦说过“一切应当尽可能简单,不过不能关于简单”。你需要仔细思考,考虑清楚要简单到哪一个层次对于使用者来说是最合适的。例如,对于描述“开关”功能,给普通用户来说,只需要“开”和“关”两个操作就可以了。但是如果是给电工使用,那可能还需要更多的操作。你需要仔细思考用户是什么样的人群。
  • 可预测(Predictable):“可预测”的反面是“出乎意料”,虽然这个词和“惊喜”有些近义。但对于API来说,不需要任何惊喜。API应当始终一致的照着接口的契约完成使命。如果接口的契约不包含出错和异常的情况,那这个API被调用1000,10000遍,都不应该出现任何异常。当然,如果实际情况是有可能出错或者失败的,那就在接口描述里面说清楚什么情况下会出什么错,并准确的在相应的时机返回相应的错误。

接口的分类

在着手接口设计之前,我们要考虑一下自己所设计的接口适用场景是什么。不同的场景,对于设计的关注点也不一样。

这里列举了几种常见的接口类型:

项目的内部接口

这是最容易处理的一种。你的接口仅仅提供给项目内部使用的,如果只有明确的几个模块使用你的接口那就更好处理了。这意味着,即便你的接口发生的改变,只要把使用你接口的模块也一起改掉就可以了(如果不改,可能编译器可能就立马报错了)。

不仅如此,这种情况下,也更容易做测试,你可以编译整个项目,然后测试调用你接口部分的功能即可。

公开的在线接口

Web Service曾经盛极一时。

这类接口在设计上要谨慎一些,因为你事先没法确定谁会来调用你的接口,而且这种接口通常要被设计成可以调用任意多次。因此你首先必须确保你接口是幂等的。

在编程中,一个幂等(Idempotence)操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。

幂等意味着前面的接口调用不能影响后面的操作。你也不能依赖多个不同接口的调用顺序。

所以,这种情况下,你的接口实现通常是无状态的。并且,每个接口要完成独立的功能,不能将一个功能分成多个接口完成。

这类接口的演进要相对容易些,在保留原先接口不变的情况,提供一个更新的版本即可。新旧接口既可以是完全独立的,也可以在实现上复用旧的接口。

对于Web API的设计者,以下两个文章可能对你有更多的帮助。

对外部发布的接口包

这类接口通常会发布一个SDK包,被包含在使用者的工程中。例如:支付宝客户端SDK微信开发 Java SDK

这类接口就有更高的要求了。因为是被其他的工程包含的,所以你要保证SDK足够的小,不能给使用者带来太大的负担。如果SDK包已经越来越大,可能需要考虑将其拆成多个小的包,供开发者按需使用。

SDK通常仅仅是服务的客户端,而服务端常常在另外一个地方。这就要求客户端要和服务端匹配,否则接口调用将失败。

为了保留演进的可能性,SDK的接口中自动带上客户端的版本号供服务端识别。这样可以保证业务延续性。

框架类接口

Java开发者对于“框架”这个词,以及对于Spring,Hibernate,Struts这些开发框架应当都特别熟悉。

框架包含了复用性更强的接口。因为这些接口不仅仅是给开发者调用,而是通常会提供很多的基类给开发者继承。

“继承”其实是一种强耦合,是一种侵入性的方案。因为使用者的很多代码都会强依赖这些基类,必须在这些基类规定的范围内运转。这也是为什么这些软件称之为“框架”。

这就对框架的设计者提出了更高的要求,因为一旦框架中的基类出现bug,所有继承这个类的子类都会受到影响。如果是直接crash那倒还好说,因为通过堆栈很容易发现问题。但如果是内存泄漏之类的问题,使用者就会一脸茫然了,因为他可能根本就不熟悉框架的实现。对他来说,处于黑盒中的模块出现泄漏,是很难发现和定位的。

再者,基类的实现要特别在乎资源占用,如果不是必须的,就应该将资源占用的控制权交给使用者。

操作系统的接口

这是最难设计的一类接口。其代表示例是:

之所以操作系统的接口最难设计的,不仅仅是因为这类接口使用的人最多,而是因为它们需要跨越最长的时间长度,这几乎是以10年为单位的(POSIX已经有33年的历史了)。这就对设计者提出了苛刻的要求。

能设计出适用于未来若干年的产品是一件有极大挑战的事情。因为我们所有人都无法预测未来,而想让我们的设计结果能在未来继续使用,这就要求设计师有非常深入的思考以及对未来可能发生变化的模型抽象。

我们知道,人类历史上有很多伟大的工程,例如:

  • 巴黎凯旋门:拿破仑为纪念1805年打败俄奥联军的胜利,于1806年下令修建而成的。200多年了,还屹立在巴黎街头。

  • 罗马斗兽场:建于公元72年-82年间,距今已经接近2000年了。

但这些工程如今仅仅是一个地标性的景点,其本身不具有什么功能性价值。

在这方面,我们中国的“都江堰”就要厉害太多了。这个建于公元前256年至251年的工程,经历了两千多年,还依然发挥巨大作用。

也就是说,这不是一个图腾,而是一个实实在在还在工作的作品。

这可能是最厉害的设计师也应该顶礼膜拜的作品了。

如何着手设计API

虽然诸如gettersetter之类的接口我们不加思索就能写出来。但更多的时候,接口设计需要消耗我们更多的精力。

前面已经说过,“深入的思考”和“反复的试验”是我觉得最重要的两点。

我们先通过面向对象的设计方法划分出类,然后再决定每个类中应该包含哪些方法。接下来再考虑这些方法的命名,并可以通过伪代码来感受接口的设计是否好用。

关于编程风格和软件设计的书非常的多,但是对于API设计的书却相当有限。以下两本是不错的开始。(截止目前这两本书还没有中文版)。

对于大部分人来说,可能并不会通过阅读书籍来学习如何设计API。主动去阅读和参与大型,主流的开源项目,可能是最常见的方法。

遵守所在项目的编码规约

不同的项目会有不同的编码规约,例如:

编码规约通常定义了变量和函数的命名规则,这其中会包含单词大小写,以及是否该使用下划线之类的问题。

当你在定义接口时,首先应当遵守所在项目的编码规约。

编码规约是什么样并非最重要的,最重要的是有编码规约。只有这样,整个项目才能看起来是一致的。

做好模块划分

接口属于模块的一部分,因此在设计接口之前,我们要首先考虑好模块的划分。对于大型项目,你可能需要按照分层的方式来划分模块。

如果我们不是整个项目的架构师,但至少得知道自己所在模块位于整个项目中的位置。清楚的知道自己提供的接口使用者是谁。

高内聚内耦合是通常要遵守的设计原则。

这意味着要划分好模块,以及模块的边界。每个模块应当尽可能自包含,并且模块间的耦合要尽可能减少。

接口并非仅仅是函数或者方法

API的全称是Application Programming Inteface,这可能让我们觉得接口仅仅是编程所用的函数,或者Java中叫做方法(Method)。

但其实,你提供给别人的每一个数据结构,每个常量(枚举)定义也是接口的一部分。甚至,如果你开发的是框架,那么给开发者所使用的的配置文件也是接口。

为什么?因为你对上面几种定义一旦改变,都会影响的使用者。

这意味着,接口不仅仅是编程调用的函数,更加是和使用者约定的规则和使用方法

正确使用模式

软件行业有很多前辈总结的经验,它们常常以模式的形式存在。这其中最出名的可能就是《Design Patterns: Elements of Reusable Object-Oriented Software》

事实上,最早使用“模式”思维方式的并非软件行业,而是建筑行业。建筑师Christopher Alexander早在1977年就使用了“The Pattern of Streets”。

除此之外,如果你在Amazon.com上搜索architecture pattern,还可以看到更多的书。

设计模式或者架构模式其实是软件行业的“行话”。既然是行话,就要正确的使用,否则别人就可能误解你的意思。

因此,当你的接口中包含了StrategyBuilderFactorySingleton这类的词语时,请确保你准确理解了这些设计模式,并且正确使用了它们。

另外,如果你确定的是在使用某个模式,那就大大方方的在名称上使用标准术语,不要随便修改词性或者打乱名称的词语顺序,这样使用者会更容易理解。

关于API的兼容性

在API版本演进的时候,我们要重点关注兼容性的保持。兼容性表达了:在我们更新接口的时候,使用者是否能够不受影响,或者会受到多大的影响。

兼容性大体分为“向后兼容性”与“向前兼容性”:

  • 向后兼容性(Backward Compatibility):新版本API需要提供旧版本的相同功能。即:新版本需要能够完全代替旧版本。这意味着,新版本的功能应当是旧版本的超集。
  • 向前兼容性(Forward Compatibility):使用新版本API开发好的程序,在不修改代码的情况下,使用旧版本的API也一样能进行编译。这意味着,新版本实现不能提供任何新增接口,否则使用者使用旧版本就会出现问题。

大部分时候我们所说的兼容性其实是向后兼容性。而向后兼容性其实又隐含了三种兼容性要求:功能兼容性,源码兼容性以及二进制兼容性。

  • 功能兼容性(Functional Compatibility):运行时行为与原先版本一致。这意味着版本N+1接口的功能与版本N是一样的。但事实上,这种“功能一样”是“灰度的”。例如:新版本仅仅是修复了旧版本的bug,在这种情况下,虽然接口的行为变更了,我们也认为是满足功能兼容性。
  • 源码兼容性(Source Compatibility):这是一种要求很低的向后兼容性。源码兼容性仅仅要求使用者可以在不修改代码的情况下,使用新版本的接口能够完成编译。但不保证运行时行为。
  • 二进制兼容性(Binary Compatibility):这种兼容性要求:使用者不用重新编译自己的程序,仅仅重新链接新的接口库就可以运行。对于C++语言来说,保持二进制兼容性不是一件容易的事。因为一旦对接口的参数做调整(包括调整类的结构)就可能会破坏二进制兼容性。

对于使用者较多,跨越时间较长的项目来说,保证接口的向后兼容性是一件非常重要的事情。因为我们不能要求所有使用者都跟着我们的版本持续修改。因为这对使用者来说,负担太重了。

准确使用英语

虽然诸如Java这样的语言,支持代码中包含汉字,但是我想大部分人都不会这么做。为了使项目工程尽可能适用性广泛,确保所有代码(包括注释)使用英语几乎是必须的。

所以,“准确使用英语”这一点对于我们这些母语不是英语的人来说尤其重要。

你可能已经觉得,使用汉语拼音来命名接口是一种比较土的方式。但其实如果接口中有明显的英语语法或者拼写错误,那也很可笑。

注意词性

首先要注意的就是词性。

  • 类的名称通常是名词,例如:ManagerServiceAnimation等。
  • 函数通常是动词或动宾结构,例如:start()createUser()startBoot()等。

对于一个名称叫做Start的类,或者一个叫做ball()的函数,你应该本能上就觉得很奇怪。

另外,还请注意及物动词和不及物动词,不要犯低级的语法错误。

对于动词,根据场景,你可能还要注意时态。例如:如果时机上就存在“开始传输”和“渲染完成”这些事件,你就应该用上transferStarted以及renderDone这类的表达。

在设计接口的时候,不停的查字典是一件很正常的事情。因为就我自己而言,很多时候一些词的表达是否准确,某个词究竟是名词还是动词,是形容词还是副词,我也不太确定。

正确使用对仗

和中文一样,在英语中同一个含义有很多个类似的词可以表达,例如:

词语 近义词
send deliver, dispatch, announce, distribute, route
find search, extract, locate, recover
start launch, create, begin, open
make create, set up, build, generate, compose, add, new

但是与中文不同的是,英语中有很多词存在对仗关系。

对于地道的表达,如果你使用了add就应该使用remove来对仗而不是destroy。使用了increase就应该使用decrease,而不应该用reduce

下面是一些常见的对仗词:

词语 对仗
add remove
increase decrease
open close
begin end
insert delete
show hide
create destroy
lock unlock
source target
first last
min max
start stop
get set
next previous
up down
new old

正确(避免)使用缩写

缩写(Abbreviation)看起来是减少了使用者的负担,但我个人认为这常常会适得其反。

对于业界常见的术语,例如httpDNS这些都没问题。但是,对于IMNSHONNRTDGMW你知道是什么意思吗

缩写不仅仅是难于理解,还有一个问题是:一个缩写可能会有多个解释,在上下文信息不全的情况下,使用者会很难分辨。

所以,如果你不确定是否正确使用了缩写,那我觉得:正确使用缩写的原则就是:尽可能避免使用缩写。现在IDE以及代码编辑器都自带了代码提示补全功能,你的函数命名太长,并不会对开发者造成负担(其实,名称越长反而越容易辨识)。所以,不用有这方面担心。

这方面,Apple似乎是典范,甚至走了极端,请看一下下面两个Apple提供的接口:

  • kBluetoothHCIExtendedInquiryResponseDataType128BitServiceClassUUIDsWithMoreAvailable
  • kBluetoothAMPManagerCreatePhysicalLinkResponseAMPDisconnectedPhysicalLinkRequestReceived

不用数了,它们的长度分别是84和88个字符。

《The Art of Readable Code》这本书描述了和我类似的观点。“项目级别特有的缩写通常是糟糕的主意”。这本书给出的建议是:“So our rule of thumb is: would a new teammate understand what the name means? If so, then it’s probably okay.”(如果团队中来了一个新员工,是否能理解缩写的含义。如果能,那也许是可以的。)

降低使用难度

只要是设计,就意味着你所做的产物是给别人用的。那就有必要从使用者的角度考虑问题,减少使用者的学习成本和出错的可能性。

从正面表达

降低使用难度的第一个建议就是减少使用者的思考成本:从正面表达。

自然语言中的双重否定常常是为了感情的表达,但是程序是人类写给计算机执行的,感情的表达完全没必要。更容易理解才是最需要考虑的方向。

通俗来说就是:接口的命名尽量使用肯定的词语,而不是否定方式的表达。对于isWritable()这个函数我们很容易理解,对于isNotWritable()也还好。但是下面这句呢:

if (!isNotAccessible() || !isNotWritable() || ! isNotPrintable())

如何接口都是正面表达,下面这样是不是更容易理解了:

if (isAccessible() && isWritable() && isPrintable())

《代码大全》这本书在说这个问题的时候,引用了《辛普森一家》中荷马·辛普森的一句台词:“I ain’t not no undummy. —- Homer Simpson”。

减少使用者出错的可能性

调用者使用错误很大程度在于参数传递上。

例如下面这个函数。第二个和第三个参数都是布尔值,这样使用者很容易记错顺序,或者搞不清楚该传入true还是false

std::string FindString(const std::string &text,
                       bool search forward,
                       bool case sensitive);

有什么改进方法吗?是的,通过枚举在定义上就避免这种错误存在的可能:

enum SearchDirection {
    FORWARD,
    BACKWARD
}; 

enum CaseSensitivity {
    CASE SENSITIVE,
    CASE INSENSITIVE
}; 

std::string FindString(const std::string &text,
                       SearchDirection direction,
                       CaseSensitivity sensitivity);

很多时候,通过枚举代替布尔值以及整数表达的类型,可能减少使用者出错的可能性。

避免顺序耦合

前面说到过,软件设计要尽可能保证高内聚,低耦合。这一点对于大型项目尤其重要。

耦合表达了软件模块之间的依赖程度,耦合过深常常意味着架构和设计没有做好。下面这个图你应该很容易分辨出,左边的依赖关系要比右边的好。

耦合有很多种,对于接口来说,顺序耦合就是我们要注意的。

类似下面这组命名的接口,很可能就是顺序耦合:

doSomethingFirst()
doSomethingSecond()
doSomethingThird()

顺序耦合是指:类中方法需要按照某个特定的顺序调用才能工作。

这种设计的问题在于:对于使用者的要求太高了。如果调用者不按顺序调用怎么办?

以汽车作为类比:如果用户在没有首先启动发动机的情况下踩油门,汽车不会崩溃、失败或抛出异常,它只是无法加速。

对于这类问题,通常可以使用Builder或者Template Method设计模式来解决。

准确表达

我在写博客时,会尽可能使用最通俗的语言。对于一些稍微复杂一点的问题,会尽可能通过括号或者注解去解释哪怕是有点重复的表述。这是因为,我希望尽可能降低我的文章的阅读门槛。

这种啰嗦的方式看起来是在浪费时间,但我觉得实际上正好相反。

这是因为,我的写作仅仅是一次性的事情,我多描述清楚一点,读者的疑惑就少一点。能够理解内容的人就更多一点。

我们在做一件事情时,自己所知道的事情,会本能的会以为别人也是理解的。能站在客观的角度,清楚的知道哪些点是需要明确说明的,并不是一件容易的事情。

“You do not really understand something unless you can explain it to your grandmother.” ― Albert Einstein

在编程时也是一样。

例如,对于一个描述编程语言的字段,我们可能会将其命名为language。这是因为我们默认认为已经在讨论编程语言了,但是language到底是编程语言还是国际化语言?是不是叫做programmingLanuage更好一些呢?

当然,对于这一条还是要举一个反例,不要走到另外一个极端:如果类名或者namespace名称中已经明确带了一个前缀,在函数中就没必要再重复一遍了。毫无信息量的冗余是没有必要的。

保证每个接口是正交的

正交(Orthogonality),意味着多个接口之间不应该出现重合的功能。

例如:一个接口提供了创建用户的能力。另外一个接口包含了创建用户并登录的功能。如果是这样,就应该把第二个接口拆开,只保留单独登录的功能。

从逻辑上来看,你的接口之间的关系应该像下面这样:

而不是这样:

当然,为了便于调用,可以允许将一些接口打包提供一个组合接口。但即便这样,首先也是要求得有单独操作的接口。这样使用者可以根据需要来选择使用。

描述接口所做的一切

有些接口是用来查询信息的,例如getUserAccount()。有些是进行操作的,会修改数据,例如createUserAccount()

永远不要在一个看起来是查询操作的接口里面去做修改数据的动作,因为这样使用者会非常的迷惑。

更通用的来讲,接口的名称应当表达它所做的所有事情,不要有所隐瞒。只有这样,在阅读代码的时候,才能更容易理解代码到底做了什么。

你可能会问,我的接口里面做了很多事情,我很难通过几个单词描述清楚所做的所有事情。对于这种情况,通常意味着你需要将这个接口拆分成多个,因为这个接口不够“内聚”。

注意接口的逻辑层次

大型软件通常都是分层来架构的。这意味着每一层所要解决的问题都在不同的层次上。

因此,我们的设计和接口也要在相应的层次上。请看一下下面这幅图。

所有的建筑都会包含墙体和房间的门。墙体是有砖块构成的,门是有木料构成的。请确认我们提供的接口是在哪一个层次,如果在最底下一层就有关于doorwall这样的接口,那很可能逻辑层次出现了问题。

做加法,不要做减法

这句话换一个表达方式就是:接口并非越多越好。

虽然从表面上看,接口越多,似乎意味着我们提供了越多的功能。但如果提供了不该提供的接口,带来的后果将是非常严重的。因为废弃接口(包括变更)是一件代价很大的事情:试想一下,对于一个你使用了10年的电话号码,有一天你要换一个号码,你需要做多大的努力才能确保通知到所有人?而又有多少人会因为信息没有及时更新而拨错号码?

要知道,每一个接口都是我们对使用者的契约,这种契约越多,我们的责任也越大。

所以,最好的做法就是:尽可能少暴露接口。刚刚好够用就行。在今后的版本中,逐步的迭代,做加法(新增接口),而不是做减法(废弃接口)。

对于内部实现的接口,对于有重复功能的接口,对于安全模型还没有理清楚的接口,都不应该提供。

Android是目前市场占有率最高的操作系统,其快速发展也带来了诸多问题,例如的废弃接口。《Characterising deprecated Android APIs》这篇论文,专门研究了Android废弃的接口以及带来的问题,有兴趣可以阅读一下。

我觉得,“奥卡姆剃刀”原理,对软件设计有着巨大的启示作用。所以,“如无必要,勿增实体”。

不要暴露实现细节

在面向对象编程的第一堂课上,老师可能就告诉过我们“封装”的重要性。

这意味着,我们应当尽可能封装好实现细节,提供简洁的接口供调用者使用。

这就好像冰山,埋在水里的部分不管多庞大,露在水面上的部分要足够的小,足够的简洁。这才是友好的界面(Interface)。

不要忘记更新接口说明

《编程珠玑(续)》中引用了Bell实验室计算机科学家Norm Schryer说过的一句话:“如果代码和注释不一致,可能两者都错了(If the code and the comments disagree, then both are probably wrong)。”

所以,当我们在更新接口实现的时候,一定要记得更新接口的说明。从功能兼容性的角度,我们不能修改原先存在的行为。通常应当提供新的接口来完成新的功能。

但在两种情况下,可以考虑修改既存接口的行为:

  1. 缺陷的修复;
  2. 为接口拓展新的特性或场景,但不影响原有特性或场景逻辑;

第一种情况,通常在Release Note里面说明;第二种情况就需要在接口说明上表述清楚了。

不要指望使用者看完所有接口文档

虽然你应该对每一个接口做详细说明,但不要指望使用者会先看完所有接口文档再使用。

前面我们已经说到,接口设计要尽可能简单,简单要看到函数名称就能明白其作用,这是最好的。

接口的说明文档就好像是产品的说明书。用户通常不是照着产品说明书来使用产品,而是遇到问题才会找出说明书来看。

如果需要使用者看完几十页的文档才能使用,这足以让绝大部分使用者望而却步。

参考资料与推荐读物

但我认为,只要脚踏实地,就能走好设计这条路。 – 原研哉《设计中的设计》


原文地址:《如何做好接口设计》 by 保罗的酒吧
 Contents