【C#】アナライザー・ソースジェネレーター開発のポイント

7 min read読了の目安(約6600字

https://github.com/naminodarie/ac-library-csharp
https://github.com/naminodarie/SourceExpander

アナライザー・ソースジェネレーターをいくつか開発したので知見をまとめておきます。

C#以外の.NETの言語でも同様かと思いますが、C#での開発なのでC#記事とします。

見出しに[A/S]とあるのはアナライザー・ソースジェネレーター共通、[A]はアナライザーのみ、[S]はソースジェネレーターのみを対象とした項目を表します。

公式リポジトリのcookbookを見る[S]

https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md

ソースジェネレーターだけですが、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 パッケージをアナライザーとして扱う

DevelopmentDependencytrueに設定すると、NuGet上でアナライザーとして扱われるので、ユーザーがインストールする際にも自動でアナライザー用の設定でインストールされます。

<PackageReference Include="SourceExpander.Embedder" Version="3.0.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

DevelopmentDependencytrueなパッケージをユーザーがインストールすると、上記のようにPrivateAssets, IncludeAssetsが自動で設定されます。

これでユーザー側のライブラリにアナライザーの依存が漏れなくなります。

Newtonsoft.Jsonを使用する

アナライザー・ソースジェネレーターで使用するライブラリはNuGetでの依存関係を解決しません。

そのため、DLLを埋め込む必要があります。

設定 説明
GetTargetPathDependsOn 依存ライブラリの解決

GeneratePathPropertytrueにすると、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]

SyntaxNodeAnalysisContextGeneratorExecutionContextなどには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();

アナライザーの場合は AnalysisContextcontext.EnableConcurrentExecution()を実行しておきましょう。