9
Help us understand the problem. What are the problem?

posted at

updated at

SourceGeneratorでらくらく(でもない)ソースコード自動生成

ボイラープレートコードを自動実装した~い!

皆さんは頻出する定形コードの実装をどのようにやっていますか?私はExcelの定義書から出力したり(宣伝)T4テンプレートを使って静的にコード生成したり(宣伝)しています。しかし、毎回Excelをいじってコード出力するのは面倒くさいし、デザイン時コード生成ではできない複雑な実装をしなければならないこともあります。
具体的には以下のようなコードです。

  1. DBテーブル定義からCRUD操作の便利メソッドを作る1
  2. APIの送受信データ構造から定義クラスを生やして独自の構造の文字列にシリアライズする処理を自動実装する1
  3. APIの通信処理インターフェイスから特定のデータを送信/受信する具象クラスを生やす1
  4. 数値で扱うものをValueObjectに変えてoperatorを自動実装する2
  5. MVVMアプリでページネーションなどの類似した機能を持つ複数のViewModelに同じ実装を与える

補足:ページネーションについて

ページネーションはページ切替処理等の実装が必要ですが、実装内容は画面によらずほぼ同一の内容になります。これを抽象クラスで共通化すると、多重継承ができないため他にも類似した機能を持ちたい場合に困ります。普通のインターフェイスだと実装作業が各クラスに発生します。C#9のインターフェイスのデフォルト実装を使うこともできますが、VMの場合は「ページネーション機能を持つVM」という振る舞いをカプセル化して外部から扱うことはまずありません。このように、カプセル化やポリモーフィズムといったオブジェクト指向設計の利点を活用しないなら、SourceGeneratorを使って各クラスに実装を閉じ込める方が適切な場合があります。ただしほとんどはデフォルト実装を使うが特定の処理だけは開発者に実装を強制させたいというケースならインターフェイスに軍配が上がります3ので、例には上げましたが弊社ではこの方法を採っています。

かつてはコピペでコーディングされている方もいらっしゃったと思いますが、時代は2021年。令和のボイラープレート実装はSourceGeneratorだ!ということでSourceGeneratorについて説明していきたいと思います。
どういうわけかQiitaのSourceGeneratorの記事ってなぜか「XX作ってみた」系ばかりで作り方の説明を目的にする記事がないようですし

用意するもの

  • Visual Studio 2019
  • .NET Standard 2.0 SDKのインストール

SourceGeneratorによるジェネレーターのプロジェクトはターゲットフレームワークをnetstandard2.0にする必要があります。ただし、ジェネレータープロジェクトを参照するプロジェクトたちはnet5.0で問題ありません。おそらくnetstandard2.0以降である.NET Coreでも問題ないと思います(.NET Frameworkは未検証)。

SourceGeneratorのしくみ

SourceGeneratorは以下のように独自のジェネレーター属性をつけてやると、その属性に紐付けた出力内容でソースコードを生成する仕組みになっています4。ソースコードが作られるので、そのコードの編集はできませんがブレークポイントを置いてデバッグすることができます

sample.cs
[SampleGenerator]                // ジェネレーター対象の目印となる属性をつけて
public partial class Sample { }  // partialクラスを定義すると
sample_AutoGenerated.cs
// こんなクラスをVisual Studioが勝手に作ってくれる
// ただし「どういうルールを生成対象とするか」「どういうコードを作るか」は
// 自分でコンパイラの処理として組む必要がある
public partial class Sample
{
    string GeneretaedMethod() => "このクラスSampleは自動生成されました";
}

この出力内容はVisual Studioにキャッシュされますので、出力内容を変えても内容が反映されていなかったり、ビルドエラーになる場合はジェネレータを修正しても警告やエラーが出っぱなしになります。ジェネレータにバグを埋め込んで不正なコードを生成したりするとVSが管理するRoslynCodeAnalysisService.exeが死んで何もできなくなります。少しでもおかしなことになった場合は生成されたコードやジェネレータをよく確認しつつ、Visual Studioを再起動しましょう。

ジェネレーターを作る前に

T4テンプレートの説明記事(宣伝)でも書きましたが、生成したいコードがどんなコードかは先に考えておきましょう。特にSourceGeneratorはエラーが出ると面倒くさいので、明確にゴールを決めておくほうがイライラする回数は少ないでしょう5

今回のゴールは以下とします。必要なインターフェイスや抽象クラスはあらかじめ作っておきましょう。本稿ではコードを省略します。

  1. 目的
    1. 独自の形式で符号化してサーバーと通信するための送受信データと処理を自動実装する。
  2. 前提条件
    1. TCP通信とする。
    2. すべての送信データはrecord型で定義し、IRequestインターフェイスを実装する。このインターフェイスは、自分自身をバイト配列に変換するbyte[] Encode()メソッドを持つ。
    3. すべての受信データはrecord型で定義し、IResponseインターフェイスを実装する。
    4. 各通信処理クラスはConnectionBase<T, U>クラスを継承することとし、このクラスにTCP通信処理の大部分を記述する。TIRequestUIResponse。このクラスは受信したバイト配列を受信データに変換するprotected abstract U Decode(byte[] received)メソッドを持つ。
    5. 各通信処理クラスはDIのためにインターフェイスと具象クラスを1:1で持つこととする。
    6. 送受信するデータはなるべく任意の型を指定可能とするが、SourceGeneratorで解析しやすいものに限る。列挙体をメンバにしてデフォルト値を持たせたりという複雑なことはしない。
  3. SourceGeneratorの動作のために必要なコード
    1. 「自動生成対象の送信データ」であることを示すためのRequestGeneretorAttribute属性
    2. 「自動生成対象の受信データ」であることを示すためのResponseGeneretorAttribute属性
    3. 「自動生成対象の送受信処理」であることを示すためのConnectionGeneretorAttribute属性
    4. ビルド時に構文解析してソース生成するための処理
  4. SourceGeneratorで作るゴールのコード
