手作りで温もりのあるMSBuildプロジェクト

あけましておめでとうございます。

最小限のMSBuildプロジェクトファイルをテキストエディタで書いてみる回です。本当に知りたい方はこれを読むより公式ドキュメントのチュートリアルを読んでもらうのが一番いいです。おつかれさまでした。

MSBuildとは

The Microsoft Build Engine is a platform for building applications. This engine, which is also known as MSBuild, provides an XML schema for a project file that controls how the build platform processes and builds software. Visual Studio uses MSBuild, but it doesn't depend on Visual Studio. By invoking msbuild.exe on your project or solution file, you can orchestrate and build products in environments where Visual Studio isn't installed.

MSBuild - Visual Studio | Microsoft Docs

ということで、アプリケーションをビルドするためのMicrosoft製のエンジンです。.NET Framework 2.0から同梱されていて、それまで使われていたNMakeの.NET Framework版みたいなもんです*1

.NET Framework 1系までは、Visual Studioでのビルドは、IDE組み込みのdevenv.exeがビルドしている一方、コマンドラインからのビルドはcsc.exeやvbc.exeを直接使っていました。コンパイラオプションを指定するのが大変だったり、ビルド以外の処理が複雑でした。これらをまとめてスクリプト化したのがMSBuild及びプロジェクトファイルです。

例えば、Visual Studioで作ったプロジェクトをJenkinsなどのCIツールでもビルドしたいとき、そのままプロジェクトファイル(.csprojとか)を入力にしてMSBuild.exeを実行すれば同様の成果物が生成されます。便利ですね。

Why 手作り

前述のとおり、通常?はVisual Studio様がご創造なされますが、細々とした設定をしたいときや、ビルド環境によって切り替えたいとき、またコンソール実行で設定を渡したいときなど、けっこう触る機会があります。少しでも手を出そうとすると、結局MSBuildが何をするかを把握しておかないといけません。こういう場合、往々にして既存のプロジェクトファイルをいじるところから始めてしまって結局無為な時間を過ごしてしまいがちです。でした。私が。

主にプロパティ、項目、ターゲット、タスクという独特な概念を理解する必要があります。ドキュメントを読んで理解できるならそれで十分ですが、実際に動かすのが一番だと思います。のでやってみた次第です。

Let's 手作り

あえてテキストファイルでプロジェクトファイルを作ってみます。MSBuildでビルドする最小限からはじめて、少し遊んでみます。OSはmac OS Mojave 10.14、MSBuildは15.6.0.0ですが、超基本的なことしかやってないので、各自の環境でも再現できると思います。

最小構成でMSBuild

最小限のC#のコードを書きます。ファイル名を helloworld.cs とします。

using System;

class HelloWorld  
{  
    static void Main()  
    {
        Console.WriteLine("Hello, world!");  
    }
}

最小限のプロジェクトファイルを書きます。ファイル名は Helloworld.proj とします。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <ItemGroup>
    <Compile Include="helloworld.cs" />
  </ItemGroup>
  <Target Name="Build">
    <Csc Sources="@(Compile)"/>
  </Target>
</Project>

XMLで記述します。根本は <Project> 要素です。 <ItemGroup> 要素は、MSBuildへの入力となるファイルを指定します。ここでは helloworld.cs というファイルを、Compileという種類のItem(=項目)にIncludeしています。 <Target> 要素は、MSBuildが順次実行する処理(タスク)をまとめたものです。Targetの名前をBuildとしています。BuildというTargetを実行すると、配下のタスクが実行されます。ここでは、 Cscタスクに対してCompileというItemを渡しており、ItemGroupで指定したhelloworld.csのみが渡されます。Cscタスクはcsc.exeをラップしており、実際のビルド処理はこのタスクが行います。

この状態で、MSBuildを実行します。 -t スイッチでTargetを指定します。

$ msbuild Helloworld.csproj -t:Build
Microsoft (R) Build Engine version 15.6.0.0 (xplat-master/ca830585 Sun Mar 25 19:24:09 EDT 2018) for Mono
Copyright (C) Microsoft Corporation. All rights reserved.

Build started 1/5/2019 9:50:17 PM.
Project "/Dev/msbuild/HelloWorld/Helloworld.csproj" on node 1 (Build target(s)).
Build:
  /Library/Frameworks/Mono.framework/Versions/5.10.1/lib/mono/msbuild/15.0/bin/Roslyn/csc.exe /out:helloworld.exe helloworld.cs
Done Building Project "/Dev/msbuild/HelloWorld/Helloworld.csproj" (Build target(s)).

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:01.01

これでhelloworld.exeが出力され、実行できます。これで最低限のビルドができます。Visual Studioが自動生成するプロジェクトファイルに比べるとかなりシンプルです。

ビルドプロパティの追加

最低限のビルドはできるようになったので、もう少し細かい制御をしてみます。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>  
   <AssemblyName>MSBuildSample</AssemblyName>  
    <OutputPath>Bin\</OutputPath>  
  </PropertyGroup>  
  <ItemGroup>
    <Compile Include="helloworld.cs" />
  </ItemGroup>
  <Target Name="Build">
    <MakeDir Directories="$(OutputPath)" Condition="!Exists('$(OutputPath)')" />
    <Csc Sources="@(Compile)" OutputAssembly="$(OutputPath)$(AssemblyName).exe" />
  </Target>
</Project>

<PropertyGroup> 要素は各種のプロパティを設定します。ここでは、出力するアセンブリ名と出力先のディレクトリを指定しています。合わせてBuildターゲットにおいて、Cscタスクの前に出力ディレクトリの作成と、Cscタスクにおいて出力アセンブリ名を指定しています。

