C#でソースをランタイムでコンパイルし、実行する

C#で、ソースコードを実行中にコンパイルして、そのプログラム中でコンパイルしたクラスやメソッドを実行する方法を紹介します。

Roslynとは

Roslynは.NETコンパイラプラットフォームの通称であり、C#やVisual Basic(、F#も?)のコンパイル・コード解析のためのAPIを提供します。

具体的には今回紹介するような動的なコンパイルであったり、コード解析(Roslyn Analyzerなどと呼ばれている)、OmniSharpというIDEのための言語機能提供のバックエンドもRoslyn実装が存在したり、単にコンパイラというより、コンパイルに関する周辺機能を諸々APIで提供しています。

最近ではC# 9から導入されたSource Generatorも、Roslynの1機能として提供されています。

イメージとしてC#コンパイラでできることが、だいたいC#でプログラムから呼び出せる、と思っても大きく違いはないのではと思ったりします。

環境構築

今回はRoslynを利用します。利用するにはMicrosoft.CodeAnalysis.CSharpをインストールします。インストールするにはdotnetコマンドで下記を実行します。

dotnet add package Microsoft.CodeAnalysis.CSharp --version 3.8.0

コンパイルのための下準備

下記のTestClassをランタイムでコンパイルして、コンパイル後にTestClass.Addを呼び出して結果を出力してみます。

private static readonly string SourceCode = @"
    public class TestClass
    {
        public static int Add(int a, int b)
        {
            return a + b;
        }
    }
";

ソースコードを実行中にコンパイルする場合は下記のようにCSharpCompilation.Createを呼び出し、生成されたCSharpCompilationインスタンスに対してEmitを呼び出します。ここで引数にはそれぞれアセンブリ名・シンタックスツリー・メタリファレンス・コンパイルオプションを渡します。数が多いのでそれぞれ順を追って説明します。

var compilation = CSharpCompilation.Create(
    "TestClass.dll",
    new[] { syntaxTree },
    references,
    compilationOptions
);

// MemoryStreamについては後述する
using (var stream = new MemoryStream())
{
    // ここでコンパイル
    var emitResult = compilation.Emit(stream);

まずソースコードをシンタックスツリーに変換するにはCSharpSyntxTree.ParseTextメソッドを用います。

var options = CSharpParseOptions.Default
    .WithLanguageVersion(LanguageVersion.CSharp8);

var syntaxTree = CSharpSyntaxTree.ParseText(
    SourceCode,
    options,
    "TestClass.cs"
);

上記では、第1引数にソースコードを表すテキストファイルを指定しています。また第3引数には、このソースコードのファイルパスを指定します。

第2引数には、このソースコードのパースオプションを指定します。CSharpParseOptionsのコンストラクタを呼び出して作成でもよいですが、CSharpParseOptions.Defaultをもとに、With*メソッドを呼び出してカスタマイズしたものを渡しても良いでしょう。上記の例では、デフォルトオプションに対して、C#のバージョンを指定したものを渡しています。

CSharpParseOptions.Default引数なしでコンストラクタを呼び出したものを保持していて、具体的にそれぞれのパラメータにはこちらパラメータが渡っているようです。

LanguageVersionDefaultでサポートされているもののうち最新のものが、DocumentationModeにはParseが、SourceCodeKindにはRegularが、preprocessorSymbolsnullが渡され、プリプロセッサシンボルが設定されていない状態です。(ちゃんと調べてなくてDocumentationModeおよびSourceCodeKindが変わると何が変わるか把握できてなくて、もし知っておられる方がおられたら教えていただけると幸いです...。)

次にメタリファレンスですが、こちらは参照するdllなどを指定するためのパラメータです。ちなみになにも指定しないと下記のようなエラーが発生します。

[Error, (TestClass.cs@Line2:26)] CS0518, 定義済みの型 'System.Object' は定義、またはインポートされていません
[Error, (TestClass.cs@Line4:39)] CS0518, 定義済みの型 'System.Int32' は定義、またはインポートされていません
[Error, (TestClass.cs@Line4:46)] CS0518, 定義済みの型 'System.Int32' は定義、またはインポートされていません
[Error, (TestClass.cs@Line4:31)] CS0518, 定義済みの型 'System.Int32' は定義、またはインポートされていません
[Error, (TestClass.cs@Line2:26)] CS1729, 'object' には、引数 0 を指定するコンストラクターは含まれていません

ちなみに上記のエラーは、コンパイル後にEmitResult.Diagnosticsを表示することで確認できます。

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()}"
    );
}