Request.cs
// こんな送信データは
[RequestGeneretor]
public partial record RequestSample(string Id, DateTime SendDate, int? Num);
// こう展開される
public partial record RequestSample : IRequest
{
    private IEnumerable<string> EnumerateMembers()
    {
        yield return Id;
        yield return SendDate.ToString();
        yield return Num?.ToString() ?? "";
    }

    public byte[] Encode()
    {
        /* エンコード処理 */
        return Encoding.UTF8.GetBytes(string.Join(",", EnumerateMembers.ToArray()));
    }
}
Response.cs
// こんな受信データは
[ResponseGeneretor]
public partial record ResponseSample(int MaxPoint, int MinPoint);
// こう展開される
public partial record ResponseSample : IResponse
{
    public const int MaxLength = 2;
    public static ResponseSample Decode(byte[] received)
    {
        /* デコード処理 */
        var text = Encoding.UTF8.GetString(received).Split(',');
        if (text.Length != MaxLength) throw new ArgumentException();
        return new ResponseSample(int.Parse(text[0]), int.Parse(text[1]));
    }
}
Connection.cs
// こんな通信処理は
[ConnectionGeneretor(typeof(RequestSample), typeof(ResponseSample))]
public interface IConnectionSample { }
// こう展開される
public class ConnectionSample : ConnectionBase<RequestSample, ResponseSample>, IConnectionSample
{
    protected override ResponseSample Decode(byte[] received) =>
        ResponseSample.Decode(received);
}

ジェネレーターをつくろう

いきなり3つのことを考えるのは大変なので、まずは送信データの自動生成から行います。

プロジェクトの作成

ソリューションエクスプローラーで[追加]→[新しいプロジェクト]を選択し、クラスライブラリを選択します。プロジェクト名はなんでもいいですが、わかりやすいように本稿ではGeneratorプロジェクトとします。ターゲットフレームワークで.NET Standard 2.0を選択することを忘れないようにしてください。

忘れた場合

.csprojファイルを開いて、TargetFrameworkをnetstandard2.0に手修正します。

Generator.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

</Project>

続いて、SourceGeneratorに必要なライブラリをNugetからインストールします。以下を実行してください。

パッケージマネージャコンソール
Install-Package Microsoft.CodeAnalysis.Analyzers
Install-Package Microsoft.CodeAnalysis.CSharp

上記のインストールが完了すれば、ジェネレータークラス(ここではGenerator.csとします)の作成が可能になります。以下のようにusingブロックを記述し、[Generator]属性をつけてISourceGeneratorを実装してください。

Generator.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Generator
{
    [Generator]
    public class Generator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            throw new NotImplementedException();
        }

        public void Initialize(GeneratorInitializationContext context)
        {
            throw new NotImplementedException();
        }
    }
}

Executeメソッドはソースの生成処理、Initializeメソッドは生成時の前処理を実装します。
ここで、SourceGeneratorのデバッグのためにデバッガーの起動を仕込んでおきます。Debugger.Launch();の行のコメントアウトを解除すると、Roslynコンパイラが働くたびにJust-In-Timeデバッガーが起動します。このデバッガーでSourceGeneratorのソリューションを選択することで、生成内容や構文解析内容を確認することができます。ただし頻繁に起動すると鬱陶しいので必要なとき以外はコメントアウトしておきましょう。

Generator.cs
using System.Diagnostics; // 追加する
        public void Initialize(GeneratorInitializationContext context)
        {
#if DEBUG
            if (!Debugger.IsAttached)
            {
                //Debugger.Launch();
            }
#endif
        }
    }
}

image.png

初期化処理の実装と構文解析

ここでは、Execute時にややこしい構文解析を省略するために生成対象の属性であるRequestGeneratorAttirbuteを付与されたクラスの検索を行いましょう。
具体的にはISyntaxReceiverインターフェイスを実装するクラスを作ってそのクラスで検索し、そのクラスのインスタンスをcontext.RegisterForSyntaxNotifications()メソッドに登録することでExecuteメソッドでの利用を行います。具体的なコードを見てください。

Generator.cs
    public void Initialize(GeneratorInitializationContext context)
    {
#if DEBUG
            if (!Debugger.IsAttached)
            {
                //Debugger.Launch();
            }
#endif
        context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
    }

    class SyntaxReceiver : ISyntaxReceiver
    {
        // attrは属性に引数がついたとき用
        public List<(RecordDeclarationSyntax type, AttributeSyntax attr)> Requests { get; } = new();
        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            if (syntaxNode is RecordDeclarationSyntax r && r.AttributeLists.Count > 0)
            {
                // データのレコードの抽出
                var all = r.AttributeLists.SelectMany(x => x.Attributes);
                var reqAttr = all.FirstOrDefault(x => x.Name.ToString() is
                        "RequestGeneretor" or "RequestGeneretorAttribute");
                if (reqAttr != null)
                {
                    Requests.Add((r, reqAttr));
                }
            }
        }
    }

