はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#】RuntimeでcsファイルをRoslynで解析してリフレクションで実行する

概要

.NET Compiler Platform SDK(通称Roslyn)は、C#で開発されたC#コンパイラープラットフォームを指します。オープンソースなので、誰でもコードが見れますしプルリクも送れます。

github.com

The Roslyn .NET compiler provides C# and Visual Basic languages with rich code analysis APIs.

// DeepL翻訳
Roslyn .NETコンパイラーは、C#およびVisual Basic言語に豊富なコード解析APIを提供します。

GitHub - dotnet/roslyn: The Roslyn .NET compiler provides C# and Visual Basic languages with rich code analysis APIs.

この新しいコンパイラーは、開発コードネーム「Roslyn」(アメリカ ワシントン州の地名が由来)として開発され、 最終的なプロダクト名は「.NET Compiler Platform となりました。 IDE との連携や第三者による IDE 拡張まで見越したものなので、単なるコンパイラーではなく、「プラットフォーム」という呼称が付いています。 (ただ、今でも俗称としてはコードネームそのままな「Roslyn」の方がよく使われます。)

[雑記] .NET Compiler Platform - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

最近だとRoslyn AnalyzerSourceGeneratorがC#界隈だとホットな話題かと思いますが、それもRoslynの機能の一部です。

インストール

NuGetよりMicrosoft.CodeAnalysis.CSharpをインストールします。

RiderでNuGetからインストールしている様子

コマンドだと以下で入れられます。

$ dotnet add package Microsoft.CodeAnalysis.CSharp

今回の流れ

RoslynのCompiler APIsを利用して、.csを動的に読み込んでコンパイル、リフレクションでメソッドを実行してみます。

1. .csに対して構文解析を行い、構文木(SyntaxTree)を生成
2. SyntaxTreeと依存先のアセンブリを設定したCSharpCompilationを生成
3. CSharpCompilation.Emitを実行して、コンパイルされたソースコードをStreamに書き込み、コンパイル結果を示すEmitResultを取得
4. Streamからアセンブリをロードし、リフレクションでメソッド呼び出しを行う

構文木を生成する

テキストから構文木(SyntaxTree)を生成するには、CSharpSyntexTree.ParseTextメソッドを利用します。
CSharpSyntaxTree.ParseText Method (Microsoft.CodeAnalysis.CSharp) | Microsoft Learn

string sourceCode = """
                 namespace Sample
                 {
                     public class SampleClass
                     {
                         public static string Execute()
                         {
                             return "Hello, World!";
                         }
                     }
                 }
                 """;
        
// sourceCodeから構文木(SyntaxTree)を生成
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);

具体的にどのようなSyntexTreeが形成されるかは、SharpLabを見ると一目瞭然です。SharpLabを開いた後に、ResultsSyntex Treeに設定します。

SharpLab

SharpLab上で青い3つの四角のアイコンがついているものはNodeを表していて、黄色い1つの四角のアイコンがついているものはTokenを表しています。またグレーの横棒みたいなアイコンはTriviaを指していて、空白、コメント、プリプロセッサ ディレクティブなど重要でないものが格納されています。

  • Node : 青い3つの四角アイコン
  • Token : 黄色い1つの四角アイコン, 黄色い丸ぽちアイコン(keyword)
  • Trivia : グレーの横棒アイコン

他にもちょこちょこ記号がありますが、おそらく色で統一されてるのかなと。(要調査)

Syntax NodesSyntax TokensSyntax Triviaに関しての詳細は以下を参照してください。
github.com

// sourceCodeから構文木(SyntaxTree)を生成
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);

// SyntaxTreeのルートを取得
// 中身はSharpLab(https://sharplab.io/)で確認してみると分かりやすい
// SharpLab上で青い3つの四角のアイコンがついているものは、Nodeを表す
// SharpLab上で黄色い1つの四角のアイコンがついているものは、Tokenを表す
CompilationUnitSyntax root = syntaxTree.GetCompilationUnitRoot();

// CompilationUnit
Console.WriteLine(root.Kind());


// rootの子であるNode
// NamespaceDeclaration
Console.WriteLine(string.Join(", ", root.ChildNodes().Select(x => x.Kind())));

// rootの子であるToken
// EndOfFileToken
Console.WriteLine(string.Join(", ", root.ChildTokens().Select(x => x.Kind())));


// NamespaceDeclarationの子であるNode
// IdentifierName, ClassDeclaration
Console.WriteLine(string.Join(", ", root.ChildNodes()[0].ChildNodes().Select(x => x.Kind())));

// NamespaceDeclarationの子であるToken
// NamespaceKeyword, OpenBraceToken, CloseBraceToken
Console.WriteLine(string.Join(", ", root.ChildTokens[0].ChildTokens().Select(x => x.Kind())));


