你或其他人刚刚写完了一段代码,提交到项目的版本仓库里面。但等一下,如果新提交的代码把构建搞坏了怎么办?万一出现编译错误,或者有的测试失败了,或者代码不符合质量标准所要求的底限,你该怎么办?
最不靠谱的解决方案就是寄希望于所有人都是精英,他们根本不会犯这些错误。但如果真的出现了这些问题,我们就希望发现的越早越好。最好的方式就是只要有代码提交,我们就有某种方式对它进行验证。这就是持续集成的作用。
持续集成相关的工具有很多。最流行的要数一款基于 Java 的名叫 Jenkins 的工具。它提供了 Web 界面,用户可以在界面上配置 Job,每个 Job 都包含一系列的构建步骤。Jenkins 可以完成开头那个场景中所提到的所有验证工作,它还能更进一步做自动化部署或者一键式部署。
Jenkins 是由 Sun 的前员工开发的,它的根基是 Java,但也可以用在非 Java 的项目里,比如 PHP、Ruby on Rails、.NET。在.NET 项目里,你除了 Jenkins 之外还要熟悉另一样工具:MSBuild。
Visual Studio 用 MSBuild 构建.NET 项目。MSBuild 所需的仅仅是一个脚本,在脚本中指定要执行的 target。项目中的 _.csproj 和 _.vbproj 文件都是 MSBuild 脚本。
在这篇文章中,我们会从头开始,一步步完成一个属于我们自己的 MSBuild 脚本。在它完成以后,我们只需要一个命令就可以删除之前的构建产物,构建.NET 应用,运行单元测试。后面我们还会配一个 Jenkins Job,让它从代码库中更新代码,执行 MSBuild 脚本。最后还会配另一个 Jenkins Job,让它监听第一个 Job 的结果,当第一步成功以后,它会把相关的构建产物复制出来,放到 web 服务器里启动运行。
我们用一个 ASP.NET MVC 3 应用做例子,在 VS 里面创建 ASP.NET MVC 3 应用并选择“application”模版就行。我们还要用一个单元测试项目来跑测试。代码可以在这里下载。
你好,MSBuild
MSBuild 是在.NET 2.0 中引入的针对 Visual Studio 的构建系统。它可以执行构建脚本,完成各种 Task──最主要的是把.NET 项目编译成可执行文件或者 DLL。从技术角度来说,制作 EXE 或者 DLL 的重要工作是由编译器(csc,vbc 等等)完成的。MSBuild 会从内部调用编译器,并完成其他必要的工作(例如拷贝引用──CopyLocal,执行构建前后的准备及清理工作等)
这些工作都是 MSBuild 执行脚本中的 Task 完成的。MSBuild 脚本就是 XML 文件,根元素是 Project,使用 MSBuild 自己的命名空间。MSBuild 文件都要有 Target。Target 由 Task 组成,MSBuild 运行这些 Task,完成一个完整的目标。Target 中可以不包含 Task,但是所有的 Target 都要有名字。
下面来一起创建一个“Hello World”的 MSBuild 脚本,先保证配置正确。我建议用 VS 来写,因为它可以提供 IntelliSense 支持,不过用文本编辑器也无所谓,因为只是写个 XML 文件,IntelliSense 的用处也不是很大。先创建一个 XML 文件,命名为“basics.msbuild”,这个扩展名只是个约定而已,好让我们容易认出这是个 MSBuild 脚本,你倒不用非写这样的扩展名。给文件添加一个 Project 元素作为根元素,把 http://schemas.microsoft.com/developer/msbuild/2003 设置成命名空间,如下所示
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> </Project>
下一步,给 Project 元素添加一个 Target 元素,起名叫“EchoGreeting”
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Target Name="EchoGreeting" /> </Project>
这就行了。我们已经有了一个可以运行的 MSBuild 脚本。它虽然还啥事都没干,但我们可以用它来验证当前环境是不是可以运行 MSBuild 脚本。在运行脚本的时候,我们要用到.NET 框架安装路径下的 MSBuild 可执行文件。打开命令行,执行“MSBuild /nologo /version”命令,看看.NET 框架安装路径是不是放到了 PATH 环境变量里面。如果一切正确,你应该能看到屏幕上打印出 MSBuild 的当前版本。倘若没有的话,或者把.NET 框架安装路径放到 PATH 里面去,或者直接用 Visual Studio Command Prompt,它已经把该配的都配好了。
进入存放刚才那个脚本的目录后,以文件名当作参数调用 MSBuild,就可以执行脚本了。在我的机器上可以看到下面的执行结果:
C:\>msbuild basics.msbuild Microsoft (R) Build Engine Version 4.0.30319.1 [Microsoft .NET Framework, Version 4.0.30319.269] Copyright (C) Microsoft Corporation 2007. All rights reserved. Build started 8/2/2012 5:59:45 AM. Build succeeded. 0 Warning(s) 0 Error(s) Time Elapsed 00:00:00.03
执行完脚本以后,MSBuild 会首先显示一个启动界面和版权信息(用 /nologo 开关可以隐藏掉它们)。接下来会显示一个启动时间,然后便是真正的构建过程。因为咱们的脚本啥都没干,所以构建就直接成功了。总计用时也会显示在界面上。下面咱们来给 EchoGreeting Target 添加一个 Task,让脚本真的干点事。
<Target Name="EchoGreeting"> lt;Exec Command="echo Hello from MSBuild" /> </Target>
现在 EchoGreeting Target 有了一个 Exec Task,它会执行 Command 属性中定义的任何命令。再运行一次脚本,你应该能看到更多信息了。在大多数时候,MSBuild 的输出信息都很长,你可以用 /verbosity 开关来只显示必要信息。不过无论怎样,MSBuild 都会把我们的文字显示到屏幕上。下面再添加一个 Target。
<Target Name="EchoDate"> <Exec Command="echo %25date%25" /> </Target>
这个 Target 会输出当前日期。它的命令要做的事情就是“echo %25date%25”,但是“%”字符在 MSBuild 中有特殊含义,所以这个命令需要被转义。当遇到转义字符的时候,“%”后面的十进制字符会被转成对应的 ASCII 码。MSBuild 只会执行 Project 元素中的第一个 Target。要执行其他 Target 的时候,需要把 /target 开关(可简写为 /t)加上 Target 名称传给 MSBuild。你也可以指定 MSBuild 执行多个 Target,只要用分号分割 Target 名字就可以。
C:\>msbuild basics.msbuild /nologo /verbosity:minimal /t:EchoGreeting;EchoDate Hello from MSBuild Thu 08/02/2012
更实用的构建脚本
演示就先到这里。下面来用 MSBuild 来构建一个真实项目。首先把示例代码下载下来,或是自己创建一个 ASP.NET 应用。给它添加一个 MSBuild 脚本,以 solution 或 project 名字给脚本命名,扩展名用“.msbuild”。照先前一样指定 MSBuild 命名空间。
开始写脚本之前,先把脚本要干的事情列出来:
- 创建 BuildArtifacts 目录
- 构建 solution,把构建产物(DLL,EXE,静态内容等等)放到 BuildArtifacts 目录下。
- 运行单元测试。
因为示例应用叫做 HelloCI,于是这个脚本也就命名为 HelloCI.msbuild。先添加命名空间,然后就可以添加第一个 Target 了,我管它叫做 Init。
<Target Name="Init"> <MakeDir Directories="BuildArtifacts" /> </Target>
这个 Target 会调用 MakeDir Task 创建一个新的目录,名叫 BuildArtifacts,跟脚本在同一目录下。运行脚本,你会发现该目录被成功创建。如果再次运行,MSBuild 就会跳过这个 Task,因为同名目录已经存在了。
接下来写一个 Clean Target,它负责删除 BuildArtifacts 目录和里面的文件。
<Target Name="Clean"> <RemoveDir Directories="BuildArtifacts" /> </Target>
理解了 Init 之后,这段脚本就应该很好懂了。试着执行一下,BuildArtifacts 目录应该就被删掉了。下面再来把代码中的重复干掉。在 Init 和 Clean 两个 Target 里面,我们都把 BuildArtifacts 的目录名硬编码到代码里面了,如果未来要修改这个名字的话,就得同时改两个地方。这里可以利用 Item 或 Property 避免这种问题。
Item 和 Property 只有些许差别。Property 由简单的键值对构成,在脚本执行的时候还可以用 /property 赋值。Item 更强大一些,它可以用来存储更复杂的数据。我们这里不用任何复杂数据,但需要用 Items 获取额外的元信息,例如文件全路径。
接下来修改一下脚本,用一个 Item 存放路径名,然后修改 Init 和 Clean,让它们引用这个 Item。
<ItemGroup> <BuildArtifactsDir Include="BuildArtifacts\" /> </ItemGroup> <Target Name="Init"> <MakeDir Directories="@(BuildArtifactsDir)" /> </Target> <Target Name="Clean"> <RemoveDir Directories="@(BuildArtifactsDir)" /> </Target>
Item 是在 ItemGroup 里面定义的。在一个 Project 中可以有多个 ItemGroup 元素,用来把有关系的 Item 分组。这个功能在 Item 较多的时候特别有用。我们在 ItemGroup 里定义了 BuildArtifactsDir 元素,并用 Include 属性指定 BuildArtifacts 目录。记得 BuildArtifacts 目录后面要有个斜杠。最后,我们用了 @(ItemName) 语法在 Target 里面引用这个目录。现在如果要修改目录名的话,只需要改 BuildArtifactsDir 的 Include 属性就好了。
接下来还有个问题要处理。在 BuildArtifacts 目录已经存在的情况下,Init 是什么事都不干的。也是就说,在调用 Init 的时候磁盘上的已有文件还会被保留下来。这一点着实不妥,如果能每次调用 Init 的时候,都把目录和目录里面的所有文件都一起删掉再重新创建,就能保证后续环节都在干净的环境下执行了。我们固然可以在每次调用 Init 的时候先手工调一下 Clean,但给 Init Target 加一个 DependsOnTargets 属性会更简单,这个属性会告诉 MSBuild,每次执行 Init 的时候都先执行 Clean。
<Target Name="Init" DependsOnTargets="Clean"> <MakeDir Directories="@(BuildArtifactsDir)" /> </Target>
现在 MSBuild 会帮我们在调 Init 之前先调 Clean 了。跟 DependsOnTargets 这个属性所暗示的一样,一个 Target 可以依赖于多个 Target,之间用分号分割就行。
接下来我们要编译应用程序,把编译后的结果放到 BuildArtifacts 目录下。先写一个 Compile Target,让它依赖于 Init。这个 Target 会调用另一个 MSBuild 实例来编译应用。我们把 BuildArtifacts 目录传进去,作为编译结果的输出目录。
<ItemGroup> <BuildArtifactsDir Include="BuildArtifacts\" /> <SolutionFile Include="HelloCI.sln" /> </ItemGroup> <PropertyGroup> <Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration> <BuildPlatform Condition=" '$(BuildPlatform)' == '' ">Any CPU</BuildPlatform> </PropertyGroup> <Target Name="Compile" DependsOnTargets="Init"> <MSBuild Projects="@(SolutionFile)" Targets="Rebuild" Properties="OutDir=%(BuildArtifactsDir.FullPath);Configuration=$(Configuration);Platform=$(BuildPlatform)" /> </Target>
上面的脚本做了几件事情。首先,ItemGroup 添加了另一个 Item,叫做 SolutionFile,它指向 solution 文件。在构建脚本中用 Item 或 Property 代替硬编码,这算的是一个优秀实践吧。
其次,我们创建了一个 PropertyGroup,里面包含两个 Property:Configuration 和 BuildPlatform。它们的值分别是“Release”和“Any CPU”。当然,Property 也可以在运行时通过 /property(简写为 /p)赋值。我们还用了 Condition 属性,它在这里的含义是,只有当这两个属性没有值的情况下,才用我们定义的数据给它们赋值。这段代码实际上就是给它们一个默认值。
接下来就是 Compile Target 了,它依赖于 Init,里面内嵌了一个 MSBuild Task。它在运行的时候会调用另外一个 MSBuild 实例。在脚本中定义了这个被内嵌的 MSBuild Task 要操作的项目。在这里,我们既可以传入另外一个 MSBuild 脚本,也可以传入.csproj 文件(它本身也是个 MSBuild 脚本)。但我们选择了传入 HelloCI 应用的 solution 文件。Solution 文件不是 MSBuild 脚本,但是 MSBuild 可以解析它。脚本中还指定了内嵌的 MSBuild Task 要执行的 Target 名称:“Rebuild”,这个 Target 已经被导入到 solution 的.csproj 文件中了。最后,我们给内嵌的 Task 传入了三个 Property。
OutDir
编译结果的输出目录
Configuration
构建(调试、发布等)时要使用的配置
Platform
编译所用的平台(x86、x64 等)
给上面这三个 Property 赋值用的就是先前定义的 Item 和 Property。OutDir Property 用的是 BuildArtifacts 目录的全路径。这里用了 %(Item.MetaData) 语法。这个语法应该看起来很眼熟吧?就跟访问 C#对象属性的语法一样。MSBuild 创建出来的任何 Item,都提供了某些元数据以供访问,例如 FullPath 和 ModifiedTime。但这些元数据有时候也没啥大用,因为 Item 不一定是文件。
Configuration 和 Platform 用到了先前定义好的 Property,语法格式是 $(PropertyName)。在这里可以看到系统保留的一些属性名,用户不能更改。定义 Property 的时候请不要用它们。
这里还有些东西值得提一下。用了 Property 以后,我们可以在不更改构建脚本的情况下使用不同的 Configuration 或者 BuildPlatform,只要在运行的时候用 /property 传值进去就行。所以“msbuild HelloCI.msbuild /t:Compile /p:Configuration:Debug”这个命令会用 Debug 配置构建项目,而“msbuild HelloCI.msbuild /t:Compile /p:Configuration:Test;BuildPlatform:x86”会在 x86 平台下使用 Test 配置。
现在运行 Compile,就可以编译 solution 下的两个项目,把编译结果放到 BuildArtifacts 目录下。在完成构建脚本之前,只剩下最后一个 Target 了:
<ItemGroup> <BuildArtifacts Include="BuildArtifacts\" /> <SolutionFile Include="HelloCI.sln" /> <NUnitConsole Include="C:\Program Files (x86)\NUnit 2.6\bin\nunit-console.exe" /> <UnitTestsDLL Include="BuildArtifacts\HelloCI.Web.UnitTests.dll" /> <TestResultsPath Include="BuildArtifacts\TestResults.xml" /> </ItemGroup> <Target Name="RunUnitTests" DependsOnTargets="Compile"> <Exec Command='"@(NUnitConsole)" @(UnitTestsDLL) /xml=@(TestResultsPath)' /> </Target>
ItemGroup 里现在又多了三个 Item:NUnitConsole 指向 NUnit 控制台运行器(console runner);UnitTestDLL 指向单元测试项目生成的 DLL 文件;TestResultsPath 是要传给 NUnit 的,这样测试结果就会放到 BuildArtifacts 目录下。
RunUnitTests Target 用到了 Exec Task。如果有一个测试运行失败,NUnit 控制台运行器会返回一个非 0 的结果。这个返回值会告诉 MSBuild 有个地方出错了,于是整个构建的状态就是失败。
现在这个脚本比较完善了,用一个命令就可以删除旧的构建产物、编译、运行单元测试:
C:\HelloCI\> msbuild HelloCI.msbuild /t:RunUnitTests
我们还可以给脚本设一个默认 Target,就省得某次都要指定了。在 Project 元素上加一个 DefaultTargets 属性,让 RunUnitTests 成为默认 Target。
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="RunUnitTests">
你还可以创建自己的 Task。这里有个例子, AsyncExec ,它允许人们以异步的方式执行命令。比如有个 Target 用来启动 Web 服务器,要是用 Exec 命令的话,整个构建都会停住,直到服务器关闭。用 AsyncExec 这个命令可以让构建继续执行,不用等待命令执行结束。
本文的完整脚本可以在这里下载。
在接下来的文章中,我会讲述如何配置Jenkins。我们不再需要手动运行命令来构建整个项目,Jenkins 会检测代码库,一旦有更新就会自动触发构建。
作者简介
Mustafa Saeed Haji Ali居住在 Somaliland 的 Hargeisa。他是个程序员,最常用的是 ASP.NET MVC。Mustafa 喜欢测试,喜欢用 Javascript 框架,如 KnockoutJS、AngularJS、SignalR。他热衷于传播最佳实践。
查看英文原文: Continuous Integration with MSBuild and Jenkins – Part 1
给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论