对于非常简单的项目,我们使用IDE(Integrated Development Environment,集成开发环境)就可以进行软件的构建和测试。然而,这却只适合最简单的任务。只要项目所需人力超过两个人,或者需要个把月的开发时间,或者输出的可执行文件多过一个,若不想让它变得更复杂和难以处理,就需要施加更多的控制了。在大型或分布式团队(包括开源项目)里,使用脚本执行应用程序构建、测试和打包工作是必须的,否则团队的新成员就要花上几天的功夫才能熟悉项目。
第一步真的非常简单,现在几乎每种平台都可以在命令行中进行构建。Rails项目可以使用默认的Rake任务,.NET项目可以使用MSBuild,Java项目(如果设置正确的话)可以使用Ant、Maven、Buildr①或Gradle,而利用SCons,无需太多工作就能让那些简单的C/C++项目运行起来。这样开始做持续集成就简单了,只要让持续集成服务器运行这个命令创建二进制包就行了。
自动化部署则稍微麻烦一点儿。向测试环境和生产环境部署软件的过程不可能是“复制一个二进制文件到生产环境,然后就坐在那里等着就了事儿”那么简单。大多数情况下,它需要一系列的步骤,比如配置应用程序、初始化数据、配置基础设施、操作系统和中间件,以及安装所需的模拟外部系统等。项目越复杂,这样的步骤就越多,所需时间越长,而且(如果没有自动化的话)就越容易出错。
除了那些最简单的情况以外,利用通用构建工具执行部署都会遇到很多麻烦。目标环境和所有中间件对于部署机制通常都会有一些约束。更为重要的是,应该由开发人员和运维人员共同决定怎么做自动化部署,因为这两个角色都需要了解这一技术。
构建和部署系统必须一直保持活力,即这个系统不仅要从项目刚开始就开发,而且一直要持续到软件在生产环境中的维护阶段。一定要细心地设计和维护它,像对待其他源代码一样对待它,并定期使用,以便当我们需要时,可以确保它还能运行。
自动化构建工具已经伴随着软件开发走过了很长一段路。很多人都会记得,作为曾经的标准构建工具,Make 以及它的很多变种已用了很多年。所有构建工具都有一个共同的核心功能,即可以对依赖关系建模。在执行过程中,它能以正确的顺序执行一系列的任务,计算如何达到你所指定的目标,而且被依赖的任务也仅需要运行一次。例如,假如你想运行测试,就需要编译自己的代码和测试,并设置测试数据,以及编译与初始化环境相关的所有东西。图6-1显示了一个依赖网络的简单例子。
构建工具能推算出它需要执行这个依赖网络中的每一个任务。它可能从初始化开始,也可能从设置测试数据开始,因为这两个任务是独立的。一旦初始化完成以后,就可以编译源代码和测试了,而且一定是两个任务都要做,并在运行测试之前准备测试数据。尽管很多个任务都依赖于初始化,但它只会运行一次。
一个值得注意的小地方是每个任务都包括两点内容,一是它做什么,二是它依赖于什么。在每个构建工具中都会对这两点进行建模。
然而,各构建工具的不同点在于它是任务导向的,还是产品导向的。任务导向的构建工具(比如Ant、NAnt和MSBuild)会依据一系列的任务描述依赖网络,而产品导向的工具,比如Make,是根据它们生成的产物(比如一个可执行文件)来描述。
乍看之下,这种区分显得有些学术气,但对于了解如何优化构建流程以确保其正确性来说,这一点是非常重要的。比如,一个构建工具必须要确保对于即定目标,每个先决条件必须只被执行一次。如果某个先决条件没有被执行,构建过程的结果就是不对的。可是,如果某个先决条件被执行了多次,最好的结果是花费较长的时间(假如这些先决条件是幂等的),而搞不好的话,构建结果是无法用的。
通常来说,构建工具会遍历整个网络,调用(但并不一定执行)每个任务。因此,在这个例子当中,我们假想的这个构建工具可能会调用“设置测试数据”、“初始化”、“编译源代码”、“初始化”、“编译测试”,然后是“运行测试”任务。在任务导向的工具中,每个任务都会知道它自己在构建过程中是否被运行过。所以,即使“初始化”任务被调用过两次,它也只会执行一次。
然而,在产品导向的工具中,它们是用一系列的文件建模的。例如,在本例中,“编译源代码”(compile source)和“编译测试”(compile test)的目标是分别在一个文件中包含所有编译过的代码,我们暂且把这两个文件叫做source.so和 tests.so。相应地,“运行测试”( Run test)可能会生成一个叫做testreports.zip的文件。一个产品导向的构建系统会确保在运行“编译源代码”和“编译测试”之后再调用“运行测试”,但是只有当这两个.so文件的时间戳晚于testreports.zip时,才会执行“运行测试”。
因此,面向产品的构建工具将状态以时间戳的形式保存在每个任务执行后生成的文件中(SCons使用MD5签名)。这在编译C或C++程序时非常好,因为Make会保证只编译那些自上次构建后发生过修改的源代码文件。在大型项目中,这种特性(称为增量式构建)会比全量构建节省数小时。在C/C++项目中,通常编译会花较长的时间,因为编译器会做很多优化代码的工作。对于运行于虚拟机上的语言来说,编译器只创建字节码就行了,虚拟机运行时(JIT)编译器会在运行时进行这种优化。
下面,我们简单总结一下当前流行的构建工具。
Make
和它的变种仍旧活跃在系统开发领域。它是一种强大的产品导向的构建工具,能在单次构建中追踪依赖关系,还能只构建那些受到本次修改影响的组件。当编译时间在开发周期中是相当大的时间成本时,对于提升团队的开发效率来说,这一点就相当重要了。
然而,Make也有很多缺点。随着应用程序复杂程度和组件之间依赖关系的增加,这种复杂性会让Make变得越来越难以调试。
为了使这种复杂性更易控制,对于在庞大的代码库上工作的团队,一个常见的约定是在每个目录下创建一个Makefile
,最上层的Makefile会递归调用每个子目录中的Makefile。这意味着构建信息和流程最终会触及很多文件。当有人提交修改给构建时,很难知道究竟改了什么,以及会对最终的交付物有什么样的影响。
在某些情况下,空白字符的影响非常大,所以很容易在Makefile中引入一些难以发现的缺陷。比如在一个命令脚本中,那些传给shell的命令必须有一个制表符在前面。如果相反地使用了空格,这个脚本就无法正常工作了。
Make的另一个缺点是,它依赖于shell做所有的事情。结果,Makefile就不得不与操作系统绑定在一起了。(的确,很多工作就由Make周边的一堆工具来承担,以便构建脚本可以在UNIX的多种变种系统中运行。) 由 于 Makefile 是一种外部的 DSL(Domain-Specific Language,领域特定语言),并不提供对核心系统的扩展能力(除非定义新的规则)。在无法使用Make的内部数据结构的前提下,所有的扩展都必须重建公共解决方案。
由于Make程序本身所用的声明式编程模型并不为大多数开发人员(那些习惯于命令式编程的)所了解,这些问题就意味着在新开发的商业应用中,Make很少被用做主要的构建工具。
现在很多C/C++的项目中,开发人员更倾向于使用SCons,而不是Make。SCons本身和它的构建文件都是用Python写的,这让它成为了比Make更强大和更适用的工具。它有很多非常有用的特性,比如支持Windows和并行构建。
随着Java的出现,开始有更多的跨平台开发项目。Make固有的局限性越来越成问题,而Java社区也先后经历了几种解决方案,先是将Make
本身移到Java上。与此同时,XML
作为构建结构化文档的方便方法开始崭露头角。二者的融合就产生了Apache
的构建工具Ant
。
由于完全跨平台的特点,Ant包含一系列用Java写的任务,可用来执行常见的操作,如编译和文件系统操作。Ant也很容易使用Java来扩展一些新的任务。Ant很快成了Java项目构建工作的事实标准。现在很多IDE和其他工具都支持Ant。Ant是一个任务导向的构建工具。Ant的运行时组件也是用Java写的,但Ant脚本是用XML书写的一种外部DSL。这种结合使Ant具有了强大的跨平台能力。它也是极其灵活和强大的系统,因为Ant的任务几乎可以让你做任何想做的事情
然而,Ant也有几个缺点。
)给用户使用。由于这些局限性,Ant文件会很长,也很难重构(数千行的Ant文件很常见)。
当初Microsoft引入.NET
框架时,很多特性与Java语言和环境中的一样,Java开发人员很快就可以将他们喜欢的开源Java工具移植到其上。所以你看到了NUnit
和NMock
,以及NAnt
(这是意料之中的),而不是JUnit
和JMock
。NAnt使用了和Ant同样的语法,只有少许不同。
Microsoft后来在NAnt上引入了少量变化,并形成了一个变体——MSBuild
。作为Ant和NAnt的直接后裔,MSBuild很容易令使用过Ant和NAnt的用户上手。然而,它与Visual Studio结合更紧密,知道如何构建Visual Studio上的解决方案和项目,以及如何管理依赖(所以,NAnt的脚本常常调用MSBuild来做编译)。尽管有些用户抱怨MSBuild的灵活性不如NAnt,但它更新比较快,而且是.NET 框架中的一部分,所以是NAnt的有力对手。
它们两个的缺点基本与Ant的缺点一样。
在相当长的一段时间里,Ant在Java社区是老大,但创新的脚步却没有停止。Maven
通过为Java项目的代码组织结构定义一些假设前提,形成一个比较复杂的模型,试图以此消除Ant文件中大量的样板文件。这种流行的“惯例胜于配置”(convention over configuration)的原则意味着,只要项目按Maven指定的方式进行组织,它就几乎能用一条命令执行所有的构建、部署、测试和发布任务,却不用写很多行的XML。这包括为项目创建网站,用来默认宿主应用软件的所有Javadoc。
Maven另一个重要的特性是,它能自动管理Java库和项目间的依赖,而这正是大型Java项目的一个痛点。Maven还支持一种复杂且严格的软件分区方案,使你能将复杂的解决方案分解成较小的组件。
Maven的问题有三个:
DSL
。也就是说,为了扩展它,你要写代码。尽管写Maven插件并不很复杂,但绝对不可能在几分钟之内搞定。你要学习Mojos
、插件描述符,以及Maven所用的控制反转(inversion-of-control)框架。幸运的是,Maven有很多插件,对于一般的Java项目,你几乎能找到所有想要的插件。对于某些团队来说,Maven的约束可能过于严格了,或者需要很多精力才能将项目整理成符合Maven的规定的结构。所以,他们宁可使用Ant。最近,出现了叫做Ivy
的工具,它可以在多个组件之间管理库文件和依赖,而不需要使用Maven。将它与Ant结合使用,在某种程度上,可以得到与使用Maven一样的效果。
值得注意的是,尽管Ivy和Maven在组件间管理依赖的能力很强,但其管理外部依赖(从Maven社区维护的因特网仓库中下载它们)的默认机制并不总是最好的选择。在做第一次构建时,你要等上一段时间,因为Maven要从网上下载依赖。更大的问题是,除非你严格指定使用哪个版本的依赖库,否则Maven会在你不知情的时候更新某个库的版本,结果可能导致菱形(diamond)依赖问题和损坏。
对于软件构建来说,Ant和它的兄弟都是外部的领域特定语言(DSL)。可是XML令这些语言很难编写、阅读、维护和扩展。主流的Ruby构建工具Rake作为一个试验品出现了,它是否能够通过在Ruby中创建内部DSL来轻松完成Make的相应功能呢?答案是肯定的。Rake和Make一样是产品导向的工具,但也可以用作任务导向的工具。
像Make一样,Rake只能理解任务和依赖。然而,由于Rake脚本是纯Ruby的,所以你可以用Ruby的API来执行任何任务。因此,用Rake可以轻松写出强大且与平台无关的构建文件,因为你能使用通用编程语言的所有本地化功能。
尽管Rake是Ruby程序员开发的,并被广泛地用于Ruby项目,但这并不意味着它无法用在使用其他技术的项目上。(比如Albacore项目提供了一套Rake任务来构建.NET系统。)Rake是一种通用的构建脚本工具。当然,这需要团队掌握一些Ruby的基本编程能力,但Ant和NAnt也同样需要基本编程能力。
Rake也有两个不便之处:首先,要确保在你的平台上装有适当的Ruby运行时环境(作为最方便最可靠的平台,JRuby势头强劲);其次,要组合使用RubyGems。
Rake的简单和强大令“构建脚本应该用一个真正的编程语言编写”有了一个令人信服的理由。新一代构建工具,比如Buildr、Gradle和 Gantt都使用了这种方式。它们都以内部DSL的形式构建软件。然而,它们试图让复杂的依赖管理和多项目构建变得简单。我们接下来详细讨论Buildr,因为它是我们最熟悉的工具之一。
Buildr建立在Rake之上,所以Rake可以做的事情,它都能做。然而,它也是Maven的简易替换,因为它也用和Maven一样的惯例,包括文件系统布局、产物规范(artifact specification)和仓库。你还可以使用Ant的任务(包括定制的任务在内),却无需配置。它利用Rake的产品为导向的架构做增量构建。令人惊讶的是,它比Maven快。可与Maven不同,定制任务或创建新的任务是极其容易的。
如果刚开始一个Java项目,或是想找Ant或Maven的替代品,我们强烈推荐Buildr,如果你喜欢Groovy中的DSL,就用Gradle吧。
Windows用户也不用错过内部DSL构建工具的大潮。Psake(发音是“saké”)是用PowerShell写的内部DSL,提供了面向任务的依赖网格。
在本节中,我们会列出构建部署脚本化时所要遵循的原则与实践,无论你使用哪种技术它们都是适用的。
当项目刚开始时,可以将部署流水线中的每个操作都放在同一个脚本文件中,即使是那些还没有被自动化的步骤,也可以有对应的哑操作。但是,一旦脚本变得太长,就要将它们分成独立的脚本,让部署流水线中的每个阶段分别使用单独的脚本。这样,一个提交阶段的脚本就可以完成编译、打包、运行提交测试套件和执行代码静态分析的工作。功能验收测试脚本会调用部署工具,将应用程序部署到适当环境中,并准备相关数据,之后再运行验收测试。你还可再用一个脚本运行任何非功能测试,比如压力测试和安全测试。
在典型的部署流水线里,提交阶段之后的大多数阶段(比如自动化的验收测试阶段和用户验收测试阶段)都需要把应用程序部署到类生产环境中,所以部署自动化也是非常关键的。然而,在做自动化部署工作时,应该使用恰当的工具,而不是通用脚本语言(除非部署流程十分简单)。几乎每种中间件都有相应的工具来配置和部署它,那就使用它们吧。比如,使用WebSphere应用服务器的话,你需要用Wsadmin工具来配置容器,并部署应用程序。
最重要的是,开发人员(至少可以在他们自己的开发机器上)、测试人员和运维人员都要做应用程序的部署工作。因此,他们要共同判定如何部署应用程序。这件事也要在项目一开始就做。
部署脚本应该能够完成应用程序的安装和升级任务。在部署之前,它要能够关闭当前运行的版本,而且既支持在当前的数据库上升级,又能够从头创建数据库。
正如5.3节所述,使用同样的流程部署应用程序到每个环境是非常必要的,这样就能确保构建和部署流程能经过有效测试。也就是说,“使用同样的脚本部署每个环境”和“环境配置信息的不同(比如服务URI或IP地址)”这两件事应该分开管理,即将配置信息从脚本中分离出来,并将其保存在版本控制库中,并用第2章所描述的一些机制让部署脚本去获得这些信息。
这里有两个关键点:
对于并行构建系统来说,很容易变成“只有开发人员使用这些脚本”,但这就丢失了可以令构建部署脚本保持灵活性、很好地被重构和测试的关键因素。如果应用程序还依赖于公司内部开发的其他组件,就要确保能很方便地将其正确版本(已知与我们的应用程序相匹配的版本)放到开发机器上,这时Maven和Ivy这样的工具就能够派上用场。
如果应用程序的部署架构比较复杂,就要做一些必要的简化工作,以便让它可以部署在开发人员的机器上。有时,这种事情的工作量会很大,比如在开发环境做部署时,可能会把Oracle数据集群替换成内存数据库。然而,这种代价是值得的。如果开发人员为了运行应用程序不得不依赖于共享资源,必然会导致执行频率下降,进而导致反馈周期变长,并进而导致更多的缺陷和更低的开发速度。问题不在于“我们怎么才能证明这些成本是值得的
”,而是在于“我们怎样才能证明,在‘本地运行应用程序’这件事上,我们不需要投入。
”
在本书中我们使用“二进制包”指代部署过程中需要放在目标环境中的所有内容。大多数情况下,它是构建过程中产生的一堆文件,以及应用程序所需的库文件,可能还包括版本库中的某些静态文件。
可是,“将一堆文件分别部署到文件系统的不同位置”这种做法效率非常低,维护起来也非常麻烦,尤其是在升级、回滚和卸载时。这也是包管理工具出现的原因。如果只有一种目标操作系统,或者一组相似的操作系统,我们强烈推荐使用操作系统自身的包管理技术把需要部署的文件打包在一起。例如,Debian和Ubuntu都使用Debian的包管理系统,RedHat、SuSE和很多其他Linux发行版都使用RedHat包管理系统,Windows用户可以使用Microsoft Installer系统等。所有这些包管理系统都相对容易使用,并有很好的工具支持。
如果在部署过程中需要把文件放在多个文件夹中或向注册表中增加键,那么就用一个包管理系统完成这样的任务吧。这会带来很多好处,不但令应用程序的维护变得非常简单,而且在部署流程中就可以使用像Puppet、CfEngine和Marimba这样的环境管理工具。只要将包放到组织级的代码库中,让这些工具来安装正确版本的包就可以了,就像让它们安装Apache的正确版本一样。假如要把不同的文件安装到不同的机器上(比如当使用N层架构时),你就可为每一层或每一类机器分别创建一个安装包。对二进制文件进行打包的工作也应该是部署流水线中需要实现自动化的部分。
当然,并不是所有的部署都能用这种方式来管理。比如商业化中间件服务器就经常需要使用特定的工具来执行部署。此时,就必须使用混合方法了。准备好所有并不需要那些特定工具的东西,然后再使用这些特定工具执行部署过程的后续部分就行了。
无论开始部署时目标环境处于何种状态,部署流程应该总是令目标环境达到同样(正确)的状态,并以之为结束点。
做到这一点的最简单方法就是,将已知状态良好的基线环境作为起点,要么是通过自动化,要么是通过虚拟化方式准备好的。这里所说的环境包括所有需要用到的中间件,以及让应用程序能正常工作的任何软硬件。然后,部署流程可以获取指定的应用程序版本,并使用(对于中间件来说)适当的部署工具将其部署到该环境中。
如果应用程序通过了测试、构建,并已集成为一个整体的话,通常应该以部署单件的方式来部署它。对于这一原则,也有一些例外情况。首先,对于集群系统来说,总是将整个集群系统同时重新部署就不可取。最后,还有一种方法,那就是使用效果幂等的工具进行部署。
我们可以从“运维团队与开发人员一起把将应用程序部署到某个测试环境的过程自动化”开始做起。另外,要确保运维人员能够接受部署所用的那些工具,还要确保开发人员能使用同样的流程在自己的开发环境中部署和运行应用程序。然后,再改进这些脚本,使其也能用于验收测试环境的部署。然后扩展这个部署流水线,使运维人员可以用同样的工具将应用程序部署到试运行环境和生产环境中。
尽管本书尽可能避免基于专属技术的讨论,但在这里,还是值得花一些笔墨来描述如何组织面向JVM的应用程序的项目结构。因为尽管有一些有用的惯例,但如果不使用Maven的话,这些就只是惯例,而不是规定。如果开发人员能够遵守这些标准结构的话,生活会更美好一些。另外,花一点儿精力也可以将下面的知识用到其他技术平台上。尤其是对于.NET项目来说,可以卓有成效地使用完全相同的结构,只是要把“/”换成“\”。
项目结构
下面是Maven所用的项目结构,称为Maven标准目录结构。即使你没有使用(或不喜欢)Maven,它最重要的贡献之一就是引入了项目代码结构的标准惯例。
一个典型的源代码结构如下所示:
/[project-name]
README.txt
LICENSE.txt
/src
/main
/java Java source code for your project
/scala If you use other languages, they go at the same level
/resources Resources for your project
/filters Resource filter files
/assembly Assembly descriptors
/config Configuration files
/webapp Web application resources
/test
/java Test sources
/resources Test resources
/filters Test resource filters
/site Source for your project website
/doc Any other documentation
/lib
/runtime Libraries your project needs at run time
/test Libraries required to run tests
/build Libraries required to build your project
如果你使用Maven子项目的话,应该知道每个子项目都在项目根目录的一个目录中,而其子目录也遵循Maven标准目录结构。值得注意的是lib目录并不是Maven的一部分,因为Maven会自动下载依赖并保存它们在由其管理的本地库中。可是如果你没有使用Maven,就最好将二进制包也作为源代码的一部分放在版本库中。
源代码管理
请坚持遵循标准的Java实践,将文件放在以包名为目录名的目录中,每个文件保存一个类。Java编译器和所有时新的开发环境都会使用这种惯例,但仍有人会违反它。如果不遵循它或语言的其他惯例,有可能引入很难被发现的缺陷。而且,更严重的是,这会令项目变得更难维护,编译器会报告很多警告。基于同样的原因,我们应该遵循Java命名习惯,如包名用PascalCase方式,而类名使用camelCase方式。在代码提交阶段做代码分析时应利用一些开源工具(比如CheckStyle或FindBugs)来做检查,迫使大家遵循这些命名习惯。
生成的任何配置或元数据(比如由annotations或XDoclet生成的那些)不应该放在src目录中,而应该放在target目录中,这样,当运行全量构建(clean build)时,它们可被删除。这样也会避免因失误把它们提交到版本控制库中。
测试管理
请将所有要测试的源代码都放在test/[language]目录中。单元测试应该放在与包名相对应的目录中。也就是说,某个类的测试应该与该类放在同一个包中。其他类别的测试,比如验收测试、组件测试等可以放在其他各包中,比如com. mycompany.myproject.acceptance.ui、com.mycompany.myproject.acceptance.api、com.mycompany. myproject.integration。但人们通常会把它们也放在test这个目录之下。
构建输出的管理
当用Maven做构建时,它把所有的东西都放在项目根目录中一个叫做target的文件夹中,包括生成的代码、元数据文件(如Hibernate映射文件)等。将这些内容放在一个单独的目录中能让我们更容易清除前一次构建结果,因为只要把整个目录删除就行了。不要把这个目录中的东西提交到版本控制库中,而如果打算把二进制文件提交到版本控制库中,请将它们先复制到另一个存储库中再提交。源控制系统应该忽略target目录。Maven在这个目录中如下创建文件:
/[project-name]
/target
/classes Compiled classes
/test-classes Compiled test classes
/surefire-reports Test reports
无论使用什么样的策略,你都要记住,创建多个JAR文件的目的有两个:一是令应用程序的部署更简单;二是令构建流程更加高效,并将构建依赖图的复杂性最小化。这些是应用程序打包的指导方针。
除了将所有代码作为一个项目保存,并创建多个JAR文件外,还可以使用另外一种方式,即为每个组件或子项目分别创建项目。一旦项目达到一定规模,从长远来说,这样更容易维护。当然,某些IDE也支持代码库的导航。具体选择哪种方式,依赖于开发环境以及不同组件间代码的耦合程度。在构建过程中利用一个独立的步骤,将不同的JAR文件组合成应用程序,这会使打包方式更灵活。
库文件管理
库文件的管理有几种不同的选择。一是完全交给工具来管理,比如Maven或Ivy工具。这时就不需要将库文件提交到版本控制库中,只需要声明一下项目中所依赖的库文件就可以了。另一个极端是把库文件(包括构建、测试和运行时必需的所有库文件)都提交到版本控制库中,最常见的做法是将它们放在项目根目录下的lib文件夹中。我们喜欢根据其用途,将这些库放在不同的目录中,比如构建时、测试时和运行时。
关于“如何保存构建时的依赖库(比如Ant这个工具本身也是一种依赖)”有一些争论。其实,这在很大程度上取决于项目的大小和持续时间。一方面,像编译器这样的工具或Ant的版本可能被用于构建很多不同的项目,因此把它们放在每个项目的版本控制库中是一种浪费。当然,这里也要有一些权衡。因为随着一个项目的不断演进,维护依赖库就会慢慢变成一个大问题。一个简单的解决方案是,在版本控制系统中,将大多数依赖库放在一个独立项目自己的独立版本库中。
一种比较高级的做法是建立组织级的第三方依赖库,将所有项目需要的所有依赖库文件都放在其中。Ivy和Maven都支持仓库自定义。在强调纪律的组织中,通常用这种方式。
作为部署流水线的一部分,你要确保应用程序所依赖的所有库文件都和应用程序的二进制包在一起打包,如6.3.4节所述。因为,Ivy和Maven通常是不会被安装到宿主生产环境的机器中的。
环境管理的核心原则之一就是:对测试和生产环境的修改只能由自动化过程执行。也就是说,我们不应该手工远程登录到这些环境上执行部署工作,而应该将其完全脚本化。有三种方式执行脚本化的部署。首先,如果系统只运行在一台机器上,我们就可以写一个脚本,让它在那台机器上本地执行所有的部署活动。
有三种方法做远程部署。第一种方法就是写个脚本,让它登录到每台机器上,运行适当的命令集。第二种方法是写个本地运行的脚本,在每台远程机器上安装一个代理(agent),由代理在其宿主机上本地运行该脚本。第三种方法就是利用操作系统自身的包管理技术打包应用程序,然后利用一些基础设施管理或部署工具拿到新版本,运行必要的工具来初始化你的中间件。第三种方式最为强大,理由如下。
如果你无法使用这种方法的话,使用支持代理模式的持续集成服务器(现代的持续集成服务器几乎都支持这种模式)会让第二种方式变得更简单。这种方法有以下几种好处。
最后,假如由于某种原因,你无法用上述任何一种工具的话,也完全可以从头到尾自己定制一个部署脚本。如果远程机器是UNIX,你可以使用原始的Scp或Rsync复制二进制包和数据,然后通过Ssh执行相关命令来进行部署。如果你使用Windows操作系统,也有两种选择:PsExec和PowerShell。当然,还有高层次的工具(如Fabric、Func和Capistrano等)让你绕过底层操作,直接将部署脚本化。
然而,无论你用持续集成系统,还是定制的脚本化部署,都无法处理某些情况,比如部署流程只执行了一半,或者刚向网格中增加了一个节点,需要环境准备和部署。基于这种原因,最好还是使用合适的部署工具。
对于软件交付或某个复杂系统的构建和部署,假如说有一个基础的核心原则的话,那就是应该总是把根基扎在已知状态良好的基础之上。我们不去测试那些没有编译成功的代码,也不会对没有通过提交测试的代码进行验收测试等。
当把候选版本发布到类生产环境中时更应该如此。在将应交付的二进制包复制到文件系统的某个正确位置之前,我们就要确保环境已经准备好了。为了做到这一点,我们喜欢把部署看做是一个层级沉积序列,如图6-2所示
底层是操作系统,然后是中间件和应用程序所依赖的其他软件。一旦这两层准备好了,就需要对其进行一些具体配置,为应用程序的部署做准备。只有这些都做完了,我们才能开始部署软件,这包括可部署的二进制包、所需要的服务或守护进程,以及其相关配置。
任何一个层级的部署出错,都可能导致应用程序无法正常运行。所以,当准备每一层级时,都要对其进行测试(参见图6-3)。如果发现问题,就要让环境配置流程快速失败,而测试结果也应该给出清晰指示,指出错误出现在哪里。
下面列出了我们认为比较有用的测试示例:
在本节中,我们将列出常见构建和部署问题的一些解决方案和策略。
构建中最常见的错误就是默认使用绝对路径。这会让构建流程和某台特定机器的配置形成强依赖,从而很难被用于配置和维护其他服务器。比如根本无法在同一台机器上签出同一项目的两个副本(但是,在很多情况下,这是一个非常有用的实践做法,比如对比调试或并行测试时)
应该默认所有位置都使用相对路径。这样,构建的每个实例都是一个完整的自包含结构,你提交到版本控制库的镜像就会自然而然地确保将所有内容放在正确的位置上,并以其应有的方式运行。
难以想象的是,现在还有很多人手工或通过图形用户界面工具来部署软件。对于很多组织来说,所谓的“构建脚本”仍旧是一个可打印出来的文档,上面写满了下面这样的执行步骤:
……
STEP 14 从光盘的E:\web_server\dlls目录下,将所有的DLL文件复制到
新的虚拟目录中
STEP 15 打开一个命令行客户端,输入命令: regsvr webserver_main.dll
STEP 16 打开微软的IIS管理控制台,单击“Create New Application”
(创建新应用)
……
这种部署是枯燥且易出错的。文档常常存在错误或过时,所以在生产环境中部署时,经常会有大量的演练成本。
那么,我们什么时候应该考虑将流程自动化呢?最简单的回答就是:“当你需要做第二次的时候。”到第三次时就应该采取行动,通过自动化过程来完成这件工作了。这种细粒度的渐进方法,可以迅速建立起一个系统,将开发、构建、测试和部署过程中的可重复部分实现自动化。
能够确定“某个二进制包是由版本控制库中的哪个具体版本生成的”是非常必要的。假如在生产环境中出了问题,能够轻松确定机器上每个组件的版本号,以及它们的来源,你的生活会轻松很多。
有很多办法可以做到这一点。在.NET平台上,你可以把已版本化的元数据放在程序集中(确保构建脚本总是这么做,而且要将版本控制库中的版本标识也放在其中)。JAR文件在MANIFEST中也包含有元数据,所以你可以做类似的事情。如果你使用的技术不支持将元数据构建进包的话,还可以使用另一种方法,即将构建流程生成的每个二进制包的MD5散列以及它的名字和版本标识符一起放在一个数据库中。这样你就可以使用二进制文件的MD5散列来确定它是什么以及它的来源了。
有时候,把二进制包或结果报告当做构建的一部分放到版本控制库中看起来是一个不错的主意。可是,一般来说,我们应该避免这种做法,原因如下。首先,版本控制标识的最重要作用之一就是能够追踪到某次提交中修改了什么。
取而代之的是,我们可以把二进制包和结果报告放在一个共享的文件系统中存储。如果你把它们弄丢了或者需要重新生成它们的话,最好是从源代码中重新构建一份。假如你无法根据源代码重新构建出一份一模一样的副本,这说明你的配置管理没达到标准,需要加以改进。
一般的经验法则是不要将构建、测试和部署过程中生成的任何产物提交到版本控制库中,而要将这些产物作为元数据,与触发该次构建的版本的标识关联在一起。大多数时新的持续集成和发布管理服务器都有制品库和元数据管理功能,如果没有的话,你也可以使用像Maven、Ivy或Nexus这样的工具。
在很多项目中,有时会有很多个测试任务。比如提交测试套件就可能包括一套单元测试、一些集成测试,可能还有几个验收冒烟测试。如果先运行的单元测试失败了,并让本次构建失败的话,那么直到下一次提交时我们才有可能知道集成测试能否通过。这样会浪费更多的时间。
一个较好的实践是:假如有错误,就设置一个标志,当生成更多的结果报告或者更完整的测试集后再令构建失败。
交互设计师常常通过界面约束来避免那些未预期的用户输入。你可以使用同样的方式来限制应用程序,使得当程序本身发现自己处于非正常状态时,它就会停止运行。比如,可以在部署之前令部署脚本先检查一下是否被部署在了正确的机器上。对于测试和生产环境配置来说,这尤其重要。
“脚本”这个术语被广泛应用,通常是指辅助我们进行构建、测试、部署和发布应用程序的所有自动化脚本。当你从部署流水线的最后环节来追溯这些脚本集时,它们看起来会相当复杂。然而,构建或部署脚本中的每个任务都非常简单,而流程本身也不太复杂。我们强烈建议你使用构建和部署流程作为组建该脚本集的一个指导。请以迭代的方式来识别最令你痛苦的步骤,并将其自动化,沿着部署流水线,逐步完善自动化构建和部署能力。请时刻牢记最终目标,即在开发、测试和生产环境中共享同一种部署机制,但不要过早地纠结于工具的创建。在制定和创建这些机制时,一定要运维人员和开发人员一起做。
现在,有很多技术可以用于将构建、测试和部署流程脚本化。随着PowerShell、IIS的脚本化接口和其他微软软件栈的到来,即使是Windows(历来对自动化支持不佳的平台)中也出现了一些极好的工具。
最后,再重申一次,脚本应该是系统中的“一等公民”。这些脚本应该贯穿应用程序的整个生命周期。我们应该对这些脚本进行版本控制、维护、测试和重构,并且将其用作部署应用程序的唯一机制。很多团队把构建系统作为一种事后工作。我们常常看到,当进行系统设计时,构建和部署系统几乎总是考虑最少的。结果,这种维护不良的系统常常是对合理的可重复发布流程的一个阻碍,而不是其基石。交付团队的确应该花上一些时间和精力写正确的构建和部署脚本。这不是让团队中的实习生拿来练手的东西。花些时间,想一想你要达到的目标,好好设计一下构建和部署流程。