TestClassで利用しているintは内部的にはSystem.Int32を利用しています。メタリファレンスは何も指定しないとこれらがインポートされていないため、コンパイルエラーとなります。

これを回避するために、今回はobjectクラスが属するアセンブリをメタリファレンスに指定しておきます。具体的には下記のように指定します。

var references = new MetadataReference[]
{
    MetadataReference.CreateFromFile(
        typeof(object).Assembly.Location),
};

typeof(object).Assemblyでアセンブリを取得し、そのLocationを取得します。このプロパティにはdllのファイルパスが格納されています。

ファイルパスからMetadataReference.CreateFromFileを通して取得できるインスタンスをリファレンスとして指定します。

最後にコンパイルオプションですが、CSharpCompilationOptionsを指定します。

var compilationOptions = new CSharpCompilationOptions(
    OutputKind.DynamicallyLinkedLibrary
);

必須パラメータはOutputKindで、コンパイル後のアセンブリの種類を指定します。dllにするか、コンソールアプリにするかなどを指定できますが、今回はコンパイル後にメソッドをリフレクションで呼び出すために、DynamicallyLinkedLibraryを指定しました。

他にも、Unsafeを許可するかどうか、Nullableをどうするか、警告レベルをどうするかなど、C#で指定できるであろうコンパイルオプションをそれぞれ指定できます。どのようなオプションが指定できるかは、こちらで確認できます。

コンパイル結果からアセンブリを取得する

前述はしましたが、上記で用意したパラメータからCreateメソッドでCSharpCompilationを作成します。

var compilation = CSharpCompilation.Create(
    "TestClass.dll",
    new[] { syntaxTree },
    references,
    compilationOptions
);

あとはcompilation.Emitを呼び出すことでコンパイルを行います。Emitの引数にはStreamを指定します。このStreamにコンパイル結果のアセンブリが書き出されます。また、コンパイル結果は戻り値のEmitResultに格納されています。この結果をもとに処理を切りかえることになります。

using (var stream = new MemoryStream())
{
    var emitResult = compilation.Emit(stream);
    if (emitResult.Success)
    {
        // コンパイル成功
    }
    else
    {
        // コンパイルエラーなどで失敗
        // エラー処理を書く
    }
}

再掲ですが、コンパイル時のメッセージはEmitResult.Diagnosticsに格納されています。ちょうどコーディング時に、エディターに表示されているような警告やコンパイルエラーなどが格納されていると考えるとわかりやすいかと思います。

メッセージ一覧は例えば下記のように出力して確認します。

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()}"
    );
}

コンパイルが成功していれば、あとは出力されたアセンブリをロードします。これにはAssemblyLoadContextを用います。

var emitResult = compilation.Emit(stream);
if (emitResult.Success)
{
    // コンパイルが成功していれば実行
    stream.Seek(0, SeekOrigin.Begin);

    // これでコンパイル後のアセンブリが取得できた
    var assembly = AssemblyLoadContext.Default.LoadFromStream(stream);
}

LoadFromStreamでストリームに書き込まれたアセンブリをロードします。ただしアセンブリ書き出し後にストリームの位置が先頭になっていないので、Seekで先頭にセットしなおしておきます。

ちなみにAssemblyLoadContext.Defaultを使ってるので、このプログラムを実行しているコンテキストをそのまま使っています。場合によっては独自のローダーを実装したほうがいいかもしれません。(サンドボックス的にプログラムを実行する場合は、独自のローダーを実装するなど? ちゃんと調べられてないので、もし詳しい方がおられたらおしえていただけると幸いです...。)

