CSharpCodeProvider + MEFで実行時に生成したDLLを簡単に扱う

これは「C# Advent Calendar 2013」の18日目の記事です。

昨日は matarillo さんの「Java8とC# - 猫とC#について書くmatarilloの雑記」でした。

さいきん(やっと)、C#で手をつけてなかった async/await や、MEFなどを勉強しています。勉強している中で、CSharpCodeProviderとMEFを組み合わせたら実行時にDLLを生成してもその読み込みに苦労しないんじゃないかと思ったので、紹介しようと思います。動的コード生成ということで、yfakariyaさんとネタが被ってヒヤヒヤしました・・。

(もしかしたらこの組み合わせは普通なのかもしれませんが、個人的に感動したので記事にしちゃいます。)

もくじ

コンソールで入力した文字をそのまま返すクラスを動的に生成して、それをMEFで読み込んで実行してみようと思います。(実用性は・・・)

  1. 文字からコードを生成する
  2. 生成したコードからDLLを作成する
  3. MEFを使いDLLを読み込む

1. 文字からコードを生成する

CSharpCodeProviderを使って、入力された文字からコードとDLLを生成します。

今回は、以下のインターフェースを実装したクラスを作成し、その中で入力された文字をそのまま返すコードを生成します。

public interface IMessage
{
    string GetMessage();
}

さて、コンソールで入力された文字からコードを生成していきますが、今回はRazorEngineを使ってみようと思います。説明は特に要りませんよね!?

Antaris/RazorEngine · GitHub

PM> Install-Package RazorEngine

App_Dataディレクトリを作成し、その中にテンプレートIMessageTemplate.razorを作成します。

using System.ComponentModel.Composition;
using CSharpCodeProviderAndMEF;

[Export("@(Model.Id)", typeof(IMessage))]
public class @(Model.Id)Message : IMessage
{
    public string GetMessage()
    {
        return @@"@(Model.Message)";
    }
}

ポイントは、クラスにつける属性ExportAttributeです。この属性をつけることで、MEFのカタログに公開するクラスであることを指定しています。また、ExportAttributeのプロパティに名前をつけることで、生成した後に名前を指定してインスタンスを取得できるようにしています。

Razorに渡すモデルとして、以下のクラスも作成します。

public class MessageModel
{
    public string Id { get; set; }
    public string Message { get; set; }
}

準備は整ったので、Razorを使ってコードを生成するクラスCodeFactoryを作成します。

public class CodeFactory
{
    private const string TemplatePath = @"App_Data\IMessageTemplate.razor";
    private const string CacheName = "message";

    public CodeFactory()
    {
        Initialize();
    }

    public string CreateCode(string id, string message)
    {
        var model = new MessageModel { Id = id, Message = message };
        var code = RazorEngine.Razor.Run(CacheName, model);
        return code;
    }


    // private

    private void Initialize()
    {
        var template = File.ReadAllText(TemplatePath);
        RazorEngine.Razor.Compile<MessageModel>(template, CacheName);
    }
}

このクラスを使ってコードを生成してみます。

class Program
{
    static void Main()
    {
        var codeFactory = new CodeFactory();

        while (true) {
            Console.Write("文字を入力してください。> ");
            var message = Console.ReadLine();

            if (string.IsNullOrEmpty(message)) break;

            var id = CreateId();
            var code = codeFactory.CreateCode(id, message);

            Console.WriteLine("==== {0} ====", id);
            Console.WriteLine(code);
        }
    }

    static string CreateId()
    {
        var id = Guid.NewGuid().ToString("N");
        return "_" + id;
    }
}

CteateIdメソッドでは、Guidからクラス名とMEFのカタログに公開する際の名前を生成しています。先頭にアンダーバーをつけているのは、クラス名は数値から始められないからです。

実行して、なにか文字を入力してみると、生成されたコードが表示されます。

f:id:rabitarochan:20131218125237p:plain

2. 生成したコードからDLLを作成する

