最近在从事自动构造工作的过程中,对 MSBuild 本身有了一些更加深入的认识。 MSBuild 不仅仅是一个构造工具,应该称之为拥有相当强大扩展能力的自动化平台。
按照笔者现在的理解, MSBuild 平台的主要涉及到三部分:执行引擎、构造工程、任务。其中最核心的就是执行引擎,它包括定义构造工程的规范,解释构造工程,执行“构造动作”;构造工程是用来描述构造任务的,大多数情况下我们使用 MSBuild 就是遵循规范,编写一个构造工程; MSBuild 引擎执行的每一个“构造动作”就是通过任务实现的 ,任务就是 MSBuild 的扩展机制,通过编写新的任务就能够不断扩充 MSBuild 的执行能力。
所以这三部分分别代表了引擎、脚本和扩展能力。
1、 构造工程(脚本文件)
先说说构造工程,只要通过 Notepad 打开任何一个 VS2005 (也就是支持 CLR 2.0 )下的 C# 工程( csproj )文件,就知道构造工程到底是怎么回事了。
如果说脚本,我们立刻想到的是 VBScript 或者 JavaScript ,构造工程内描述的内容,和常见的脚本语言的源文件之间还是有蛮大差距的,为什么也称之为“脚本”呢?因为笔者觉得没啥区别。脚本不就是纯文本形式保存,不经编译解释执行,可以实现一定逻辑分支的程序么?
再看构造工程,在构造工程中我们我们可以定义和使用变量(通过 Property/PropertyGourp/Item/ItemGroup 等元素),可以使用条件分支(通过 Choose/When/Otherwise 等元素)、能够在运行时给变量赋值(通过执行任务,获取其返回类型参数的方式)、能够定义执行块(通过 Target 元素,相当于函数)、能够进行异常处理(通过 OnError 元素)、还可以复用已有工程定义的内容(通过 Import 元素)。拥有这些能力和高级语言已经相差无几了,所以笔者认为构造工程不是描述性语言,而是脚本语言。
这里还需要强调一点的是,项目级元素( Property )可以在 <PropertyGroup> 元素下定义,也可以在构造过程中作为外部参数传入(具体参见《 MSBuild 命令行参考 》)。这是一个非常有用的特性,一般编译时选择配置项( Debug 或者 Release )就是利用这个特性实现的。
有关构造工程的编写规范可以参考《 MSBuild 项目文件引用 》。
2、 执行引擎
接下来看执行引擎,通常我们使用下面的命令行开始执行构造:
MSBuild.exe <ProjectFile>
其中 <ProjectFile> 是前面提到的构造工程,也就是脚本文件,那么 MSBuild.exe 就应当是执行引擎了。
没错,不过看一下源代码就会发现 MSBuild.exe 非常简单,其实主要做的工作就是命令行解析、构造环境的准备(如生成日志记录模块准备一些全局变量),然后就是创建 Microsoft.Build.BuildEngine.Engine 类的实例,然后调用其 BuildProjectFile 方法来完成。所以真正的构造逻辑是在 Microsoft.Build.Engine.dll 中定义并且实现的。下面简单的代码就模拟了 MSBuild.exe 的工作。
具体的对象模型参见 CLR 类库参考中的《 Microsoft.Build.Framework 命名空间 》和《 Microsoft.Build.BuildEngine 命令空间 》。
笔者简单地分析了一下 MSBuild.exe 和 Microsoft.Build.Engine.dll 的源代码, MSBuild 的构造过程大致如下:
a) 先创建一个构造请求( BuildRequest ,构造请求是用来记录构造状态的数据结构),创建完毕之后将构造请求投递到请求队列中。
b) 在执行模块中,从请求队列中获取请求,然后开始处理。
c) 通过 Project 类加载构造工程,加载过程中检查是 Sulotion 、 VC 工程还是其它语言的工程。如果是 Solution 的话,生成一个临时的包装工程,逐一构造 Solution 中包含的工程;如果是 VC 工程的话,也生成一个包装工程,在这个工程中直接执行 VCBuild 任务来执行构造。否则直接通过 XmlDocument 加载项目文件,解析其中的元素,识别 Property 、 Item 、 Target 之类元素。
d) 工程解析完毕后按照 Target 的顺序逐一执行。
e) 在执行 Target 的过程中先解析是否存在依赖的 Target 以及 OnError 子句(即产生错误时需要执行的 Target )。
f) 先执行 Target 依赖的 Target ,然后通过 TaskEngine 执行本 Target 中的每一个任务。如果 Target 每一个任务都正确执行的话,那么执行下一个 Target ;否则执行错误处理的 Target 。
g) 执行 Task 的过程就是实例化注册为 Task 的类,然后调用其 Execute 方法。
h) 所有 Target 执行完毕,则本次构造也执行完毕。
以上仅仅为了便于理解概念进行的描述,实际的构造过程可能是考虑到多 CPU 以及内联编译,内部逻辑相当复杂,很多地方应用了 Proxy 模式。
3、 任务(Task )
通过对执行引擎的描述可以发现执行引擎主要是维护执行流程以及记录执行流程中各类变量( Property 和 Item ),具体构造过程中的每一个动作,都是通过 Task 实现的。也就是说单靠 Microsoft.Build.Engine.dll 虽然可以加载并且解析构造工程,但是无法完成构造动作。之所以 MSBuild 能够完成编译、链接、创建目录、复制文件等一系列工作,都是因为在 Microsoft.Build.Tasks.dll 中实现了与之对应的一个个任务。具体请参考《 MSBuild 任务参考 》以及 CLR 类库参考中的《 Microsoft.Build.Task 命名空间 》
通过观察 MSBuild 自带的这些常用任务,可以发现其中分为两类:一类从 ToolTask 继承,这类任务基本上就是直接调用外部二进制文件执行完成某个特定的动作,例如 VCBuild 和 Exec 等;另一类直接从 Task 继承,是通过内部代码逻辑完成特定动作,例如 Copy 和 MSBuild 等。那些直接执行外部文件的任务虽然功能强大,但是有比较大的局限性,执行结果的反馈非常有限,通常只有 ExitCode ,很难获得其内部执行的更多信息,例如日志输出或者操作影响的结果。
大部分情况下我们只需要一个 Exec 任务就能够完成全部的构造动作,但是这样做的结果和我们写一个命令行的批处理文件没什么区别了。 MSBuild 平 台和命令行批处理最大不同在于,它是一个更紧密的工作环境,任务之间通过一系列自定义的全局参数互相协同工作,比较灵活并且移植性高;同时共享日志模块统 一输出执行过程中的各类信息,便于观察和分析。而批处理中各个命令之间几乎是完全孤立的,只能通过硬编码的方式进行协同,协作能力比较差。
举个最简单的例子,编译三个工程,然后复制编译结果到目标目录下。如果用批处理可能会写成这个样子:“编译工程1 、复制编译结果、编译工程2 、复制结果、编译工程3 、复制结果”。而利用MSBuild 就可以简化很多工作,例如“申明需要编译的工程(工程1 、工程2 、工程3 、... )、编译需要编译的工程、复制编译结果”。
顺便说一句, MSBuild 这个任务比较有趣,在内部直接调用了构造引擎的方法( IBuildEngine2.BuildPrejectFilesInParallel )。这个例子又一次告诉我们这个世界上许多看似强大的东西,其实什么都没有干,只是因为他们手中掌握了有效的资源。 -^o^-
除了基础的任务之外,任务还可以任意扩展,并且实现这种扩展非常方便。创建一个 CLR 2.0 以上的类库工程,编写实现了 ITask 接口的类,然后在构造工程中通过 <UsingTask> 元素注册任务就可以使用了。例如:
所以说 MSBuild 能够成为一个构造引擎,不是因为有个叫 MSBuild 的 Exe 文件,也不是因为脚本文件被称之为构造工程,而是因为与之配套的 Microsoft.Build.Task.dll 中主要实现了主要是和构造相关的任务。换句话说如果提供和测试相关的任务库的话, MSBuild 也就是一个自动测试的平台。
总之,MSBuild 本身更趋向于一个自动化执行平台,可以根据需求编写不同的脚本文件来满足不同的应用,当现有能力无法满足时,通过编写新的任务进行扩展。不仅限于构造,自动安装、自动测试等都可以依赖这个平台来实现。