コンパイルしたクラスを呼び出す

上記でソースコードをコンパイルしたアセンブリを取得できたので、あとはメソッドを呼び出してみます。呼び出しにはリフレクションを用います。 静的メソッドを呼び出す場合は下記のように呼び出せます。

// 生成したアセンブリから、生成したクラス情報を名前で取得する
var testClassType = assembly.GetType("TestClass");
// クラス情報からメソッド情報を取得する
var addMethod = testClassType.GetMethod("Add");
// メソッドを呼び出す
// 第1引数をnullにすると静的メソッド呼び出しとなる
// 第2引数でメソッドの引数を指定する
// 戻り値もobjectなので適宜キャスト
// var result = TestClass.Add(1, 2); を実行している
var result = (int)addMethod.Invoke(null, new object[]{1, 2});
// 実行結果を取得する
Console.WriteLine($"TestClass.Add(1, 2) = {result}");
// > TestClass.Add(1, 2) = 3

また、下記のようなクラスのインスタンスを生成し、メソッドやプロパティを呼び出すことも可能です。

private static readonly string SourceCodeTestClass2 = @"
    public class TestClass2
    {
        public string Name { get; }
        public TestClass2(string name)
        {
            Name = name;
        }

        public string GetName()
        {
            return Name;
        }
    }
";

具体的には下記のように呼び出します。インスタンス化にはAssembly.CreateInstanceまたはActivator.CreateInstanceを用います。今回はActivatorを用いました。(やり方は他にもたくさんあるかもしれません。)

便宜上、先程までのコンパイル処理をCompileメソッドに纏めています。戻り値としてコンパイル結果のアセンブリを返すこととします。

var assembly = Compile(SourceCodeTestClass2, "TestClass2.cs", "TestClass2.dll");
var classType = assembly.GetType("TestClass2");
// Activatorを用いてTestClass2のコンストラクタを呼び出す
// var instance = new TestClass2("Name1"); を実行している
var instance = Activator.CreateInstance(classType, new object[] { "Name1" });

// プロパティ呼び出し
var property = classType.GetProperty("Name");
// var result = instance.Name;
var result = (string)property.GetValue(instance);
Console.WriteLine($"instance.Name = {result}");
// > instance.Name = Name1

// メソッド呼び出し
var method = classType.GetMethod("GetName");
result = (string)method.Invoke(instance, null);
Console.WriteLine($"instance.GetName() = {result}");
// > instance.GetName() = Name1

ただし、事前に型がコンパイルする側に定義されている場合は、インスタンス生成後のメソッドやプロパティ呼び出し処理にわざわざリフレクションを用いなくてもよいです。

具体的な例として、ゲームの敵を表すIEnemyインターフェイスを用意し(GetNextEnemyActionは次の敵の行動を取得するメソッドだとします。)、その実装クラスをランタイムでコンパイルしてインスタンス化して、IEnemyとして扱うなど行うことができます。

IEnemyEnemyActionTestEnemyのクラスは下記とします。

// 敵行動を表す列挙型
public enum EnemyAction
{
    TurnLeft,
    TurnRight,
    GoStraight,
    Attack,
}

// 敵インターフェイス
public interface IEnemy
{
    EnemyAction GetEnemyNextAction();
}

// このソースコードをコンパイルして、IEnemyとして扱いたいとする
private static readonly string SourceCodeTestEnemy = @"
    using SampleCompilingSourceAtRuntime;
    public class TestEnemy : IEnemy
    {
        public EnemyAction GetEnemyNextAction()
        {
            return EnemyAction.GoStraight;
        }
    }
";

上記のTestEnemyをランタイムでコンパイルして、IEnemyとして扱います。もちろんGetEnemyNextActionを(リフレクションなしで)呼び出す事ができます。

var assembly = Compile(
    SourceCodeTestEnemy,
    "TestEnemy.cs",
    "TestEnemy.dll"
);
var classType = assembly.GetType("TestEnemy");

