判断一个API是否优秀,并不是简单地根据第一个版本给出判断的,而是要看多年后,该API是否还能存在,是否仍旧保持得不错。
第一个版本远非完美
第一个版本总是来得特别容易,不仅容易开发,而且容易发布。API的需求会随着时间而变,那些过去有效的API可能现在已经不再适用了。而且每个程序中都 会存在Bug,需要不断地来修复,这样做带来的副作用人所共知:修复一个Bug的同时会引入两个新Bug。这些观点普遍适用于所有软件系统,API也不例 外。
但我们没必要为这个结论而感到悲观。API因为需要不断改进的事实算不上什么坏事,只是对现实的一种坦诚。每一个API的作者都应该为未来的改进做出计 划。这种计划是一种比较高层次的,要考虑未来版本会对API中哪些内容加以改进。这种计划可能会用到两种方式。一种极端的方式是放弃老的版本,重新开始做 一套新系统。还有一种方式则是修正用户提出的问题,并强化现有的API,保证兼容性,从而使得现有客户端的功能不会有所改变。
放弃现有的API,并从头开始编写一个新的API来完成同样的任务,可以避免不兼容问题。这样做唯一的问题就在于:那些使用旧API的客户端只能继续沿用老的API,除非重新编写他们的代码,以升级到API的新版本上。所以这样做的缺点也是不容忽视的。
完全重新编写API的优点在于避免了细微的不兼容问题,但让客户端被锁定在一个特定的版本中,即使新的版本提供了大量的改进,这些客户端也无法从新版本中 获益。虽然对API进行改进固然是一件重要的事情,但相比之下,兼容性更为重要。只有在这两者之间巧妙地取得平衡才能让一个API成为可用的API。
向后兼容
对于每一个API的设计者来说,都渴望做到“向后兼容”,因为不管是现在的API用户,还是潜在的API用户,都只信任那些可兼容的API。但向后兼容有多个层次上的意义,而且不同层次的向后兼容,也意味着不同的重要性和复杂度。
源代码兼容
说到兼容性,最先要面对的问题,就是保证源代码编译时的兼容。如果基于Java 1.3版本开发程序,那么可以用Java 1.4版本来编译这些程序的源代码吗?如果能做到这一点,那么可以说Java 1.3和Java 1.4这两个版本是源代码兼容的。但源代码兼容是非常难以达到的。之所以出现这种问题,主要是因为每个新版本的Java语言都会添加一些语法上面的新功 能,这种改变往往都会体现在执行文件的格式上,也就是Class文件的格式会有所调整。
二进制兼容
如果一个基于老版本类库开发的程序,在不需要重新编译的前提下,可以和新版本类库进行正常连接并执行,那么这种情况可以称作二进制兼容。因为有两种场景需 要这种兼容性方面的支持,所以要做到这一点也是非常重要的。首先,用户基于某个版本的类库编写了一个程序以后,原先开发的程序应该都可以一直正常运行,不 管用户手中的类库是哪个版本,是否升级到了最新版本,程序的运行都应该是正常的。这样做可以极大地简化程序的维护、打包和发布工作。其次,如果用户只有一 个老版本的二进制类库,也同样可以开发程序,随后再移植到新版本上,这样就无须用户来重新编译程序 。这两种场景都有各自的用途,它们提升了配置方面的灵活性,并赋予模块开发人员和用户更多的自由。为了达到这种相互调用的灵活性,开发人员至少需要了解一 些源代码编译后生成的二进制格式。对于Java语言来说,就表示开发人员需要去了解Class文件的格式,以及Java虚拟机如何加载Class文件。
二进制字节码的格式与Java源代码的格式非常相似,这有好的方面,也有坏的一面。说它好,是因为这样的格式很容易理解。说它坏,是因为它会引发一些误 解。但大家应该记住,在编写API的时候,只有通过二进制格式才能最终判断不同版本的API是否兼容,也就是说代码执行时的兼容性才是最根本的。所以一定 要了解Java源代码是编译成何种样子的字节码。如果有疑问的话,最好反编译一下Class文件,检查一下到底是不是自己期望的样子。有可能你会为反编译 的结果大吃一惊!
功能兼容——阿米巴变形虫效应
如果一个类库在运行时,不管所引用的是老版本还是新版本的类库,其产生的结果完全相同,那么这两个版本可以称为功能兼容。这个定义看起来简单,但背后的含义却不简单。
作为开发人员,你可能会清楚地知道你所开发类库都提供了哪些功能,假设你提供了优秀的规范和完善的文档还有其他的信息,从而能够清楚地对类库的功能加以说 明。当然这只是一个假设,从来都没有什么优秀的文档可以做到上面所说的目标。在现实世界中,文档总是比程序慢上一步,而且其描述的信息也只是整个程序的一 部分内容。但还是先假设有一些开发人员已经完美地分析了程序,并清楚地知道程序的所有功能。如图 1中所描绘的那样。
但大家都很清楚地知道软件开发中的一条金科玉律:每个程序至少都有一个Bug。什么意思呢?所谓的Bug其实就是程序的功能不符合预期定义的内容。即使开 发人员愿意相信程序的行为如图1中定义的那样完美,而现实中,程序的功能与其预期定义的内容都是存在出入的。在特定情况下,代码并没有实现预期的功能,而 在其他情况下,所完成的功能要超出预期。如图2所展示的样。
问题就隐藏在这张图的背后!假设这张图同时描述了现实世界与理想世界中的程序功能。那么圆是一个清楚的功能定义,而具体程序则有所出入,有时没有完成规范 中定义的某些功能,有时却又超出了规范中定义的某些功能。而程序员在开发代码时,往往不会去阅读相应的规范。事实上,几乎没有哪个开发人员会去读这些 API的规范,直到出现严重的问题。他们会用“编码/运行”这样的方式来完成自己的工作。比如说开发人员写了一些代码,运行之后发现完成了自己需要的功 能。此时,他们关注的是那些API背后具体实现所完成的功能,并不关心规范怎么说。这样做,其实是让代码依赖于具体的实现,而这些实现内容并不会写到规范 中。显然,不管是少提供了功能,还是多提供了功能,这两种情况都是非常危险的,会影响到未来类库版本的共存,更会影响使用这些类库的程序。一旦该类库有新 版本发布,修正了某些bug,或者再添加一些功能,都有可能会影响你的程序,就像图3一样,从圆变成了不规则形状。
要为自己的行为负上责任也不是一件容易的事。所以API设计者首要的目标就是要减少阿米巴变形虫效应,要让API的功能行为尽可能地与规范保持一致。这事 做起来决不简单。需要开发人员对API要完成的功能有清楚的认识,同样还要有良好的技术水平,才能将自己的意图贯彻到代码上,此外还得评估一下API的用 户会如何来使用(还要想一下这些用户如何来误用API)。
面向用例的重要性
请记住,如果一个API被广泛使用了,那么就不可能了解所有使用该API的用户。比如说,Linux内核的作者不可能知道所有使用该内核的开发人员以及他 们使用内核的动机所在,也不清楚地球上有多少人使用了ioctl 这个方法从内核得到相应的内容。所以,如果设计者希望能够设计出像Linux和Java这样被广泛使用的API,那么必须站在用户的角度来理解如何设计 API库,以及如何才能设计出这样的API库。
既然不知道自己的客户,自然也无法进行交流,那么就有两种解决方案:一是找一些用户,对其进行研究,还是一种方式就是基于用例。基于用例也就表示站在用户 的视角,然后再对部分用例进行针对性的处理。从用户处取得反馈信息,并对可用性进行研究,这是一种很好的工作方式,可以通过反馈来检验设计的用例是否正 确。在做设计之前,必须进行分析,明确为什么要写这样的API,API应该长什么样,以及如何才能完成目标。
当然这些用例都是编的。当API有了真实的用户时,才会发现这些用例与与真实情况可能相距甚远,与真实需求也有很多不同之处。从这一点上来说,第一个版本 决不可能完美。但可以减少API中存在的错误。说到API设计错误,并不是指这些API不能满足用户需求,这是很正常的,因为在整个系统的生命周期中,会 不断地有用户提出新的需求。所谓的错误是指API不能在后续的版本中以扩展的方式来满足用户的这些需求。但如果你学习了本书,在设计API时,不仅可以通 过扩展的方式满足新需求,而且新版本也不会破坏客户基于第一个版本开发的那些代码。
前文展示的阿米巴变形虫模型说明了对外的功能描述和其内在实现之间是存在着差异的,正是这种“差异”引发了API维护中的主要问题。所以要尽可能地减少这 种不一致。但想要实现这个目标也要有一个前提,就是能对外提供一个定义清楚而且明确的规范,否则没有规范,拿什么来进行比较呢!
API设计评审
过去,人们一直认为设计工作是不能由一个集体来完成的,它需要一个架构师对所有的设计进行决策。当然,这样做可以简化很多事情,但仍然有一个规模上的限 制。就算不考虑模块规模大小这方面的限制,这位首席架构师的压力也是非常庞大的,其责任包括设计、维护API,还要告诉别人API应该怎么使用,这些工作 内容都需要占用大量的时间,毕竟这位架构师一天只有24小时,不可能无限制地工作。
解决方法就是从团队成员中选择一些技术最好的人,指导他们来设计自己所需要的API。但这样做会造成一致性方面的问题,因为每个人在设计API时都有其个人风格。肯定无益于API的质量,必须解决这一问题。
但每一个设计良好的API,都有着相同的动机。所以要有一个团队来配合API的作者对API进行评审工作。
一旦有一个API需要改动,任何人都可以提交一个改变的请求。其他人则需要在代码正式提交前,进行一次评审,检查新调整的内容是否符合一个优秀API的基本要求。比如说,我们会按照“优秀API规则”进行检查,保证能够满足这些规则。下面详细地列出了这些规则。
用例驱动的API设计:设计API时,要基于一些具体的场景和对API的认识进行抽象分析,最终给出设计。
API设计的一致性:API往往是由多位设计者来完成的,但整个团队中必须能够保持“最佳实践”的一些基本原则。一个接口设计得再好,只要它违反整个团队的一致性,就宁愿退而求其次。
简单明了的API:简单而且常见的任务应该更容易处理。如果基于用例驱动的方式进行设计,就可以很容易地通过那些可以简单实现的场景来验证这些API是否可以完成那些重要的用例。
少即是多:一个API对外提供的功能应该只包括用例中说明的功能。这样可以避免出现需要的功能与实际提供的功能两者之间出现差异。
支持改进:以后也必须能够维护这个类库。如果出现新的需求,或者原作者离开,都不会出现放弃这个类库的情况。
一个API的生命周期
开发API的过程其实就是一个沟通交流的过程。沟通的双方就是API用户和API设计者。
API有可能是这样产生的,有些人写了一些代码,而另外的人发现这些代码的价值,就开始使用这些代码。在这种情况下,API是以一种自然的方式产生的。随 后API用户和API作者有了相互沟通的渠道,开始交流经验,可能发现这个功能一开始的设计并不是很通用,或者说一开始时,作者并没有把这个功能当成一个 API来设计。为了让这个功能成为一个API,他们开始讨论如何进行调整才能改善这个功能。经过几轮的迭代,才会带来一个有用而且稳定的API。
但再换一个角度来看,API设计者希望在没有对外提供一个API之前就能和相关的用户进行沟通。这种API的开发方式更接近于基于业务的设计方式。在这种 场景下,系统中的两个组件间的协定是已经明确的,至少说也是已经有了需求。收集需求,定义问题域,明确用例,再由指定人员来设计API。现在,其他人员就 可以使用这个API,并可以给出自己的建议,列出Bug,提出一些功能方面的改进意见。这些建议都有助于改进API,使其用途更广,也更稳定。
尽管这些API的案例各有其不同的缘由,但有相同的特点:每一个都需要时间来让用户进行试用,并进行反馈,然后才能宣称这个API是可以正常运行的。当然 不是说这样做就可以带来稳定的API,有时候,也有可能最终什么都得不到。如果出现这种情况,最好还是放弃这个API吧。有时候,可能交流的双方无法进行 高质量的正式交流。在开始的时候可以简单地聊一下,交流相应的需求,但如果新发布的版本也证明了这种简单的沟通方式并不合适,那么双方也许应该更进一步地 进行交流,使得沟通能更简单更有效一些。
一切皆有可能。但对于那种开发人员之间的沟通问题,最好还是要描述清楚。如果你要设计一个API,那么在这个API没有成熟前,最好能够清楚地告诉其用 户:“这个API还没有完善,你可以尝试使用这个API,但一定要小心。”在有了稳定的版本以后,还可以骄傲地告诉用户:“这是我开发过的最好的API! 放心使用这些API,我可以保证它能一直提供支持。”这样做可以俘获API用户的“芳心”,让他们“拜倒在你的石榴裙”下。但请一定要注意,对于API, 要能够清楚地标识其当前状态,以便用户了解相应版本是否可以稳定使用。
如果想告诉类库的用户当前发布的版本还不稳定,那么最简单的方式就是把其版本标识为0.x。因为它还没有到达1.0版本,表示还在开发中,也就可能还会有 所变化。不管用哪种版本标识方式,最重要的是要让API的用户清楚地知道当前版本的状态,以便他们决定如何使用该API。
逐步改善
我已经提过多次,这里再多重复一次,第一个版本远非完美。事实上,不仅第一个版本,哪个版本都不会是一个完美的版本。不管怎么样,设计的场景不可能完全准确,不可能完全符合之前的方案。对于版本间的变化,也有两种极端的处理方式:一种是逐步改善,还是一种则是完全重写。
什么叫逐步改善呢?比如说,增加了一个方法或一个类,或者是向DTD文件中加了一个新的元素,又或者是增加了一个能够影响类库功能的属性。在保证老版本API能正常运行的情况下,演化出新版本API。这种改进是一步步进行的。
关于“逐步改善”有一个谬论,就是说“因为只有少许的改动,所以以前用户使用老版本API编写的程序可以在新版本上继续运行”。每一个改变都有潜在的风 险,因为它可能引发一个甚至更多的不兼容问题。每一个不兼容问题(即使看似微不足道)都会反映到客户开发的程序中,可能就会产生非常严重的后果。开始时, 要能够把真正的内容与最初的设想保持一致,而且任何一个小的改变都不会引发任何问题,这样才可能避免阿米巴虫模型出现。
如果考虑到向后兼容性问题,那么也许重写一个全新的版本可能是更现实一些,这样可以清楚地告诉类库的用户,如果要移植到一个新版本上,就要花费一些时间进 行代码迁移工作。与前面“逐步改善”的方式相比,这样做显得更诚实一些。但这样做也在很多方面都存在问题。首先,如果新旧版本保持兼容,那么迁移的工作量 就非常小,否则完全重写一个实现需要投入大量的时间,还需要一个充分的理由来说服用户接受这样的方式。如果没有令人信服的原因来说服用户,相信用户宁愿守 着老的版本。要知道,每个项目最重要的问题就是日程计划的安排。如果没有一个充分的理由,没有人愿意花费大量的时间将代码升级到新版本上,他们会去做其他 更重要的事情。
如果用这种态度为API的客户提供服务,那么就不会带来一个好的合作氛围。但还有更坏的合作方式,就是完全不提供迁移的方案。有时候,对于一个愿意迁移到 新版本的API客户来说,还是可以接受一个API完全重写。但如果说让所有的用户都只使用老的版本或者强迫他们立即都升级到最新版本,对于分布式开发来 说,这两种方式都不现实,正如本书一开始所说的那样。如果API经常产生重大的变化,而且要强迫用户随之迁移,那么客户就会转向其他的方案,而放弃现有的 API。
总而言之,还是要准备使用增量改进的方式!人们需要软件加以改进,但改进时引入的伤害也应该最小化,特别是要避免重新编写的那种大变化。如果因为API设 计上的问题,使得无法增量改进,那么也许会有充足的理由进行一次重新编写,但这种大的变化应该限定于开发方式上的一些基础性变化。本书的大部分篇幅都会讨 论用于API增量改进的设计实践。如果出现了很大的变化,我们会同时强调要为一个API提供多个大版本类库。只有这种方式才能保证API能够变得更好,而 且使API用户的痛苦最小化。
本文摘选自《软件框架设计的艺术》(PracticalAPI Design: Confessionsof a Java FrameworkArchitect)一书,中文版由人民邮电出版社图灵公司推出,特此感谢图灵公司的授权支持。
原文地址:http://blogfeifei.iteye.com/blog/1228410