ソースコードの生成ができましたので、次はDLLを作成していきます。DLLの作成にはCSharpCodeProviderを利用します。

CSharpCodeProvider クラス (Microsoft.CSharp)

@IT:.NET TIPS プログラムからソース・コードをコンパイルするには? - C# VB.NET

DLLを生成する処理については、ほぼ@ITの記事そのままですので、特に説明はしません。注意する点としては、参照するDLLを指定する際に、自分自身のexeファイルを指定する必要があることです。

class DllFactory
{
    public const string ExtensionDirectoryPath = @"App_Data\Extensions";

    private CSharpCodeProvider csc;

    public DllFactory()
    {
        Initialize();
    }

    public CompilerResults Compile(string id, string code)
    {
        var parameter = CreateParameter(id);
        var result = csc.CompileAssemblyFromSource(parameter, code);

        return result;
    }


    // private

    private void Initialize()
    {
        if (!Directory.Exists(ExtensionDirectoryPath)) {
            Directory.CreateDirectory(ExtensionDirectoryPath);
        }

        csc = new CSharpCodeProvider(new Dictionary<string, string> {
            { "CompilerVersion", "v4.0" }
        });
    }

    private CompilerParameters CreateParameter(string id)
    {
        var dllPath = Path.Combine(ExtensionDirectoryPath, id + ".dll");

        var parameter = new CompilerParameters(new[] {
            "mscorlib.dll",
            "System.dll",
            "System.Core.dll",
            "System.ComponentModel.Composition.dll",
            "CSharpCodeProviderAndMEF.exe" // 自分を含めるのを忘れずに!!
        }, dllPath);

        return parameter;
    }
}

このクラスを使って、DLLを生成するように、Mainメソッドを書き換えます。

static void Main()
{
    var codeFactory = new CodeFactory();
    var dllFactory = new DllFactory();

    while (true) {
        Console.Write("文字を入力してください。> ");
        var message = Console.ReadLine();

        if (string.IsNullOrEmpty(message)) break;

        var id = CreateId();
        var code = codeFactory.CreateCode(id, message);

        var compileResult = dllFactory.Compile(id, code);

        Console.WriteLine("CompileResult: {0}", compileResult.NativeCompilerReturnValue);
        if (compileResult.NativeCompilerReturnValue != 0) {
            Console.WriteLine(string.Concat(compileResult.Output.Cast<string>()));
        }
    }
}

実行してみると、文字を入力するたびにApp_Data\Extensionsディレクトリ以下にDLLがポンポンと作成されていきますw

3. MEFを使いDLLを読み込む

DLLが作成できるようになったので、MEF (Managed Extensibility Framework)を使い作成したDLLを読み込むクラスを作成していきます。

Managed Extensibility Framework (MEF)

MEFについては、okazukiさんのブログに入門記事がまとまっています。とても分かりやすいです。

Managed Extensibility Framework入門 まとめ - かずきのBlog@hatena

DLLを読み込む手順は、以下のとおりになります。

  1. カタログを作成する
  2. カタログを指定してコンテナを作成する
  3. インターフェースと名前を指定してコンテナからインスタンスを取得する

それぞれの細かい手順については、うえで紹介した入門記事にすべてまとまっています。(丸投げ)

上記の処理を実行するクラスMessageContainerを作成します。

public class MessageContainer
{
    private DirectoryCatalog dirCatalog;
    private CompositionContainer container;

    public MessageContainer()
    {
        Initialize();
    }

    public IMessage Resolve(string id)
    {
        var instance = container.GetExportedValue<IMessage>(id);
        return instance;
    }

    public void Refresh()
    {
        // ディレクトリ以下の最新ファイルでカタログを更新する
        dirCatalog.Refresh();
    }

    // private

    private void Initialize()
    {
        var asmCatalog = new AssemblyCatalog(Assembly.GetExecutingAssembly());
        dirCatalog = new DirectoryCatalog(DllFactory.ExtensionDirectoryPath);
        var catalog = new AggregateCatalog(asmCatalog, dirCatalog);

        container = new CompositionContainer(catalog);
    }
}

