【C#】アナライザー・ソースジェネレーター開発のポイント
アナライザー・ソースジェネレーターをいくつか開発したので知見をまとめておきます。
C#以外の.NETの言語でも同様かと思いますが、C#での開発なのでC#記事とします。
見出しに[A/S]とあるのはアナライザー・ソースジェネレーター共通、[A]はアナライザーのみ、[S]はソースジェネレーターのみを対象とした項目を表します。
公式リポジトリのcookbookを見る[S]
ソースジェネレーターだけですが、cookbookがあるので非常に役立ちます。
おまじないをcsprojに書く[A/S]
アナライザー・ソースジェネレーターではcsprojにいろいろなおまじないが必要です。
NuGet パッケージを作る
アナライザー・ソースジェネレーターでは普段と異なる手順でNuGet パッケージを作ります。
そのため、いろいろとおまじないがあります。
<PropertyGroup>
<IncludeBuildOutput>false</IncludeBuildOutput>
<IncludeSymbols>false</IncludeSymbols>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" />
</ItemGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
<Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
<ItemGroup>
<TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
<TfmSpecificPackageFile Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" PackagePath="analyzers\dotnet\cs" />
<TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
</ItemGroup>
</Target>
設定 | 説明 |
---|---|
IncludeBuildOutput=false | パッケージにDLLを自動追加しない |
IncludeSymbols=false | シンボル情報は不要 |
TargetsForTfmSpecificContentInPackage | パッケージの構成設定 |
SuppressDependenciesWhenPacking=true | パッケージの依存関係を含めない |
DevelopmentDependency=true | パッケージをアナライザーとして扱う |
DevelopmentDependency
をtrue
に設定すると、NuGet上でアナライザーとして扱われるので、ユーザーがインストールする際にも自動でアナライザー用の設定でインストールされます。
<PackageReference Include="SourceExpander.Embedder" Version="3.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
DevelopmentDependency
がtrue
なパッケージをユーザーがインストールすると、上記のようにPrivateAssets
, IncludeAssets
が自動で設定されます。
これでユーザー側のライブラリにアナライザーの依存が漏れなくなります。
Newtonsoft.Jsonを使用する
アナライザー・ソースジェネレーターで使用するライブラリはNuGetでの依存関係を解決しません。
そのため、DLLを埋め込む必要があります。
設定 | 説明 |
---|---|
GetTargetPathDependsOn | 依存ライブラリの解決 |
GeneratePathProperty
をtrue
にすると、Newtonsoft.Json
ならば$(PkgNewtonsoft_Json)
というような変数名でライブラリがダウンロードされるパスが取得できるので、これもnupkgに埋め込みます。
Newtonsoft.JsonはMITライセンスなのできちんと表記もしておきましょう。
<PropertyGroup>
<IncludeBuildOutput>false</IncludeBuildOutput>
<IncludeSymbols>false</IncludeSymbols>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" GeneratePathProperty="true" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" />
</ItemGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
<Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
<ItemGroup>
<TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
<TfmSpecificPackageFile Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" PackagePath="analyzers\dotnet\cs" />
<TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
</ItemGroup>
</Target>
こまめにキャンセルをチェックする[A/S]
SyntaxNodeAnalysisContext
やGeneratorExecutionContext
などにはSystem.Threading.CancellationToken
が渡されています。
こまめにcontext.CancellationToken.ThrowIfCancellationRequested()
を実行してキャンセルのチェックをしましょう。
Visual Studioではバックグラウンドでアナライザー・ソースジェネレーターが実行されるので頻繁にキャンセルされます。しかし、キャンセルチェックをしていないとアナライザー・ソースジェネレーターの実行が完了するまで待つことになり、フリーズしたりします。
Diagnosticの出力[A/S]
これについては好みが分かれるかと思いますが、DiagnosticDescriptor
から Diagnostic
を作るのはDiagnosticDescriptor
ごとにメソッド化してしまって良いと思います。
メッセージに渡す引数の数は固定にしたいはずなので、Diagnostic.Create
で作る箇所は隠蔽してしまいましょう。
アナライザーだとDiagnosticDescriptor
を渡す必要がある都合上、private
にはできませんが、それ以外では使わないほうが良いかと思います。
public static Diagnostic EMBED0001_UnknownError(string message)
=> Diagnostic.Create(EMBED0001_UnknownError_Descriptor, Location.None, message);
private static readonly DiagnosticDescriptor EMBED0001_UnknownError_Descriptor = new(
"EMBED0001",
new LocalizableResourceString(
nameof(DiagnosticsResources.EMBED0001_Title),
DiagnosticsResources.ResourceManager,
typeof(DiagnosticsResources)),
new LocalizableResourceString(
nameof(DiagnosticsResources.EMBED0001_Body),
DiagnosticsResources.ResourceManager,
typeof(DiagnosticsResources)),
"Error",
DiagnosticSeverity.Warning,
true);
マルチスレッド実行[A/S]
compilation.Options.ConcurrentBuild
でコンパイルがマルチスレッド環境で実行されているかを取得できます。
マルチスレッドなときは並列実行も活用して高速に動作させたいですね。
SyntaxTree[] newTrees;
if (compilation.Options.ConcurrentBuild)
newTrees = compilation.SyntaxTrees.AsParallel().WithCancellation(cancellationToken)
.Select(Rewrited).ToArray();
else
newTrees = compilation.SyntaxTrees
.Select(Rewrited).ToArray();
アナライザーの場合は AnalysisContext
でcontext.EnableConcurrentExecution()
を実行しておきましょう。
Discussion