OnVisitSyntaxNodeメソッドで構文解析をしています。引数のsyntaxNodeは構文木です。ここで前提条件に振り返ると、「すべての送信データはrecord型で定義」という規約にしていました。その「record型で定義」を特定するのがsyntaxNode is RecordDeclarationSyntax rの部分になります。「RecordDeclarationSyntax」を分解すると「Recordを宣言する構文」になりますよね。つまり、「この構文木がrecordの宣言をする構文木RecordDeclarationSyntaxであるか」を判定すればよいことになります。構文解析を行うと、InterfaceDeclarationSyntaxを取得してinterfaceの宣言を特定してインターフェイス名を取得したり、MemberDeclarationSyntaxでクラス等のメンバーの宣言内容を特定したりすることが可能です。これらのクラスはすべて「DeclarationSyntax」というサフィックスがついたクラスになっているので、他の解析をしたければ探してみてください。OnVisitSyntaxNodeの先頭にブレークポイントを置いてデバッグすることで、syntaxNodeがどんな形で私達のコードを解釈しているかがわかると思います。

ところで、すべてのrecord型を自動実装対象としたいわけではありません。RequestGenerator属性のついているrecord型のみを対象としていました。そのため、recordの宣言に何らかの属性がついているかをr.AttributeLists.Count > 0で判定し、ついている属性の中にRequestGeneratorAttributeがいるかどうかを検索しています。属性付与は末尾のAttributeをつけてもつけなくても可能なため、属性の名前がフルネームのRequestGeneratorAttributeと略称のRequestGeneratorのどちらかであるかを判定しています。
最終的に、自動実装対象となるコードの構文木(syntaxNode)をRequestsメンバにAddして保持しておきます。AttributeSyntaxも保持しているのは、将来的に属性の内容が拡張されたとき用です。今回は不要かもしれません。

コード生成:RequestGeneratorAttribute.cs

初期化処理が完了すれば後はExecuteメソッド内でコード生成を行います。まず、RequestGeneratorAttributeクラスの生成を行いましょう。自動生成は、GeneratorExecutionContextAddSource(string filename, string source)メソッドを使います。生成したい文字列を作ってsourceに指定することで、filenameのファイル名で自動生成され、コンパイル対象に追加される仕組みです。

RequestGeneratorAttributeクラスを普通に作ることも可能ですが、そうすると「ジェネレーターで生成したコードを使うが自動生成自体はしないプロジェクト」にもジェネレータープロジェクトへの参照が必要になってしまいます。そのため、internalなRequestGeneratorAttributeを自動生成してジェネレーターの利用者プロジェクトの内部に閉じ込めることによってSourceGeneratorに関するコードを流出させないようにします。一般の開発者はジェネレーターの細かい仕組みを知る必要はないですし、ジェネレーターにバグがあるとVSが死ぬのを防ぐ必要もあるためです。

ところで、コード生成といえば思い出すものがありませんか?そう、T4テンプレートです。RequestGeneratorAttributeExecuteメソッド内で生成するので、内容は不変ですがランタイムテキストテンプレートで定義します。注意点として、クラスをinternalとして定義することを忘れないでください。publicにするとジェネレーターの利用者プロジェクトが複数存在する場合に名前の競合が発生してコンパイルエラーになります。また、VS2019ではランタイムテキストテンプレートを追加してもTextTemplatingFileGeneratorになってしまうので、追加した後はTextTemplatingFilePreprocessorに変えることも忘れずに行ってください。

RequestGeneratorAttributeTemplate.tt
<#@ template debug="false" hostspecific="false" language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY Generator. DO NOT CHANGE IT.
// </auto-generated>
#pragma warning disable CS8669
#pragma warning disable CS8625
using System;
namespace Generator
{
    /// <summary>クライアントからサーバに送るデータであることを示します。<summary>
    [AttributeUsage(AttributeTargets.Class)]
    internal class RequestGeneratorAttribute : Attribute
    {
        public RequestGeneratorAttribute() { }
    }
}

T4テンプレートができたら後は文字列生成してAddSourceします。以下のようにExecuteメソッドを実装してください。

Generator.cs
    public void Execute(GeneratorExecutionContext context)
    {
        SetDefaultAttribute(context);
    }
    /// <summary>自動生成する対象を示す属性のコードを生成に追加する</summary>
    private void SetDefaultAttribute(GeneratorExecutionContext context)
    {
        var attr1 = new RequestGeneratorAttributeTemplate().TransformText();
        context.AddSource("RequestGeneratorAttribute.cs", attr1);
    }

コード生成:送信データ

すべての準備が整ったので、本当に生成したい内容の出力処理を書きます。

テンプレート作成

最初に、自動生成する内容をT4テンプレートで記述しておきましょう。この作業により、構文解析で収集する必要のある情報も整理することができます。

RequestGeneratorTemplate.tt
<#@ template debug="false" hostspecific="false" language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY HanchoTrcGenerator. DO NOT CHANGE IT.
// </auto-generated>
#pragma warning disable CS8669
#pragma warning disable CS8625
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Generator;
namespace <#= Property.Namespace #>
{
    /// <summary>クライアントからサーバに送るデータであることを示します。/summary>
    public partial record <#= Property.Name #> : IRequest
    {
        private IEnumerable<string> EnumerateMembers()
        {
<# if (Property.Members.Any()) { #>
<# foreach (var member in Property.MembersToString) { #>
            <#= member #>;
<# }} else { #>
            yield break;
<# } #>
        }
        public byte[] Encode()
        {
            /* エンコード処理 */
            return Encoding.UTF8.GetBytes(string.Join(",", EnumerateMembers.ToArray()));
        }
    }
}

