MSBuildのバッチ機能とは

公開日:2018-10-16
最終更新:2018-10-24
※この記事は外部サイト(https://azyobuzin.hatenablog.com/entry/201...)からのクロス投稿です

みなさん MSBuild プロジェクトの手書き、していますか?プロジェクトの手書きはさまざま苦労はあると思いますが、そんなご時世に、知っているとちょっと楽に書けて、しかもインクリメンタルビルドがしやすくなるバッチ機能について、詳しく見ていきましょう。

バッチの概要と使い方については、公式ドキュメントに書いてありますが、腑に落ちないというか、あまり理解できた感じがしなかったので、 MSBuild のソースコードとステップ実行してみた結果を基に、具体的な動作から挙動を説明していきます。

用語について

用語については、日本語版の公式ドキュメントで使われているものをできるだけ使っていきます。プロジェクトのタグ名と一致しない言葉として、 Item のことを「項目」と呼んでるので注意してください。

基本的なバッチの動作

バッチは、式が指定できるパラメータに %(Item.Metadata)%(Metadata) といった書き方でメタデータを参照したときに、そのメタデータの値ごとに処理を何度も呼び出してくれる機能です。使う場所ごとに微妙に動作が違うので、それぞれについて見ていきます。「バッチ実行計画を作成するプログラム」の章では、その実行計画を作成する部分について、 MSBuild の実装の解説をしているので、見比べながら挙動を理解してもらえると幸いです。

タスク

公式ドキュメントの焼き直し感がある)

簡単な例

タスクでは、そのパラメータや条件でメタデータの参照を行うことで、そのタスクを何度も実行します。

例えば、次のようなプロジェクトを用意します。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" />
    <FooItem Include="Foo2" />
  </ItemGroup>

  <Target Name="Build">
    <Message Text="Message! %(FooItem.Identity)" />
  </Target>
</Project>

これを実行すると、 FooItem は 2 件あるので、 Message タスクは 2 回実行され

Message! Foo1
Message! Foo2

と出力されます。

注意してほしいポイントとして、何度も実行されるのはタスク単位なので、

<Target Name="Build">
  <Message Text="Message1! %(FooItem.Identity)" />
  <Message Text="Message2! %(FooItem.Identity)" />
</Target>

とすると、「Message1!」を 2 回実行した後、「Message2!」を 2 回実行します。したがって、出力は

Message1! Foo1
Message1! Foo2
Message2! Foo1
Message2! Foo2

となります。

同じ値を持つメタデータ

同じ値を持つメタデータを参照した場合、その値ごとにグルーピングされ、実行されます。

例えば、次のようなプロジェクトを用意します。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" Meta="A" />
    <FooItem Include="Foo2" Meta="A" />
    <FooItem Include="Foo3" Meta="B" />
    <FooItem Include="Foo4" Meta="B" />
  </ItemGroup>

  <Target Name="Build">
    <Message Text="%(FooItem.Meta) @(FooItem)" />
  </Target>
</Project>

このとき、式に表れるメタデータは FooItem.Meta で、その値は Foo1 と Foo2 が「A」、 Foo3 と Foo4 が「B」です。したがって、まず Foo1 と Foo2 について Message タスクが実行され、次に Foo3 と Foo4 について Message タスクが実行されます。

また、 Text パラメータで @(FooItem) を使用していますが、これは、すべての FooItem を表すのではなく、 1 回のタスクの実行に該当する項目のみを表します。

したがって、実行結果はこのようになります。

A Foo1;Foo2
B Foo3;Foo4

複数の項目の種類を使う

複数の項目の種類を使うとき、例えば FooItem と BarItem のメタデータを 1 つのタスクで使用する場合、 FooItem のメタデータ値についてそれぞれ実行された後、 BarItem のメタデータ値についてそれぞれ実行されます(「後」と書きましたが、順番は保証されません)。

例えば、次のようなプロジェクトを用意します。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" Meta="A" />
    <FooItem Include="Foo2" Meta="A" />
    <FooItem Include="Foo3" Meta="B" />
    <FooItem Include="Foo4" Meta="B" />
    <BarItem Include="Bar1" Meta="A" />
    <BarItem Include="Bar2" Meta="A" />
    <BarItem Include="Bar3" Meta="B" />
    <BarItem Include="Bar4" Meta="B" />
  </ItemGroup>

  <Target Name="Build">
    <Message Text="Foo(%(FooItem.Meta)):@(FooItem), Bar(%(BarItem.Meta)):@(BarItem)" />
  </Target>
