利用抽象分支技术,进行大规模软件的增量式改造

| 2009-05-18

很多开发团队通常严重依赖于版本控制系统的分支功能。分布式版本控制系统让分支操作更加方便。然而,在《持续交付》一书中描述的很多非常规言论中,就有一条是:“使用长周期分支,你就无法做持续集成”。有一种很常见的情况,会让人很自然地想到利用版本控制工具的分支功能:那就是“对应用程序进行大规模改造时”。然而,还有一种替代这种真实代码仓库中分支的做法,技术上叫做“抽象分支(Branch by Abstraction)”。

抽象分支:在主干上进行以增量方式对软件进行大规模改造的一种模式。

我曾经工作的一款商业产品(持续集成与敏捷发布管理平台 GoCD)上,我们团队就是使用这种抽象分支的方式,将产品从 iBatis 改到了 Hibernate ,这已经是两年前做的事情了。

我们还把产品 UI 层技术栈慢慢地从「 Velocity 和 JsTemplate 」迁移到了「 JRuby on Rails 」上。

这两种变化都是以小步增量式地逐步完成的。即在技术堆栈变更的同时,还做新功能的开发。这种做法并不妨碍我们每天向代码主干合入数次代码。甚至,我们在做这种技术堆栈切换过程中还发布了数个新版本。

我们是如何做到的呢?

案例 1:从 iBatis 迁移到 Hibernate

团队决定从 iBatis 迁移到 Hibernate,有两个原因:第一,我们可以更高效地使用ORM,因为我们对产品数据库的结构有绝对控制权,这样就不用写太多的定制SQL;第二,它有二级缓存,对性能有帮助。

当然,我们并没想一次性把整个代码库所有的iBatis全都迁移到Hibernate上。

我们的策略是:

当增加新的功能时,若需要增加新的方法来访问数据库的话,就使用 Hibernate 来 完成。在这一过程中,如果需要修改原有的 iBatis 代码,就将这一局部代码 迁移到 Hibernate 上来。

对持久层逻辑的更新相对来说比较直接,因为产品 GoCD 的代码库使用标准的分层结构,Controller Layer 使用 Service Layer,而Service Layer 调用 Rrepositories。所有需要访问数据库的代码都利用 Repository Pattern 封装在了「对象仓库」这一层中。所以,每次将一个对象仓库从 iBatis 改成 Hibernate 。

由于架构分层明确,增量式地完成修改是一件比较容易的事情。

Service Layer 本不知道底下的持久层框架是什么。

我的同事 Pavan K.S. 说:

抽象分支有一个严格要求,就是纪律性,即:开发人员不能再以任何借口添加原有模式的代码。

也就是说,作为第一原则,不要再增加 iBatis 查询(尽管这么做可能更快更省事儿),必须用 Hibernate 来做。这是确保项目进度的唯一方法。

一种强制手段是在持续集成构建时只要发现新增了iBatis查询, 就让持续集成构建失败。并且,只能不断减少这个阈值,绝不能增加。

案例2:从 Velocity 和 JsTemplate 转向 JRuby on Rails

产品GoCD 还从“以Java为基础的UI软件栈”转向“JRuby on Rails”的软件栈。

这有两个原因:一是新的框架更容易写测试,二是它会加速了UI界面层的开发。

当然,我们的这次技术栈变更也是增量式完成的。

当在应用程序中创建新的页面时,我们会使用 JRuby on Rails。一旦做好以后,就让应用程序的其它部分指向这个新页面。

当需要对某个旧页面进行大量变更时,我们就把它迁移到 JRuby on Rails 上。

一旦做好,就把应用程序中所有指向这个页面的 URI 都更改为这个新的页面。这后,再把对应的旧页面删除。

所以,当 GoCD 的界面大部分都是JRuby on Rails的实现时,仍旧有一些页面是原有JAVA版的实现。