上記テンプレートより、必要なパラメータは以下であることがわかりました。

  1. recordを定義する名前空間
  2. recordの名前
  3. EnumerateMembersメソッドで必要な、recordに定義された各メンバーの名前と型
    1. stringならそのままメンバーを返す
    2. Nullableでないそれ以外ならToString()の戻り値
    3. Nullableの場合はnullのときは空文字、そうでなければToString()の戻り値

パラメータを渡すためのpartialクラスも作っておきます。これはコード生成プロセスでしか使用しないため、通常のファイルで問題ありません。

RequestGeneratorTemplate_partial.cs
namespace Generator
{
    partial class RequestGeneratorTemplate
    {
        public RequestGeneratorTemplate(RequestProtocol property)
        {
            Property = property;
        }

        public RequestProtocol Property { get; }
    }
    internal struct RequestProtocol 
    {
        public string Name;
        public string Namespace;
        public string[] MembersToString;
    }
}

Executeメソッドの実行

Executeメソッドでは自動生成の対象となるコードを構文解析して必要な情報を取得します。対象となるコードの構文木はSyntaxReceiverクラスで持っていますので、Initializeメソッドで生成したSyntaxReceiverを取得します。もしSyntaxReceiverが取れなければそもそもソースコードがないので解析終了です。
構文解析はコードが長くなるため、専用のメソッドGenerateRequestに渡して処理します。GenerateRequestメソッドの内容は、SyntaxReceiverで抽出した対象のソース(Requestsプロパティで保持)の構文を解析して必要なデータを取得(GetProtocol)し、T4テンプレートに流すだけです。

Generator.cs
    public void Execute(GeneratorExecutionContext context)
    {
        SetDefaultAttribute(context);

        var receiver = context.SyntaxReceiver as SyntaxReceiver;
        if (receiver == null) return;

        try
        {
            GenerateRequests(context, receiver);
        } 
        catch (Exception ex)
        {
            // logging
        }
    }
    /// <summary>送るデータのソースコードを生成する</summary>
    private void GenerateRequests(GeneratorExecutionContext context, SyntaxReceiver receiver)
    {
        try
        {
            if (!receiver.Requests.Any()) return;
            foreach (var (type, attr) in receiver.Requests)
            {
                var prop = GetProtocol(context, type);
                var template = new RequestGeneratorTemplate(prop);
                var code = template.TransformText();
                context.AddSource($"{prop.Namespace}.{prop.Name}_Auto.cs", code);
            }
        }
        catch (Exception ex)
        {
            Trace.WriteLine(ex.ToString());
        }
    }

ここで、最初に作った属性生成メソッドSetDefaultAttribute必ず正常に実行されるようにしてください。具体的には、SetDefaultAttribute以降のコードで例外が送出されないようにすべての処理をtry-catchで囲みます。以下に、株式会社CySharpの河合さんのブログのSourceGeneratorに関する記事より引用します2

やりかたは単純に最初に必要な属性を突っ込んでしまうという、ただそれだけなのですが一点注意なのは、この生成は絶対死守しましょう。Execute内で例外が発生したりすると、ここでAddSourceした属性の追加はキャンセルされます。
(コード略)
特にIDEのインクリメンタルコンパイルが稼働している状態だと、入力途中の「不完全なコード」が頻繁に飛んできます。こうした不完全なコードによる不正な構文木を正しくハンドリングするのはかなり難しく、例外を飛ばしてしまうのは正直避けられません。しかし、何があっても最初に生成する属性のAddSourceだけは維持しないと、「入力途中の不完全コード→例外発生で属性が吹っ飛ぶ→属性が吹っ飛ぶので入力補完が効かないどころか書いてるものが全てエラーになる」という負のループが発生します。なので、これに関してはtry-catchで握り潰しOKです。

GenerateRequestsメソッドでの構文解析

T4テンプレートの作成より、必要なパラメータはわかっています。表にまとめると以下内容となります。なお、解析を簡略化するため送信データの各メンバは位置指定パラメーターによって定義するものとします。

パラメータ 解析内容
recordを定義する名前空間 ソースコードに記述したnamespace
recordの名前 ソースコードに記述したrecordの名前
recordに定義された各メンバーの名前 record宣言時にパラメータとして指定した各メンバーの名前
recordに定義された各メンバーの型 record宣言時にパラメータとして指定した各メンバーの型

以下に、私が実際に調べた範囲での構文解析の方法を記します。以下の節のコードはすべて、自分で定義したGetProtocol(GeneratorExecutionContext context, RecordDeclarationSyntax syntax)メソッドの中のコードです。構文解析結果はT4テンプレートのpartialクラスに定義したRequestProtocol構造体として返します。

型の名前と属する名前空間の取得

以下のコードで取得できます。GetSemanticModel等の詳細はちゃんと調べてないのですが、おそらくrecord本体の構文木syntaxを含むより上位のコード(ファイル全体?)の構文木を取得し、そこからnamespaceに記述された文字列を取得していると思われます。

