LoginSignup
3
4

More than 3 years have passed since last update.

Roslynによるインターフェースの実装クラスの構築

Posted at

前書き

RoslynのCodeAnalysisを使用し、渡されたインターフェースから実装クラス構築に関するメモ。
構築の様を見るため、nullを返すだけのスタブクラスにとどめていることに注意。

環境

Roslynが作るコードツリーを覗くと同じ

プロジェクト準備

手順の見極めのため、NUnitベース

% dotnet nunit -o StubClassGenTest

Roslyn APIのパッケージを加える

% dotnet add StubClassGenTest package Microsoft.CodeAnalysis.CSharp

作成

以下のインターフェースに対する実装クラスを作成する。

using NUnit.Framework;

using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Reflection;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace GenSyntaxTest {
    public class _スタブクラス生成に関するテスト {
        private static readonly CompilationUnitSyntax syntaxRoot;

        static _スタブクラス生成に関するテスト() {
            var sourceText = @"
            namespace GenSyntaxTest {
                public interface IColorDao {
                    IEnumerable<ColorData> ListAll();
                }
            }
            ";

            syntaxRoot = CSharpSyntaxTree.ParseText(sourceText).GetCompilationUnitRoot();
        }
    }
}

名前空間、クラス、メソッドについてのユニットテストを書くため、CompilationUnitRootを定数として保持。

また、構文木の操作が何かと面倒なため、Syntaxクラスに対するExtensionおよびHelperを作成(内容は徐々に追記)

public static class SyntaxGeneratorExtension {
    // (snip)
}

public static class SyntaxGeneratorHelper {
    // (snip)
}

クラスパート生成

public class _スタブクラス生成に関するテスト {
    // (snip)

    [Test]
    public void _クラス定義パートの生成() {
        var ns = (NamespaceDeclarationSyntax)syntaxRoot.Members[0];
        var intf = (TypeDeclarationSyntax)ns.Members[0];

        Assert.AreEqual(SyntaxKind.InterfaceDeclaration, intf.Kind(), "元の型のSyntax");

        var cls = intf.ToClassDeclaration("Impl");

        Assert.That(cls.Kind(), Is.EqualTo(SyntaxKind.ClassDeclaration), "生成したSyntax");
        Assert.That(cls.Modifiers.Any(SyntaxKind.PublicKeyword), Is.True, "生成したクラスのアクセス就職子");
        Assert.That(cls.Keyword.ToString(), Is.EqualTo("class"), "生成した型種");
        Assert.That(cls.Identifier.ToString(), Is.EqualTo("ColorDaoImpl"), "生成したクラスの型名");

        Assert.That(cls.BaseList.Types.Count, Is.EqualTo(1), "親クラス or インターフェースの数");
        Assert.That(cls.BaseList.Types[0].ToString(), Is.EqualTo("IColorDao"), "親インターフェース名");

        Assert.That(cls.Members.Count, Is.EqualTo(0), "生成されたメソッド数");
    }
}

ToClassDeclarationメソッドは、拡張クラスのメソッドとして用意

public static class SyntaxGeneratorExtension {
    public static ClassDeclarationSyntax ToClassDeclaration(this TypeDeclarationSyntax inInterfaceSyntax, string inSuffix) {
        var intfName = inInterfaceSyntax.Identifier.Text;
        var clsTree = SyntaxFactory.ParseCompilationUnit($"public class {intfName.Substring(1)}{inSuffix}: {intfName}" + "{}");

        return (ClassDeclarationSyntax)clsTree.Members[0];
    }
}

アクセス修飾子や親クラスなど、直接構文木でやろうとするとかなり面倒。
string interpolationで構築した文字列をパーズすることで手を抜けそう。

メソッドパート生成

public class _スタブクラス生成に関するテスト {
    // (snip)

    [Test]
    public void _引数を持たないメソッドの生成() {
        var ns = (NamespaceDeclarationSyntax)syntaxRoot.Members[0];
        var intf = (TypeDeclarationSyntax)ns.Members[0];

        Assert.That(intf.Members.Count, Is.EqualTo(1), "用意されたインターフェースのメソッド数");
        Assert.That(intf.Members[0], Is.InstanceOf<MethodDeclarationSyntax>(), "用意されたインターフェースのメソッドSyntax");

        var meth = SyntaxGeneratorHelper.ToMethodStub((MethodDeclarationSyntax)intf.Members[0]);

        Assert.That(meth.Kind(), Is.EqualTo(SyntaxKind.MethodDeclaration), "生成したSyntax");
        Assert.That(meth.Modifiers.Any(SyntaxKind.PublicKeyword), Is.True, "生成したメソッドのアクセス就職子");
        Assert.That(meth.Identifier.ToString(), Is.EqualTo("ListAll"), "生成したメソッド名");
        Assert.That(meth.ReturnType.ToString(), Is.EqualTo("IEnumerable<ColorData>"), "生成したメソッドの戻り値型");

        Assert.That(meth.ParameterList.Parameters.Count, Is.EqualTo(0), "生成したメソッドの引数の数");

        Assert.That(meth.Body.Statements.Count, Is.EqualTo(1), "生成したメソッドの本文行数");
    }
}