この状態でMSBuildを実行すると、 Binディレクトリが作成され、その配下にMSBuildSample.exeが作成されます。

ビルドターゲットの追加

Build以外に、CleanとRebuildというターゲットを追加します。また、プロジェクトのデフォルトのターゲットを指定します。

<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <AssemblyName>MSBuildSample</AssemblyName>  
    <OutputPath>Bin\</OutputPath>  
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="helloworld.cs" />  
  </ItemGroup>
  <Target Name="Build">
    <MakeDir Directories="$(OutputPath)" Condition="!Exists('$(OutputPath)')" />
    <Csc Sources="@(Compile)" OutputAssembly="$(OutputPath)$(AssemblyName).exe" />
  </Target>
  <Target Name="Clean" >  
    <Delete Files="$(OutputPath)$(AssemblyName).exe" />  
  </Target>
  <Target Name="Rebuild" DependsOnTargets="Clean;Build" />  
</Project>

Cleanターゲットでは、Deleteタスクを用い、Buildターゲットで生成したアセンブリを削除します。RebuildターゲットはCleanとBuildのターゲットをこの順序で実行します。

この状態でCleanターゲットを実行してみます。

$ msbuild Helloworld.csproj -t:Clean
Microsoft (R) Build Engine version 15.6.0.0 (xplat-master/ca830585 Sun Mar 25 19:24:09 EDT 2018) for Mono
Copyright (C) Microsoft Corporation. All rights reserved.

Build started 1/5/2019 10:21:19 PM.
Project "/Dev/msbuild/HelloWorld/Helloworld.csproj" on node 1 (Clean target(s)).
Clean:
  Deleting file "Bin/MSBuildSample.exe".
Done Building Project "/Dev/msbuild/HelloWorld/Helloworld.csproj" (Clean target(s)).

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.42

Bin下のMSBuildSample.exeが削除されています。また、MSBuildを-tスイッチなしで実行すると再度MSBuildSample.exeが作成されます。

インクリメンタルビルド

ターゲットが依存しているファイルのタイムスタンプを見て、変更があった場合にのみビルドします。BuildターゲットにInputsとOutputsを指定します。

  <Target Name="Build" Inputs="@(Compile)" Outputs="$(OutputPath)$(AssemblyName).exe">>
    <MakeDir Directories="$(OutputPath)" Condition="!Exists('$(OutputPath)')" />
    <Csc Sources="@(Compile)" OutputAssembly="$(OutputPath)$(AssemblyName).exe" />
  </Target>

この状態で msbuild -v:d を実行します。ビルドの詳細を出力するスイッチをしています。

Skipping target "Build" because all output files are up-to-date with respect to the input files.
Input files: helloworld.cs
Output files: Bin/MSBuildSample.exe

入力ファイルhelloworld.csに変更がないので、Buildターゲットがスキップされています。

ビルドしない

ここまでくるとわかるかと思いますが、上記のプロジェクトにおいて、実際にビルドを実行しているのは結局Cscタスクであり、その前後は付随する処理でしかありません。つまり、MSBuildが行うのはビルドだけでなくその前後を含めたプロセスといってよさそうです。ビルドしないMSBuildプロジェクトもありえます。

例として、まったく意味のないプロジェクトを作ってみます。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="Greeting">
    <Message Text="Hello" Importance="low" />
    <Message Text="World" />
    <Message Text="!!!!!" Importance="high" />
  </Target>
  <Target Name="Copy">
    <Touch Files="File1.cs;File2.cs" AlwaysCreate="true" />
    <Copy SourceFiles="File1.cs" DestinationFolder="Bin\" />
  </Target>
</Project>

GreetingターゲットとCopyターゲットの2つがあります。まずGreetingターゲットは、Messageタスクを使って、文字列を出力するだけです。実行してみます。

$ msbuild NoBuild.csproj -t:Greeting -v:d
(略)
Target "Greeting" in project "/Dev/msbuild/HelloWorld/NoBuild.csproj" (entry point):
Using "Message" task from assembly "Microsoft.Build.Tasks.Core, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a".
Task "Message"
  Hello
Done executing task "Message".
Task "Message"
  World
Done executing task "Message".
Task "Message"
  !!!!!
Done executing task "Message".
Done building target "Greeting" in project "NoBuild.csproj".
Done Building Project "/Dev/msbuild/HelloWorld/NoBuild.csproj" (Greeting target(s)).
(略)

「Hello」「World」「!!!!!」が出力されています。HelloはImportanceがlowなので、 -v:dオプションがないと出力されません。

次に、Copyターゲットを実行してみます。Touchタスクを使ってFile1.csとFile2.csを作成した上で、CopyタスクでFile1.csをBin配下にコピーします。

$ msbuild NoBuild.csproj -t:Copy
(略)
Project "/Dev/msbuild/HelloWorld/NoBuild.csproj" on node 1 (Copy target(s)).
Copy:
  Touching "File1.cs".
  Touching "File2.cs".
  Copying file from "File1.cs" to "Bin/File1.cs".
Done Building Project "/Dev/msbuild/HelloWorld/NoBuild.csproj" (COpy target(s)).
(略)

まとめ

MSBuildプロジェクトファイルを0から手で書いてみました。ほとんど公式チュートリアルの丸パクリです。本年度のブログのハードルを下げていく方針なので大成功です。

Visual Studioを使って普通に開発を進めていくと気づいたらプロジェクトファイルが巨大になっていて面くらいがちです。でもやっていることは単純なことがわかります。基本的に手で書くものではないですが、上記内容くらいは知っておいて損はない気がします。他にもいろいろなタスクがあったり、条件指定ができたり、細かい制御ができます。そのあたりはぜひリファレンスを参照ください。

*1:MSBuildの開発コード名は「XMake」でした