名前空間の取得
    var typeSymbol = context.Compilation.
                             GetSemanticModel(syntax.SyntaxTree).
                             GetDeclaredSymbol(syntax);
    if (typeSymbol == null) throw new Exception("can not get typeSymbol.");

    var recordName = typeSymbol.Name; // 型名
    var ns = typeSymbol.ContainingNamespace.ToDisplayString(); // 名前空間の文字列

位置指定パラメーターの取得

位置指定パラメーターの構文木の型はParameterListSyntaxです。ここで、以下のようなレコードの宣言を考えます。レコードAは位置指定パラメーターがあり、Bにはありません。

// パラメータあり
public partial record A(object Param1, object Param2, object Param3);
// なし
public partial record B;

この場合、Aの後ろの(object Param1, ~~)の部分は構文木の種別(SyntaxKind列挙体)でいうとParameterListになります。SyntaxKindの判定は構文木の各ノードのIsKindメソッドで判定可能です。そのため、以下のコードで位置指定パラメーターのカッコの中身の構文木を取得することができます。

位置指定パラメーターの構文木の取得
    // 位置指定パラメーターは高々1このみなのでFirstOrDefault
    var tmp = syntax.DescendantNodes().
                     FirstOrDefault(d => d.IsKind(SyntaxKind.ParameterList));
    if (tmp == null) return default; // 空のレコードをどうするかは仕様で決めておく
    // 位置指定パラメータの構文木
    var paramList = tmp as ParameterListSyntax;

位置指定パラメーターの構文木が取得できれば、後は各パラメータの構文木を取得して型情報や名前を解析するだけです。各パラメーターの構文木の型はParameterSyntaxとなります。また、それぞれのパラメーターはParameterListSyntaxParametersプロパティから取得できます。よって、以下のコードで取得できます。

各パラメーターの構文木の取得
    // 各パラメータの構文木を収めたIEnumerable<T>
    var param = paramList.Parameters.Cast<ParameterSyntax>();

レコードのパラメーターの解析と取得

レコードのパラメーターを解析して何がやりたいのかというと型名と名前の取得でした。名前は以下で簡単に取得できます。

各パラメーターの構文木の取得
    foreach (ParameterSyntax p in param) // 説明のためにvarでなく型を明示します
    {
        var name = p.Identifier.Text; // 各パラメータの名前を取得
    }

型情報はParameterSyntaxTypeに定義されているのですが、プリミティブ型やらジェネリックコレクションやらを考慮すると、任意の型を特定するのはそれなりに難解ですので、デバッガーで中身を見ながら必要な情報がどこにあるのか探すことをおすすめします。また、あらゆる型を受け入れ可能にすると前提条件で述べたような「デフォルト値の指定(デフォルト値の構文はどうやって解析する?)」「IEnumerable<T>の入れ子(入れ子は何段まで許容する?)」など頻度が低い割にややこしいコードも考慮する必要があり、今後の保守も考えると割りに合わないことになるかもしれません。遅くともこの段階で自動生成対象のコードを記述する際のルール決めをして利用者に情報共有するべきでしょう。

以下に、私が調べた型の特定方法を記述します。型名がわかっているなら文字列で引っ掛けるのが一番手っ取り早いです。配列の場合はArrayTypeSyntaxが絡んでくると思うのですが、どうなんでしょう?IEnumerable<T>の判定に近いやり方かもしれません。

特定する条件
string p.Type.ToString() == "string"
object p.Type.ToString() == "object"
DateTime p.Type.ToString() == "DateTime"
Nullable<T> p.Type is NullableTypeSyntax n
組み込み型 p.Type is PredefinedTypeSyntax pre
IEnumerable<T> p.Type is GenericNameSyntax g && g.Identifier.Text == "IEnumerable"

補足:組み込み型について

似たようなものにプリミティブ型がありますが、これはintdoubleなどの予約語に含まれる数値型のうちdecimal以外のものです。組み込み型は組み込み値型組み込み参照型の2種類が存在します。組み込み値型はプリミティブ型+decimalで、組み込み参照型はobjectstringdynamicです。PredefinedTypeSyntaxは組み込み参照型+組み込み値型の構文木となります6

最終的なGetProtocolメソッド

最終的に以下のようなコードになります。これで送信データ型を自動生成するGeneratorを作ることができました。長いので折りたたんでおきます。この例では解析と同時にコードに埋め込むyield returnの行の内容を生成していますが、型名とメンバ名を返してT4テンプレート内で制御するほうが見通しがよくなるかもしれません。

GetProtocolメソッドのサンプルコード
GetProtocol
    private RequestProtocol GetProtocol(GeneratorExecutionContext context, RecordDeclarationSyntax syntax)
    {
        var typeSymbol = context.Compilation.
            GetSemanticModel(syntax.SyntaxTree).
            GetDeclaredSymbol(syntax);
        if (typeSymbol == null) throw new Exception("can not get typeSymbol.");

        // recordの各メンバーの名前を取得
        var protocol = new RequestProtocol
        {
            Name = typeSymbol.Name,
            Namespace = typeSymbol.ContainingNamespace.ToDisplayString(),
            MembersToString = Array.Empty<string>()
        };
        var tmp = syntax.DescendantNodes().
            FirstOrDefault(d => d.IsKind(SyntaxKind.ParameterList));
        if (tmp == null) return protocol; // 空のレコードならメンバーは不要

        var paramList = tmp as ParameterListSyntax;
        var param = paramList.Parameters.Cast<ParameterSyntax>();

        protocol.MembersToString = Create(param).ToArray();
        return protocol;
        static IEnumerable<string> Create(IEnumerable<ParameterSyntax> para)
        {
            foreach (var p in para)
            {
                var name = p.Identifier.Text;
                if (p.Type.ToString() == "string")
                {
                    yield return $"yield return {name}";
                }
                else if (p.Type.ToString() == "DateTime")
                {
                    yield return $"yield return {name}.ToString()";
                }
                else if (p.Type is NullableTypeSyntax n)
                {
                    yield return $"yield return {name}?.ToString() : string.Empty";
                }
                else if (p.Type is PredefinedTypeSyntax pre)
                {
                    yield return $"yield return {name}.ToString()";
                }
                else if (p.Type is GenericNameSyntax g && g.Identifier.Text == "IEnumerable")
                {
                    yield return $"foreach (var __{name}__ in {name}) yield return __{name}__.ToString()";
                }
            }
        }
    }