ToMethodStubメソッドはヘルパークラスに定義した

public static class SyntaxGeneratorHelper {
    public static MethodDeclarationSyntax ToMethodStub(MethodDeclarationSyntax inIntfMember) {
        var returnType = inIntfMember.ReturnType.WithLeadingTrivia(SyntaxFactory.Space);

        return 
            inIntfMember
            .WithSemicolonToken(default)
            .WithLeadingTrivia(SyntaxFactory.Space)
                .WithModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword).AsTokens())
            .WithBody(
                SyntaxFactory.Block(
                    SyntaxFactory.ReturnStatement(
                        SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)
                        .WithLeadingTrivia(SyntaxFactory.Space)
                    )
                )
            )
        ;
    }        
}

注意点はインターフェースのメソッドに付与されたセミコロンを除去する必要があることのみ

また、SyntaxTokenからSyntaxTokenListへの変換は、メソッドチェインで書けるよう拡張クラスに定義した

public static class SyntaxGeneratorExtension {
    // (snip)

    public static SyntaxTokenList AsTokens(this SyntaxToken inSyntax) {
        return new SyntaxTokenList(inSyntax);
    }
}

名前空間宣言の生成

public class _スタブクラス生成に関するテスト {
    // (snip)

    [Test]
    public void _名前空間宣言の生成() {
        var u1 = SyntaxGeneratorHelper.ToUsingDirective("System");

        Assert.That(u1.Kind(), Is.EqualTo(SyntaxKind.UsingDirective), "生成されたusing[1]");
        Assert.That(u1.Name, Is.Not.InstanceOf<QualifiedNameSyntax>(), "生成された名前空間のSyntax[1]");
        Assert.That(u1.Name.ToString(), Is.EqualTo("System"), "生成された名前空間名[1]");

        var u2 = SyntaxGeneratorHelper.ToUsingDirective("System.Collections.Generic");

        Assert.That(u2.Kind(), Is.EqualTo(SyntaxKind.UsingDirective), "生成されたusing[2]");
        Assert.That(u2.Name, Is.InstanceOf<QualifiedNameSyntax>(), "生成された名前空間のSyntax[2]");
        Assert.That(u2.Name.ToString(), Is.EqualTo("System.Collections.Generic"), "生成された名前空間名[2]");

        var u2_3 = (QualifiedNameSyntax)u2.Name;

        Assert.That(u2_3.Right, Is.Not.InstanceOf<QualifiedNameSyntax>(), "生成された名前空間[2]の第3パートSyntax");
        Assert.That(u2_3.Right.ToString(), Is.EqualTo("Generic"), "生成された名前空間[2]の第3パート名");
        Assert.That(u2_3.Left, Is.InstanceOf<QualifiedNameSyntax>(), "生成された名前空間[2]の第2パートSyntax");

        var u2_2 = (QualifiedNameSyntax)u2_3.Left;

        Assert.That(u2_2.Right, Is.Not.InstanceOf<QualifiedNameSyntax>(), "生成された名前空間[2]の第2パートSyntax");
        Assert.That(u2_2.Right.ToString(), Is.EqualTo("Collections"), "生成された名前空間[2]の第2パート名");
        Assert.That(u2_2.Left, Is.Not.InstanceOf<QualifiedNameSyntax>(), "生成された名前空間[2]の第1パートSyntax");
        Assert.That(u2_2.Left.ToString(), Is.EqualTo("System"), "生成された名前空間[2]の第1パート名");
    }
}

ToUsingDirectiveはヘルパクラスに定義した。

public static class SyntaxGeneratorHelper {
    // (snip)

    public static UsingDirectiveSyntax ToUsingDirective(string inUsing) {
        return 
            SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(inUsing).WithLeadingTrivia(SyntaxFactory.Space))
            .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)
        ;
    }
}

Roslyn公式WikiのGetting Started C# Syntax Analysisでは、1パートずつQualifiedNameSyntaxを組み立てる方法が提示されているが、ドット繋ぎが多いとクソめんどくさいことこの上ないため、クラス定義の時同様、文字列からパーズするのが楽だと思われる(そこまで高コストでもなさそう)。

生成クラスのビルドと実行

クラスの生成まで

public class _スタブクラス生成に関するテスト {
    // (snip)