</Project>

FooItem については、前の例と同じようにまとめられ、 BarItem も同様にまとめられます。そして、それぞれが別々に実行されるので、出力はこのようになります。

Foo(A):Foo1;Foo2, Bar():
Foo(B):Foo3;Foo4, Bar():
Foo():, Bar(A):Bar1;Bar2
Foo():, Bar(B):Bar3;Bar4

動作としては、後の節の「項目分割アルゴリズム」の表で、 FooItem を相手にするとき、 BarItem.Meta は空文字になり、 BarItem を相手にするとき、 FooItem.Meta が空文字になっていると考えてください。

項目名を指定しないでメタデータを参照する

ここまでは %(Item.Metadata) という書き方でメタデータを参照してきましたが、 %(Metadata) という書き方をすることもできます。この書き方をした場合、ひとつのタスクで参照されるすべての項目に対して、そのメタデータでグルーピングを行います。

前の例を少し編集した、このようなプロジェクトを用意します。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" Meta="A" />
    <FooItem Include="Foo2" Meta="A" />
    <FooItem Include="Foo3" Meta="B" />
    <FooItem Include="Foo4" Meta="B" />
    <BarItem Include="Bar1" Meta="A" />
    <BarItem Include="Bar2" Meta="A" />
    <BarItem Include="Bar3" Meta="B" />
    <BarItem Include="Bar4" Meta="B" />
  </ItemGroup>

  <Target Name="Build">
    <Message Text="%(Meta) Foo:@(FooItem), Bar:@(BarItem)" />
  </Target>
</Project>

このとき %(Meta) は、 FooItem であるか、 BarItem であるか関係なく、 Meta という名前のメタデータでグルーピングを行います。したがって、 Foo1, Foo2, Bar1, Bar2 でひとつのグループ、 Foo3, Foo4, Bar3, Bar4 でひとつのグループになります。

実行結果はこのようになります。

A Foo:Foo1;Foo2, Bar:Bar1;Bar2
B Foo:Foo3;Foo4, Bar:Bar3;Bar4

このとき、使用したすべての項目の種類において、メタデータが定義されている必要があります。定義されているとは、 <ItemDefinitionGroup> でそのメタデータを使用しているか、現在までに 1 度でもその項目の種類の項目で、そのメタデータを持ったことがあることを指します。これを満たしていない場合、エラーが発生します。ただし、項目が 0 件の場合は検証されないので、エラーは発生しません。

例えば

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" />
    <BarItem Include="Bar1" Meta="A" />
  </ItemGroup>

  <Target Name="Build">
    <Message Text="%(Meta) Foo:@(FooItem), Bar:@(BarItem)" />
  </Target>
</Project>

では、このようなエラーが発生します。

error MSB4096: 項目一覧"FooItem" の項目 "Foo1" はメタデータ "Meta" に対して値を定義していません。このメタデータを使用するには、%(FooItem.Meta) を指定してメタデータを限定するか、またはこの一覧のすべての項目がこのメタデータに対して値を定義していることを確認してください。

バッチが適用されるパラメータ

どのパラメータの値が、バッチ実行に影響するのかについて説明します。今までの説明で「ひとつのタスクで」と書いてきたように、例えば <MyTask Param1="%(Item.Meta1)" Param2="%(Item.Meta2)" /> と書いた場合も、今までのように <Message Text="%(Item.Meta1) %(Item.Meta2)" /> と書いたことと同じように、項目の分割が行われます。

さて、バッチ実行に影響する式を取るパラメータには何があるでしょうか。ソースコードから調べてきた結果、次の属性に指定された式が使用されるようです。

  • タスク固有のパラメータ(例えば Message なら Text)
  • Condition
  • ContinueOnError
  • Output 要素
    • TaskParameter
    • PropertyName
    • ItemName
    • Condition

