前書き
RoslynのCodeAnalysisを使用し、渡されたインターフェースから実装クラス構築に関するメモ。
構築の様を見るため、null
を返すだけのスタブクラスにとどめていることに注意。
環境
プロジェクト準備
手順の見極めのため、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
メソッドの戻り値であるEmitResult
のDiagnostics
プロパティをにコンパイルエラーの内容が記録されている。
生成クラスの実行まで
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%無駄にはならなさそう(と信じたい)。