読者です 読者をやめる 読者になる 読者になる

graphics.hatenablog.com

テクニカルアーティストの技術を書き殴るためのメモ帳

C# をランタイムコンパイルしてPythonとかRubyの代わりに使う

var p = new Process();
var pi = p.StartInfo;
pi.FileName = "python.exe";
pi.Arguments = "test.py";
pi.UseShellExecute = false;
pi.CreateNoWindow = true;
pi.RedirectStandardOutput = true;
pi.RedirectStandardError = true;
p.Start();

 みたいなやつ、インタプリタの起動に時間かかるし、データのやりとり面倒だし、IronPythonはCPythonとの使い分けがだるいし、IronRubyに至ってはメンテされてないし、1スクリプト1機能にすると.pyとか.rbのファイル数がとんでもないことになるし。

 もっと速く楽に自由に、こういうのやりたかった。

理想

 このくらいできれば、(スクリプトを書く手間を除いて)↑の代わりに使える気がする。

object result;
using (var external = new ExternalSpace()) {
    external.StandardOutput = (str) => MyOutputLogging(str);
    external.StandardError = (str) => MyErrorLogging(str);
    try {
        var script = new CsScript(external);
        using(var reader = new StreamReader("test.cs")) {
            var source = reader.ReadToEnd();
            script.Compile(source);
        }
        var testClass = script.CreateInstance("Test");
        var testMethod = testClass.GetMethod("test");
        result = testMethod(args);
    } catch (ScriptException e) {
        MyErrorHandling(e);
    }
}

現実

 というわけで、実際にやってみる。

C#のランタイムコンパイル

 CSharpCodeProvider を使う。ぐぐればいくらでも情報がでてくる。

 注意点としては、.net framework 4.5 が使いたいときでも、CSharpCodeProvider のコンストラクタに渡す "CompilerVersion" には "v4.0" を指定すること。"v4.5" だと動かない。
 .net 4.5 は 4.0 のマイナーバージョンアップだから、アセンブリ的には 4.0 のやつに上書きされるかたちでインストールされてる。実際、C:\Windows\Microsoft.NET\Framework あたりをみると v4.0.3019 って名前のフォルダがあって、ここに 4.5 の中身がはいってる。"v4.0" を指定するとここにあるやつらがロードされる。"v4.0.3019" を指定してももちろん構わない。

メモリ空間の分離

 AppDomain を使う。これは名前の通り「アプリケーション(を実行するため)のドメイン」で、プロセス空間内に複数存在することができる。メモリを含む各種リソースは、ドメイン間で暗黙の共有が行われない。
 あと、CSharpCodeProvider でコンパイルしたアセンブリは、Provider を Dispose してもそのままメモリ上に残り続ける。アセンブリ単体でアンロードすることもできないから、ほっとくとメモリリークする。その対策として、ドメインの中にアセンブリを閉じ込めて、必要なくなったらドメインごと削除してしまう。

 つまり、ランタイムコンパイラを閉じ込めるための AppDomain を新規に用意して、その中でコンパイルしたり、その中からクラスのインスタンスを取り出したりすることになる。

 このときに面倒なのがアセンブリの扱い。例えばデフォルトの AppDomain の中で、別の AppDomain の中にあるコンパイラが吐くインスタンスオブジェクトを使うには、AppDomain 間で滞りなく受け渡しができるようにしなきゃいけない。要するにマーシャリングが必要、ということになる。
 単純なデータ構造体の受け渡しだけだったら Serializable にするんでも大丈夫なんだけど、各種操作を含むインスタンスオブジェクトを受け渡すときは MarshalByRefObject を継承する。シリアライズ (marshal by value) だと AppDomain をまたぐ操作ができない(="自分の" ドメインで処理しようとしてしまう)けど、marshal by reference だと、.net 側でドメイン間通信用のプロキシを用意してくれて、ちゃんと相手のドメインで処理を行ってくれる。

 アセンブリでもうひとつ面倒なのが、クラス定義を保持する場所の問題。つまり、DomainA にアセンブリが置かれている ClassA の定義を DomainB で使ってしまうと、DomainB の中にも ClassA の定義(=ClassA が内在するアセンブリ)がロードされることになる。なんか嫌だ。
 回避方法は Gushwell さんのブログ にある通り。今回はインターフェイスを使うことにした。dynamic とかあんま使いたくないし。