Output でも使えるということは、例えばこんな使い方ができます。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <Framework Include="Net40" FullName=".NETFramework,Version=4.0" />
    <Framework Include="Net45" FullName=".NETFramework,Version=4.5" />
  </ItemGroup>

  <Target Name="Build">
    <GetReferenceAssemblyPaths TargetFrameworkMoniker="%(Framework.FullName)">
      <Output TaskParameter="ReferenceAssemblyPaths"
              PropertyName="%(Framework.Identity)Path" />
    </GetReferenceAssemblyPaths>
    <Message Text="Net40: $(Net40Path)" />
    <Message Text="Net45: $(Net45Path)" />
  </Target>
</Project>

結果

Net40: C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\
Net45: C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5\

やばいなこれ

ItemGroup

ターゲット内の <ItemGroup> の各項目は、それぞれバッチ実行されます。(ターゲットの外の <ItemGroup> ではそもそも %() の形式が評価されません。)

実用的な使い方としては、フィルタリングが挙げられます。次の例では、 Meta が「A」の FooItem のみを BarItem にコピーします。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <Target Name="Build">
    <ItemGroup>
      <FooItem Include="Foo1" Meta="A" />
      <FooItem Include="Foo2" Meta="A" />
      <FooItem Include="Foo3" Meta="B" />
      <BarItem Include="@(FooItem)"
               Condition="'%(FooItem.Meta)' == 'A'" />
    </ItemGroup>
    <Message Text="@(BarItem)" />
  </Target>
</Project>

結果

Foo1;Foo2

フィルタリング以外の使い道が特に思いつかない。。

項目でのバッチで注意すべき点として、 %(Metadata) の形式を使うときに、操作する項目自体もメタデータをチェックする対象に入ることです。

例えば

<ItemGroup>
  <FooItem Include="Foo1" Meta="A" />
  <BarItem Include="Bar1" />
  <BarItem Include="@(FooItem)"
           Condition="'%(Meta)' == 'A'" />
</ItemGroup>

は、すでに存在する BarItem に Meta メタデータがないため、エラーになります。

バッチ実行に影響する属性などは次の通りです。

  • Include
  • Exclude
  • Remove
  • Condition
  • メタデータ要素
    • メタデータの値
    • Condition

Update 属性が入っていないなぁと思って調べてみたところ、ターゲット内では Update 属性は使ってはいけないようです。

PropertyGroup

ターゲット内の <PropertyGroup> の各プロパティは、それぞれバッチ実行されます。といっても、プロパティは 1 つしか値を持てないので、バッチ実行したところで最後の値が記録されるだけです。

ということで悪用すると、フィルターした結果の最後の値を取得することができます。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" Meta="A" />
    <FooItem Include="Foo2" Meta="B" />
    <FooItem Include="Foo3" Meta="B" />
    <FooItem Include="Foo4" Meta="C" />
  </ItemGroup>

  <Target Name="Build">
    <PropertyGroup>
      <LastB Condition="'%(FooItem.Meta)' == 'B'">%(FooItem.Identity)</LastB>
    </PropertyGroup>
    <Message Text="Last: $(LastB)" />
  </Target>
</Project>

結果

Last: Foo3

最初の値を取得するには、別の項目に Reverse したものを入れてあげる必要がありそうです(失敗例)。

ターゲット

ターゲットのバッチ実行の基本

ターゲットでは、Inputs、Outputs、Returns 属性から分割を行いバッチ実行します。分割されてから Inputs と Outputs の評価を行い、スキップするかどうかの判断が行われます。

例えば、 bar1.txt は存在するけれど、 bar2.txt が存在しない状態で、このプロジェクトをビルドしてみます。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <CopyItem Include="foo1.txt" Destination="bar1.txt" />
    <CopyItem Include="foo2.txt" Destination="bar2.txt" />
  </ItemGroup>

  <Target Name="Build"
          Inputs="%(CopyItem.Identity)"
          Outputs="%(CopyItem.Destination)">
    <Copy SourceFiles="%(CopyItem.Identity)"
          DestinationFiles="%(CopyItem.Destination)" />
  </Target>
</Project>

すると、出力は

Build:
すべての出力ファイルが入力ファイルに対して最新なので、ターゲット "Build" を省略します。
Build:
  "foo2.txt" から "bar2.txt" へファイルをコピーしています。

となり、インクリメンタルビルドのチェックが別々に行われていることが確認できます。無駄が減らせて良いですね。

