はじめに
これは、C#でソースコード生成を実現するための記事となります。
コード生成というとコンパイラーとか低レベルなイメージがありますが、今回行うのは.csの生成となるので、そんなに難しいことはないと思います。
注意: この文書はBuildalyzer-0.4.0を基準に書かれています。現在の最新版は2.1.0ですが、仕様が変更されているので、そのうち更新する予定です。
なぜソースコード生成か
例えば、デバッグ用にあるクラスの全てのパブリックフィールドを文字列として生成する実装を書きたい、となったとします。
ぱっと思いつく方法としては、
- 手で書く
- リフレクションを使用して動的に解析
があります。
手で書く場合、それが小規模なうちはいいですが、管理するクラスが増えてくると、抜けがないように実装するのが大変になります。
リフレクションを使用する場合、愚直に毎回Typeからメンバーを取り出して...とすると、オーバーヘッドが割と大きく、かといってキャッシュしようとすると、キャッシュをどこに置くのか、オーバーヘッドは無いか、そもそもリフレクションが使えない場合もあるのではないか等、こちらも多少問題があります。
また、リフレクションで解析する場合、実際に処理を走らせないとエラーが見つけにくい、見つかっても問題個所がわかり辛いという欠点もあります。
そこで、事前にコード解析を行い、コンパイル時点(または以前)に予め静的に特化処理を作っておく、という方法を使うことにより、
上記の問題を解決することが可能になります。
今回紹介するBuildalyzerを使用した方法の他に、以下のようなものもあります。
それぞれ個性はあるので、色々試してみるのがいいかと思います。
個人的にはVisual Studioを使うことができるなら、T4あるいはRoslyn Analyzerで事足りる場合が多いです。
しかし、これらの方法は依存関係に制限が入ることが多く、たまに要件に合わない場合もあったりするので、そういう場合は今回の記事の方法が役に立つのではないかなと思っています。
前提条件
以下、この記事内の想定環境について記載します。
- Windows7以降
- .NET Core sdk 2.0以降
- Visual Studio Code 1.18以降+C#拡張1.13以降
Buildalyzerについて
詳しくは公式のREADMEを読むことになりますが、とりあえず取っ掛かりとして必要な部分を説明します。
ライブラリの機能自体はcsprojの解析とワークスペース作成が主で、解析後はroslynの各種APIを駆使して解析を行うことになります。
導入
以下二つのnugetパッケージをプロジェクトに追加します。
Buildalyzer
-
Buildalyzer.Workspaces
- 必須ではないが、アセンブリに関する型情報を取得するためにあった方が便利
使用
実装
以下二つの名前空間をusingします。
Buildalyzer
Buildalyzer.Workspaces
最初にAnalyzerManager
をnewして、次にAnalyzerManager.GetProject(path_to_proj)
を実行して、プロジェクト情報を取得することになります。具体的には以下のようなコードになります。
// Buildalyzerの起点
AnalyzerManager manager = new AnalyzerManager();
// C#プロジェクトのオープン
ProjectAnalyzer analyzer = manager.GetProject(@"C:\MyCode\MyProject.csproj");
// プロジェクト情報からRoslynで使うWorkspace(プロジェクトやソリューションをまとめたもの)を生成する。
// Buildalyzer.Workspacesが必要
Microsoft.CodeAnalysis.AdhocWorkspace workspace = analyzer.GetWorkspace();
// 以後Microsoft.CodeAnalysis.AdhocWorkspaceを使用する
foreach(var project in workspace.CurrentSolution.Projects)
{
// 個々のプロジェクト処理
// プロジェクトのコンパイル(シンボル情報構築)
// 型がAssemblyではなく、Microsoft.CodeAnalysis.Compilationなので注意
var compilation = await project.GetCompilationAsync();
foreach(var diag in compilation.GetDiagnostics())
{
// コンパイルエラー等がある場合、
// 完了後にGetDiagnostics()を使用してエラー情報を取得する
}
// compilation.GetSymbolsWithNameでクラス定義等が取得可能
// キャストしないとISymbolで取得されるため、情報が非常に限定される
foreach(var clsSymbol in compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type).OfType<ITypeSymbol>())
{
// メンバーの取得
// (メソッドやインナークラス等も入るため、ISymbol.Kindで場合分けを行う事)
foreach(var memberSymbol in clsSymbol.GetMembers())
{
}
}
}
実行
コード生成部分の実装を終えた後は、それを実際に動かす必要があります。
.NET Core CLI ツールの拡張モデルによると、
いくつかの方法がありますが、今回は各プロジェクト ベースの拡張機能
の方法を使用するのが、PATH環境変数も汚さずに済み、カスタムタスクに比べても作りが素直なのでいいのではないかと思います。
MSBuildのカスタムタスクを実装する方法が最も応用が効く方法なのですが、どうもRoslyn関連のDLLが読み込めなかったので、この記事では深入りはしません。
具体的には以下のようにします。
-
dotnet-[サブコマンド名]
という名前でコンソールプロジェクトの作成- Project/PropertyGroup/AssemblyNameを追加し、名前を
dotnet-[サブコマンド名]
のようにしても可
- Project/PropertyGroup/AssemblyNameを追加し、名前を
- コンソールプロジェクトでコード生成プログラムを実装
- nugetパッケージ(nupkg)を作成
- 使用するプロジェクトのProject/ItemGroup/DotNetCliToolReference要素に追加
- 例:
<DotNetCliToolReference Include="[パッケージ名]" Version="[パッケージバージョン]"/>
-
PackageReference
やProjectReference
は不可
- 例:
- 使用するプロジェクトのcsprojと同フォルダにNuGet.configを作成(
dotnet new nugetconfig
で雛形作成できる) - nuget.configの
configuration/packagesSources
に、<add key="[任意の名前]" value="[nupkgがあるフォルダ]"/>
という要素を追加 - 要素を追加したプロジェクトでnugetリストアを実行
これで、DotNetCliToolReferenceを追加したcsprojをカレントディレクトリにして、dotnet [サブコマンド]
とすると、作成したコンソールプロジェクトが実行されます。
注意点としては、dotnetサブコマンドのプロジェクトが更新された場合、パッケージ作成プロジェクトと参照プロジェクト両方のパッケージバージョンを上げないと更新が反映されないという点でしょうか。この辺りはnugetのキャッシュシステムが関係しているので、わかっている人ならば該当パッケージのキャッシュをクリアした上で、restoreするという手もあります。
実装例
- BuildalyzerTest.LibUser
- コード解析対象
- dotnet-buildalyzertest
- コード解析、生成するコンソールアプリケーション
pack.ps1でnupkgを作成し、dotnet restore
でBuildalyzerTest.LibUserのrestoreを行うと、以後dotnet buildalyzer-test
というコマンドでコード生成を行うことができます。
このプロジェクトでは、-p [csprojのファイルパス]
オプションを指定して、クラスの解析と、
メンバーをToStringでつなげたpublic static string MemberwiseToString(this T obj)
という拡張メソッドを作成し、標準出力に出しています。もう少し手を加えれば、csファイルの出力も十分可能です。
注意点
Buildalyzerが依存しているMicrosoft.Buildと、dotnet-sdkのバージョンの食い違いにより、
解析時に例外が発生したり、正しくドキュメント一覧が取れなくなる場合がある。
そのような場合は、コード解析を行うプロジェクトのPackageReferenceに、以下のパッケージを
直接追加すれば、大体の場合現象は回避できる。
- Microsoft.Build
- Microsoft.Build.Tasks.Core
- Microsoft.Build.Tasks.Utilities
また、回避方法として、Buildalyzerが使用するMicrosoft.Buildのバージョンに対応するSDKを入れた上で、
解析対象のプロジェクトにglobal.jsonを置いて、sdk/versionの値をそのSDKのバージョンに指定する方法もある。
(Buildalyzer-0.2.1はMicrosoft.Build-15.3.409を参照しているので、sdkバージョンは2.0.xを指定すればOK)
最後に
いい点
- オーバーヘッドが少ない
- 基本的には愚直に書くのと同じなので、多くの場合、最高効率を目指しやすい
- デバッグしやすい
- できあがるのは普通のC#なので、デバッグの際にどこに問題があるかわかりやすくなる
- 文法エラー等、単純なミスにコンパイル時に気づくことができる
- 実装をシンプルにできる(処理のキャッシュを持つ必要がない)
悪い点
- 前準備が必要
- ソースコード生成モジュールの作成
- 使用するプロジェクトのprojファイルに要素追加
- 生成した成果物を使用するプロジェクトのアセンブリファイルサイズが大きくなりやすい
- 生成したコードが全てコンパイルされるため
特に前準備が手間というのは、大きいと思います。大体の場合、時間対効果を考えるならば、リフレクション等で済ませた方が楽です。しかし、ソースコード生成はハマるところにはハマるので、選択肢の一つとして持っておくのもいいのではないかと思います。