自動生成対象の属性に追加情報をつける場合

自動生成する型がジェネリック型の場合はジェネリック制約をかけたり、特定の定数値や初期値を持たせたいことがあります。この場合、目印となる属性に設定値をもたせたうえで、設定内容の構文木を解析して行います。解析方法さえわかれば列挙体のメンバ名の取得等も可能です。
例えば以下のようなパターンです。

SampleAttribute.cs(これはSourceGenerator内部でT4から自動生成する)
namespace Generator
{
    /// <summary>設定値つき属性</summary>
    [AttributeUsage(AttributeTargets.Interface)]
    internal class SampleAttribute: Attribute
    {
        private string ComName;
        private Type RequestType;
        private Type ResponseType;
        private CommunicationType CommunicationType;
        /// <summary>
        /// 通信処理インターフェイスの内容を初期化します。
        /// </summary>
        /// <param name="trcName">通信先の処理名</param>
        /// <param name="reqestType">送信するデータの型。</param>
        /// <param name="responseType">受信するデータの型。</param>
        /// <param name="comType">通信の種別。</param>
        public SampleAttribute(string comName, Type reqestType, Type responseType, CommunicationType comType = CommunicationType.Normal)
        {
            ComName = comName;
            RequestType = reqestType;
            ResponseType = responseType;
            CommunicationType = comType;
        }
    }

    /// <summary>通信処理のタイプを規定します。</summary>
    internal enum CommunicationType
    {
        /// <summary>複数回の送信が可能な通常のタイプです。</summary>
        Normal,
        /// <summary>一回の受信でOnCompleteを送出するタイプです。</summary>
        OnlyOnce,
    }
}
Generator.cs
    /// <summary>
    /// 通信処理のソースコードを生成する
    /// </summary>
    /// <param name="context"></param>
    /// <param name="receiver"></param>
    private void GenerateCommunicator(GeneratorExecutionContext context, SyntaxReceiver receiver)
    {
        try
        {
            if (!receiver.Models.Any()) return;
            var list = new List<CommunicatorTemplate.CommunicatorProperty>();
            foreach (var (type, attr) in receiver.Models)
            {
                if (attr.ArgumentList is null) continue;

                var model = context.Compilation.GetSemanticModel(type.SyntaxTree);

                var typeSymbol = context.Compilation.
                    GetSemanticModel(type.SyntaxTree).
                    GetDeclaredSymbol(type);
                if (typeSymbol == null) throw new Exception("can not get typeSymbol.");
                var name = typeSymbol.Name;
                if (name.Length <= 1)
                {
                    throw new Exception("Length of interface name is too short.");
                }
                else if (name[0] != 'I')
                {
                    throw new Exception("Interface name has to start 'I'.");
                }
                var prop = new CommunicatorTemplate.CommunicatorProperty()
                {
                    Namespace = typeSymbol.ContainingNamespace.ToDisplayString(),
                    InterfaceName = name,
                    ClassName = name.Substring(1) // 先頭の一文字が "I" であることを期待する
                };

                for (int i = 0; i < attr.ArgumentList.Arguments.Count; i++)
                {
                    var arg = attr.ArgumentList.Arguments[i];
                    var expr = arg.Expression;
                    if (i == 0)
                    {
                        // 処理名はリテラルで指定可能
                        if (expr is LiteralExpressionSyntax literal &&
                            literal.IsKind(SyntaxKind.StringLiteralExpression))
                        {
                            // 設定された文字列リテラルを大文字変換して使用する
                            prop.TrcName = literal.Token.ValueText.ToUpper();
                        }
                        else
                        {
                            throw new Exception("need string literal");
                        }
                    }
                    else if (i == 1)
                    {
                        // request type
                        prop.RequestType = GetTypeName(model, expr);
                    }
                    else if (i == 2)
                    {
                        // response type
                        prop.ResponseType = GetTypeName(model, expr);
                    }
                    else if (i == 3)
                    {
                        // CommunicationType列挙体の設定値を取得する
                        if (expr is MemberAccessExpressionSyntax mem &&
                            mem.Expression is IdentifierNameSyntax syn &&
                            syn.Identifier.ValueText == "CommunicationType")
                        {
                            prop.ComKind = mem.Name.Identifier.ValueText;
                        } 
                        else
                        {
                            // 指定の列挙体でなければNG
                            // 属性の第4引数はoptionalなので未指定ならそもそもelse if (i == 3)の中に来ない
                            throw new Exception("invalid enum type");
                        }
                    }
                }
                list.Add(prop);
            }

            foreach (var prop in list)
            {
                var template = new CommunicatorTemplate(prop);
                var code = template.TransformText();
                context.AddSource($"{prop.Namespace}.{prop.InterfaceName}_Auto.cs", code);
            }

            static string GetTypeName(SemanticModel model, ExpressionSyntax expr)
            {
                // "typeof"キーワードかどうかを判定する
                if (expr is TypeOfExpressionSyntax typeOfExpr)
                {
                    // typeof(xxx)のxxxが型でないならそもそも異常コードなのでエラー
                    if (model.GetSymbolInfo(typeOfExpr.Type).Symbol is not ITypeSymbol typeSymbol)
                        throw new Exception("require type-symbol.");
                    return typeSymbol.Name;
                }
                else
                {
                    throw new Exception("type failed");
                }
            }
        }
        catch (Exception ex)
        {
            Trace.WriteLine(ex.ToString());
        }
    }