然而,只从页面展现上看的话,你根本不会觉察。因为它们的样式是统一的,但从 URL 是能够看出来的。所有使用 /go/tab 前缀的 URL 都会跳转到旧的 Velocity 页面上。其它页面会跳转到 JRuby on Rails 页面上,当然它也同样会使用原有界面所用的 java 服务层。

抽象分支究竟如何操作呢?

抽象分支通过如下几个步骤进行大规模增量式修改:

  1. 在你想改变的那部分代码之上创建一个抽象层。
  2. 对其余部分的代码进行重构,使其使用这个抽象层使用其之下的代码提供的功能。
  3. 在新的实现代码里实现一些新的类,让其上的抽象层根据需要,选择性的导向旧代码或新增的类上。
  4. 剔除原有的旧实现。
  5. 清理,并重复前两步,如果需要,可同时交付你的软件。
  6. 一旦旧实现完全被代替后,如果你愿意,可以移除那个抽象层。

马丁.福勒(Martin Fowler)指出,这些步骤也可以变化一下。“在最简单的情况下,你可以创建一个抽象层,然后重构,让所有的代码都调用它,然后再新写一个实现,最后切换一下就行了。但是,还可以将它分开做。比如,不创建整个抽象层,而只是创建将要修改的功能的一个子集,迁移这部分代码,然后再做下一部分(此时新旧代码共存)。”

在上面iBatis/Hibernate的例子中,抽象层就是指那个Repositories Layer,它隐藏了持久层框架使用的细节。在JRuby on Rails的例子中,抽象层是Servlet Engine,通过URI的匹配,它可以决定是将Request分发到JRuby on Rails框架,还是标准的Java Servlets上。

尽管GoCD这个项目相对比较小,开发人员只有十个左右。但是,这些原则完全可以应用于各种大小的项目上。即使在大型且分布式的团队项目里,也可以成功地使用这种模式。

不可否认的是,抽象分支在开发过程上增加了开销,而且当你的代码库结构性很差时,开销会更多一些。为了能够以这种方式做增量式变更,你必须仔细思考,一点儿一点儿地慢慢向前走。但是,在很多情况下,这种额外的工作量是值得的,越是大的重构,就越应该考虑使用这种抽象分支。

抽象分支的关键收益是你的代码在整个结构调整的过程中都能够正常工作,能够做到持续交付。​也就是说,你的发布计划与架构上的调整完全解耦,因此,在任何时间点你都可以停止重构工作,做优先级更高的事情,比如发布一个你刚刚想到的非常好的功能特性。

对于抽象分支来说,需要定义一个终止策略,这一点非常重要。当你能够做到“不完成全部的结构调整也可以发布”时,很容易产生一种倾向,即:一旦完成了重要部分的改造后,剩下的那部分尚未完成的工作,就放在那里不管了。然而,在系统中混合多种技术会让系统更难维护,也要求团队非常了解哪些地方还在使用旧有技术实现。这也许是一种可接收的权衡状态,但至少要对整个团队做到可见。

抽象分支与版本控制系统(VCS)分支功能的对比

抽象分支其实或多或少有那么一点儿“文不达意”,因为它是对系统做大规模变更时所用的替代VCS中分支操作的一种方式。很多团队经常使用VCS的分支功能进行大规模变更,以便能够在主干上正常开发功能,修复缺陷。当然,头痛的问题也就随之而来,即:将分支合并回主干时相当痛苦,痛苦的程度决定于你在分支上所做修改的多少、大小,以及在这段时间里,主干上已经修改了多少。

也就是说,迫使你使用VCS分支的力量越强,当你需要合并回主干时,痛苦就越大。如果你还在分支上开发了新功能,那情况就更糟糕。一般来说,“利用分支开发特性或者做大的变更”不是一个好主意,原因有很多种,但最重要的一个原因是:它妨碍了持续交付和重构。Martin Fowler写了两篇文章来论述,即:“为什么特性分支不好?”, 以及“如何使用特性开关做为分支的替代方案?”

什么时候使用分支?

