认识 MSBuild - 1

shineyshiney
5 min read

前言

很多人一谈到 MSBuild,脑子里就会出现 “XML”、“只能用 VS 的属性框图形界面操作”、“可定制性和扩展性差” 和 “性能低” 等印象,但实际上这些除了 “XML” 之外完全都是刻板印象:这些人用着 Visual Studio 提供的图形界面,就完全不愿意花个几分钟时间翻翻文档去理解 MSBuild 及其构建过程。

另外,再加上 vcxproj (Visual C++ 项目)的默认 MSBuild 构建文件写得确实谈不上好(默认只能项目粒度并行编译,想要源码级并行编译你得加钱),但这跟 MSBuild 本身没有关系,单纯是 Visual Studio 自带的构建文件没支持罢了。

实际上,MSBuild 是一个扩展性极强、开源、跨平台且构建管道中都是传递的对象的构建系统,包含结构化信息处理和结构化日志输出的支持;另外,还提供了完整的 .NET Runtime 供你调用里面任何的 API,甚至用 MSBuild 编程都不在话下。

本系列文章就来让大家以新的视角重新认识一下 MSBuild,并借助 MSBuild 来构建自己的项目。

安装和使用

MSBuild 的开源代码仓库:https://github.com/dotnet/msbuild ,另外,MSBuild 也支撑了整个 .NET 的构建流程,因此安装 MSBuild 最简单的方法就是安装一个 .NET SDK,同样也是开源和跨平台的。

安装好后,你就可以通过运行 dotnet msbuild 调用 MSBuild 了。当然,你也可以选择从源码自行构建出一个 msbuild 可执行文件来用。

注意事项

在本系列文章中,将会编写一个 build.proj 用来测试 MSBuild,并且本文中涉及到的 MSBuild 调用都是直接运行 msbuild 来完成的,如果你是用安装 .NET SDK 的方法来安装 MSBuild 的话,则需要使用 dotnet msbuild 来调用 MSBuild。

一些基础

MSBuild 的构建文件中,主要分为以下几个部分:

  • 项目(Project)

  • 属性(Property)

  • 项(Item)

  • 任务(Task)

  • 目标(Target)

  • 导入(Import)

项目

项目是 MSBuild 构建文件的顶级节点。

Copy<Project Sdk="...">
</Project>

可以用来引入 SDK 等元素,允许直接引用 SDK 中定义的构建文件,这个我们以后再具体说,目前只需要知道 Project 是 MSBuild 的顶层节点即可。

我们目前不需要引入什么 SDK,因此新建一个 build.proj,在其中写入以下代码就行了:

Copy<Project>
</Project>

属性

属性顾名思义,就是用来为 MSBuild 构建过程传递的参数,有多种方式可以定义属性。

第一种方式是在构建的时候通过命令行参数 -property-p 传入,例如:

Copymsbuild build.proj -property:Configuration=Release

这样就传入了一个名为 Configuration 的属性,它的值是 Release

还有一种方式是在构建文件中编写:

Copy<PropertyGroup>
  <Configuration>Release</Configuration>
</PropertyGroup>

PropertyGroup 就是专门用来编写属性的组,你可以在里面利用 XML 来设置属性。

对属性的引用可以使用 $ 来引用,例如:

Copy<Foo>hello</Foo>
<Bar>$(Foo) world!</Bar>

这样 Bar 的值就会变成 hello world!。另外要注意,通过命令行传入的属性值优先级比顶层 PropertyGroup 中定义的属性更高,因此如果用户调用了:

Copymsbuild build.proj -property:Foo=goodbye

则此时 Bar 的值就变成了 goodbye world!

属性的计算顺序是从上到下计算的,并且属性在 MSBuild 的构建过程中也是最先计算的。MSBuild 中也有一些内置属性可以直接使用,例如 MSBuildProjectFullPath 表示当前项目的文件路径等等,可以在 MSBuild 文档中查阅。

项就是 MSBuild 构建过程中要用的集合对象了,你可以利用项来在 MSBuild 中定义你想使用的东西。

