はじめに
今回はRuntimeでcsファイルをRoslynで解析をしてリフレクションで実行する方法を紹介したいと思います。
概要
.NET Compiler Platform SDK(通称Roslyn
)は、C#で開発されたC#コンパイラープラットフォームを指します。オープンソースなので、誰でもコードが見れますしプルリクも送れます。
The Roslyn .NET compiler provides C# and Visual Basic languages with rich code analysis APIs.
// DeepL翻訳
Roslyn .NETコンパイラーは、C#およびVisual Basic言語に豊富なコード解析APIを提供します。
この新しいコンパイラーは、開発コードネーム「Roslyn」(アメリカ ワシントン州の地名が由来)として開発され、 最終的なプロダクト名は「.NET Compiler Platform となりました。 IDE との連携や第三者による IDE 拡張まで見越したものなので、単なるコンパイラーではなく、「プラットフォーム」という呼称が付いています。 (ただ、今でも俗称としてはコードネームそのままな「Roslyn」の方がよく使われます。)
[雑記] .NET Compiler Platform - C# によるプログラミング入門 | ++C++; // 未確認飛行 C
最近だとRoslyn Analyzer
やSourceGenerator
がC#界隈だとホットな話題かと思いますが、それもRoslynの機能の一部です。
インストール
NuGetよりMicrosoft.CodeAnalysis.CSharp
をインストールします。
コマンドだと以下で入れられます。
$ 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を開いた後に、Results
をSyntex Tree
に設定します。
SharpLab上で青い3つの四角のアイコンがついているものはNode
を表していて、黄色い1つの四角のアイコンがついているものはToken
を表しています。またグレーの横棒みたいなアイコンはTrivia
を指していて、空白、コメント、プリプロセッサ ディレクティブなど重要でないものが格納されています。
Node
: 青い3つの四角アイコンToken
: 黄色い1つの四角アイコン, 黄色い丸ぽちアイコン(keyword)Trivia
: グレーの横棒アイコン
他にもちょこちょこ記号がありますが、おそらく色で統一されてるのかなと。(要調査)
Syntax Nodes
・Syntax Tokens
・Syntax 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
の際に設定してあげる必要があります。上のサンプルではstring
・Console
に依存しているため、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
を同様に利用することができるようになります。