ポイントは、Refreshメソッドです。RefreshメソッドではDirectoryCatalog#Refreshを呼び出しています。このメソッドを呼び出すことで、コンストラクタで指定したディレクトリ以下に新しいDLLがあれば、それを含むようにカタログを更新してくれます。DLLを生成後にRefreshメソッドを呼び出すことで、毎回1からコンテナを作成する必要がありません。

Resolveメソッドに、コード生成とDLL作成時に指定したIDを指定することで、生成したクラスのインスタンスが取得できます。そのインスタンスのメソッドGetMessageを実行することで、入力した文字が取得できるようになります。

Mainメソッドを次のように書き換えます。ついでに時間も測っておきます。

static void Main()
{
    var codeFactory = new CodeFactory();
    var dllFactory = new DllFactory();
    var container = new MessageContainer();

    while (true) {
        Console.Write("文字を入力してください。> ");
        var message = Console.ReadLine();

        if (string.IsNullOrEmpty(message)) break;

        var sw = Stopwatch.StartNew();

        var id = CreateId();
        var code = codeFactory.CreateCode(id, message);

        var compileResult = dllFactory.Compile(id, code);

        if (compileResult.NativeCompilerReturnValue != 0) {
            Console.Error.WriteLine(string.Concat(compileResult.Output.Cast<string>()));
            continue;
        }

        container.Refresh();
        var instance = container.Resolve(id);
        Console.WriteLine(
            "from MEF. [Message: {0}, Time: {1}]",
            instance.GetMessage(),
            sw.Elapsed);
    }

f:id:rabitarochan:20131218140211p:plain

実行してみると、マシンの性能によりますが、コード生成からメッセージ出力までだいたい200ミリ秒くらいでできます。

また、一度生成したDLLについてはMEFのコンテナでキャッシュされていますので、時間がかかりません。IDをキャッシュして、コンソールからの入力がなかった場合は前回のメッセージを出力するように修正してみます。

static void Main()
{
    var codeFactory = new CodeFactory();
    var dllFactory = new DllFactory();
    var container = new MessageContainer();

    string preId = null;

    while (true) {
        Console.Write("文字を入力してください。> ");
        var message = Console.ReadLine();

        var sw = Stopwatch.StartNew();

        if (string.IsNullOrEmpty(message)) {
            var preInstance = container.Resolve(preId);
            Console.WriteLine(
                "cached MEF. [Message: {0}, Time: {1}]",
                preInstance.GetMessage(),
                sw.Elapsed);
            continue;
        }

        var id = CreateId();
        var code = codeFactory.CreateCode(id, message);

        var compileResult = dllFactory.Compile(id, code);

        if (compileResult.NativeCompilerReturnValue != 0) {
            Console.Error.WriteLine(string.Concat(compileResult.Output.Cast<string>()));
            continue;
        }

        container.Refresh();
        var instance = container.Resolve(id);
        Console.WriteLine(
            "from MEF. [Message: {0}, Time: {1}]",
            instance.GetMessage(),
            sw.Elapsed);

        preId = id;
    }
}

f:id:rabitarochan:20131218140216p:plain

まとめ

CSharpCodeProvider と MEFを組み合わせることで、実行時に生成したDLLを簡単に扱う方法を紹介しました。今回の例は特に役に立たないと思いますが、これを使うことでLINQPadのようなものが簡単に作れます。実際に、Fluentdを使ってMongoDBに集めたログなどのデータに対して、入力したLINQを利用して検索・集計するWebツールを作ってます。

CSharpCodeProviderではなく、いま話題(?)のRoslynなどを使えば、もっと面白いことができるかもしれないですね。まだ追いかけてませんが・・・。

ということで、CSharpCodeProvider + MEFを組み合わせる方法の紹介でした。明日は yfakariya さんです。

今回のソースコードは、以下においてあります。

https://github.com/rabitarochan/CSharpCodeProviderAndMEF