    [Test]
    public void _生成したクラスのビルド_and_実行() {
        var ns = (NamespaceDeclarationSyntax)syntaxRoot.Members[0];
        var intf = (TypeDeclarationSyntax)ns.Members[0];
        var cls = intf.ToClassDeclaration("Impl");

        cls = cls.AddMembers(
            intf.Members.CollectMethod()
                .Select(SyntaxGeneratorHelper.ToMethodStub)
                .ToArray()
        );

        var usings = new[] {
            "System.Collections.Generic"
        };

        var newUnit = 
            SyntaxFactory.CompilationUnit().AddMembers(ns.WithLeadingTrivia(null).WithMembers(cls.AsMemberDecls()))
            .WithUsings(usings.Select(SyntaxGeneratorHelper.ToUsingDirective).ToSyntaxList())
        ;

        // TestContext.Progress.WriteLine(newUnit.ToFullString());
}

最終行のコメントを外すと、テスト出力で構築されたクラスのコードが確認できる。

CollectMethod、およびToSyntaxListメソッドは拡張クラスに定義し、メソッドチェインで書けるようにした。

public static class SyntaxGeneratorExtension {
    //  (snip)
    public static IEnumerable<MethodDeclarationSyntax> CollectMethod<TSyntax>(this IEnumerable<TSyntax> inSyntaxes) 
        where TSyntax: CSharpSyntaxNode 
    {
        return inSyntaxes.OfType<MethodDeclarationSyntax>();
    }

    public static SyntaxList<TSyntax> ToSyntaxList<TSyntax>(this IEnumerable<TSyntax> inSyntaxes) 
        where TSyntax: CSharpSyntaxNode
    {
        return new SyntaxList<TSyntax>(inSyntaxes);
    }
}

生成クラスのビルドまで

public class _スタブクラス生成に関するテスト {
    // (snip)

    [Test]
    public void _生成したクラスのビルド_and_実行() {
        // (snip)

        using(var stream = new MemoryStream()) {
            var dotnetCoreDirectory = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();

            var opts = new CSharpCompilationOptions(
                outputKind: OutputKind.DynamicallyLinkedLibrary
            );

            var compiler = CSharpCompilation.Create("autoGen", 
                syntaxTrees: new[] { SyntaxFactory.SyntaxTree(newUnit) },
                references: new[] {
                    AssemblyMetadata.CreateFromFile(typeof(object).Assembly.Location).GetReference(),
                    MetadataReference.CreateFromFile(Path.Combine(dotnetCoreDirectory, "netstandard.dll")),
                    MetadataReference.CreateFromFile(Path.Combine(dotnetCoreDirectory, "System.Runtime.dll")),
                AssemblyMetadata.CreateFromFile(typeof(IColorDao).Assembly.Location).GetReference(),
                },
                options: opts
            );

            var emitResult = compiler.Emit(stream);

            Assert.That(emitResult.Success, Is.True, "コンパイル結果");
        }
    }
}

コード内でのコンパイラの起動は、こことか、ここを参考にした。

ビルドする上で必要となるため、インターフェースおよび戻り値の実定義を用意した。

// (snip)

namespace GenSyntaxTest {
    // (snip)
    public struct ColorData {

    }

    public interface IColorDao {
        IEnumerable<ColorData> ListAll();
    }
}

ビルドに失敗した場合、Emitメソッドの戻り値であるEmitResultDiagnosticsプロパティをにコンパイルエラーの内容が記録されている。

生成クラスの実行まで

public class _スタブクラス生成に関するテスト {
    // (snip)

    [Test]
    public void _生成したクラスのビルド_and_実行() {
        // (snip)

        using(var stream = new MemoryStream()) {
            // (snip)
            Assert.That(stream.Length, Is.GreaterThan(0), "生成したバイナリサイズ");

            stream.Position = 0;
            var buf = new byte[stream.Length];
            stream.Read(buf, 0, buf.Length);

            var asm = Assembly.Load(buf);

            Assert.That(asm.GetTypes().Length, Is.EqualTo(1));
            Assert.That((asm.GetTypes()[0]).FullName, Is.EqualTo("GenSyntaxTest.ColorDaoImpl"), "生成された型名"); 

            var instance = (IColorDao)asm.CreateInstance("GenSyntaxTest.ColorDaoImpl");

            Assert.IsNull(instance.ListAll(), "スタブ関数へのアクセス");
        }
    }
}

ビルドにより生成したバイナリをAssemblyとして展開、リフレクションでインスタンス化、実行してるだけ。
テスト上、インターフェースの型が既知のため、静的キャストでお茶を濁す。

余談(2020/5/3時点)

Roslynいぢりをやろうと決心した翌日に、Source Generatorsが発表される。

当初、コンパイル時コード生成をCodeGeneration.Roslynに頼るつもりであったけど、突然の電撃発表に心揺らぐ。

ただし、まだファーストプレビューゆえのVisual Studioでとりあえず動くようにしましたレベルらしく、また生成結果も文字列でしか受け付けない模様なので、もう少し様子見するつもり。

構文木から、ソースコードへの変換も、Trivia(インデントや改行)を適切に付与しておけば、`ToFullString()メソッドで行えるため、100%無駄にはならなさそう(と信じたい)。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4