第八章 版本
到目前为止,我主要把API的设计当作一个离散的任务,似乎API一旦有了完整的说明并发布给用户,任务就算完成了。当然,在实际工作中,这不过是一连串复杂过程的开始。API发布后,真正的工作才拉开序幕,API的开发过程需要接受检验。
API 很少在1.0版本发布后就停止开发。总是会有漏洞要修复,新功能要整合,工作流程要简化,架构要改进,其它的平台要支持等。
API的初始版本发布后,后续的所有版本的主要目标是对已有用户不造成影响,或者尽可能不造成影响。两个版本间的接口或者接口的行为不一致,会迫使用户只有升级他们的代码才能使用新的API。用户人工干预的必要性越小,用户就越可能升级到新版API,越可能一直使用你的API。如果你的API的每个版本总是有重大的不兼容改动,必将导致用户弃你而去,寻找其他替代的解决方案。不管怎样,一个稳定并且健壮的API是产品成功的最重要因素。
为此,本章涵盖了API版本化的细节,解释了几种向后兼容的情况,并且描述了如何实现向后兼容API。
8.1 版本号
API的每个版本都必须有一个唯一的标识,以区别于其它的版本。标准的做法是使用版本号。
8.1.1 版本号的重要性
许多不同的方案用来为软件产品提供版本信息。大多数方案试图通过使用一系列的数字,通常使用一个点号(.)分隔——来赋予一个版本的某种程度上的改动幅度。最为常见的用法或者是两个或者是三个分隔的整数,例如,“1.2”或者“1.2.3”。下面的列表解释这些整数的各自意义。
[图 P242 第一张]
图8.1
演示了连续的版本号,使用标准的主要的(MAJOR)、次要的(MINOR)、修订(PATCH)编号方案。
(1).主版本号。这是版本号中的第一个整数,例如,1.0.0。这个数字通常在初始版本设置为1并且当有重大改动的时候自增。就API的改动来说,主版本号的改动意味着向后兼容除了大量的新功能,或者是向后兼容已被打破。一般说来,API的主版本号的改动应该标记让用户所期待的重要意义的API变动。
(2).副版本号。这是复合版本号中的第二个整数,例如,1.0.0。这个数字通常在每个主要版本被设置为0并在较小的新功能或者重要的漏洞修复版本中自增。副版本号的改动通常不应该包含任何导致API不兼容的改动。用户应该能够升级到新的副版本而不用改动他们的自己的软件。不管怎样,一些新的功能可能增加到API中,如果用户使用了这些新功能,意味着用户无法回滚到较早的副版本而不修改他们的代码。
(3).修订号。第三个整数(可选)是修订号,例如,1.0.0。这个数字通常在每个副版本被设置为0并且在重要的漏洞或者是安全问题修复版本中自增。修改号的改动应该用于对API没有实质的改动,即只修改API的行为。换言之,修订版本的改动应该先后并且向前兼容。即用户能够回滚到之前的版本然后再回滚到更早的修订版本而不用修改他们的代码。
一些软件产品使用额外的数字或者符号来进一步描述版本。例如,可能使用一个自动编译数,这样软件的每次编译可以被彼此区分开。这个编译数可能来自上次提交到版本控制系统的改动的修订数或者也许是来自当前的日期。
为了获得有价值的反馈和现场测试,软件经常在最终发布前就提供给用户。在这种情况下,通常会在版本字符串中增加一个符号标示要发布的软件开发的阶段。例如,“1.0.0a”可能表示alpha版本,“1.0.0b”也许是beta版本,而“1.0.0,rc”大概是指候选版本。不管怎样,你应该记住一旦你开始不使用纯数字标识系统,比较版本数字就开始变的更为复杂(这种复杂的例子可以参见
Python PEP 0386, 网址http://www.python.org/dev/peps/pep-0386/)。
提示
在库名中包含你的API主版本号是很好的做法,尤其是如果你有无法向后兼容的改动,例如,libFoo.so, libFoo2.so, and libFoo3.so.
8.1.2 复杂的版本号方案
我还将列举一些在过去的软件工程中使用过的非标准或者富于想象力的版本化方案。这一节娱乐型多于实际的实用建议,尽管每个方案为特定情况提供了明显的优势。但是我还是建议坚持在API的开发中使用最为通俗易懂的主版本,副版本,修订版本方案。
TeX文档处理系统最初由Donald Knuth设计,通过增加来自π的额外数字精度来生成版本数字。第一个TeX版本数字是3,接着是3.1,然后是3.14,等等。2010年的当前版本是3.1415926。类似的,Knuth的METAFONT项目的版本数字采用渐进e的值,2.718281。
虽然这可能刚开始看起来像一个诙谐幽默的数学家的恶搞,这种数字方案的确表达了软件重要的品质。虽然Knuth自己认识到TeX某些地方能够改进,他说只要系统没有新的根本性的改动,所有的新版本应该只包含漏洞修复。因此,使用引入浮点数字的版本化方案实际上相当有见地。事实上,Knuth对特征的稳定和向后兼容的重要性认识在他的软件的版本化中得到了重要的体现,这是其他API设计者的重要的精神食粮[l1]。
另外一种有趣的版本化方案是使用日期来作为版本数字。显然这么做对于大量的最终用户是透明的,例如软件Microsoft’s Visual Studio 2010和游戏EA的 FIFA 10。不管怎样,一个更微妙的系统被用于Linux操作系统Ubuntu falvor。它分别使用发布的年和月作为主副版本号。第一个Ubuntu版本,4.10,出现在2004年10月,而9.04是在2009年4月发布的。Ubuntu的各版本也都有一个代号名,由相同首字母的形容词和动物名字组成,例如,“Breezy Badger”
和 ”Lucid Lynx。”除了前两个版本外,这些代号名的首字母按字母顺序每个版本递增。这种方案好处是能透漏Ubuntu版本发布时间,但是无法传达版本的变动幅度。也许对不断改进的操作系统来说是不错的,但是你的API最好使用传统的数字方案来向你的用户传达API的每个版本的改动幅度。
Linux 内核目前使用奇偶数方案区分稳定版本(偶数)和开发版本(奇数)。例如,Linux2.4和2.6是稳定版本,而2.3和2.5是开发版本。这种数字方案也被用于Second Life Server版本控制中。
8.1.3 创建一个版本API
API的版本信息从代码上看,让用户可以根据API的版本号来编写程序,例如,要调用一个在最近的API版本中的新方法或者修复一个已知版本API的实现漏洞。
为了提供最大的弹性,用户在编译时以及运行时应该能够查询API版本。编译时间条件是必须的,这样用户能够使用#if预编译指令针对链接到旧版API会导致未定义错误的新的类和方法进行条件编译。运行时条件让用户动态调用不同的API或者提供带有包含API版本号的后勤日志。这些规定为一个版本的API的创建提供了建议。下面我提供一个普通API来说明这个目的:
[代码 P244 第一段]
这个Version类中只有几个属性。首先,给出了访问器以返回组成当前版本的独立的主版本,副版本和修订版本。这些访问器分别返回#define语句的值,API_MAJOR, API_MINOR,和 API_PATCH。虽然我在C++用法章节中阐明应该避免使用#define定义常量,不过这里是一个例外,因为你需要让你的用户能够通过预编译器访问这些信息。
方法GetVersion() 返回给用户友好的版本信息字符串,例如,“1.2.0”。这个函数对于用户在关于对话框中或者是在终端用户的调试日志中显示版本信息很有用。
下面我提供了一个方法让用户能比较版本。这使得用户可以在代码中作检查,例如将API版本与指定的三个数字(主版本,副版本,修订版本)进行比较。显然这里你可以增加另外一个数学函数,但是IsAtLeast() 提供了最普遍的用法。
提示
为你的API提供版本信息。
最后,我提供了HasFeature() 方法。通常当用户想要比较版本数字时,他们并非关心版本数本身,而是使用这一指示器做为确定他们所需要的功能是否在API中所提供的一种方法。HasFeature() 方法可以让用户直接测试某一功能是否可用,而不是让用户注意哪一个版本API中提供了这些功能。例如,在API版本2.0.0中,也许你增加了线程安全。你可以因此增加一个功能标签叫做“THREADSAFE”以便用户能够像这样进行检查:
[代码 P245 第一段]
虽然你可能不必在你的1.0版本中定义任何的功能标签,但是你应该明确地在你的API版本中包含这个方法以便用户在任何版本的API中调用。这个方法在1.0中返回false即可,但是在以后的版本中,你可以为新的功能或者是主要的漏洞修复增加标签。这些字符串可以延期保存在std::set结构中(第一次调用的时候初始化),因此可以更有效率地判断某个功能标签是否被定义。这本书附带的代码提供了这一概念的具体实现。
功能标签的使用特别有用,如果你有一个开源项目,用户可能会改动你的代码或者是在一个开放规范的项目,开发组织能够生产基于你的规范的不同实现。这些情况中你可以提供具有相同版本号的不同功能集合的版本。这种概念在OpenGL API中使用,同一版本的OpenGL API也许由不同的开发厂商用不同的扩展实现。例如,OpenGL API提供glGetString(GL_EXTENSION, n)函数调用返回名称为n的扩展。
8.2 软件分支策略
在更深入地介绍API版本之前,让我们先大概了解一些关于软件分支策略的基础知识。虽然小项目通常只有一到两个工程师可以获得单独的代码行,更大的项目通常采用某种分支策略的方式来进行同步开发、稳定化和维护不同的软件版本。接下来的两节介绍了何时为软件选择分支策略和方针。
8.2.1 分支策略
每个软件项目需要有个“主干”的代码线(code line),它是项目的长期源码库。由主干代码线衍生的分支被用于单独的版本或者是必须与下一版本的开发工作隔离。这种模式支持并行开发,当需要增加新功能的时候,可以将临近发布的版本锁定修改并稳定现有的功能。
有很多不同的分支方案被设计出来。所有的工程师团队都通常会采用各自不同的需求、程序和工作流的策略。不管怎样,图8.2提供了一个分支策略最常见的例子。在这
[图 P246 第一张]
图8.2
软件产品的多个发布的分支图例子。
种情况中,主版本是从主干分支出来的,并且副版本发生在那些分支线。如果在下个副版本前有紧急补丁的话,则创建一条新的特定“hotfix”分支。长期的开发工作因为无法及时准备好需要跳过下一版本,通常在主干中进行开发,然后在适当的时候加入。记住图8.1和8.2的相似性。这些相似性当然不是巧合。
8.2.2 分支方针
这种基础架构被用于许多项目中,用以支持并行开发和发布管理。不管怎样,许多的方针决定能够用于定制实际的工作流,例如,开发人员在哪个分支工作,有多少个分支同时并行,在开发中的什么时候创建版本分支,分支版本合并差异的频率,是否试图自动合并分支等。
虽然不同的版本分支方针对不同的情况有各自的意义,根据我的经验,在开发中的工作应该在主干线中进行,分支对于长期开发是必须的。虽然QA必须要关注即将发布的特定的版本分支,他们也应该关注主干,特别是阶段性的版本。主干是项目的灵魂所在:这里保留的是以往各个版本的代码。如果没有开发人员或者QA工程是实际在主干上工作,并且代码合并到主干无人看管,主干将很快出现不稳定和漏洞。
你选择的版本控制系统同样会影响到你的分支方针,因为不同的源码管理(SCM)产品使用比其它要早的特定的分支策略。例如,对于Subversion支持合并分支是痛苦的事情,然而在分布式SCM系统中,比如Mercurial,在日常工作中合并分支是容易且低影响的操作。合并分支越是频繁,代码冲突就越少发生,并且越早能够并入到主干中,如果这是你的最终目标的话。版本分支通常在发布版本后就“结束生命”,就像在图8.2中的1.1版本后的X版本。另外与你的SCM系统相关的决定因素为是否所有的工程师都在线上,这种情况下一个基于服务端的解决方案例如Perforce能够被接受,或者是否有开源工程师在家里工作,这种情况下一个分布式的解决方案例如git或者Mercurial更加合适。
提示
分支仅当在需要时再分支。建议分支冻结代码线。合并分支要早并且要经常。(来自Perforce High-Level Best Practices white paper)
8.2.3 API与平行分支
一旦API发布后,API的改动应该(至少从外部)在接下来的系列处理。换句话说,不要发布API的不兼容的非线性版本:在版本N中的功能应该是版本N-1的功能的严格超集。虽然这是显而易见的,但是大多数的软件项目趋向于开发人员工作在代码的几个并行的版本,并且会有同时支持的多个API版本。因此,团队工作在不同的并行分支中,不引入功能不兼容的特性是十分重要的。有好几种方法来处理这个潜在的问题。
q瞄准开发分支。 你的项目一般会有开发分支和发布分支。通过强制不在发布分支上修改,你可以降低之前分支版本合并到主干的改动在下个发布版本中“丢失”的机会,因为分支版本不会合并到主干。如果API的改动需要在发布版本中处理,你应该先提交主干然后开始合并。这么处理对于分支版本的任何改动基本是对的,但是接口的改动会付出更高的代价,如果两个版本间有交集的话。
q经常合并到主干。任何对公共API的改动应该在公共的主干代码线中开发或者尽可能及早合并到主干。也可以假设团队定期把他们的开发分支合并到主干代码中,这是一种良好的编码方式。这样可以避免出现两个团队试图合并冲突的API开发分支的意外。
q审核过程。一个单独的API审核组应该在发布前监督并审查公共API的所有改动。审查组的工作是保证没有冲突和不向后兼容的对APIs的改动。他们是守门员和最后的防线。如果有必要处理API问题,这个组织必须有充分授权,那么可以取消最后的发布时间节点。在后续的章节中我会讨论如何进行审核程序。
这些解决方案试图保证主干代码中API的正确定义而不是在多个分支中分离改动。这也许不总是可行的,但是如果你为这个目标努力的话,这会让你以后更轻松。
问题会变得更加的困难:如果你有一个开源的项目,用户可以叉分你的源码并且对你的APIs作出你无法控制的改动,这种情况下显然你鞭长莫及。不管怎样,如果这些改动被合并到你的源码库中,则你可以也应该对社区补丁采取同样周全的审核过程,就像你在内部开发的改动一样。拒绝开源开发人员的改动会很困难或者很为难,但是你可以来减少这种伤害感,通过把审核过程和期望明确的文档化,提前提供技术方向的建议,并提供如何修改补丁以便更易被接受这样建设性的反馈意见。
8.2.4 文件格式和平行产品
一个同事曾经向我描述过他工作过的一个项目,该项目做出了支持两个产品变种的决定:一个基础版本和一个高级版本。直到那个时候,该产品也只有一个版本和一种文件格式。这个团队有个方针,当不可兼容的改动引入到该格式中便增加文件格式的主版本号,上一次单变种版本是3.0。文件格式基于XML并且包含版本标签,这样便可以知道哪一个版本生成了该文件。这种文件格式的读取器会忽略在版本中只是用于副版本区分的无法理解的标签,因此读取器能够继续读取新的版本兼容的产品生成的文件。基本和高级变种都能够读取3.0或者更早的文件。
到目前为止这一切看似很合理。
但是不久高级版本引入了新的功能,这些功能导致文件格式的额外向后不兼容,因此团队决定增加主版本号到4.x。不管怎样,接下来必然会演变成整个文档格式不兼容的方式,也就是说改动文件的基础版本和高级版本的主版本号。为了解决这个问题,基础变种格式被升级到5.x并且高级变种版本被调整到6.x。这意味着:
q3.x版本无法读取4.x到6.x任意版本的格式,即便这些文件没有任何问题。
q4.x版本(旧的高级版本)无法读取5.x文件(新的基础版本)或者是6.x文件(新的高级版本)。
q5.x版本(新的基础版本)无法读取4.x文件(旧的高级版本)。
q6.x版本(新的高级版本)可以读取任何现有的格式。
接着,最后另一个主要版本需要重大更新,引入了7.x(更新的基础版本)和8.x(更新的高级版本)。事情就开始变得混乱了。
现在我们知道了会发生这种糟糕的情况,接着我们讨论一下如何避免。本例中的关键点是:变种版本创建的文件信息和文件格式版本混为一谈了。有种解决方案是把那两种概念分隔开来,都写到文件中去。也就是说,一个版本号,如“3.2”,和变种名,如“Basic”(基础)。用这种方式,基础变种版能够容易地知道它是否可以读取某种格式:它可以读取任何空或基础变种名的文件。本质上这是创建了两个版本号间隔数,这两个变种版本号可以提前相互独立。产品会首先检测变种名来了解兼容性,接着是版本号兼容性,以平时的线性方式工作。
从这种经验得知,我提出如下建议:支持一个产品的不同变种时,可以把变种名存储在任何文件中,变种之间应该相互共享,不要把变种的版本号写入到那个文件。
提示
当在同一个API中创建基础和高级版本时,伴随的“基础”或“高级”的版本号字符串可以在任何生成的文件中。不要只用版本号来获取文件是否由基础或高级API生成的。
8.3 API的生命周期
本节讨论API的生命周期和它从诞生到结束的各种阶段。
维护API和维护普通的软件产品是不一定一样的。这是因为在API开发中的额外限制不能影响现有的用户。在普通的最终用户软件产品中,如果你修改了代码中的类名或方法名,这不会对用户造成程序上的可见影响。不过,如果你修改了API中类名或方法名,那么你将破坏所有用户的代码。API就是一个契约,你必须坚守这份契约。
[图 P249 第一张]
图8.3
API的生命周期。在初始发布前,API可以进行大量的重新设计。在初始发布后,只有增加功能的改动才是能够容忍的。
图8.3提供了一个典型的API生命周期的概述。这个生命周期中最重要的事件就是初始发布,在图8.3中用粗的垂直条标记出来。在这个中枢点之前,可以对设计和接口进行主要的修改。然而,在初始发布后,一旦用户使用你的API来编写代码,你就要提供向后兼容性和你所能做的修改范围就很有限了。从整体上观察这个生命周期,在API开发中可以分成四个阶段(Tulach, 2008)。
(1).预发布(Prerelease):在初始发布前,开发API的流程和标准的软件开发周期一样,包括:需求搜集、计划、设计、实现和测试。要特别注意的是:前面已经讲过,接口可以在这个阶段进行大的修改和重新设计。你可以发布API的早期版本给用户,以便得到他们的反馈和建议。对于这些预发布版本,你应该使用0.x版本号,这样可以让用户清楚地知道这个API仍然是在开发阶段,可能会在1.0发布后发生重大改动。
(2).维护:在API发布后,仍然可以修改,但是要做到保持向后兼容性,任何的改动都要限制为添加新的方法和类,还包括修复已有方法的错误。换句话说,在维护阶段,你要做的是改进API,而不是把它改成不兼容的。要确保修改不会破坏向后兼容性,在发布新的版本前,进行回归测试(regression testing)和API审查是不错的方式。
(3).完成:从某种观点上来说:项目负责人决定API已经成熟且不需要对接口进行更多的改动。这可能因为API已经解决了它所涉及的问题或者团队成员已经从事其它项目了,不再支持这个API了。从这点上来说,在生命周期中稳定性是最重要的品质。因此,只有修复错误才是要考虑的。这个阶段仍然可以进行API审查,但是如果改动还是限制在实现代码中,而不是公共头文件,那么或许就不需要它们了。最后,API会到达被认为完成的那个点,不会再进行修改了。
(4).舍弃:一些API最终会走到它们生命的尽头,它们被舍弃了且不再发行。舍弃意味着API不该再被使用,现有的用户应该移除这个API。这会发生在API不再起什么作用或有一个新的、已开发出不兼容的API来取代它时。
提示
在发布后,你可以改进一个API,而不是更改它。
8.4 兼容性级别
到目前为止,我只是大概地介绍了向后兼容性的意思。现在是时候具体和详细地定义这些术语了。因此,接着几个部分的细节是关于这些特定的术语:向后兼容性、向前兼容性、功能兼容性和源码(或API)兼容性和二进制(或ABI)兼容性。
你常常会为发布的API的主版本、次版本和补丁版提供几种不同的兼容性承诺。例如,你可能承诺补丁发布版本的向后和向前都是兼容的或者你可能承诺只会在主版本破坏二进制兼容性(KDE为核心库承诺这个)。
8.4.1 向后兼容性
向后兼容性可以简单地定义为API提供的功能和上一个版本的API一样。换句话说,如果API是向后兼容的,那么可以完全替换上个版本的API,而不要用户做任何改动。
这隐含的意思是新的API是旧的API的超集。它可以添加新的功能,但是它不能对旧版本API已经定义的功能做出任何不兼容的修改。API维护的重要原则是不能从接口移除任何东西。
API的向后兼容性有几种不同的类型,包括:
(1).功能兼容性
(2).源码兼容性
(3).二进制兼容性
我将在下面的章节中定义这些更多的细节。此外,还有关于面向数据和向后兼容性的问题,例如:
(1).客户端/服务器端 兼容性
(2).文件格式兼容性
例如,API包含基于网络的通信,那么你就需要考虑使用的CS协议的兼容性。这表示用户使用旧版本的API仍然可以和新版本的服务器进行通信。或者,用户使用新版本的API也可以和使用旧版本API的服务器进行通信(Rooney, 2005)。
此外,如果API中把数据存储到文件或数据库中,那么你就需要考虑文件格式或数据库模式的兼容性。例如,新版本的API需要能够读取旧版本API生成的文件。
提示
向后兼容性意味着使用API版本N的用户代码可以升级而无需修改成版本N+1。
8.4.2 功能兼容性
功能兼容性关注的是实现的运行时行为。一个API的功能兼容是指它和上一个版本的API有着完全一致的行为。然而,正如Jaroslav Tulach提到过的,在这方面,API很难做到100%的向后兼容性。即使只是修复实现代码中的错误也会修改一些用户依赖的API行为。
例如,如果有个API提供下面的函数:
[代码 P252 第一段]
这个函数在API的1.0版本有个错误,如果传入一个NULL指针的话会导致崩溃。在版本1.1.中,你要修复这个错误,这样代码就不会在这种情况下崩溃了。这已经修改了API的行为,因此这不是严格意义上的功能兼容了。然而,它是用比较好的方式去改变行为:它修复了崩溃错误。因此,这种修改API运行时的行为是有用的,这种功能行为也不是什么坏事。绝大多数API更新都是有意地破坏功能兼容性。
做为一个说明功能兼容性用途的例子,考虑有个API的新版本是专注于解决性能问题。在这种情况下,API的行为根本没有发生任何变化。然而,在接口后面的算法得到提高,得到同样的结果只需要更少的时间了。在这方面,新的API可以认为是100%功能兼容的。
提示
功能兼容性意味着API的N+1版本的行为和API的N版本是一样的。
8.4.3 源码兼容性
源码兼容性是向后兼容性的不精确的定义。它基本上是陈述:用户可以使用新版本的API重新编译他们的程序,而不用对他们的代码做任何修改。这和程序的结果行为无关,只是说可以成功的编译和链接。源码兼容性有时也叫做API兼容性。
例如,下面的两个函数是源码兼容的,即使它们的的函数签名是不同的:
[代码 P252 第二段]
这是因为任何调用1.0版本函数的用户代码也都可以在1.1版本下通过编译(新的参数是可选的)。做个比较,下面的两个函数就不是源码兼容的,因为用户被强制遍历他们的代码来寻找所有的SetImage()方法实例,并添加所需的第二个参数。
[代码 P252 第三段]
实现代码中只要有任何带有完全限制的更改,那么就不要在公共头文件中包含这些更改,很明显这样是100%源码兼容的,因为在这两种情况下的接口都是完全一样的。
提示
源码兼容性意味着用户在不修改他们的源码的情况下,使用N版本的API所编译的代码也可以在使用N+1版本的情况下通过编译。
8.4.4 二进制兼容性
二进制兼容性的意思是:用户只需要用新版本的静态库来重新链接他们的程序或仅仅是在他们的最终用户程序的安装目录中丢入一个新的共享库也能重新链接他们的程序。这和源码兼容性不同,那种情况下用户必须在任何新版本的API发布后都要重新编译他们的程序。
这意味着API的任何更改都不会影响库文件中的任何类、方法或函数的行为表现。所有API元素的二进制表现必须还是一样的,包括结构的类型、大小和对齐,还有所有函数的签名。这也常常叫做程序二进制接口(Application Binary Interface,ABI)兼容性。
在使用C++时很难实现二进制兼容性。在C++中对接口的绝大多数修改都会导致它的二进制表现的更改。例如,这里有两个符号名不同的函数(在对象或库文件中的符号名是用来识别一个函数的):
[代码 P253 第一段]
这两个方法是源码兼容的,但它们不是二进制兼容的,这是因为每个生成的符号名是不一样的。这意味着使用。1.0版本编译过的代码无法简单地使用1.1版本的库,因为_Z8SetImageP5Image符号是未定义的。
如果使用不同的编译标志也会导致API二进制表现的更改。这是和特定的编译器有关的。这其中的一个原因是C++标准委员会决定不再规定符号名的细节。因此,某个编译器的符号名模式可能和另一个编译器的不同,即使是在同一个平台上。(前面的符号名是由GNU C++ 4.3生成的。)
提示
二进制兼容性意味着由N版本API编写的程序可以简单地替换或重新链接新版本API动态库就可以得到更新。
以下是两个特定的API改动列表,详细陈述了那些需要用户重新编译他们的代码的行为和可以安全执行而不破坏二进制兼容性的操作。
与二进制不相容的API改动:
q移除一个类、方法或函数
q添加、移除或重排类成员变量。
q添加或移除一个类的基类。
q修改任何成员变量的类型。
q用任何方式修改现有方法的签名。
q添加、移除或重排模板参数。
q把一个非内联方法改成内联的。
q把一个非虚方法改成虚的,反之亦然。
q修改虚方法的顺序。
q往一个没有虚方法的类中添加一个虚方法。
q添加新的虚方法(如果你只是在一个现有的虚方法后添加一个新的,一些编译器可能仍然维持二进制兼容性)。
q重写一个现有的虚方法(在有些情况下这是可能的,但最好要避免)。
二进制兼容的API改动:
q添加新类、非虚方法或*函数。
q往类中添加新的静态变量。
q移除私有静态变量(如果它们没有被一个内联方法引用)。
q移除非虚私有方法(如果它们没有被一个内联方法调用)。
q修改内联方法的实现(然而,新的实现需要重新编译)。
q把一个内联方法改成非内联的(然而,如果实现也发生改动的话,那么就需要重新编译)。
q修改方法的默认参数(然而,如果使用新的默认参数,那么就需要重新编译)。
q添加或移除类中的友元声明。
q往类中添加新的枚举。
q往现有的枚举中添加新的枚举值。
q使用一个位字段未使用的剩余位。
把API的任何改动限制成第二个列表中罗列的那些就允许你在各个发布的API版本间维持二进制兼容性。下面还有几条建议可以帮助你实现二进制兼容性。
q不往现有的方法中添加参数,你可以定义一个新的重载的方法版本。这确保原始的符号继续存在,但是也提供了更新的调用协议。在.cpp文件中,旧的方法实现仅仅是简单地调用新的重载方法。(要注意的是:如果方法尚未重载,这个技术会影响源码兼容性,因为在没有显式转换的情况下,用户的代码无法应用函数指针&SetImage)。
[代码 P254 第一段]
qPimpl用法有助于维持接口的二进制兼容性,因为它移除了所有的实现细节,那些元素在将来很有可能发生变化,那些都移入到一个.cpp文件中,这样就不会影响公共的.h文件了。
q采用纯C风格的API可以很容易地实现二进制兼容性,因为C没有提供诸如继承、可选参数、重载、异常和目标这些特性。为了两全其美,你可能决定使用面向对象的C++风格来开发API,接着提供纯C风格来包装C++ API。
q如果你确实需要做出二进制不兼容的改动,那么你可以考虑命名一个不同的库,以便你不会破坏现有的程序。这种方法被libz库所采用。在Windows中,版本1.1.4前构建的叫做ZLIB.DLL。然而,这个库在后面使用了二进制不兼容得编译器设置,因此库被重命名为ZLIB1.DLL,这里的“1”表示API的主版本号。
8.4.5 向前兼容性
API的向前兼容性是指:如果用户代码使用将来的API版本,当使用旧API版本时可以不做修改就可以通过编译。因此,向前兼容性意味着用户可以降级使用上个发布(的API版本),而无需修改就可以让他们的代码正常运行。
向API中添加新功能会破坏向前兼容性,因为用户会利用这些新的功能来编写代码,这样就会导致无法在不存在那些变化的旧版本中通过编译。
例如,下面的两个函数版本是向前兼容的:
[代码 P255 第一段]
因为代码是使用函数的1.1版本编写的,第二个参数是需要的,可以在1.0版本中通过编译,第二个参数是可选的。然而,下面的两个版本就不是向前兼容的了:
[代码 P255 第二段]
这是因为使用1.1版本编写的代码提供一个可选的第二个参数,如果指定了,就无法在1.0版本的函数中通过编译。
很明显,向前兼容性是一种很难保证的品质,因为你无法预测将来的API会发生什么。你能做的就是在1.0版本的API发布之前尽量考虑周全。事实上,这是一种很不错的举措,在首次公开发布之前,让API尽可能地经得起时间的考验。
这意味着你必须考虑到API在未来如何改进的问题。用户可能提出什么样的新功能需求?性能优化将如何影响API?API是怎么被滥用的?你是否有可能在将来暴露更通用的概念?你是否已经知道在将来计划实现的功能会影响API?
这里有几种方式让你可以使一个API向前兼容:
q如果你知道在将来需要为方法添加一个参数,那么你可以使用之前给出的第一个例子中的技术。也就是说,你可以在这个功能实现前添加一个参数,只要在文档中注明这个参数((同时给这个参数命名))是未使用的即可。
q如果你预见到将来会在不同的内建类型中切换,那么可以使用透明指针或类型定义来代替直接使用内建类型。例如,创建一个float类型的类型定义叫Real,以便你在将来的API版本中把类型定义修改成double而不会导致API的改变。
qAPI设计的数据驱动风格(在设计风格一章中有提到过)是固有地向前兼容的。方法是简单地接收一个ArgList变体容器,从本质上这是允许在它运行时传入任意的参数集合。因此,该实现能够为新命名的参数添加支持而不需要修改函数的签名。
q
提示
向前兼容意味着用户代码使用的N版本的API可以在不修改的情况下降级为使用N-1版本的API。
8.5 如何维持向后兼容性
现在我已经定义了多种类型的兼容性,当发布新版的API时,我要描述下维持向后兼容性的一些策略。
8.5.1 添加功能
就源码兼容性而言,向API中添加新的功能通常上是安全的行为。添加新类、新方法或新的*函数不会改变已经存在的API元素的接口,因此不会破坏现有的代码。
根据经验这有个例外,向一个抽象基类中添加纯虚成员函数不是向后兼容的。也就是说:
[代码 P257 第一段]
这是因为所有现有的用户现在都必须为这个新的方法定义一个实现,否则他们的派生类就没有具体化并无法通过编译。为此有一个解决方法就是简单地为所有的新方法提供一个默认实现,添加到一个抽象基类中。也就是说,把它们设置成虚的而不是纯虚的。例如:
[代码 P257 第二段]
就二进制(ABI)兼容性而言,你可以往API中添加的元素集而不破会兼容性是有更多限制的。例如,添加第一个虚方法到类中会导致类的大小增加,这通常是一个指针的大小,是为了那个类包含虚拟表的指针。相似地,添加新的基类、添加模板参数或添加新的成员变量都会破坏二进制兼容性。一些编译器让你往已经有虚方法的类中添加一个虚方法而不会破坏二进制兼容性,只要你添加的新虚方法在所有其它虚方法的后面就可以。
请参考二进制兼容章节中的列表来了解因改变API而破坏二进制兼容性的更多细节。
8.5.2 修改功能
修改功能而不影响现有的用户是一件棘手的任务。如果你只在乎源码兼容性,那么还是有可能往一个方法中添加新的参数,只要你把这些参数放在所有先前参数的后面并把它们声明成可选的就可以了。这意味着用户并不需要强制更新所有现有的调用来增加额外的参数。先前我举过例子,为了方便起见我这里再重复一次:
[代码 P258 第一段]
还有,修改一个现有的方法的返回类型,先前的方法有一个void返回类型,这是一个源代码兼容的修改,因为不存在现有的代码会去检查那种返回值。
[代码 P258 第二段]
如果你希望添加一个不是在所有现有参数后面的参数或者如果你正在编写的纯C API的可选参数是不可用的,那么你可以引入一个命名不同的函数,或者重构旧方法来调用新的方法。例如,Win32 API就是通过创建后缀为“Ex”(代表扩展功能的意思)的函数来广泛使用这项技术。例如:
[代码 P258 第三段]
Win32 API也提供废弃旧版本函数的例子,为新函数引入一个可选的名字来代替简单地在名字的末尾添加一个“Ex”。例如,OpenFile()方法已经遭到废弃,取而代之的是应该在所有当下的程序中使用CreateFile()函数。
就模板的使用而言,往API中添加显式模板初始化会潜在地破坏向后兼容性,因为用户可能已经为那个类型添加了显式初始化。如果出现这样的情况,那么那些用户会在试图编译他们的代码时收到一个重复显式初始化错误。
就维持二进制兼容性而言,你对现有的函数签名所做的任何修改都会破坏二进制兼容性,如修改参数的顺序、类型、数量或常量,还有就是修改返回类型。如果你需要修改现有方法的签名的同时又要维持二进制兼容性,那么你应该为此创建一个新的方法,重载现有的函数名称。这项技术在本章的前面提到过:
[代码 P259 第一段]
最后,在不修改方法签名的情况下就修改API的行为就变得很平常了。这可以用来修复实现中的错误或修改一个方法支持的有效值或错误条件。这些修改类型都是符合源代码和二进制兼容的,但是它们会破坏API的功能兼容性。这些修改往往也都是会受影响的用户所可以接受的。不过,为了防止修改的行为可能不是所有用户所期望的,你可以把新的行为设置成可选的。例如,你为API添加了多线程锁定,那么你可以允许用户可选的调用SetLocking()方法来采用这个新的行为(Tulach,
2008)。还有,你可以利用在前面提到过的Version类中的HasFeature()方法来集成打开或关闭此种特性的功能。
[代码 P259 第二段]
通过上述的代码,你的用户就可以显式地启用新的功能,而原来的功能也为现有用户所保留,这样就可以维持功能兼容性。例如:
[代码 P259 第三段]
8.5.3 废弃功能
一个废弃的特性是不鼓励用户使用的,通常是因为已经有新的首选功能可以取代了。因为废弃的特性仍然存在于API中,用户仍然可以调用它,虽然这么做会得到一些警告。废弃的功能可能在将来的API版本中被完全移除。
废弃是一种开始移除一个特性的过程,同时给予用户时间来更新的代码,使用新的被认可的语法。
有很多种原因导致废弃,包括解决安全漏洞、引入一个更加强大的特性、简化API或支持API功能的重构。例如,标准C函数tmpnam()函数已经被废弃了,采用更加安全的实现,如tmpnam_s()或mkstemp()。
当你废弃一个现有的方法,你应该为这个方法在文档中标明出这个,同时要写明可以用来替代的新功能。除了这种文档上的工作,如果有使用这些函数的话还要生成警告信息。绝大多数编译器都提供一种方式修饰废弃的类、方法或变量,并且如果程序视图访问废弃的符号时会输出一个编译器警告。在Visual Studio
C++中,你可以在方法声明前加上__declspec(deprecated),而在GNU C++编译器中你可以使用__attribute__ ((deprecated))。下面的代码定义了一个DEPRECATED宏,可以同时适用在这两种编译器上。
[代码 P260 第一段]
使用这种定义,你可以用下面的方式来把方法标记成废弃的:
[代码 P260 第二段]
如果一个用户试图调用GetName()方法,那么他的编译器就会输出一个警告信息,这表明这个方式已经被废弃了。例如,下面的警告是由GNU C++ 4.3编译器生成的:
[代码 P260 第三段]
做为一种可选的生成编译时警告的方式,你可以通过编写代码在运行时生成一个废弃警告。这么做的一个原因是你可以提供更为详细的警告信息,如表明可以替代使用的方法。例如,你可以声明一个函数,然后在你想要废弃的每个函数内的第一个语句中调用这个函数,如下所示:
[代码 P260 第四段]
Deprecated()方法的实现可以利用std::set为已经生成警告信息的每个函数维持其名称。如果方法被多次调用的话,这可以允许只在第一次调用废弃方法时输出一个警告。Noel Llopis在他的宝石游戏中也使用了一种相似的技术,不过他的解决方案还记录唯一的调用点的数量和在程序运行结束时打包警告以生成一个单独的报告。
8.5.4 移除功能
一些功能会在API发布后并至少在一个版本中被废弃,最后将被移除。移除功能会影响到依赖这些功能的用户,所以在移除之前先标记废弃警告是十分重要的。
从API中移除功能是一个重大的步骤,当有些方法出于安全的原因不再被允许调用,这时候是必要的。如果只是简单地不再支持那个功能,或者如果是限制改进API的那个功能。
移除功能的一种方式是仍然允许以前的用户访问旧功能,同时升级主版本号,把新的版本声明成不向后兼容的。接着你就可以在最新的API版本中完全移除这个功能,不过仍然提供旧版本API的下载,同时标明它们已经被废弃和不被支持了,应该只用在旧程序中。你甚至还要考虑把API头文件存储到不同的目录中并重命名库,这样两个API才不会相互冲突。这事关重大,不要经常这么做。当API处于生命周期中的黄金时期,这是再好不过的了。
诺基亚的Qt库中使用过该技术,当时是从3.x版本升级成4.x的版本。Qt4推出了很多新的功能,不过这是以牺牲Qt3源码和二进制兼容性为代价的。很多函数和枚举采用了更为一致的命名,一些功能从API中移除了,还有其它功能被隔离到新的Qt3Support库中。还提供了一份全新的移植指南,帮助用户过渡到新的发布版本。这是让Qt能够对API做出重大改进和提供一致性的同时仍然为以往的程序提供某些Qt特性的支持。
8.6 API审查
向后兼容性不是从天上掉下的,它需要我们付出热心和勤奋的努力来确保API的新修改不会暗中破坏现有的代码。实现这个的最好做法就是在我们开发过程中添加API审查。这部分内容将集中讨论如何成功地实现API审查,并看看一些更好管理API审查的工具。
实现API审查有两种不同的模型:一种是在发布前开个会,审查自从上个发布版本以来的所有改动;另一种模型是强制提交修改请求过程前,API在签入前必须通过批准和请求。当然,你可以两种都选。
8.6.1 API审查的目的
在没有编译之前,你不会把源码发布给你的用户。同样,在没有检查过是否会破坏他们的程序前,你不应该把API的修改版本发布给用户。API审查对于任何认真对待API开发的人都是一个重要而基本的步骤。为了给你更多的激励,这里再列出几条在API发布给用户之前进行强制显式检查的原因。
q维持向后兼容性:在发布之前审查API的主要原因就是不存在对API未知的会破坏向后兼容性的修改。前面提到过,如果有很多工程师负责修复错误和添加新的特性,那么很可能有些人不理解维持公共接口一致的重要性。
q维持设计一致性:架构和设计计划是十分重要的,你要留意从发布的版本1.0和对后续发布版本的维持。首先要注意的是不适合API的设计应该尽早发现并做出适当的修改,否则就会慢慢破坏原先的设计结构,最后会导致系统不再有内聚性和一致性。第二个问题是修改是不可避免的;如果API的结构必须修改,那么这就需要重新再看看架构,根据新的功能需求和案例来进行更新。需要小心的是,John
Lakos指出如果你为10个用户实现了一个新的特性,而每个用户都得到9个他们没有要求过的特性,那么你就必须实现、测试和支持你当初没有设计过的这10个特性。
q修改控制:有时候某个修改是十分冒险的。例如,有个工程师试图为一个发布版本添加一个主要的新特性,着重关注错误的修复和稳定性。改动的代价可能比较大,没有充分测试,要赶着发布,违背了API的基本原则,如暴露实现细节或不符合API的编码标准。API的维护者应该拒绝这种修改,因为他们感觉到这对于当前的API发布版本是不恰当的。
q允许将来的改进:源代码的一个单一的修改往往可以通过几种不同的方式实现。当考虑到将来需要改进,有些方式会比其它的更好,采用一种更通用的机制来允许将来改进的添加且不会破坏API。API的维护者会要求所做的修改应该是不容易过时的技术。Tulach把这叫做“改进前的准备”(Tulach,
2008)。
q重温解决方案:一旦API已经在交付使用且你已经收到来自用户关于它的可用性的反馈,你可能会想出更好的解决方案。API审查也可以用来重新考量先前的决定,看看它们是否是有效和适合的。
如果你推出的API非常成功,成为一个标准,那么其它供应商也可能编写相似的实现。这使得API的修改控制变得尤为重要。例如,OpenGL的创建者就认识到这个需要,成立了一个OpenGL架构审查委员会(Architecture Review Board,ARB)来管理标准。ARB负责OpenGL API的修改,改进标准和定义一致的测试。这种运作方式从1992年一直持续到2006年,后来把API规范的控制交给了Khronos组织。
提示
引入API审查过程可以检测所有在发布新版本的API之前的改动。
8.6.2 预发布 API审查
预发布API审查是在你准备发布API之前所举行的一个会议。这常常是一种有益的举动,在内部测试发布阶段前或期间执行。这些审查对你的组织来说是最好的工序,这明显和其它的最好之处不同。然而,这里还是有一些通用的建议可以帮助你。首先,你应该确定这个审查会议的关键参与者。
(1).产品所有者:这种人是负责整个产品的规划和代表用户的需求。很明显,这种人必须是技术人员,因为你的用户也是技术人员。按照Scrum(译者注:Scrum是迭代的,增量项目管理框架,常见于敏捷软件开发中)中的术语,产品所有者表示利益相关者和业务。
(2).技术主管:审查可以让人产生这样的疑问:为什么要添加一个特殊的修改和它是否是通过最佳的方式来完成的。这需要有很深的技术积累和很强的计算机设计技巧。如果这种人并不亲自参与开发的话,那么他们应该非常熟悉架构和API设计的基本原理。
(3).文档主管:API不仅仅是代码,它还包括文档。并不是在审查会议中提出的所有问题都要靠修改代码来解决,很多是需要文档的修改。因为文档也是API的重要组成部分,有一个掌握技术写作技巧的人在审查中是非常重要的。
你或许会决定增加更多可选的审查会议参与者,尽力确保新的API发布是最好的。在皮克斯的时候,我们在把所有动画系统的公共API交付给我们的电影制作者前,都有执行API审查。除了上面提到的参与者,我们还邀请了QA的代表,还有几个天天都在编写源码的高级工程师。你还要安排负责项目管理的人记录会议的所有要点,以便不会遗漏任何重要的东西。
这些会议的长短和折腾与否是取决于API的规模和修改的程度。我们通常在所有的API修改中开了几个2小时的会议。你不应该匆匆举行这些会议或开会开到大家都筋疲力尽。他们将需要几天时间才能完成,要保证开发人员和技术写手在API要发布前有时间实现任何会议中要求的修改。
就API审查中应该执行的活动而言,最重要的事情是会议应该专注于要交付的接口,而不是代码的细节问题。换句话说,主要的关注点应该在文档这块,如自动生成API文档(参见第九章中Doxygen的使用)。更具体地说,你更应该关注自从上次发布以来的文档中的修改。这可能涉及到利用工具来报告当前API版本和上一个版本之间的差异。我编写的API Diff程序能够很好地实现这个功能。你可以在http://www.apidiff.com/免费下载。
对于每次修改,应该会有以下几个问题:
q本次修改会破坏向后兼容性吗?
q本次修改会破坏二进制兼容性吗(如果这是一个目标)?
q本次修改有充分的文档吗?
q本次修改是否可以用更加经得起时间考验的方式完成?
q本次修改是否会带来负面的性能影响?
q本次修改是否会破坏架构模型?
q本次修改是否会把循环依赖引入到代码中?
qAPI修改是否应该包含更新脚本来帮助用户更新他们的代码或数据文件?
q是否存在修改代码的自动化测试来验证功能兼容没有受到影响?
q修改需要自动化测试吗?是否编写好了?
q这个修改要发布给用户吗?
q在新API中是否有样例代码?
[图 P264 第一张]
图 8.4
塞班系统的公共接口修改的请求过程。
(版权所有?2009 塞班基金有限公司。基于Stichbury的知识共享许可证。参见:http:// developer.symbian.org/。)
8.6.3 预提交 API审查
当然,你并不需要一直等待,直到在发布前捕获这些问题。预提交API审查会议是确保不合格的修改不被发布给用户的最后一道防线。然而,如果API作者在开发过程中时刻保持警惕:注意对源码的检查和在API审查会议之前就早早地解决标记出来的问题,那么API审查的工作量可以大为减少。
因此,许多组织或项目都会采用预提交API审查。也就是说,他们将设立修改请求的过程,凡是工程师希望的对公共API的修改都要正式向API审查委员提出请求并获得许可。这在开源软件项目中特别有用,提交的补丁可以来自很多拥有不同背景和技术的工程师。
例如:开源的塞班移动设备操作系统对塞班平台的公共API的所有修改都强加一个修改控制过程。这么做的目的是确保公共API的改进符合可控的风格。这个过程的开始是提交一个修改请求(CR),包含下面的信息:
q修改的描述和为什么这是必要的。
q对API的所有用户的影响的分析。
q新版本API的移植指南。
q向后兼容性测试样例的更新。
接着会由架构委员会进行审查并决定批准或拒绝请求,同时提供他们决定的依据。一旦批准了,开发人员就能够提交他们的代码、文档和测试更新。图8.4提供了这个过程的概述。
再举个例子,基于Java 的NetBeans项目为接受来自开发人员的补丁定义了API审查过程。这样做是为了监督NetBeans IDE的架构及相关产品。修改现有的API或请求新的API,在它们允许成为主干代码行前进行审查。这个过程是由NetBeans错误追踪系统管理的,审查的请求被标记成关键字API_REVIEW或API_REVIEW_FAST。审查过程会导致修改出现接受或拒绝的结果。在被拒绝的情况下,或者文档让修改变得更容易接受。当然,类似的反馈仍然可以接受修改。[l2]要想了解这个过程的更多细节,请查看:http://wiki.netbeans.org/APIReviews。
预提交审查是在开发过程中很好的一种方式,可以了解最新的API修改。然而,它还可以用来安排单个预提交API审查会议。这可以用来捕获错过的任何修改,这是因为工程师不知道流程或没有意识到适用于他的修改。
is food
for thought for any API designer
Of course, similar
feedback may still be provided for accepted changes.