NuGetを使うと、サードパーティ(MSではない)アセンブリの導入が簡単に行えます。他にも、MSBuildのビルドプロセスをカスタマイズ出来るパッケージを作る事も出来ます。Visual Studioはビルド時にMSBuildを使っているので、Visual Studio上でも、MSBuildによるコマンドラインのビルドでも、シームレスにビルドプロセスをカスタマイズ出来ます。
例えば、C#やF#のソースコードをコンパイルする直前に、カスタムコードを自動生成させたりする事ができます。このようなスクリプトをNuGetパッケージ化すると、配布と導入が非常に簡単になり、アンインストールも簡単で、多くの人に気軽に使って貰えるようになります。
# MSBuildを介さない操作のカスタマイズは出来ません。例えばVisual Studio自体のカスタマイズは、VSIX拡張(VSSDK)を使いますがここでは触れません。
この記事では、どうやって最小限の労力でNuGetパッケージを作るのかのポイントを示します。NuGetのパッケージを作ったことがある方が、より理解しやすいでしょう。
MSBuildとtargetsスクリプト
MSBuildは、*.csprojのようなプロジェクトファイルを入力として、一種の宣言ベーススクリプト処理を行います。中身を見たことがある人が多いと思いますが、XMLで記述します。大まかには、以下のような構造になっています(一部のみ抜粋)。
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> <PropertyGroup> <ProjectGuid>{3FCFCD0E-B40D-4A48-BF4C-85D552E46471}</ProjectGuid> <OutputType>Library</OutputType> <RootNamespace>CenterCLR.SampleCode</RootNamespace> <AssemblyName>CenterCLR.SampleCode</AssemblyName> <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> <!-- ... --> </PropertyGroup> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <DebugSymbols>true</DebugSymbols> <OutputPath>bin\Debug\</OutputPath> <!-- ... --> </PropertyGroup> <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> <DebugType>pdbonly</DebugType> <Optimize>true</Optimize> <OutputPath>bin\Release\</OutputPath> <!-- ... --> </PropertyGroup> <ItemGroup> <Reference Include="System" /> <Reference Include="System.Core" /> <Reference Include="Microsoft.CSharp" /> <!-- ... --> </ItemGroup> <ItemGroup> <Compile Include="Program.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> <!-- ... --> </ItemGroup> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <!-- To modify your build process, add your task inside one of the targets below and uncomment it. Other similar extension points exist, see Microsoft.Common.targets. <Target Name="BeforeBuild"> </Target> <Target Name="AfterBuild"> </Target> --> </Project>
場合によってはもっと複雑なスクリプトになっている事もあります。MSBuildはこのXMLを読み取って、ビルド対象の情報を取得しています。例えば、PropertyGroupタグ内の定義は、ビルドに必要な各種定義(.NET Frameworkのターゲットバージョンや、アセンブリ名など)が含まれています。この定義は一種の変数のように振る舞い、スクリプト内からこれらの名前で参照する事ができます。
ざっと眺めると、Referenceタグでアセンブリ参照を定義し、Compileタグでコンパイル対象のファイルを定義しているように見えますね。
この記事で一番のポイントは、「Import」タグです。このタグのProject属性には、「*.targets」ファイルへのパスが示されています。上記の例ではImportタグは二か所ありますが、例えば最後のタグでは「$(MSBuildToolsPath)\Microsoft.CSharp.targets」を指定していますね。「$(MSBuildToolsPath)」がPropertyGroupなどで定義された値を参照する構文で、このフォルダ配下の「Microsoft.CSharp.targets」というファイルが指定されています。
このImportタグは、この場所にここで示されたファイルの定義をインポートするという定義です。つまり、「Microsoft.CSharp.targets」ファイルを読み取り、ここに挿入すると考えてください。丁度C言語のincludeのように機能します(XMLなので実際は違いますが)。
Microsoft.Common.propsやMicrosoft.CSharp.targetsファイルには、非常に多くの定義が含まれています。全てMSBuildのスクリプトです。Microsoft.Common.propsには、先ほどのMSBuildToolsPathのような共通のPropertyGroup定義が含まれているため、ほぼすべてのプロジェクトファイルはこのファイルをImportします。
Microsoft.CSharp.targetsはC#のビルドに必要な定義が含まれています。これには、*.csのコンパイルだけではなく、*.resxの変換やXAMLのコンパイル定義なども含まれます。F#のような他の言語では、「Microsoft.FSharp.Targets」のような感じで、その言語に合わせて変えています。
カスタムなスクリプトを直接プロジェクトファイルに書くことも出来るのですが、別のファイルにカスタムスクリプトを書いて、それをImportした方が、プロジェクトファイルを損傷させる可能性が減る(XMLなので容易に構造を壊してしまう可能性がある)ため、ベターです。基本的な方針は、この「カスタムtargetsスクリプト」を用意することと、これをどうやって既存のプロジェクトファイルに「自動的に挿入(Importタグを追加)」させるのかと言う事になります。
Visual Studio 2010未満の環境では、VSIX拡張を使って自動的に挿入させることができました(この手法は今でも使えます)。しかし、VSIX拡張は作ったり保守したりするのが非常に困難なため、お勧めしません。また、VSIX拡張を使ってしまうと、ビルドにdevenv.exe(つまりVisual Studio本体)が必要になってしまい、MSBuildによる継続インテグレーションが出来なかったり自由度が失われてしまいます。
# わかりやすい例として、AppVeyorのようなクラウドベースのビルドエージェントで継続インテグレーションが困難です(不可能ではありませんが、事前インストールと自動構成が必要)。
NuGetでビルドプロセスに介入する流れ
Visual Studio 2010からNuGetが使えるようになり、このtargetsスクリプトをNuGetで配布する事が出来るようになりました。どういう事かと言うと:
- NuGetパッケージに、targetsスクリプトとその他必要なファイル群を加える
- NuGetパッケージを配布する
- NuGetパッケージをプロジェクトに導入すると、プロジェクトファイルに自動的にImportタグが追加され、targetsの定義が利用可能になる
という流れです。但し、単にtargetsスクリプトを含めれば良い訳ではありません。以下の条件が存在します。
- パッケージ内に「build」フォルダを追加し、その直下にtargetsスクリプトを配置する。
パッケージを作ったことがある方なら分かると思いますが、通常ライブラリアセンブリを配布する場合は「lib」フォルダ配下に「net40」「net20」のようなプラットフォームターゲット名のフォルダを作り、その中にアセンブリファイルを配置します。しかし、targetsスクリプトファイルは必ずbuildフォルダ配下に配置しなければなりません。 - targetsスクリプトファイル名は、必ず「[NuGetパッケージID].targets」でなければならない。
例えばパッケージIDが「CenterCLR.RelaxVersioner」の場合は、「build/CenterCLR.RelaxVersioner.targets」に配置します。名前が違っていると、パッケージの生成には成功して配布も出来るのに、導入時に「ターゲットプラットフォームが存在しない」という不可解なエラーで失敗します。 - targetsスクリプトで使用する追加のファイル群がある場合は、それらもbuildフォルダ内に全て配置する。
targetsスクリプトからファイルを相対参照する場合、起点はbuildフォルダになります。どうしても別フォルダにしたい場合でも一旦buildフォルダに配置して、参照できることを確認してから移動した方が良いでしょう。
スクリプト実行レベルでの挿入や乗っ取りの方法
さて、これで物理的なパッケージングの枠組みは出来たのですが、実際カスタムスクリプトはどうやって書けば良いのでしょうか?
MSBuildのカスタムスクリプトは、これだけで膨大なテーマであるので、全部網羅的に調べるのは大変です。そこで以下のポイントを知っておけば、取り掛かりが楽になると思います。
- PropertyGroupタグ内には、参照可能な定義を含める(これは前述で説明しました)。
定義した値は、「$(Name)」という記法で参照出来ます。 - ItemGroupタグ内には、操作対象のファイルなどを指定させる。
「Compile」タグはコンパイル対象を指定させますが、これは「@(Compile)」という記法で参照できます。当然これらは複数定義されることがあるため、@(Compile)の評価結果はコレクション(複数)となります。 - Condition属性に式を書くことで、そのタグがMSBuildの評価の対象になるかどうかが決定されます。
csprojの例を見るとわかりますが、デバッグビルドやプラットフォームの識別を式で判定して、それぞれに定義されるPropertyGroupを変えたり、と言う事が出来ます。これはPropertyGroupだけではなく、ほぼすべてのタグで使用できます。 - PropertyGroupやItemGroup内の定義は、串刺し的に評価される。
例えば、PropertyGroupが何個定義されていても構わず、それらすべての定義が一つのPropertyGroupに定義されているのと同じように評価されます。この機能を利用して、カスタムスクリプト内で自由に定義を追加したり、Condition属性に応じて定義するかしないかを切り替えたりと言ったハックが実現出来ます。
余談ですが、「Debug|AnyCPU」という値の判定には、意味ありげに演算子っぽい「|」を使っていますが、これ、文字列としてただ連結しているだけです(ConfigurationとPlatformの値が結合して別の値と誤認しないようにするためと思われる)。
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> <DebugSymbols>true</DebugSymbols> <OutputPath>bin\Debug\</OutputPath> <!-- ... --> </PropertyGroup>
and演算子が使えるにもかかわらず、このような「ダーティハック的な記述」が至る所にあるため、それらに惑わされれないように注意しましょう :)
このような前提で、まずは「Microsoft.Common.props」や「Microsoft.CSharp.targets」ファイルを眺めてみて下さい。これらのファイルは、「C:\Program Files (x86)\MSBuild\12.0」や、「C:\Windows\Microsoft.NET\Framework\v4.0.30319」配下にあります。
標準のMSBuildスクリプト群は非常に巧妙で複雑なのですが、ビルドを実現する戦略として以下のような手法を使っているようです。
Targetタグを論理的にネストさせて、ビルドシーケンスに階層構造を構築する
以下はMicrosoft.Common.targetsの一部です。
<PropertyGroup> <BuildDependsOn> BeforeBuild; CoreBuild; AfterBuild </BuildDependsOn> </PropertyGroup> <Target Name="Build" Condition=" '$(_InvalidConfigurationWarning)' != 'true' " DependsOnTargets="$(BuildDependsOn)" Returns="$(TargetPath)" />
この定義は、「Targetタグ」によって「Build」という名前の「ターゲット」を定義しています。この名前は、MSBuildのコマンドラインオプションで指定することができます。ビルドする場合は、ここからスクリプトの評価が開始されると思って下さい。ただし、このTargetタグは空タグなので、このターゲット自体は何も実行しません。その代わり、「DependsOnTargets属性」で参照されるサブターゲットを評価します。
DependsOnTargets属性は、「BuildDependsOn」の値が参照されています。前半のPropertyGroup内に定義されている、「BeforeBuild;CoreBuild;AfterBuild」が対象です。改行と空白は無視し、セミコロンで区切られます。
このTargetタグの後、更に別のTargetタグによって「BeforeBuild」「CoreBuild」「AfterBuild」それぞれのターゲットが定義されています。このリストは、まずBeforeBuildの定義が評価され、次にCoreBuildの定義が評価され、最後にAfterBuildの定義が評価される、というように、リストに書かれた順に従って、サブターゲットが評価されることになります。
CoreBuildの定義は後で示しますが、BeforeBuildとAfterBuildはそれぞれ以下のように定義されています。
<Target Name="BeforeBuild"/> <Target Name="AfterBuild"/>
要するに空定義なので何も実行しませんが、このターゲット名、何か見覚えがありませんか? 実は、csprojのデフォルトのテンプレートには、BeforeBuildとAfterBuildのひな型が含まれています。再度抜粋します:
<!-- ... --> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <!-- To modify your build process, add your task inside one of the targets below and uncomment it. Other similar extension points exist, see Microsoft.Common.targets. <Target Name="BeforeBuild"> // 必要ならここにカスタムの事前実行スクリプトを書く </Target> <Target Name="AfterBuild"> // 必要ならここにカスタムの事後実行スクリプトを書く </Target> --> </Project>
つまり、csprojでこのタグのコメントアウトを外してここに何か書くと、BeforeBuildとAfterBuildの内容をオーバーライドできると言う事です。こういうカラクリで、プロジェクトファイルにカスタムスクリプトを記述出来るようになっています。
さて、CoreBuildの定義を見ると:
<PropertyGroup> <CoreBuildDependsOn> BuildOnlySettings; PrepareForBuild; PreBuildEvent; ResolveReferences; PrepareResources; ResolveKeySource; Compile; ExportWindowsMDFile; UnmanagedUnregistration; GenerateSerializationAssemblies; CreateSatelliteAssemblies; GenerateManifests; GetTargetPath; PrepareForRun; UnmanagedRegistration; IncrementalClean; PostBuildEvent </CoreBuildDependsOn> </PropertyGroup> <Target Name="CoreBuild" DependsOnTargets="$(CoreBuildDependsOn)"> <OnError ExecuteTargets="_TimeStampAfterCompile;PostBuildEvent" Condition="'$(RunPostBuildEvent)'=='Always' or '$(RunPostBuildEvent)'=='OnOutputUpdated'"/> <OnError ExecuteTargets="_CleanRecordFileWrites"/> </Target>
Buildとおなじような構造の定義になっています(細かい相違は省略)。CoreBuildもまた空タグなので、このターゲット自体は何も実行しません。そしてDependsOnTargets値は「CoreBuildDependsOn」を参照し、今度は更に細かくサブターゲット群が定義されています。このリストそれぞれに対応するターゲット定義が、この後のスクリプトに全て含まれていて、この順にTargetタグが評価されます。このように、ネストしたサブターゲットを再帰的に探索してビルドを実現しているのです。
名前からして分かりやすそうな「Compile」というターゲットは、各処理系(C#やF#)のスクリプトに定義されているTarget定義に処理が移譲され、更に細かくターゲットがネストし、最終的にはC#やF#のコンパイラを起動してコンパイルを行っています。
さて、これでどうやってビルドプロセスに介入したら良いか分かってきましたね? 必要となるタイミングに対応するTargetタグを調べて(実はここが大変)、そのターゲットと同じ名前でオーバーライドしてしまうか、あるいは「*DependsOn」をオーバーライドして独自のターゲット名のリストに入れ替え、そこに自分のターゲット名を挿入しておいたり、標準のターゲットの代わりに自分のターゲットを実行させるように入れ替えたりすれば良い訳です。
ビルドプロセスに介入する例
私が取り組んでいるプロジェクトに、RelaxVersionerというパッケージがあります。これは、Gitを前提として、バージョン番号の埋め込みなどを自動的に行うNuGetベースのパッケージです。
# バージョン番号を簡単に自動的に埋め込むには、何のツールが良いかとか、悩んでいる人多いですよね? ね??
# フィードバックやPR貰えるとやる気が出ます、よろしく :)
デザインの指針は、
- とにかく簡単(ほぼパッケージをインストールするだけ)
- ビルドの副作用による影響なし
- 独自のUIを作らない。コントロールはすべてGitの標準機能(ブランチ・タグ・メッセージ)を使って実現する
- 約束事を最小にする
- 新たな学習コストを最小にする
- Gitのリポジトリを汚さない(バージョン番号の変更を明示的にステージングから除外したりしなくても良い)
- 継続インテグレーションにもそのまま使用可能
- 必要であればカスタマイズ可能
というところを目指しています。現状で大体実現出来ています(mono対応が出来てない。コードは書いたけどうまく動かず… 誰か助けてー)。使い方はGitHubの方を見てもらうとして、とにかく導入が簡単で影響を最小化したかったので、この記事で説明したような、NuGetによる導入で即使用可能というところが重要でした。
やっていることは、各ソースファイルのコンパイル直前に、Gitのローカルリポジトリから吸い上げた情報を元に、AssemblyVersion属性を含んだソースファイルをテンポラリフォルダに自動生成して、コンパイルの対象に含める、という内容です。こんなコードが自動的に生成されてコンパイルされます:
[assembly: AssemblyVersion("0.5.30.0")] [assembly: AssemblyFileVersion("2016.1.15.41306")] [assembly: AssemblyInformationalVersion("a05ab9fc87b22234596f4ddd43136e9e526ebb90")] [assembly: AssemblyMetadata("Build","Fri, 15 Jan 2016 13:56:53 GMT")] [assembly: AssemblyMetadata("Branch","master")] [assembly: AssemblyMetadata("Tags","0.5.30")] [assembly: AssemblyMetadata("Author","Kouji Matsui <[email protected]>")] [assembly: AssemblyMetadata("Committer","Kouji Matsui <[email protected]>")] [assembly: AssemblyMetadata("Message","Fixed tab")]
細かいコードは除外して、ポイントだけに絞ったtargetsスクリプトはこのようなものです(オリジナルはココ):
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!-- Common --> <PropertyGroup> <RelaxVersionerOutputPath Condition="'$(RelaxVersionerOutputPath)' == ''">$([System.IO.Path]::Combine('$(ProjectDir)','$(IntermediateOutputPath)','RelaxVersioner$(DefaultLanguageSourceExtension)'))</RelaxVersionerOutputPath> <CoreBuildDependsOn> RelaxVersionerBuild; $(CoreBuildDependsOn); </CoreBuildDependsOn> </PropertyGroup> <!-- Build definition --> <Target Name="RelaxVersionerBuild" BeforeTargets="BeforeCompile" Outputs="$(RelaxVersionerOutputPath)"> <Exec Command="CenterCLR.RelaxVersioner.exe "$(SolutionDir.TrimEnd('\\'))" "$(ProjectDir.TrimEnd('\\'))" "$(RelaxVersionerOutputPath)" "$(TargetFrameworkVersion)" "$(TargetFrameworkProfile)" "$(Language)"" Outputs="$(RelaxVersionerOutputPath)" WorkingDirectory="$(MSBuildThisFileDirectory)" /> <ItemGroup> <Compile Include="$(RelaxVersionerOutputPath)" /> </ItemGroup> </Target> </Project>
RelaxVersionerOutputPathは、中間ファイルフォルダの配下にソースファイルを配置するためのパスを示します。Conditionはパスのカスタマイズの余地を残すためです。このパスは、ほとんどの場合、プロジェクトフォルダ配下の「obj\Debug\RelaxVersioner.cs」のようなパスとなります。
そして、CoreBuildDependsOnをオーバーライドし、この直前にRelaxVersionerBuildターゲットを実行するように再定義します。リストを丸ごと置き換えると、標準targetsスクリプトが更新された時におかしくなってしまう可能性があるので、元の定義を生かしたまま挿入するハックです。
# CoreBuildではなく、もっと深いコンパイルに近い位置でのターゲットでも良いかもしれません。どこの位置が適切なのかが難しいところですが、RelaxVersionerはマルチ言語対応の予定があり、言語毎に細分化されたターゲットに挿入すると意味がないので、CoreBuildを選択しています。
実際にソースコードを生成するのは、「CenterCLR.RelaxVersioner.exe」という実行ファイルで、これはNuGetパッケージのbuildフォルダに一緒に含めておきます。もちろん、ほかに必要なアセンブリも全て含める必要があります。RelaxVersionerはlibgit2sharpを使っているので、これらも配置してあります。
# MSBuild Taskではなく実行ファイルであるのは… MSBuild Taskはデバッグとかバージョンとかつらいんです。後でTask化にトライする予定。
コマンドラインにソースファイルのパスや、対象の言語名、.NET Frameworkのバージョン情報などを渡して、生成するソースコードの内容を変えています。最後に、生成したソースコードがコンパイルのターゲットとなるように、Compileタグで追加しておきます。
補足
.NET Core環境は、現在のところMSBuildを使用しないことになっているために、この記事の手法が使えません。ご存知の通り.NET Coreでは「project.json」がプロジェクト定義となっているのですが、これはdotnetコマンドが解釈を行います。
また、.NET Core toolsetをインストールしたVisual Studioのテンプレートから.NET Coreのプロジェクトを作った場合、project.jsonの他に*.xprojが生成されます。このxprojはMSBuildのプロジェクトスクリプトなのですが、NuGetの管理を行わない(NuGetの管理はproject.jsonで行われるため、結局Importタグが埋め込まれない)ので、やはり機能しません。
# ややこしいことになっていますが、VSでビルドした場合に、xproj (MSBuild) –> DNX Task (MSBuild) –> dotnet (project.json) –> NuGetting & Build というように処理が移譲されるようです(あまり細かく追っていないので、間違っているかもしれません)
.NET Coreはプロジェクト定義をproject.jsonベースに移行する事を前提として作業していたのですが、RTMのリリース直前になってこれがキャンセルされ、MSBuildをマルチプラットフォームに移植するという方針に変わりました。
Changes to project.jsonhttps://t.co/Lytqwa7ID4
— .NET Team (@dotnet) 2016年5月23日
しかし、結局RTMには移植が間に合わず、project.jsonベースでNuGetを管理する状態のままリリースされてしまいました。従って、まだしばらくは.NET Coreでのスムーズなビルドのカスタマイズは出来ないと言う事になりますが、MSBuildが移植される方針なので、将来的に同じ手法でビルドプロセスをコントロール出来そうです。
mono環境ではMSBuildのmono実装である「XBuild」が使えます。サブセットであるので一部互換性がありませんが、概ね同じような手法が使えると思います。モウコレデイインヂャナイノ?
余談
度々見るのと、私自身も思っているのですが、C#/F#/VB.netなど、複数の言語を一つのプロジェクト内でまとめて扱えるようにして欲しいという要望があります。しかし、現在のMSBuildのtargetsスクリプト群を見てわかる通り、これを実現するには「標準のtargets全部書き直し」しか無いですね。.NET Coreのxprojを見ていると、ひょっとしたらこれを進めてproject.jsonだけどうにかすれば出来るんじゃないか?と思ったんですが、.NET Coreチームはxprojもcsprojに戻すことを選択したようで、がっかりです。
# まあ、なんか、何でそこだけjsonなんだよ、分離したければどっちかに統一すれば良いのに、と思った。あと「project.json」っていう命名も誤解の元。
# それに、targetsがまともに機能しなくなるのが分かってた筈なのに、何で再考しなかったんだというのも… 互換性無くなるなら無くなるで、一部だけどうにかするんじゃなくて全体から(つまりMSBuild自体)見直して欲しかった。
MSBuild、複雑すぎます。何故こうなった感が大きいです。初期導入が古いとは言え、最初から不満でした。まず、階層構造をタグの論理的なネストで実現したのも、正直言ってXMLで処理系作ってその枠組みで無理やり実現しました感があるし、既存のスクリプトを壊さないように処理を追加したり交換したりといった事が簡単に出来ないし、targetsスクリプトを追って行くのがとにかく大変で、この記事のようにシンプルにまとめ上げれるようになるまでにかなり時間が掛かりました。こんな構造だから、拡張のためのエコシステムが発展しないんだと思う…
仕事でVSIX拡張を触っているのですが、VSIXの難解なインターフェイスと、MSBuildの複雑構造と、NuGetのどうしようもなく整理されていない感が組み合わさって、出自が違うので仕方ないとは言え本当につらい。この記事は備忘録も兼ねて書いたのですが、少しでもお役に立てれたら嬉しいです。