这并不是说,版本控制系统的所有分支都不好。如果只是想到了一个好点子,拉个分支出来做试验,到最后并不需要将代码合并回主干的话,此时用分支也无所谓。另外,当需要发布时才为该发布建立一个发布分支也是可以的。但要记住,在这个发布分支上,你只能做一些小的、严重缺陷的修复。 然而,对于那些正在做持续部署的团队来说,通常他们都不会这么做。因为在主干上修复任何问题对他们来说很轻松,可以通过向前发布来代替向后回滚。 这种所谓的“轻松”是因为前后两个版本之间的差异非常小。

另外,最后一种可容忍使用分支的情况是:你的代码基正处于一团乱麻模式。在这种情况下,创建抽象层可能非常困难。为了能创建这个抽象层,你必须首先找到一个“接缝”(如果你使用静态类型面向对象的编程语言的话,典型地是一个接口集合),在这个“接缝”处放上抽象层。如果找不到“接缝”的话,你就要通过一系列的重构创造出一个。然而,如果因为某种原因实在弄不出来“接缝”的话,那么你就只能依靠在分支上重构,以达到那种有“接缝”的状态了。当然,这是一个极端手段。

还有一种争论是:分布式版本控制系统让代码合并变得很容易,所以我们不应该害怕分支。这种误解有两个原因。首先,正如老马指出的,自动合并工具无法捕获语义冲突。其次,即使使用世界上最好的合并工具,也改变不了“分支存在的时间越长,合并的困难越大”这种事实。你到GitHub上看一看,就不难发现,每个人可能都想把他的分支合并回去,但无奈的是,当它与主干的差异太大,合并需要大量的集成工作。

当然,每个规则都有一些例外。如果你的团队比较小,而且每个成员都很有经验,并且分支的生命周期很短(比如少于一天),那么,此时的分支操作未尝不可。

抽象分支与其它模式之间的关系

重构:重构被定义为“一种对软件内部的变更,使软件更容易理解,更方便地修改,但却不改变软件的外部行为”。从这个角度来讲,上面提到的两种抽象分支的例子也都属于重构。关键是,抽象分支是与重构相关的一种有效步骤,能够对软件架构进行大规模的修改。你不但能够随时发布软件,而且同时还能进行重构,这也许是在主干上开发最重要的收益了。

特性开关: 大家常常把抽象分支和 特性开关搞混。二者都能够让你在主干上做出增量式改变的模式。不同点在于,特性开关的主要目的是在开发新功能时,如果需要发布,让这些未完成的新功能对用户不可见。所以,特性开关通常是用于部署或者运行时选择是否让某个或某组特性在应用程序中可见。

抽象分支是为了增量式地对应用程序进行大面积改动,所以是一种开发技术。抽象分支当然可以与特性开关一起使用,比如,你可能通过开关决定是使用 iBatis,还是Hibernate来访问一组特定的数据调用,用于运行时的性能对比。但这种实现的选择通常是由开发人员决定,是硬编码进去的,也可能是构建时设定的,比如,通过依赖反转的配置项放进去的。

抽象分支与绞杀应用(strangler application)的关系

绞杀应用模式是指使用增量开发方式,逐步用一个全新的系统替代原有的一个系统(通常是一个遗留系统)。所以,与抽象分支相比,它是一种更高层上的抽象,即增量式地改变系统中某个组件。如果你的系统结构是面向服务的架构,那么两者之间的界​线是比较模糊的。

你可能会说,这不就是一个“好的面向对象设计”嘛? 是的。在遵循SOLID 原则的代码库上,非常容易使用这种模式,尤其是遵循了依赖反转原则和接口分离原则(ISP)。ISP原则非常重要,因为它提供了良好的粒度划分,很容易做到对不同实现的切换。David Rice(敏捷项目管理产品Mingle的的Lead)指出,对于改变软件系统中某个特定组件的具体实现方式,抽象分支是唯一一种明智的选择。老马(Martin Fowler)也有相同的观点,他把组件(component)定义为系统中可以被另一种实现方式完全替代的那个部分。