プロジェクト構成

 今回は 3 つのプロジェクトを用意して、このあたりを扱うことにした。

  1. cstest
  2. lib
  3. compiler

 ちょっとややこしい。

 lib.dll は複数のアプリから利用されることを前提に、共通化できる機能を詰め込んでおくためのクラスライブラリ。「C#のランタイムコンパイル機能」もここに含まれる。その他「ランタイムコンパイルされるC#スクリプト」で使えるような機能群も、ここに詰め込んでおく。
 lib.dll は cstest.exe がデフォルトで持っているドメインで動作しながら、別のドメイン内でランタイムを動作させる。このどきに「デフォルトのドメイン」に「ランタイムコンパイラアセンブリ」をロードしないようにするために、ランタイムコンパイラへは lib.dll 内にあるインターフェイス (ICompiler と IClassInstance) を通じてアクセスする。

 compiler.dll は、ICompiler と IClassInstance を実装するかたちで、ランタイムコンパイラ本体を抱え込む。この 2 つのインターフェイスはもちろん lib とは他の DLL 内に定義してもいいんだけど、DLL の数をあまり増やしたくなかったのと、C#スクリプトの利便性を考えて lib.dll を「参照に追加」した状態でランタイムコンパイルできるようにしたかった (=lib.dll はいずれにせよ必ず参照される) から、いっそ全部 lib.dll にまとめることにした。

 cstest.exe は、lib.exe 内にある各種機能を利用して動作するメインアプリケーション、という位置付け。ドメインとかは特に意識しないで使えるようにしたかった。compiler.dll は別に参照しなくてもビルドできるんだけど、動作時に結局 compiler.dll が必要だし、いろいろめんどくさいからとりあえず参照してみた。compiler.dll がリビルドされたタイミングで cstest 側にコピーするようにしてると、参照しなくて済む。

StdOut/Error の受け渡し

 本来の目的はあくまで「Python/Rubyプロセスを立ち上げる代わりにC#スクリプトを使う」だから、プロセスからの StdOut/Error を受け取るのと同じように、C#スクリプトからの StdOut/Error を受け取りたい。最初はどうしようか悩んだけど、実はすごく簡単だった。
 ↑にも書いたようにドメイン間でリソースの共有はされないから、StdOut/Error も当然共有されない。なので、ランタイムコンパイラが動作するドメイン内で Console.SetOut()/SetError() するだけで大丈夫だった。

 具体的には、lib.dll の中でランタイムコンパイラ用のドメインを作成したあとに、そのドメインの中に「SetOut/SetError するためのインスタンスオブジェクト」を置いてやればいい。これはランタイムコンパイラインスタンスをつくるときと同じように CreateInstanceAndUnwrap を使う。

 Console.Out/Error の実体は単なる TextWriter だから、今回はそいつらを適当に拡張して使ってみた。

 「SetOut/SetError する」ためのクラス実装はこんな↓かんじ。すごくシンプル。

パフォーマンスとか

 AppDomain の作成と破棄が重そうだなーと思ってたんだけど、実際にやってみたらそれぞれ 4-5ms くらいで、ひとまず問題はなさそうだった。メソッドのリフレクションなんかも基本的にはあまり軽い処理じゃないんだけど、実際に使うときは GUI 構築とかの裏スレッドでまわすことになるし、ひとまずはこのまま使っても大丈夫っぽい。

その他

 あとは、Roslyn を使えば「C#スクリプトを動かすのに必要な DLL を見つけてコンパイルするときに追加で参照する」とかもできるっぽい。.net って便利ですね。