例如:

Copy<ItemGroup>
  <Foo Include="hello" />
</ItemGroup>

这样就定义了一个叫做 Foo 的项,它包含了一个 hello。其中,ItemGroup 是专门用来编写项的组。

项之所以说是集合对象,因为它可以被理解为一个数组,你可以在构建文件中通过 IncludeUpdateRemove 来操作这个数组,Include 用来添加一个元素,Exclude 用来排除一个元素,Remove 用来删除一个元素,Update 用来更新一个元素的元数据(metadata,至于元数据是什么我们稍后再说),计算顺序同样也是从上到下的。

比如:

Copy<ItemGroup>
  <Foo Include="1" />
  <Foo Include="2" />
  <Foo Include="3" />
  <Foo Include="4" />
  <Foo Remove="3" />
</ItemGroup>

就会得到一个项 Foo,它包含 1、2、4。

在 MSBuild 中,多个元素可以用 ; 分隔,因此也可以写成:

Copy<ItemGroup>
  <Foo Include="1;2;3;4" />
  <Foo Remove="3" />
</ItemGroup>

而 MSBuild 很贴心的为我们准备了一些通配符,用来快速添加项,例如 ***、和 ?,分别用来匹配一段路径中的零个或多个字符、零段或多段路径以及一个字符,然后配合 Exclude 可以筛选掉你不想要的东西。例如:

Copy<ItemGroup>
  <Foo Include="**/*.cpp" Exclude="foo.cpp">
</ItemGroup>

就可以把当前目录和子目录中所有的 C++ 文件都添加到 Foo 项中,但是不包含 foo.cpp

什么叫做元数据呢?例如我们如果想给 Foo 附带一个数据 X,那么可以这么写:

Copy<ItemGroup>
  <Foo Include="1">
    <X>Hello</X>
  </Foo>
  <Foo Include="2">
    <X>World</X>
  </Foo>
</ItemGroup>

这样 Foo 中的 1 就带了一个值为 Hello 的 X,而 Foo 中的 2 则带了一个值为 World 的 X,这个 X 就是项元素的元数据。

如果再加一个:

Copy<Foo Update="1">
  <X>Goodbye</X>
</Foo>

则可以把 1 的 X 元数据更新为 Goodbye。

另外,我们可以通过 % 来从项上引用元数据,例如 %(Foo.X)

任务

任务是 MSBuild 真正要执行的东西,例如编译、打包和下载文件等等任务,可以由我们自行用 C# 或者 VB.NET 等语言实现。

关于任务的编写,我们将在以后进行介绍,这里只简单介绍一下任务的使用。

MSBuild 也内置了很多任务,例如 Message 用来打印信息、WarnError 分别用来产生警告和错误、CopyDelete 分别用来复制和删除文件、 MakeDir 用来创建目录、Exec 用来执行程序以及 DownloadFile 用来下载文件等等,具体的内置任务可以去 https://docs.microsoft.com/zh-cn/visualstudio/msbuild/msbuild-task-reference 查看。

例如我们想要打印信息,那么可以利用 Message 任务来完成,根据 Message 任务的文档,我们知道它有 ImportanceText 两个参数。

比如我们想要打印一下 Hello,就可以这么写:

Copy<Message Text="Hello" />

任务需要在目标中使用。

目标

目标是一组任务的集合,我们简单理解为:为了完成一个目标,需要执行一系列的任务。

例如:

Copy<Target Name="Print">
  <Message Text="Hello" />
</Target>

这样我们就定义了一个叫做 Print 的目标,它用来输出一个 Hello

此时我们用 -target-t 指定执行 Print

Copymsbuild build.proj -target:Print -verbosity:normal

将会输出 Hello。注意 Message 的默认重要性为 normal,而 MSBuild 默认的日志详细等级为 quiet,只输出 high 或以上优先级的东西,因此我们指定 -verbosity:normal 让 MSBuild 同样把 normal 等级的日志也输出出来。