// TestEnemyクラスをインスタンス化し、
// IEnemyにキャストして扱う(もちろんこれは問題なく動く)
var enemy = (IEnemy)Activator.CreateInstance(classType, null);

// 次の行動を取得する。リフレクションなしでメソッド呼び出しできる
var action = enemy.GetEnemyNextAction();
// -> next action = GoStraight
System.Console.WriteLine($"next action = {action}");

上記はつまり、ランタイムでコンパイルしたコードを、事前にコンパイルしたコードと(インスタンス化を除いて)同様のパフォーマンスで扱うことができるということです。

TestEnemyはとてもシンプルなのでイメージが湧きづらいかもしれませんが、より実用的な用途としては、ゲームのエディタは起動しつつ、ランタイムでAIのコードをコンパイルし、ロジックを差し替えるホットリロードなどの用途にも利用できるのかもしれません。ただし、上記だとアセンブリのアンロードが不足しているので、そのへんが必要になりそうです。具体的には.NET Coreでアセンブリをアンロードする - AYU MAXが参考になりそうです。(ちゃんと調べられてないのでまたの機会に...)

また、CMS(別にCMSに限らないですが)サービスのプラグイン機構などにもそのまま応用が効きそうです。

補足として、TestEnemyをコンパイルするには、(Enumなどを利用するために)メタリファレンスとして下記が追加で必要になります。


// .NET標準のアセンブリは、概ね同じフォルダに入っているので、
// どれか1つのファイルパスからディレクトリパスを割り出しておく。
// 具体的に、だいたい下記のようなフォルダに入っている
// /usr/local/share/dotnet/shared/Microsoft.NETCore.App/5.0.0
var assemblyDirectoryPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
var references = new MetadataReference[]
{
    MetadataReference.CreateFromFile(
        $"{assemblyDirectoryPath}/mscorlib.dll"),
    MetadataReference.CreateFromFile(
        $"{assemblyDirectoryPath}/System.Runtime.dll"),
    MetadataReference.CreateFromFile(
        typeof(object).Assembly.Location),
    // IEnemyおよびEnemyActionを利用するのに必要
    MetadataReference.CreateFromFile(
        typeof(SampleCompilingSourceAtRuntime.IEnemy).Assembly.Location),
};

これはSystem.Enumを利用するのに外部アセンブリとして、mscorlib.dllおよびSystem.Runtime.dllが必要なためです。

そのクラスがどのアセンブリが必要かどうかは、公式ドキュメントのNamespace下のAssembliesに書いてあります。(上記のSystem.Enumを確認すると、「mscorlib.dll,System.Runtime.dll」と書いてあることが確認できるかと思います。

また、上記で利用したCompileメソッドは下記のとおりです。

private static Assembly Compile(string sourceCode, string sourceCodePath, string assemblyName)
{
    var assemblyDirectoryPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
    var references = new MetadataReference[]
    {
        MetadataReference.CreateFromFile(
            $"{assemblyDirectoryPath}/mscorlib.dll"),
        MetadataReference.CreateFromFile(
            $"{assemblyDirectoryPath}/System.Runtime.dll"),
        MetadataReference.CreateFromFile(
            typeof(object).Assembly.Location),
        MetadataReference.CreateFromFile(
            typeof(SampleCompilingSourceAtRuntime.IEnemy).Assembly.Location),
    };

    var parseOptions = CSharpParseOptions.Default
        .WithLanguageVersion(LanguageVersion.CSharp8);

    var syntaxTree = CSharpSyntaxTree.ParseText(
        sourceCode,
        parseOptions,
        sourceCodePath
    );

    var compilationOptions = new CSharpCompilationOptions(
        OutputKind.DynamicallyLinkedLibrary
    );

    var compilation = CSharpCompilation.Create(
        assemblyName,
        new[] { syntaxTree },
        references,
        compilationOptions
    );

    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);
        return AssemblyLoadContext.Default.LoadFromStream(stream);
    }
}

まとめ

Roslynを用いてソースコードをランタイムでコンパイルして、それを実行する方法について紹介しました。

参考