ジェネレーター利用者の例
// こんな書き方で属性を設定すると
[Sample("Abc", typeof(SampleRequest), typeof(SampleResponse)]
public interface IAbcCommunicator { }
// こんなコードが自動生成できる
public class AbcComunicator : CommunicationBase<SampleRequest, SampleResponse>, IAbcCommunicator
{
    public string ComName => "ABC";
    public SampleResponse Request(SampleRequest req) => base.Request(req);
}

ジェネレーターを使おう

長々と記述したのはSourceGeneratorの実装方法です。SourceGeneratorができたならそれを使う利用者側も作らないといけません。

利用者プロジェクトを作る

通常の手順で新規プロジェクトを同じソリューションに追加してください。ここではCustomerプロジェクトとします。ターゲットフレームワークは.NET Standard 2.0でなくても問題ありません。初期値とすればよいでしょう(2021年9月現在なら.NET 5)。
CustomerプロジェクトはGeneratorプロジェクトをプロジェクト参照してください。参照設定後、Customerプロジェクトに以下のNugetパッケージを追加します。

パッケージマネージャコンソール
Install-Package Microsoft.Net.Compilers.Toolset

パッケージ追加後、Customer.csprojファイルを保存してからGeneratorプロジェクトへの参照設定にAnalyzer設定とReferenceOutputAssembly設定を追加します。

Customer.csproj(変更前)
  <!-- 変更前 -->
  <ItemGroup>
    <ProjectReference Include="..\Generator\Generator.csproj" />
  </ItemGroup>
Customer.csproj(変更後)
  <!-- 変更後 -->
  <ItemGroup>
    <ProjectReference Include="..\Generator\Generator.csproj" 
                      OutputItemType="Analyzer" 
                      ReferenceOutputAssembly="false" />
  </ItemGroup>

ジェネレーターがエラーを吐かないのにコード生成されない場合、上記3つ「プロジェクト参照の追加」「Nugetライブラリの取得」「.csprojファイルの編集」のいずれかを忘れている可能性が高いです。それでもうまくいかない場合はVisual Studioの再起動をしましょう。利用者がWPFプロジェクトの場合は最後の補足も確認してください。

コードを生成してみよう

ここまでくれば、後は自動生成用の属性のついたrecord型を定義するのみです。

SampleRequest.cs
using Generator;
namespace Customer
{
    [RequestGenerator]
    public partial record SampleRequest(string Id, DateTime SendDate, int? Num);
}

このようにコードを書いてビルドしてみてください。SourceGeneratorにエラーがなくビルド完了すると、SampleRequestを選択して「定義へ移動」を選択したときに移動先の候補が2つ表示されるはずです。手で作った覚えのない方を選択して、タブに以下の文言が表示されれれば自動生成されています(赤色部分はGeneratorの名前)。生成内容におかしな部分がないか、確認してみてください。はじめのうちは不正なコードを出力しがちなので、だいたいビルドエラーになって意味不明なエラーが出たりJust-In-Timeデバッガーが起動したりします。デバッガーでSourceGeneratorの構文解析内容やT4テンプレートの出力コードを見てみることで修正を行ってください。ただしVisual Studioは前に生成した内容のキャッシュを残しているため修正してもエラーは残り続けますので、Visual Studioの再起動を忘れずに実施してください。
image.png
送信データでエラーなく期待通りの結果が得られたら、後は受信データや通信処理の自動生成を同様に組むだけです。

自動生成したコードのテスト

SourceGeneratorによる生成コードのテストはおおよそ以下の2種類に分かれるのではないでしょうか。T4テンプレートで生成したコードのテストと同様に、特定の属性をつけたコードを自動生成とする以上、属性を外せばコードは消えてしまいますので、実際に使うコードでテストするのはやめましょう。必要ならユニットテスト以降のフェーズで実施すべきです。

  1. 自動生成内容に実装を含まない場合
    1. 抽象クラスを継承させるようなパターンでは、抽象クラスを継承するモックを作って抽象クラスそのもののテストを行ってください。これはSourceGeneratorとは無関係に行えるはずです。
  2. 自動生成内容にテストの必要な実装を含ませる場合
    1. テストプロジェクトを利用者プロジェクトとみなして参照設定等を行います。その後、テストプロジェクト内でモックを自動生成させ、その内容を検証します。

まとめ

SourceGeneratorの実装は非常に複雑で大変です。構文解析をしないといけないですし、とにかくVisual Studioの再起動が頻発するからです。これに関してはいずれ改善されるとは思うのですが、利用者が少ないと優先順位も下がってしまうでしょう。しかしみんなでSourceGeneratorを使うことで、開発工数は減るわ残業は減るわVSは更に使いやすくなるわ元々クソコードだった部分をコピペで増やされたあげく修正作業を押し付けられることがなくなって済むわといいことずくめです。みんなでSouceGeneratorを駆使してボイラープレートコードを駆逐していきましょう!
よいSouceGeneratorライフを!

補足:CS8032警告が出て自動生成されない場合

利用者側で以下の警告が出ることがあります。

コード 説明 プロジェクト
CS8032 アナライザー Generator.Generatorのインスタンスは C:\(ソースのパス)\Generator\Generator\bin\Debug\netstandard2.0\Generator.dll: ファイルまたはアセンブリ 'Microsoft.CodeAnalysis, Version=3.11.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'、またはその依存関係の 1 つが読み込めませんでした。指定されたファイルが見つかりません。 から作成できません。 Customer

この警告が表示された場合、GeneratorプロジェクトがNugetからインストールしたMicrosoft.CodeAnalysis.CSharpを含むライブラリMicrosoft.CodeAnalysis.dllのバージョンが高すぎるようです(SourceGenerator絡みで破壊的変更があったんでしょうか?)。-version 3.9と指定してインストールし直してみてください。

補足:WPFプロジェクトでジェネレーターを使用するとCS0234エラーになる場合

利用者側のビルド時に、自動生成対象の属性の定義が見つからないエラーとなることがあります。特徴的なのは、Visual Studioの「定義へ移動」で自動生成ソースへ移動できるのにビルド時にエラーになるエラー一覧タブにはエラーが表示されない、さらに利用者がコンソールプロジェクトだと起きないが、WPFプロジェクトだと起きるという3点です。
以下エラーでは自動生成対象の属性をGenerateTargetAttribute.csに定義しているとします。

コード 説明 プロジェクト
CS0234 (属性を使用するソースファイル): error CS0246: 型または名前空間の名前 'GenerateTargetAttribute' が見つかりませんでした (using ディレクティブまたはアセンブリ参照が指定されていることを確認してください) Customer

まず「定義へ移動」で移動できる時点で、実装したソースジェネレーターとジェネレータープロジェクトそのものは正しく動作しています。対策として、利用者側のプロジェクトファイルの<PropertyGroup>タグに以下を定義するとビルドが通るようになります。

Customer.csproj
  <PropertyGroup>
<!-- ターゲットフレームワーク等の定義
    <TargetFramework>net5.0-windows10.0.19041.0</TargetFramework>
-->

<!-- 以下をそのまま追加 --><IncludePackageReferencesDuringMarkupCompilation>true</IncludePackageReferencesDuringMarkupCompilation>
  </PropertyGroup>

利用者のWPFプロジェクトのターゲットフレームワークが.NET 6だとこのプロパティはデフォルトで`true`になるようです。.NET 5だとopt-inのため自分で設定が必要です。

この問題については私もまだきちんと理解できていませんが、githubの以下issueを見て解決しました。内容のわかる方がいらっしゃったらコメントにて教えていただけると幸いです。

※Qiitaにすでに記事がありました。WPFでSourceGeneratorを使おうとすると起きる問題のようです。

補足:VS2019以前で作成した利用者プロジェクトをVS2022のC#10.0のコードにコンバートするとビルドエラーになる場合

Microsoft.Net.Compilers.Toolsetを参照しているプロジェクトでは、ビルド時に使用されるRoslynコンパイラのバージョンは言語バージョンやターゲットフレームワークではなくプロジェクトで参照しているMicrosoft.Net.Compilers.Toolsetのバージョンになります。
コンバート後に「エディタではエラーがないのにビルド時にエラーになる!」という現象が発生した場合はMicrosoft.Net.Compilers.ToolsetのNugetパッケージを最新に更新してください。


  1. Excelからの自動実装でもまかなえますが、APIはコードベースで管理したほうが開発体験はいいです 

  2. 株式会社Cysharpの河合さんのUnitGeneratorが好例です。本稿を作るにあたって河合さんのブログを参考にさせていただきました 

  3. partialメソッドを定義する方法もありますが、SourceGeneratorによるpartialメソッドの活用は実装をSourceGeneratorが行う場合で提案されています。実装を開発者が行いたい場合とは逆パターンですし、メソッドのシグネチャの統制がわかりやすくリファクタリング機能も使えるインターフェイスのほうが適切でしょう 

  4. 正確にはRoslynコンパイラに割り込んで「自分で構文解析した後に」「自分で文字列を作ってコンパイル対象として追加する」ことで生成される仕組みになっています。そのため属性を使わずに「特定の3文字から始まるクラスならコード生成」なんてことも可能です。ただ、「自動生成する対象は専用の属性をつける」というほうがあとから見て明らかにわかりやすいので、素直に属性を使いましょう 

  5. おそらく1回は「なんじゃこりゃ!?」と叫びたくなり状況になります。ほとんどの場合、しょうもない文法不正ですのでよ~く内容を確認してください。ジェネレータ完成まで数十回はVSの再起動をすると思ってください。(これは将来的には改善されると思うのですが) 

  6. 型の一覧はMSDNを参照してください。わかりやすい図が紹介されていたのでStackOverFlowのリンクも載せておきます。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
9
Help us understand the problem. What are the problem?