この例では、ターゲット内のタスクでも、メタデータの参照が行われていますが、まずターゲット内で CopyItem を参照した場合は、ターゲットの項目分割で分割された結果になります。また、タスクについてもバッチ実行が行われますが、ターゲットと同じメタデータ参照の組み合わせなので、まったく同じ分割が行われることと、ターゲット内では該当項目しか参照できないことから、 1 回だけ実行されることが分かります。

悪用

あまり使用しないと思われる Returns 属性に、 Identity メタデータを入れることで、項目の foreach が実現できます。これができると、項目ごとに決まった順序でタスクを実行することができ、 MSBuild の表現力がぐっとアップします。

例えば、項目ごとに 2 つのタスクを連続して実行したい場合は、このように書くことができます。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" Meta1="A" Meta2="B" />
    <FooItem Include="Foo2" Meta1="C" Meta2="D" />
  </ItemGroup>

  <Target Name="Build" Returns="%(FooItem.Identity)">
    <Message Text="Meta1: %(FooItem.Meta1)" />
    <Message Text="Meta2: %(FooItem.Meta2)" />
  </Target>
</Project>

結果

Build:
  Meta1: A
  Meta2: B
Build:
  Meta1: C
  Meta2: D

プロパティや項目を参照するときの注意

ターゲットのバッチ処理を使うにあたっての注意点として、プロパティの失敗例で示した(つまりターゲット以外でも同じ話ですが)ように、プロパティや項目の値を参照した場合、バッチ実行される前の値が使用されるという点がより顕著に表れてくるので、気を付けて書くようにしましょう。

バッチ実行されるターゲットで、プロパティや項目を書き換える例を示します。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
         DefaultTargets="Build">
  <ItemGroup>
    <FooItem Include="Foo1" />
    <FooItem Include="Foo2" />
    <BarItem Include="Bar1" />
  </ItemGroup>

  <PropertyGroup>
    <FooProp>Initial</FooProp>
  </PropertyGroup>

  <Target Name="Loop" Returns="%(FooItem.Identity)">
    <Message Text="FooProp Before: $(FooProp)" />
    <PropertyGroup>
      <FooProp>%(FooItem.Identity)</FooProp>
    </PropertyGroup>
    <Message Text="FooProp After: $(FooProp)" />

    <Message Text="BarItem Before: @(BarItem)" />
    <ItemGroup>
      <BarItem Include="@(FooItem)" />
    </ItemGroup>
    <Message Text="BarItem After: @(BarItem)" />
  </Target>

  <Target Name="Build" DependsOnTargets="Loop">
    <Message Text="FooProp: $(FooProp)" />
    <Message Text="BarItem: @(BarItem)" />
  </Target>
</Project>

このとき、 Loop ターゲット内でプロパティや項目を参照したときに得られる値は、バッチ実行を行う前のときの値になります。 Build ターゲットは、 Loop ターゲットの後に実行され、そのときに得られるプロパティの値は、最後に書き換えが行われた結果の値で、項目は分割実行した結果が統合されます。

出力はこのようになります。

Loop:
  FooProp Before: Initial
  FooProp After: Foo1
  BarItem Before: Bar1
  BarItem After: Bar1;Foo1
Loop:
  FooProp Before: Initial
  FooProp After: Foo2
  BarItem Before: Bar1
  BarItem After: Bar1;Foo2
Build:
  FooProp: Foo2
  BarItem: Bar1;Foo1;Foo2

バッチ実行計画を作成するプログラム

ここからは、バッチ実行がどのように行われるのか、ソースコードを確認していきます。

バッチをどのように実行するか(項目をどのように分割するか)のプログラムは BatchingEngine クラスにまとめられており、ターゲットやタスクの実行部が呼び出す形で使われています。

PrepareBatchingBuckets の入出力

外部から呼び出されるメソッドは BatchingEngine.PrepareBatchingBuckets です。ソースコードリーディングをしていくにあたって、まず入出力が分かっていると理解しやすいと思うので、入出力値についてまとめておきます。

List<ItemBucket> PrepareBatchingBuckets
(
    List<string> batchableObjectParameters,
    Lookup lookup,
    string implicitBatchableItemType,
    ElementLocation elementLocation
)