目标之间可以用过 BeforeTargetsAfterTargets 来设置顺序(但是相互之间没有依赖),还可以使用 DependsOnTargets 来设置依赖,例如:

Copy<Target Name="PrintBye" DependsOnTargets="PrintHello;PrintWorld">
  <Message Text="Bye" Importance="high" />
</Target>

<Target Name="PrintHello">
  <Message Text="Hello" Importance="high" />
</Target>

<Target Name="PrintWorld" AfterTargets="PrintWorld">
  <Message Text="World" Importance="high" />
</Target>

执行构建 PrintBye

Copymsbuild build.proj -target:PrintBye

将会输出:

CopyHello
World
Bye

可以通过在项目上设置 DefaultTargets 表示如果没有通过命令行参数传入目标则默认执行的目标,还可以设置 InitialTargets 来表示始终最先执行的目标。

此外,目标还支持设置 InputsOutputsReturns,分别表示预计作为输入的项、输出的项和目标返回值,前两个用于缓存和增量编译,ReturnsOutputs 用法基本相同,但是 Returns 不参与增量编译,关于增量编译我们以后再介绍。

我们可以利用 CallTarget 任务来调用一个目标,然后获取调用的目标的 Outputs 的输出,通过这种方式,我们不需要手动编写任务也能实现函数调用,例如:

Copy<Target Name="Hello" Returns="$(Result)">
  <Message Text="你好,$(Name)" Importance="high" />
  <PropertyGroup>
    <Result>和 $(Name) 打了招呼</Result>
  </PropertyGroup>
</Target>

<Target Name="Build">
  <PropertyGroup>
    <MyResult>还没调用结果</MyResult>
  </PropertyGroup>
  <Message Text="$(MyResult)" Importance="high" />
  <CallTarget Targets="Hello">
    <!-- 把 Hello 目标的输出存到 MyResult 属性中 -->
    <Output TaskParameter="TargetOutputs" PropertyName="MyResult"/>
  </CallTarget>
  <Message Text="$(MyResult)" Importance="high" />
</Target>

我们执行构建:

Copymsbuild build.proj -target:Build -property:Name=Bob

得到输出:

Copy还没调用结果
你好,Bob
和 Bob 打了招呼

这个时候你可能会想,如果要给这个 Hello 目标像函数调用那样传入参数怎么办?此时可以用 MSBuild 任务,通过 Properties 来传递属性,多个属性同样是通过 ; 分隔,并且其中可以用 $@ 等引用其他属性和项:

Copy<Target Name="Hello" Returns="$(Result)">
  <Message Text="你好,$(Age) 岁的 $(Name)" Importance="high" />
  <PropertyGroup>
    <Result>和 $(Name) 打了招呼</Result>
  </PropertyGroup>
</Target>

<Target Name="Build">
  <PropertyGroup>
    <MyResult>还没调用结果</MyResult>
  </PropertyGroup>
  <Message Text="$(MyResult)" Importance="high" />
  <MSBuild Targets="Hello" Properties="Age=18" Projects="build.proj">
    <Output TaskParameter="TargetOutputs" PropertyName="MyResult"/>
  </MSBuild>
  <Message Text="$(MyResult)" Importance="high" />
</Target>

然后我们传入一个 Age 属性进去来调用构建:

Copymsbuild build.proj -target:Build -property:Name=Bob -p:Age=18

这次将会得到输出:

Copy还没调用结果
你好,18 岁的 Bob
和 Bob 打了招呼

要注意的是,目标的相互调用并不是一个好的实践,容易带来性能问题,本文之所以写了这个只是为了展示有这样的功能,实际编写构建文件时不要滥用这个特性。

导入

导入顾名思义,就是导入其他的构建文件,这样我们就可以不需要在一个文件中编写所有的构建配置了。

导入很简单,只需要在 Project 节点里加入:

Copy<Import Project="foo.proj" />

即可把引入的构建文件里的内容直接插入到所在的位置。

一点示例

截止到现在,我们已经了解了很多东西,那么我们综合起来用一下。

首先,创建一个 build.proj,里面编写:

Copy<Project InitialTargets="PrintName" DefaultTargets="PrintInfo">
  <PropertyGroup>
    <Name>Alice</Name>
  </PropertyGroup>

  <ItemGroup>
    <Foo Include="1">
        <X>Hello</X>
    </Foo>
    <Foo Include="2">
        <X>World</X>
    </Foo>
  </ItemGroup>

  <Target Name="PrintName" >
    <Message Text="你好 $(Name)" Importance="high" />
  </Target>

  <Target Name="PrintInfo" DependsOnTargets="BeforePrint">
    <Message Text="@(Foo) 的元数据是 %(X)" Importance="high" />
  </Target>

  <Target Name="BeforePrint">
    <Message Text="即将输出数据" Importance="high" />
  </Target>

  <Target Name="AfterPrint" AfterTargets="PrintInfo">
    <Message Text="数据已经输出" Importance="high" />
  </Target>

</Project>

然后我们试着执行一下 MSBuild:

Copymsbuild build.proj

将会输出:

Copy你好 Alice
即将输出数据
1 的元数据是 Hello
2 的元数据是 World
数据已经输出

条件

至此,你可能会觉得 MSBuild 里的一切都是线性的,然而要怎么表达逻辑关系呢?这个时候就要用到条件(Condition)了。

我们可以在任何地方使用 Condition 来控制是否计算或执行一个属性、项、任务、目标和引入,也就是说你可以在任何你想要的地方通过 Condition 来进行条件的控制。

例如:

Copy<PropertyGroup>
  <Name>Alice</Name>
  <IsDefaultName Condition=" '$(Name)' == 'alice' ">true</IsDefaultName>
  <IsDefaultName Condition=" '$(Name)' != 'alice' ">false</IsDefaultName>
</PropertyGroup>

MSBuild 中允许我们进行字符串比较,并且默认是不区分大小写的。上述代码中,如果构建的时候 NameAlice,那么 IsDefaultName 就是 true, 否则是 false

我们定义一个目标输出一下看看:

Copy<Target Name="Print">
  <Message Text="你好,$(Name),是否默认名字:$(IsDefaultName)" Importance="high" />
</Target>

运行:

Copymsbuild build.proj

得到输出

Copy你好,Alice,是否默认名字:true

而如果我们通过命令行传入一个 -property:Name=Bob,则输出就变成了:

Copy你好,Bob,是否默认名字:false

另外,我们还可以使用 ChooseWhenOtherwise 来根据 Condition 选择 When 或者 Otherwise 下的内容,例如:

Copy<Choose>
  <When Condition=" '$(Name)' == 'Alice' ">
    <PropertyGroup>
      <Age>16</Age>
    </PropertyGroup>
    <ItemGroup>
      <Files Include="Alice/**/*.*" />
    </ItemGroup>
  </When>
  <When Condition=" '$(Name)' == 'Bob' or '$(Name)' == 'David' ">
    <PropertyGroup>
      <Age>18</Age>
    </PropertyGroup>
    <ItemGroup>
      <Files Include="$(Name)/**/*.*" />
    </ItemGroup>
  </When>
  <Otherwise>
    <PropertyGroup>
      <Age>20</Age>
    </PropertyGroup>
    <ItemGroup>
      <Files Include="Other/**/*.*" />
    </ItemGroup>
  </Otherwise>
</Choose>

上面当 Name 是 Alice 的时候,将会选择第一个 When 里的东西,而如果是 Bob 或者 David,则会选择第二个 When 里的东西,否则选择 Otherwise 里的东西。

条件将允许我们在构建过程中进行复杂的计算,并且控制整个构建流程。

任务错误处理

任务可能会发生错误,在 MSBuild 中,可以通过 Error 产生错误、Warn 产生警告;一些内置的任务(例如 DeleteCopy 等)也可能产生错误;对于自行编写的任务而言,也有其方式产生错误或者警告。

