我们每天都在.Net 平台开发程序,但是试问我们又能有多少人去想过在.Net 平台我们的编写的代码到底运行的呢?是怎么从编译到发布的呢?如果我们不了解这些底层的东西,那我们又怎么能去为了提高我们的开发效率,软件质量而去搭建一个更适合我们自己开发的自动,智能化的环境呢?要想在 .Net 平台能搭建一个更适合我们自己开发的环境,而不是去使用别人蹩脚的开发环境,我们首先必须得先去,了解 .Net 平台的 程序构建机制。今天我们就来揭开.Net 平台的程序构建这层神秘的面纱。
其实,在.Net 平台能够实现程序的构建,无非是用了一个最核心的东西,那就是传说中MSBuild 。
MSBuild 全称是 Microsoft Build Engine,它是一个用于生成应用程序的平台。 此引擎(简称为 MSBuild)为项目文件提供了一个 XML 架构,用于控制生成平台如何处理和生成软件。它是集成在 .Net Framework 中的。
VisualStudio 会使用 MSBuild,但它不依赖于 Visual Studio。 通过在你的项目或解决方案文件中调用 msbuild.exe,你可以在未安装Visual Studio 的环境中安排和生成产品。Visual Studio 使用 MSBuild来加载和生成托管项目。 Visual Studio 中的项目文件(.csproj、.vbproj、vcxproj 等)包含 MSBuild XML代码,当你使用 IDE 来生成项目时,此代码就会运行。 Visual Studio 项目会导入所有必要的设置和生成过程来执行典型的开发工作,但你可以从Visual Studio 内或通过使用 XML 编辑器对其进行扩展或修改。
下面的示例介绍了什么情况下可使用 MSBuild命令行而不是 Visual Studio IDE 来运行生成。
未安装 Visual Studio。
你想要使用 64 位版本的 MSBuild。 通常情况下不必使用此版本的MSBuild,但它可以让 MSBuild 访问更多内存。
你想要在多个进程中运行同一生成。 不过,对于 C++ 和 C# 中的项目,你可以使用IDE 实现相同的结果。
你想要修改生成系统。 例如,你可能想要实现以下操作:
在文件到达编译器之前先进行预处理。
将生成输出复制到其他位置。
从生成输出创建压缩文件。
执行后处理步骤。 例如,你可能希望使用其他版本来标记程序集。
你可以在 Visual Studio IDE中编写代码,但使用 MSBuild 来运行生成。 或者,你也可以在开发计算机的 IDE 中生成代码,但使用 MSBuild命令行生成从多个开发人员集成的代码。
说明说明
你可以使用 Team FoundationBuild 自动编译、测试和部署你的应用程序。你的生成系统会在开发人员签入代码(例如,作为持续集成策略的一部分)时或按照计划(例如,夜间版本验证测试生成)自动运行生成。 Team FoundationBuild 使用 MSBuild 来编译你的代码。 有关更多信息,请参见生成应用程序。
MSBuild是集成在.net framework 中的
在运行脚本的时候,我们要用到.NET框架安装路径下的MSBuild可执行文件。打开命令行,执行“MSBuild/nologo/version”命令,看看.NET框架安装路径是不是放到了PATH环境变量里面。如果一切正确,你应该能看到屏幕上打印出MSBuild的当前版本。倘若没有的话,或者把.NET框架安装路
你或其他人刚刚写完了一段代码,提交到项目的版本仓库里面。但等一下,如果新提交的代码把构建搞坏了怎么办?万一出现编译错误,或者有的测试失败了,或者代码不符合质量标准所要求的底限,你该怎么办?
最不靠谱的解决方案就是寄希望于所有人都是精英,他们根本不会犯这些错误。但如果真的出现了这些问题,我们就希望发现的越早越好。最好的方式就是只要有代码提交,我们就有某种方式对它进行验证。这就是持续集成的作用。
持续集成相关的工具有很多。最流行的要数一款基于Java的名叫Jenkins的工具。它提供了Web界面,用户可以在界面上配置Job,每个Job都包含一系列的构建步骤。Jenkins可以完成开头那个场景中所提到的所有验证工作,它还能更进一步做自动化部署或者一键式部署。
Jenkins是由Sun的前员工开发的,它的根基是Java,但也可以用在非Java的项目里,比如PHP、Rubyon Rails、.NET。在.NET项目里,你除了Jenkins之外还要熟悉另一样工具:MSBuild。
VisualStudio用MSBuild构建.NET项目。MSBuild所需的仅仅是一个脚本,在脚本中指定要执行的target。项目中的.csproj和.vbproj文件都是MSBuild脚本。
在这篇文章中,我们会从头开始,一步步完成一个属于我们自己的MSBuild脚本。在它完成以后,我们只需要一个命令就可以删除之前的构建产物,构建.NET应用,运行单元测试。后面我们还会配一个JenkinsJob,让它从代码库中更新代码,执行MSBuild脚本。最后还会配另一个JenkinsJob,让它监听第一个Job的结果,当第一步成功以后,它会把相关的构建产物复制出来,放到web服务器里启动运行。
我们用一个ASP.NET MVC3应用做例子,在VS里面创建ASP.NET MVC 3应用并选择“application”模版就行。我们还要用一个单元测试项目来跑测试。代码可以在这里下载。
你好,MSBuild
MSBuild是在.NET2.0中引入的针对VisualStudio的构建系统。它可以执行构建脚本,完成各种Task──最主要的是把.NET项目编译成可执行文件或者DLL。从技术角度来说,制作EXE或者DLL的重要工作是由编译器(csc,vbc等等)完成的。MSBuild会从内部调用编译器,并完成其他必要的工作(例如拷贝引用──CopyLocal,执行构建前后的准备及清理工作等)
这些工作都是MSBuild执行脚本中的Task完成的。MSBuild脚本就是XML文件,根元素是Project,使用MSBuild自己的命名空间。MSBuild文件都要有Target。Target由Task组成,MSBuild运行这些Task,完成一个完整的目标。Target中可以不包含Task,但是所有的Target都要有名字。
下面来一起创建一个“HelloWorld”的MSBuild脚本,先保证配置正确。我建议用VS来写,因为它可以提供IntelliSense支持,不过用文本编辑器也无所谓,因为只是写个XML文件,IntelliSense的用处也不是很大。先创建一个XML文件,命名为“basics.msbuild”,这个扩展名只是个约定而已,好让我们容易认出这是个MSBuild脚本,你倒不用非写这样的扩展名。给文件添加一个Project元素作为根元素,把http://schemas.microsoft.com/developer/msbuild/2003设置成命名空间,如下所示
<Projectxmlns="http://schemas.microsoft.com/developer/msbuild/2003">
</Project>
下一步,给Project元素添加一个Target元素,起名叫“EchoGreeting”
<Projectxmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="EchoGreeting"/>
</Project>
这就行了。我们已经有了一个可以运行的MSBuild脚本。它虽然还啥事都没干,但我们可以用它来验证当前环境是不是可以运行MSBuild脚本。在运行脚本的时候,我们要用到.NET框架安装路径下的MSBuild可执行文件。打开命令行,执行“MSBuild/nologo/version”命令,看看.NET框架安装路径是不是放到了PATH环境变量里面。如果一切正确,你应该能看到屏幕上打印出MSBuild的当前版本。倘若没有的话,或者把.NET框架安装路径放到PATH里面去,或者直接用VisualStudio Command Prompt,它已经把该配的都配好了。
进入存放刚才那个脚本的目录后,以文件名当作参数调用MSBuild,就可以执行脚本了。在我的机器上可以看到下面的执行结果:
C:\>msbuildbasics.msbuild
Microsoft (R) BuildEngine Version 4.0.30319.1
[Microsoft .NETFramework, Version 4.0.30319.269]
Copyright (C) MicrosoftCorporation 2007. All rights reserved.
Build started 8/2/20125:59:45 AM.
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:00.03
执行完脚本以后,MSBuild会首先显示一个启动界面和版权信息(用/nologo开关可以隐藏掉它们)。接下来会显示一个启动时间,然后便是真正的构建过程。因为咱们的脚本啥都没干,所以构建就直接成功了。总计用时也会显示在界面上。下面咱们来给EchoGreetingTarget添加一个Task,让脚本真的干点事。
<TargetName="EchoGreeting">
lt;Exec Command="echo Hello fromMSBuild" />
</Target>
现在EchoGreetingTarget有了一个ExecTask,它会执行Command属性中定义的任何命令。再运行一次脚本,你应该能看到更多信息了。在大多数时候,MSBuild的输出信息都很长,你可以用/verbosity 开关来只显示必要信息。不过无论怎样,MSBuild都会把我们的文字显示到屏幕上。下面再添加一个Target。
<TargetName="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:\>msbuildbasics.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。
<TargetName="Init">
<MakeDirDirectories="BuildArtifacts" />
</Target>
这个Target会调用MakeDirTask创建一个新的目录,名叫BuildArtifacts,跟脚本在同一目录下。运行脚本,你会发现该目录被成功创建。如果再次运行,MSBuild就会跳过这个Task,因为同名目录已经存在了。
接下来写一个CleanTarget,它负责删除BuildArtifacts目录和里面的文件。
<TargetName="Clean">
<RemoveDirDirectories="BuildArtifacts" />
</Target>
理解了Init之后,这段脚本就应该很好懂了。试着执行一下,BuildArtifacts目录应该就被删掉了。下面再来把代码中的重复干掉。在Init和Clean两个Target里面,我们都把BuildArtifacts的目录名硬编码到代码里面了,如果未来要修改这个名字的话,就得同时改两个地方。这里可以利用Item或Property避免这种问题。
Item和Property只有些许差别。Property由简单的键值对构成,在脚本执行的时候还可以用/property 赋值。Item更强大一些,它可以用来存储更复杂的数据。我们这里不用任何复杂数据,但需要用Items获取额外的元信息,例如文件全路径。
接下来修改一下脚本,用一个Item存放路径名,然后修改Init和Clean,让它们引用这个Item。
<ItemGroup>
<BuildArtifactsDirInclude="BuildArtifacts\" />
</ItemGroup>
<TargetName="Init">
<MakeDirDirectories="@(BuildArtifactsDir)" />
</Target>
<TargetName="Clean">
<RemoveDirDirectories="@(BuildArtifactsDir)" />
</Target>
Item是在ItemGroup里面定义的。在一个Project中可以有多个ItemGroup元素,用来把有关系的Item分组。这个功能在Item较多的时候特别有用。我们在ItemGroup里定义了BuildArtifactsDir元素,并用Include属性指定BuildArtifacts目录。记得BuildArtifacts目录后面要有个斜杠。最后,我们用了@(ItemName)语法在Target里面引用这个目录。现在如果要修改目录名的话,只需要改BuildArtifactsDir的Include属性就好了。
接下来还有个问题要处理。在BuildArtifacts目录已经存在的情况下,Init是什么事都不干的。也是就说,在调用Init的时候磁盘上的已有文件还会被保留下来。这一点着实不妥,如果能每次调用Init的时候,都把目录和目录里面的所有文件都一起删掉再重新创建,就能保证后续环节都在干净的环境下执行了。我们固然可以在每次调用Init的时候先手工调一下Clean,但给InitTarget加一个DependsOnTargets属性会更简单,这个属性会告诉MSBuild,每次执行Init的时候都先执行Clean。
<TargetName="Init" DependsOnTargets="Clean">
<MakeDirDirectories="@(BuildArtifactsDir)" />
</Target>
现在MSBuild会帮我们在调Init之前先调Clean了。跟DependsOnTargets这个属性所暗示的一样,一个Target可以依赖于多个Target,之间用分号分割就行。
接下来我们要编译应用程序,把编译后的结果放到BuildArtifacts目录下。先写一个CompileTarget,让它依赖于Init。这个Target会调用另一个MSBuild实例来编译应用。我们把BuildArtifacts目录传进去,作为编译结果的输出目录。
<ItemGroup>
<BuildArtifactsDirInclude="BuildArtifacts\" />
<SolutionFileInclude="HelloCI.sln" />
</ItemGroup>
<PropertyGroup>
<Configuration Condition="'$(Configuration)' == '' ">Release</Configuration>
<BuildPlatform Condition="'$(BuildPlatform)' == '' ">Any CPU</BuildPlatform>
</PropertyGroup>
<TargetName="Compile" DependsOnTargets="Init">
<MSBuildProjects="@(SolutionFile)" Targets="Rebuild"
Properties="OutDir=%(BuildArtifactsDir.FullPath);Configuration=$(Configuration);Platform=$(BuildPlatform)"/>
</Target>
上面的脚本做了几件事情。首先,ItemGroup添加了另一个Item,叫做SolutionFile,它指向solution文件。在构建脚本中用Item或Property代替硬编码,这算的是一个优秀实践吧。
其次,我们创建了一个PropertyGroup,里面包含两个Property:Configuration和BuildPlatform。它们的值分别是“Release”和“AnyCPU”。当然,Property也可以在运行时通过/property(简写为/p)赋值。我们还用了Condition属性,它在这里的含义是,只有当这两个属性没有值的情况下,才用我们定义的数据给它们赋值。这段代码实际上就是给它们一个默认值。
接下来就是CompileTarget了,它依赖于Init,里面内嵌了一个MSBuildTask。它在运行的时候会调用另外一个MSBuild实例。在脚本中定义了这个被内嵌的MSBuildTask要操作的项目。在这里,我们既可以传入另外一个MSBuild脚本,也可以传入.csproj文件(它本身也是个MSBuild脚本)。但我们选择了传入HelloCI应用的solution文件。Solution文件不是MSBuild脚本,但是MSBuild可以解析它。脚本中还指定了内嵌的MSBuildTask要执行的Target名称:“Rebuild”,这个Target已经被导入到solution的.csproj文件中了。最后,我们给内嵌的Task传入了三个Property。
OutDir
编译结果的输出目录
Configuration
构建(调试、发布等)时要使用的配置
Platform
编译所用的平台(x86、x64等)
给上面这三个Property赋值用的就是先前定义的Item和Property。OutDirProperty用的是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>
<BuildArtifactsInclude="BuildArtifacts\" />
<SolutionFileInclude="HelloCI.sln" />
<NUnitConsole Include="C:\ProgramFiles (x86)\NUnit 2.6\bin\nunit-console.exe" />
<UnitTestsDLLInclude="BuildArtifacts\HelloCI.Web.UnitTests.dll" />
<TestResultsPathInclude="BuildArtifacts\TestResults.xml" />
</ItemGroup>
<TargetName="RunUnitTests" DependsOnTargets="Compile">
<Exec Command='"@(NUnitConsole)"@(UnitTestsDLL) /xml=@(TestResultsPath)' />
</Target>
ItemGroup里现在又多了三个Item:NUnitConsole指向NUnit控制台运行器(consolerunner);UnitTestDLL指向单元测试项目生成的DLL文件;TestResultsPath是要传给NUnit的,这样测试结果就会放到BuildArtifacts目录下。
RunUnitTestsTarget用到了ExecTask。如果有一个测试运行失败,NUnit控制台运行器会返回一个非0的结果。这个返回值会告诉MSBuild有个地方出错了,于是整个构建的状态就是失败。
现在这个脚本比较完善了,用一个命令就可以删除旧的构建产物、编译、运行单元测试:
C:\HelloCI\> msbuildHelloCI.msbuild /t:RunUnitTests
我们还可以给脚本设一个默认Target,就省得某次都要指定了。在Project元素上加一个DefaultTargets属性,让RunUnitTests成为默认Target。
<Projectxmlns="http://schemas.microsoft.com/developer/msbuild/2003"
DefaultTargets="RunUnitTests">
你还可以创建自己的Task。这里有个例子,AsyncExec,它允许人们以异步的方式执行命令。比如有个Target用来启动Web服务器,要是用Exec命令的话,整个构建都会停住,直到服务器关闭。用AsyncExec这个命令可以让构建继续执行,不用等待命令执行结束。