// SyntexTree全体に対して含まれるNode
// NamespaceDeclaration, IdentifierName, ClassDeclaration, MethodDeclaration, PredefinedType, ParameterList, Block, ReturnStatement, StringLiteralExpression
Console.WriteLine(string.Join(", ", root.DescendantNodes().Select(x => x.Kind())));

// SyntexTree全体に対して含まれるToken
// NamespaceKeyword, IdentifierToken, OpenBraceToken, PublicKeyword, ClassKeyword, IdentifierToken, OpenBraceToken, PublicKeyword, StaticKeyword, StringKeyword, IdentifierToken, OpenParenToken, CloseParenToken, OpenBraceToken, ReturnKeyword, StringLiteralToken, SemicolonToken, CloseBraceToken, CloseBraceToken, CloseBraceToken, EndOfFileToken
Console.WriteLine(string.Join(", ", root.DescendantTokens().Select(x => x.Kind())));

// SyntexTree全体に対して含まれるNode + Token
// NamespaceDeclaration, NamespaceKeyword, IdentifierName, IdentifierToken, OpenBraceToken, ClassDeclaration, PublicKeyword, ClassKeyword, IdentifierToken, OpenBraceToken, MethodDeclaration, PublicKeyword, StaticKeyword, PredefinedType, StringKeyword, IdentifierToken, ParameterList, OpenParenToken, CloseParenToken, Block, OpenBraceToken, ReturnStatement, ReturnKeyword, StringLiteralExpression, StringLiteralToken, SemicolonToken, CloseBraceToken, CloseBraceToken, CloseBraceToken, EndOfFileToken
Console.WriteLine(string.Join(", ", root.DescendantNodesAndTokens().Select(x => x.Kind())));

// Triviaは空白、コメント、およびプリプロセッサ ディレクティブなど、コードを通常に理解するためにはさほど重要ではないソース テキストの部分
// WhitespaceTrivia, EndOfLineTrivia, EndOfLineTrivia, WhitespaceTrivia, SingleLineCommentTrivia, EndOfLineTrivia, WhitespaceTrivia, WhitespaceTrivia, WhitespaceTrivia, EndOfLineTrivia, WhitespaceTrivia, EndOfLineTrivia, WhitespaceTrivia, SingleLineCommentTrivia, EndOfLineTrivia, WhitespaceTrivia, WhitespaceTrivia, WhitespaceTrivia, WhitespaceTrivia, EndOfLineTrivia, WhitespaceTrivia, EndOfLineTrivia, WhitespaceTrivia, WhitespaceTrivia, EndOfLineTrivia, WhitespaceTrivia, EndOfLineTrivia, WhitespaceTrivia, EndOfLineTrivia
Console.WriteLine(string.Join(", ", root.DescendantTrivia().Select(x => x.Kind())));

また情報を取り出す際にはSharpLabに表示されているKindを参考にしながら、専用の〇〇Syntaxを利用していくのが定番の手法です。

// TODO: 書きたかった内容と微妙にコードがズレてるので後で書き換える

// sourceCodeから構文木(SyntaxTree)を生成
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);

// SyntaxTreeのルートを取得
CompilationUnitSyntax root = syntaxTree.GetCompilationUnitRoot();

// 専用の〇〇Syntexに型変換をする
// MemberDeclarationSyntaxからNamespaceDeclarationSyntaxに型変換
var syntex = (NamespaceDeclarationSyntax)root.Members[0];

// Sample (=namespaceの名前)
Console.WriteLine(syntex.Name);

Syntax Treeの走査

CSharpSyntaxWalkerを用いることでSyntax Treeの中で条件に会うようなノードを見つけ出すことができます。
CSharpSyntaxWalker Class (Microsoft.CodeAnalysis.CSharp) | Microsoft Learn

class MethodCollector : CSharpSyntaxWalker
{
    public ICollection<MethodDeclarationSyntax> Usings { get; } = new List<MethodDeclarationSyntax>();

    public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
    {
         Usings.Add(node);
    }
}
var sourceCode = """
                 namespace Sample
                 {
                     public class SampleClass
                     {
                         public static string Execute()
                         {
                             return "Hello, World!";
                         }
                         
                         public static string Hoge()
                         {
                             return "Hoge";    
                         }
                     }
                 }
                 """;

// sourceCodeから構文木(SyntaxTree)を生成
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);

// SyntaxTreeのルートを取得
CompilationUnitSyntax root = syntaxTree.GetCompilationUnitRoot();

// 定義したMethodCollectorを利用
var collector = new MethodCollector();
collector.Visit(root);
// Execute, Hoge
Console.WriteLine(string.Join(", ", collector.Usings.Select(x => x.Identifier.ToString())));

C#のバージョン指定

CSharpSyntexTree.ParseTextメソッドの引数にCSharpParseOptionsを渡すことで、Syntex Treeを構築する際のC#のバージョンを指定したりすることができます。

