C#
MSBuild
Buildalyzer


はじめに

ソース生成時にたまにお世話になるBuildalyzerだが、実際に使う上でいくつか躓いた点があったので、書いておく


Buildalyzerの大雑把な説明

Buildalyzerは、msbuildのビルド結果からプロパティやプロジェクトアイテムを組み立てるという仕組みになっている(2.x時点)。

また、Buildalyzer.Workspacesを使うことにより、この結果からRoslynワークスペースを作成し、アセンブリのシンボル等を取得することができる。


問題点1: コンパイル結果が異なる

Buildalyzer.Workspacesから作成した結果は、必ずしもdotnet buildの結果と一致するとは限らないという場合がある。

これはMSBuildとRoslynは互いに独立する概念であり、相互変換が必要になるために起きる問題であるということを踏まえる必要がある。


例えば、Roslynワークスペースへの変換クラスを見てみると、一つ一つMSBuildプロパティからRoslynプロジェクトへの変換を行っていることがわかる。

このため、AllowUnsafeBlocks等の、MSBuildプロパティで指定されるコンパイルスイッチは無視される場合もある(2.1.0まではDefineConstantsも渡されていなかった)。大抵のプロジェクトでは問題はないが、一部の特殊ケースでは問題になる場合もある。


対策

以下のように、GetWorkspaceなどで変換後、コンパイルオプションを再セットする。

// 仮にAllowUnsafeBlockオプションを有効にしてみる

// Buildalyzerの分析結果をanalyzerResultとする
// 分析結果から、RoslynWorkspaceを取得する
var ws = analyzerResult.GetWorkspace();
// roslynプロジェクトを取得
var project = ws.CurrentSolution.Projects.First();
var cscompopt = project.CompilationOptions as CSharpCompilationOptions;
// 基本的にroslyn関連クラスのプロパティは直接書き換えできないので、With...で再セットする
project = project.WithCompilationOptions(cscompopt.WithAllowUnsafe(true));
// コンパイル結果取得
var compilationUnit = project.GetCompilationAsync().Result;
// 諸々の処理


問題点2: 余計なステップが走ってしまう

Buildalyzerは、プロジェクト解析のため、msbuildの実行結果を解析している。

そのため、ある程度のビルドが走るのは致し方ないが、時にビルド後に特殊なステップを導入していると、予期しない動作を招いてしまう場合がある。


対策

以下のようにビルドするステップを絞る。

var analyzerManager = new AnalyzerManager();

var projectAnalyzer = analyzerManager.GetProject();
var envopts = new EnvironmentOptions();
// "Clean"と"Build"がデフォルトで含まれている
envopts.TargetsToBuild.Clear();
// 必ずコンパイルを走らせないと、アイテムリストを作ってくれないため
envopts.TargetsToBuild.Add("Clean");
envopts.TargetsToBuild.Add("ResolveAssemblyReferencesDesignTime");
envopts.TargetsToBuild.Add("ResolveProjectReferencesDesignTime");
envopts.TargetsToBuild.Add("ResolveComReferencesDesignTime");
envopts.TargetsToBuild.Add("Compile");
// 分析の開始
foreach(var result in projectAnalyzer.Build(envopts))
{
// ビルド結果の解析
}

具体的に何をTargetsToBuildに指定すればいいかという点は、 dotnetのプロジェクトシステムのドキュメントや、ビルドログ等が参考になると思う。

何のタスクを最低限指定すればいいかという点に関しては、テキストログ等から解析するのもいいが、バイナリログから構造的に解析するのもいいかと思う。


Cleanタスク問題

さて、Buildalyzerは2.x現在cscの実行ログを元に、参照するドキュメントリストの取得を行っている。そのため、実際にコンパイルが実行されないと、空のRoslyn workspaceができてしまうことがある。そのため、Cleanタスクでファイルが消えることを避けるために除外すると、Build済みのプロジェクトで解析を行ったとき、空のRoslyn Workspaceができてしまうことがある。

var analyzerManager = new AnalyzerManager();

var projectAnalyzer = analyzerManager.GetProject();
var envopts = new EnvironmentOptions();
// "Clean"と"Build"がデフォルトで含まれている
envopts.TargetsToBuild.Clear();
// Cleanタスクを省略
envopts.TargetsToBuild.Add("ResolveAssemblyReferencesDesignTime");
envopts.TargetsToBuild.Add("ResolveProjectReferencesDesignTime");
envopts.TargetsToBuild.Add("ResolveComReferencesDesignTime");
envopts.TargetsToBuild.Add("Compile");
// 分析の開始
foreach(var result in projectAnalyzer.Build(envopts))
{
using(var ws = result.GetWorkspace())
{
foreach(var item in ws.CurrentSolution.Projects.First().Documents)
{
// 何も列挙されない場合がある
}
}
}


Cleanタスク問題回避策

これを回避するには、IntermediateOutputPathにテンポラリのパスを設定し、実行前後で消すという対策が考えられる。

// 必ず末尾をパス区切り文字(System.IO.Path.DirectorySeparatorChar)で終わらせること

// ディレクトリは実行時に生成されるため、存在していなくても問題ない。
var tmppath = [ランダムなディレクトリ];
try
{
// ProjectAnalyzerとEnvironmentOptionsの準備...
var analyzerManager = new AnalyzerManager();
var projectAnalyzer = analyzerManager.GetProject();
var envopts = new EnvironmentOptions();
envopts.TargetsToBuild.Clear();
envopts.TargetsToBuild.Add("ResolveAssemblyReferencesDesignTime");
envopts.TargetsToBuild.Add("ResolveProjectReferencesDesignTime");
envopts.TargetsToBuild.Add("ResolveComReferencesDesignTime");
envopts.TargetsToBuild.Add("Compile");
// 中間出力ファイルディレクトリの指定
envopts.GlobalProperties["IntermediateOutputPath"] = tmppath;
// 分析の開始...
}
finally
{
if(Directory.Exists(tmpppath))
{
Directory.Delete(tmppath, true);
}
}

なお、IntermediateOutputPathは、末尾に必ずディレクトリセパレータが入っていることが期待されているため、注意すること。

また、IntermediateOutputPathには$(TargetName)-AssemblyInfo.csが含まれているので、そのまま消さないでおくと、本体のビルド時に重複エラーが出てしまうので、プロジェクトツリー内に一時ディレクトリを作る場合、解析終了時には必ず消しておく必要がある。


終わりに

通常は凝ったビルド設定をすることもないと思うが、ハマるときはハマるので、この辺りは注意しておきたい。