如果发生了错误,则构建默认会直接停止并以失败告终。但这不能满足所有需要,因此我们还可以在任务上利用 ContinueOnError 来控制发生错误后的行为:

  • ErrorAndContinue:当任务失败时继续执行

  • WarnAndContinue 或 true:当任务失败时继续执行,并且把该任务中的错误视为警告

  • ErrorAndStop 或 false:当任务失败时停止构建

例如,这次我们使用上面的例子,对非默认名字产生错误:

Copy<Target Name="Print">
  <Message Text="你好,$(Name),是否默认名字:$(IsDefaultName)" Importance="high" />
  <Error Condition=" '$(IsDefaultName)' == 'false' " Text="发生错误了" />
</Target>

<Target Name="Build" DependsOnTargets="Print">
  <Message Text="构建完了" Importance="high" />
</Target>

此时执行构建:

Copymsbuild build.proj -target:Build

将会输出

Copy你好,Alice,是否默认名字:true
构建完了

而如果执行:

Copydotnet msbuid build.proj -target:Build -property:Name=Bob

则会输出

Copy你好,Bob,是否默认名字:false
build.proj(10,5): error : 发生错误了

但如果我们把构建代码改成:

Copy<Target Name="Print">
  <Message Text="你好,$(Name),是否默认名字:$(IsDefaultName)" Importance="high" />
  <Error Condition=" '$(IsDefaultName)' == 'false' "  ContinueOnError="ErrorAndContinue" Text="发生错误了" />
</Target>

<Target Name="Build" DependsOnTargets="Print">
  <Message Text="构建完了" Importance="high" />
</Target>

再执行上述命令,则会输出:

Copy你好,Bob,是否默认名字:false
build.proj(10,5): error : 发生错误了
构建完了

MSBuild 和 .NET 函数调用

MSBuild 允许我们直接调用 MSBuild 内置的或者 .NET 中的函数,调用方法为 [类型名]::方法名(参数...),例如:

Copy<PropertyGroup>
  <Foo>1</Foo>
  <Foo Condition="[MSBuild]::IsOsPlatform('Windows')">2</Foo>
</PropertyGroup>

Foo 在 Windows 上为 2,而在其他系统上为 1。

属性和项都有各自的 MSBuild 内置函数可以用,例如 ExistsHasMetadata 等等,具体可在 MSBuild 官方文档上查阅:

有了这些,我们便可以利用 MSBuild 完成各种事情。

结构化日志

有时编写好了构建文件之后,我们希望能够查看整个构建流程或者失败的原因等,这个时候文本的日志就不够用了。在 MSBuild 中,我们有强大的结构化日志。

只需要构建的时候传入一个 -bl 参数指定 binlog 的位置,MSBuild 就能在构建时为我们生成一个极其强大的结构化日志,例如使用 “一点示例” 小节中的例子:

Copymsbuild build.proj -bl:output.binlog

然后就可以在 MSBuild 结构化日志查看器上查看我们的 output.binlog 了。这个查看器有网页版和 Windows 客户端版,因此无论在哪个平台上都是可以用的:https://msbuildlog.com

利用 MSBuild 结构化日志查看器,我们将能够从头到尾详细掌控整个构建流程,包括属性和项是怎么计算出来的、目标为什么被跳过了、目标的执行结果和执行时长是多少、有哪些目标依赖关系以及目标都是来自哪个构建文件的等等信息一览无遗,这样非常有助于我们快速编写和诊断构建文件。

小结

MSBuild 依托于 .NET 运行时,利用 XML 来描述构建文件,是一个无需守护进程(daemon)的非常强大的构建系统。

本文主要介绍了 MSBuild 的基本概念和编写方法,以及结构化日志的使用方法。

如果你厌烦了编写 CMakeLists.txt、Makefile 的那种难以调试、文档不全并且到处都是纯字符串处理的体验,不如试试 MSBuild,将能快速写出可靠的构建配置,加速你的开发。

在下一篇文章中,我们将来介绍缓存、增量编译、任务的编写以及并行编译等,让 MSBuild 的构建变得又快又省心。

0
Subscribe to my newsletter

Read articles from shiney directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

shiney
shiney