// C#12を指定
CSharpParseOptions options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12);

// sourceCodeから構文木(SyntaxTree)を生成
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, options);

ちなみにCSharpParseOptions.Defaultは単にnew CSharpParseOptions()をしたときと同じ内容です。

public CSharpParseOptions(
      LanguageVersion languageVersion = LanguageVersion.Default,
      DocumentationMode documentationMode = DocumentationMode.Parse,
      SourceCodeKind kind = SourceCodeKind.Regular,
      IEnumerable<string>? preprocessorSymbols = null)
      : this(languageVersion, documentationMode, kind, preprocessorSymbols.ToImmutableArrayOrEmpty<string>(), (IReadOnlyDictionary<string, string>) ImmutableDictionary<string, string>.Empty)
    {
    }

コンパイル

CSharpCompilation.Createメソッド + CSharpCompilation.Emitメソッドを利用することでコンパイルを実行できます。

// sourceCodeから構文木(SyntaxTree)を生成
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, options);

// コンパイル準備
// Createメソッドの引数にAssemblyNameを指定する
CSharpCompilation compilation = CSharpCompilation.Create("Sample")
    // stringの定義が含まれているアセンブリを依存先として設定する
    .AddReferences(MetadataReference.CreateFromFile(typeof(string).Assembly.Location))
    // コンパイル後はDLLにする(ConsoleAppにしたりとか設定できる)
    .WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
    // syntaxTreeをコンパイル対象に設定
    .AddSyntaxTrees(syntaxTree);

// コンパイル実行
using (MemoryStream stream = new MemoryStream())
{
    EmitResult emitResult = compilation.Emit(stream);

    if (!emitResult.Success)
    {
        throw new ArgumentException("Compile error occured.");
    }

    Console.WriteLine("コンパイル成功!");
}

CSharpCompilation.Createにてコンパイル対象のSyntaxTreeの設定と依存先のアセンブリの設定などを設定します。

次にCompilation.Emitにてコンパイルした結果のソースコードはStreamに書き込まれます。今回はMemoryStreamなのでメモリです。
またEmitResultにてコンパイルの結果成功したのかどうかなどが書き込まれます。

MetadataReferenceで依存先のアセンブリを追加

コンパイルするコードが他のアセンブリに依存している場合は、それをCSharpCompilation.Createの際に設定してあげる必要があります。上のサンプルではstringConsoleに依存しているため、MetadataReference.CreateFromFile(typeof(string).Assembly.Location)を設定しています。

これを適切に設定しないとコンパイルができないので注意してください。

CSharpCompilationOptionsでコンパイル後のアセンブリの種類を設定

CSharpCompilationOptionsを用いてコンパイル後のアセンブリの種類を設定します。

// Microsoft.CodeAnalysisより一部抜粋
public enum OutputKind
{
    ConsoleApplication = 0,
    WindowsApplication = 1,
    DynamicallyLinkedLibrary = 2,
    NetModule = 3,
    WindowsRuntimeMetadata = 4,
    WindowsRuntimeApplication = 5,
}

コンパイル結果を調べる

コンパイルの後、その中身を取り出してみたいと思います。

using (var stream = new MemoryStream())
{
    var emitResult = compilation.Emit(stream);

    foreach (var diagnostic in emitResult.Diagnostics)
    {
        var pos = diagnostic.Location.GetLineSpan();
        var location = "(" + pos.Path + "@Line" + (pos.StartLinePosition.Line + 1) + ":" +
                       (pos.StartLinePosition.Character + 1) + ")";
        Console.WriteLine($"[{diagnostic.Severity}, {location}] {diagnostic.Id}, {diagnostic.GetMessage()}");
    }

    if (!emitResult.Success)
    {
        throw new ArgumentException("Compile error occured.");
    }

    stream.Seek(0, SeekOrigin.Begin);
    var assembly = AssemblyLoadContext.Default.LoadFromStream(stream);

    // 読み込んだコードを実行
    var classType = assembly.GetType("Sample.SampleClass");
    var methodType = classType.GetMethod("Execute");
    var instance = Activator.CreateInstance(classType);
    Console.WriteLine(methodType.Invoke(instance, null));
}

また前回はEmitResult.Successしか確認していませんでしたが、EmitResult.Diagnosticsを用いることで、コンパイル中に発生したエラー情報を取り出すことができます。

最後にStreamに書き込まれたデータからアセンブリをロードします。注意点として、Stream.Seekでストリームの読み取り位置を最初に戻してあげてください。

stream.Seek(0, SeekOrigin.Begin);
var assembly = AssemblyLoadContext.Default.LoadFromStream(stream);

あとはリフレクションで情報を取ってこれます。

Unityで扱う場合

ちなみにUnityではUPMでcom.unity.code-analysisを指定することでRoslynを同様に利用することができるようになります。

docs.unity3d.com