利用“抽象分支”做增量式大规模软件改造

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

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

Paul Hammant在2007年就提到过用这种方式把OR mapping的方案从Hibernate切换到iBatis(详见这里)。同样,我曾经工作的一款商业产品(持续集成与敏捷发布管理平台 Go)则从iBatis改到了Hibernate,这已经是两年前做的事情了。我们也把产品的UI层慢慢地从“使用Velocity和JsTemplate”转移到了“JRuby on Rails”上。

这两种变化都是慢慢地增量式地完成的,在改变的同时,也做新功能的开发,但这并不妨碍我们每天向Mercurail的版本库主干上提交数次代码,甚至在切换过程中做了数次正式发布。我们是如何做到的呢?

从iBatis迁移到Hibernate

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

当然,我们并没想一次性把整个代码库都迁移到Hibernate上。我们的策略是:当开始增加新功能时,如果需要增加新的方法去访问数据库的话,就使用Hibernate来完成,当必要时,才将原有对iBatis的调用迁移过来。

对持久层逻辑的更新相对来说比较直接,因为产品 Go的代码库使用标准的分层结构,控制器层使用服务层,而服务层使用仓库层(repositories)。因为所有需要访问数据库的代码都利用repository pattern封装在仓库层中,所以每次将一个仓库从iBatis改成Hibernate,增量式地完成修改是一件比较容易的事情。服务层根本不知道底下的持久层框架是什么。

我 的同事Pavan K.S.说:“抽象分支有一个严格要求,那是纪律性,即:开发人员不能再以任何借口添加原有模式的代码。也就是说,作为第一原则,不要再增加iBatis查询(尽管这么做可能更快更省事儿),必须用Hibernate来做。这是确保你进度的唯一方法。一种强制手段是在持续集成构建时只要发现新增了iBatis查询, 就令持续集成构建失败。并且,只能不断减少这个阀值,绝不能增加。”

从Velocity和JsTemplate转向JRuby on Rails

产品GO还从“以Java为基础的UI软件栈”转向“JRuby on Rails”的软件栈。这也有两个原因:一是新的框架更容易写测试,二是它会加速UI的开发。当然,这个变更也是增量式完成的。当在应用程序中创建新的页面时,我们会使用JRuby on Rails,一旦做好以后,就让应用程序的其它部分指向这个新页面。

当需要对某个旧页面进行大量变更时,我们就把它迁移到JRuby on Rails上。一旦做好,就把应用程序中所有指向这个页面的URI都更改为这个新的页面。此时,要把对应的旧页面删除。所以,当Go的界面大部分都是JRuby on Rails的实现时,仍旧有一些页面是原有JAVA版的实现。

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

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

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

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

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

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

尽管Go这个项目相对比较小,开发人员不到十个,而且到现在也仅有五年的时间,但是,这些原则完全可以应用于各种大小的项目上。即使在大型且分布式的团队项目里,也可以成功地使用这种模式。

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

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

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

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

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

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

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

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

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

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

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

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

抽象分支与强制应用(strangler application)的关系。 强制应用模式包括增量式地用一个全新的系统替代整个系统(通常是遗留系统)。所以,与抽象分支相比,它是一种更高层上的抽象,即增量式地改变系统中某个组件。如果你的系统结构是面向服务的架构,那么两者之间的界​线是比较模糊的。

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


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

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

 

英文见《持续交付》英文站

About 乔梁

乔梁,百度项目管理部高级架构师
This entry was posted in 持续交付 and tagged , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>