batchableObjectParameters は、バッチ処理を行うかの判断を行うために必要なパラメータ群をまとめたリストです。例えば、 <Task Parameter1="foo" Parameter2="bar" /> を実行するプログラムは、 batchableObjectParameters として "foo""bar" を指定します。この値は、評価を行う前の値なので、 @(Item)%(Item.Metadata) といった文字列を含んでいるので、これらが解析されて、実行計画が作成されます。

lookup は、項目の取得や、式の評価に使用するオブジェクトです。

implicitBatchableItemType は、項目について PrepareBatchingBuckets を呼び出すときに、その項目名が implicitBatchableItemType として渡されます。それ以外の場合は null です。 implicitBatchableItemType として渡された項目は、 batchableObjectParameters に含まれる式で参照されていなかったとしても、 @(Item) として参照されたものとして扱われていきます

elementLocation は、エラーレポートを行うためのものです。

戻り値は、どのように分割して実行すべきかの情報です。 %(Metadata) の形によるメタデータへの参照がなければ、すべての入力項目を含む 1 個の ItemBucket を返します。そうでなければ、アルゴリズムに従って項目を分割します。

項目分割アルゴリズム

  1. batchableObjectParameters から、@(Item)@(Item->'%(Metadata)') の形(変換)はこれに該当します)、%(Item.Metadata)%(Metadata) を収集。
  2. 参照された項目リストとして、 @(Item) および %(Item.Metadata) の項目名を収集。
  3. 収集されたすべての項目の項目値について、収集した %(Item.Metadata)(Item が項目名と一致するなら)、%(Metadata) のメタデータの値を評価し、その値ごとにグルーピングする。メタデータが存在しない場合(項目名が一致しない場合も含む)は、メタデータ値は空文字として扱われる。

例を示します。

<ItemGroup>
  <FooItem Include="Foo1" MetaPrivate="X" MetaCommon="A" />
  <FooItem Include="Foo2" MetaPrivate="Y" MetaCommon="B" />
  <BarItem Include="Bar1" MetaPrivate="X" MetaCommon="A" />
  <BarItem Include="Bar2" MetaPrivate="Y" MetaCommon="B" />
</ItemGroup>

という項目があるとき

<Message Text="Foo:%(FooItem.MetaPrivate), Bar:%(BarItem.MetaPrivate), Common:%(MetaCommon)" />

を実行しようとすると、手順 3 はこのような次のように評価されます。

項目値 FooItem.MetaPrivate BarItem.MetaPrivate MetaCommon
Foo1 X
A
Foo2 Y
B
Bar1
X A
Bar2
Y B

このとき、値がすべて同じ行があるならば、それがまとめられます。今回の例では、すべての行が異なるので、 4 つに分けられて実行されるので、実行結果はこのようになります。

Foo:X, Bar:, Common:A
Foo:Y, Bar:, Common:B
Foo:, Bar:X, Common:A
Foo:, Bar:Y, Common:B

まとめられる例を示すと、

<Message Text="%(MetaCommon) Foo:@(FooItem), Bar:@(BarItem)" />

ならば、表は

項目値 MetaCommon
Foo1 A
Foo2 B
Bar1 A
Bar2 B

となるので、実行結果はこのようになります。

A Foo:Foo1, Bar:Bar1
B Foo:Foo2, Bar:Bar2

終わりに

ドキュメントを読んでもあまりぱっとしないバッチという存在でしたが、やっていることを理解したら、どんどん活用していける便利な機能であることがわかりました。それでは、よい MSBuild ライフを。

記事が少しでもいいなと思ったらクラップを送ってみよう!
18
+1
@azyobuzinの技術ブログ

よく一緒に読まれている記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう
目次をみる

技術ブログをはじめよう

Qrunch(クランチ)は、ITエンジニアリングに携わる全ての人のための技術ブログプラットフォームです。

技術ブログを開設する

Qrunchでアウトプットをはじめよう

Qrunch(クランチ)は、ITエンジニアリングに携わる全ての人のための技術ブログプラットフォームです。

Markdownで書ける

ログ機能でアウトプットを加速

デザインのカスタマイズが可能

技術ブログ開設

ここから先はアカウント(ブログ)開設が必要です

英数字4文字以上
.qrunch.io
英数字6文字以上
ログインする