8.3构建
《奔跑吧,程序员》章节:8.3构建,宠文网网友提供全文无弹窗免费在线阅读。!
构建过程由三个部分构成:版本控制、构建工具以及一个将它们集中起来持续集成过程。
8.3.1 版本控制
版本控制系统(version control system,VCS)可以让你对一组文件的变化进行持续跟踪。即便你从来没有使用过VCS,可能也有好多次可以东拼西凑出自己的系统来。你是否曾把一个文件的副本通过email发给自己作为备份?是否和同事间用DropBox共享文档?你的硬盘上是不是存在15个版本的简历(resume-v1.doc、resume-v2.doc、resume-09-03-14.doc)?这些临时办法对几个Word文档或电子表格来说也许没问题,但并不是管理软件的好方法。
如果你正在从事编码工作,却还没有找到好的解决方法,那么你需要试试VCS了。没有什么正当理由可以不去使用VCS,一个都没有——即便你在做一个单人的项目,即便只是一个小项目,即便这个项目你开发了几年都没有用VCS。另外,设置一个VCS的成本也是非常低的,而它带来的好处却很多。现在你是没有任何借口了。通过存储每个文件完整的修改历史,VCS可以给你和你的团队带来超能力。如果你犯了错,可以恢复到任何文件的最近版本。如果你发现了bug,可以挖掘近期的提交和提交信息去追踪引发这个bug的修改。如果你同时开发多个功能,可以把它们放在独立的分支上。如果你需要和团队成员合作,可以基于提交钩子、合并和pull请求去实现一个工作流。
到2015年为止,最流行的版本控制系统就是SVN和Git。SVN是一个集中式的VCS,信任源是一个中心服务器上的版本库,所有开发人员使用客户端软件和该库进行交互,如图8-1所示。要获得代码的副本,我们要将其从中心服务器上签出。如果修改了其中的部分代码,要将这些修改提交回中心服务器上。
图8-1:集中式版本控制
Git是一个分布式的VCS,每个人都有一份代码库的副本,同时起到服务器和客户端的作用,如图8-2所示。我们可以克隆别人的代码库来获得代码的副本,可以把修改提交到本地的代码库,可以把最新的提交通过推送提交给其他人,也可以通过拉取获得他人最近的提交。
图8-2:分布式版本控制
在实际中,即便是分布式的VCS,通常也有一个中心节点作为正式的信任源,用于所有的构建和发布。例如,GitHub就是大部分开源项目的信任源,我们必须把提交推送到上面,使它们成为“正式的”。
那么是应该在创业公司使用集中式的VCS,还是分布式的VCS呢?对于大多数公司来说,Git可能是最好的选择,部分原因是因为它是分布式的,部分原因则因为它正开始在版本控制领域占据统治地位,这是由于GitHub和开源软件的流行。也可以这么说,任何VCS都比没有要好,所以如果你更喜欢其他工具,使用它也完全没问题。无论你选择的是哪一种VCS,都应该遵循两个最佳实践:编写良好的提交信息、及早提交并经常提交。
1. 编写良好的提交信息
假设我们部署新版网站到生产环境中时,发现搜索功能出了问题。为了找出原因,我们打开提交日志,浏览最近的发布做了什么改变:
> git log --pretty=oneline --abbrev-commit e456b8b Maybe this will do it d98846a Fix another issue 59635e9 Fix stuff 964ce4c Initial commit
你能猜到这些提交哪一次把搜索功能弄出问题了吗?不行?好吧,如果看到的是下面的情况呢?下面是同样的提交,但描述是不同的:
> git log --pretty=oneline --abbrev-commit e456b8b Add alt text to all Images d98846a Improve search performance by adding a cache 59635e9 Update logo on homepage 964ce4c Initial commit
现在可以清楚地看到编号d98846a的提交“添加缓存以改进搜索性能”是最有可能引发这个bug的。我们可以查看该提交的差异,准确了解修改了什么,这将节省调试的时间。所有的VCS都允许我们在签入代码的时候提供提交信息。如果我们能够正确使用这一功能,它们就会成为跟踪bug和尝试弄清楚代码变化情况的关键来源。这就是我们为什么应该花时间编写好的提交信息。
良好的提交信息由概要和描述构成。概要就像论文的标题:单独位于第一行,应该简短而切中要点。在概要之后,应该插入一个新行,以段落或要点的格式解释我们修改了什么、为什么要修改、哪里可以找到更多信息(例如指向bug跟踪器、wiki或代码评审的链接)。下面是一个例子:
Improve search performance by adding a cache To improve the latency of our search page, we are now using memcached to cache search results for a configurable amount of time (default is 10 minutes). Results from memcached come back in 1ms instead of the 100ms it takes to hit the search cluster. - Full design: http://wiki.mycompany/search-caching - JIRA ticket: http://jira.mycompany/12345 - Code review: http://reviewboard.mycompany/67890
2. 及早提交并经常提交
如果我们长时间没有提交,有地方出现了错误,VCS就无法提供帮助。因此,我们应该及早提交并经常提交。如果出现了问题,这些提交就能像检查点那样,让我们更容易地跟踪到原因。在必要的情况下,还可以向前恢复几步。理想状态就是每一次提交都是完全实现了某一个单一目的、大小合理的单元。我们把这句话拆开看看。
单一目的意味着我们不应该在同一次提交中修复两个bug或者实现两个功能,或者在一次提交中重构现有代码和实现新的代码。完全实现意味着我们不应该提交会给构建过程带来问题的代码,或者让用户看到未完成功能的代码。大小合理的单元意味着我们应该把工作分解成较小的、增量式的步骤。并非巧合,这也恰好就是测试驱动开发、重构和代码评审(阅读第7章了解更多信息)成功的秘密。例如,如果我们开发的功能要花好几天才能实现,也许就应该把它分成三次提交:一次提交可能添加一些失败的测试用例(先标记为忽略,这样构建过程就不会失败);第二次提交可能对现有代码进行重构,以便可以轻松实现新功能;最后一次提交则实现实际的功能。读者可以阅读8.3.3节,了解如何将大的功能分解为较小的、安全的提交。
8.3.2 构建工具
每一个代码库都需要构建工具对其进行编译、运行测试,以及封装代码成用户产品。目前有许多开源的构建工具可供使用,具体使用哪一种取决于我们所要编译的代码类型。例如,如果你正在用Ruby开发,可能就要使用Rake;如果你正在用Scala,可能要用SBT;如果你正在用许多不同的编程语言,Gradle也许是最佳选择;如果你正在编译静态内容,Grunt.js和Gulp提供了大量插件可以应对各种常见任务,比如连接CSS和JavaScript或者减小它们的尺寸,或对CoffeeScript、Sass和Less进行预处理。
大部分构建系统也都可以帮助我们管理依赖项。如果代码依赖于第三方库或开源库,我们就不应该只是把依赖项的代码直接复制并粘贴到项目中。假如这么做的话,也得把传递依赖项的整棵树的代码复制、粘贴进去。举个例子,如果你依赖A库,A又依赖B和C,C又依赖D、E和F,就必须把所有这些库的代码都复制到项目中。当你想要升级到新版的A库时,也许会发现它正在使用新版的B和C,还加入了新的依赖项C,这时你就不得不也去升级所有这些依赖树。
这样下去很快就没办法管理了,所以大部分的构建系统都可以让我们制定顶层的依赖项,它们会负责为你传递依赖项。例如,下面的代码是在Gradle中制定依赖项:
dependencies { compile group: 'commons-io', name: 'commons-io', version: '2.4' testCompile group: 'junit', name: 'junit', version: '4.+' } repositories { mavenCentral() }
这段代码将告诉Gradle,我们需要版本为2.4的commons-io库才能编译代码,还需要版本为4.0或4.0以上的junit库去编译测试代码,Gradle可以在Maven Central代码库中找到这些库。所以,当我们编译或运行代码时,Gradle将会自动下载这些库,加入它们的所有传递依赖项,把它们包含在类路径中。
8.3.3 持续集成
假设你正在负责建立国际空间站(International Space Station,ISS ),它是由几十个组件构成的,如图8-3所示。
图8-3:国际空间站
每个组件都由独立的团队实现,组织的方式取决于你,你有两个选择。
(1)提前做出所有组件的设计,然后让每个团队单独完成各自的组件,直到完成为止。当所有团队都完成的时候,把所有组件发射到太空,然后尝试同时将它们组合起来。
(2)做出所有组件的初步设计,然后让每个团队去投入工作。在他们推进的过程中,不断对每个组件和其他所有组件进行测试,如果有问题则对设计进行更新。当组件完成后,把它们同时发射到太空中,增量式地组装起来。
对于第一个选择,在最后一分钟尝试组装整个ISS将会暴露出大量冲突和设计问题。A团队认为B团队会处理线路连接,而B团队认为A团队会处理;所有的团队都使用公制,只有一个除外,没有一个团队记得要安装马桶。不幸的是,因为所有东西都已经完全做好了并在太空中漂浮,把它们弄回来再修复将会非常昂贵和困难。很明显,这一选择将会是一个灾难,但是这正是许多公司做软件的方法。开发人员同时完全隔离工作数周或数月,到了最后一分钟又尝试将所有的代码合并到一起。这个过程就是所谓的后期集成,就像我们在本章开头看到的LinkedIn的故事一样,通常都会导致灾难。
更好的方法就是第二种选择所描述的,是持续集成,所有开发人员定期(每天或者每天多次)将代码合并到一起,这一过程可以尽早暴露设计中的问题,避免在错误的方向上走得太远,我们也可以增量式地改进设计。实现持续集成最常见的方法就是使用基于主干的开发模型。
1. 基于主干的开发
在基于主干的开发模型中,开发人员在同一个分支(通常是trunk、HEAD或master,取决于你的VCS怎么命名)上进行他们的工作。这里不存在功能分支。看起来似乎让所有开发人员在单一的分支上工作是不可能扩展的,但现实是这种方法也是扩展的唯一方法。LinkedIn去掉功能分支,并把基于主干的开发作为实现项目反转的一部分,也是现实从100名左右的开发人员扩展到超过500名开发人员的必备条件。Facebook使用基于主干的开发很好地实现了超过1000名开发人员的扩展。Google使用这种模型已有多年,表明基于主干的开发可以支持15 000名以上开发人员、4000个以上的项目和每分钟20~60次提交。
成千上万的开发人员如何才能做到频繁地签入同一分支却没有冲突呢?事实证明,如果你进行小的、频繁的提交,而不是规模庞大的提交,冲突的数量就会相当小,而这样的一些冲突也是可接受的。这是因为不管使用什么集成策略,处理只需一两天工作(使用持续集成)造成的冲突要比处理需要几个月的工作(使用后期集成)造成的冲突要更容易一些。
基于主干的开发让冲突问题没有那么严重,但是稳定性又如何呢?如果所有开发人员都在同一分支上工作,而一名开发人员签入了无法编译或引起严重bug的代码,可能就会阻碍所有的开发。为了防止这种情况发生,我们必须使用自测试构建。自测试构建是一种完全自动化的构建过程(即可以在单条命令中运行),该过程具有足够的自动化测试。如果测试均通过,可以确信代码是稳定的(阅读7.2.1节了解更多信息)。常用的方法是添加一个提交钩子到VCS中,每一次提交均在持续集成服务器上(CI服务器,比如Jenkins或Travis)进行构建,如果构建失败则拒绝该提交。CI服务器是你的守护者,它在允许代码进入主干之前会对每一次签入进行验证。
如果没有持续集成,在有人证明你的软件可行之前,可以说它都是有问题的,而通常得等到测试或集成阶段才能被证明。对于持续集成,你的软件所出现的每一次新变化都被证明是可行的(假设具有非常全面的自动化测试)——你也知道它什么时候出现问题,可以立即把问题修复。
——Jez Humble和David Farley,《持续交付》
持续集成对于小规模、频繁的提交是很有好处的,但如何应对大的修改呢?如果你开发的是耗费数周或数月的东西,如何才能定期提交未完成的工作,又不至于破坏构建过程或不小心发布未完成的功能给用户呢?答案就是使用抽象分支和功能开关。
2. 抽象分支
解释抽象分支最简单的方法就是使用一个例子。我们假设正在Java应用程序中使用Redis键-值存储,并使用Jedis客户端库去访问它:
Jedis jedis = new Jedis("localhost"); String value = jedis.get("foo");
我们要用Voldemort的键-值存储去代替Redis,但整个代码库中有数以千计的地方都在用Jedis库。有一种选择就是创建一个功能分支,然后就可以花几个星期将所有客户隔离开,修改为使用Voldemort库,然后再寄希望于可以安全地把代码合并回去。要实现同样的隔离,更好的办法是在代码中使用抽象。举例来说,可以先为键-值存储定义一个简单的接口:
public interface KeyValueStore { String get(String key); }
然后创建该接口的实现,在底层使用Jedis:
public class JedisKeyValueStore implements KeyValueStore { private final Jedis jedis = new Jedis("localhost"); @Override public String get(String key) { return jedis.get(key); } }
可以在主干中直接实现并测试这个新的类。因为没有人使用它,所以可以很轻松地通过几次小的提交来实现,不会把构建过程弄出问题。准备好这个类之后,就可以开始迁移所有使用Jedis客户端的代码,让它们使用我们的抽象:
KeyValueStore store = new JedisKeyValueStore(); String value = store.get("foo");
由于这一改变并不影响任何外部的行为,我们可以增量式地修改客户端,期间可以进行多次小的签入。与此同时,我们也可以实现并测试底层使用了Voldemort的KeyValueStore抽象的新的实现:
public class VoldemortKeyValueStore implements KeyValueStore { private final StoreClient client = new SocketStoreClientFactory( new ClientConfig().setBootstrapUrls("tcp://localhost:6666") ).getStoreClient("my_store_name"); @Override public String get(String key) { return client.getValue(key); } }
同样,由于没有人使用该实现,我们可以直接在主干中进行构建和测试,进行多次小的签入。准备好之后,所有客户端都已经被迁移到这个抽象中,我们可以开始迁移它们去使用新的Voldemort实现:
KeyValueStore store = new VoldemortKeyValueStore(); String value = store.get("foo");
还是一样,可以在主干中增量式地进行这种修改。事实上,对于新的键-值存储,一次测试一个用例可能比较合适,我们可以在完成整个产品的迁移之前找到bug。最终,我们将完成所有客户端的迁移,安全地从代码库中去掉Jedis抽象。
值得注意的是,抽象分支实际上仅仅是依赖反转原则的实践(阅读6.8节了解更多信息),它不仅仅是在没有功能分支的情况下进行重大的重构,在很多情况下,它还可以产生更整洁的代码。
3. 功能开关
功能开关背后的思路是未完成或有风险的代码在默认情况下应该是不可用的,在它完成的时候应该有简单的方法去启用它。这种方式可以让你把大的功能分解为小的、增量式的部分,只要一稳定就将其签入,而不是等到完全完成。例如,当我们为网站首页实现一个新的大模块时,可以把这个模块放在一个if语句中:
private static final String NEW_HOMEPAGE_MODULE_TOGGLE_KEY = "showNewHomepageModule"; if (featureToggles.isEnabled(NEW_HOMEPAGE_MODULE_TOGGLE_KEY)) { // 在主页上显示模块 } else { // 不要显示新的模块 }
在上面的代码段中,featureToggles类会查找键,比如showNewHomepageModule,它要么放在应用程序的配置中,要么从远程服务获取(例如键-值存储)。所有功能开关的默认状态都是关闭的,所以只要代码编译并通过了现有的测试,我们就可以在新模块的代码完成之前将其提交到主干中,没有用户会看到它。当我们完成该功能之后,就可以在配置或远程服务中开启这个功能。
在LinkedIn中,我们用来实现功能开关的远程服务被称为XLNT。它有一个Web UI,可以让我们动态地决定哪些会员可以看到哪些功能。例如,我们可以用showNewHomepageModule这个键决定让它只对LinkedIn员工可见,或者只对法语会员可见,或者只对美国1%的会员可见,如图8-4所示。
图8-4:XLNT Web界面
XLNT不仅可以决定功能的开和关,还能够渐进式地让一个功能从完全关闭,到提供给1%,到10%,最后到100%的会员。在这其中的每个步骤中,如果我们发现了任何bug或者性能上的问题,都可以快速地让这个功能的覆盖率降下来。
当然,我们也不应该把所有东西都放到功能开关中。功能开关与风险管理相关,如果风险较低,代码中遍布的if语句所带来的额外的复杂性也许就是不值得的。如果你使用了功能开关,必须在功能已经启用后严格清理干净,否则你的代码库将会被各种已经不再执行的代码分支弄得乱七八糟。至少,也要在每个功能开关的上方放一条TODO语句作为提醒:
// TODO: remove this feature toggle after the ramp on 09/01/14. // See http://mycompany.wiki/new-homepage-module for more info. private static final String NEW_HOMEPAGE_MODULE_TOGGLE_KEY = "showNewHomepageModule";