C#で書かれたスクリプトを実行する の履歴(No.4)
更新C#の実行環境はコンパイラを含んでいる†
C#はライブラリの中にコンパイラを含んでいるので、 ライブラリを適切に呼び出すだけでC#のソースコードを 記述したテキストデータから .exe を作るようなことが できてしまいます。
このページでは、C#のコンパイラ機能を使って、 自作アプリケーション内にC#の実行系を組み込むことが 目的です。
つまり、アプリケーションの動作をカスタマイズするための マクロ言語としてC#をそのまま使おうという話。
C#スクリプトをメモリ上でコンパイルして実行する†
検索サイトで CompileAssemblyFromSource というキーワードを 入れると、使用例がいくつも出てきます。
Google:CompileAssemblyFromSource
これらを参考にすると、スクリプトをコンパイルするための基本形は こんな感じになります。
LANG:C# using Microsoft.CSharp; using System.CodeDom.Compiler; using System.Reflection; CompilerResults compilerResults = null; /// <summary> /// アセンブリ参照名を指定してスクリプトをコンパイルする /// </summary> /// <param name="script">スクリプトソース</param> /// <param name="assemblyNames"> /// アセンブリ参照名 /// <code> /// new string[]{"System.dll", "System.Windows.Forms.dll"} /// </code>のようにして与える。 /// </param> /// <returns>成功したら true</returns> public bool Compile(string script, string[] assemblyNames) { // コンパイル時のオプション設定 CompilerParameters param = new CompilerParameters(assemblyNames); param.GenerateInMemory = true; // exe を作らない param.IncludeDebugInformation = false; // デバッグ情報を付加しない // コンパイルする CSharpCodeProvider codeProvider= new CSharpCodeProvider(); compilerResults = codeProvider.CompileAssemblyFromSource(param, script); // エラーメッセージが無ければ成功 return compilerResults.Errors.Count == 0; }
コンパイル時に名前の分からないクラスや関数とのやりとり†
スクリプトをコンパイルしたのは良いですが、実際には このスクリプトから特定の関数を指定して実行できないと 意味がありません。
ただし、スクリプト中のクラス名や関数名はアプリケーション自身を コンパイルする時点では分かっていませんので、コンパイル結果の アセンブリにクラス名や関数名を与えてハンドルを得て、 それをちまちま操作していく感じになります。
LANG:C# private Type getClassReference(string ClassName) { if (compilerResults == null || compilerResults.CompiledAssembly == null) throw new NoExecutableAssemblyError(); return compilerResults.CompiledAssembly.GetType(ClassName); } /// <summary> /// クラス関数を呼び出す。 /// </summary> /// <param name="ClassName">クラス名</param> /// <param name="FunctionName">クラス関数名</param> /// <param name="Parameters">パラメータ</param> /// <returns></returns> public object InvokeClassFunction(string ClassName, string FunctionName, object[] Parameters) { Type type = getClassReference(ClassName); if (type == null) throw new NoSuchClassNameError(); MethodInfo mi= type.GetMethod(FunctionName); if (mi == null) throw new NoSuchClassFunctionError(); return mi.Invoke(null, Parameters); } /// <summary> /// クラスのインスタンスを作成する。 /// </summary> /// <param name="ClassName">クラス名</param> /// <param name="Parameters">コンストラクタに渡すパラメータ</param> /// <returns>クラスのインスタンス</returns> public object CreateInstance(string ClassName, object[] Parameters) { Type type = getClassReference(ClassName); ConstructorInfo[] info= type.GetConstructors(); for(int i=0; i<info.Length; i++){ if (Parameters.Length != info[i].GetParameters().Length) continue; bool unmatch = false; for (int j = 0; j < info.Length; j++) { if (info[i].GetParameters()[j].ParameterType != Parameters[j].GetType()) { unmatch = true; break; } } if (unmatch) continue; return info[i].Invoke(Parameters); } return null; } /// <summary> /// オブジェクトのメンバ関数を呼びだす。 /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FunctionName">関数名</param> /// <param name="Parameters">パラメータ</param> /// <returns></returns> public object InvokeFunction(object Object, string FunctionName, object[] Parameters) { Type type= Object.GetType(); MethodInfo mi = type.GetMethod(FunctionName); if (mi == null) throw new NoSuchClassFunctionError(); return mi.Invoke(Object, Parameters); }
上記コードの問題点†
一応、上記コードを使えば文字列として与えたスクリプトを アプリケーション上でコンパイルして、特定の関数を実行する ようなことが可能です。
ただ、記事をよく読んでみると、このようなコードを何も考えずに アプリケーションに組み込んだ場合、メモリリークに似た問題が 生じることが指摘されています。
これは、一旦コンパイルしたC#スクリプト(コンパイル済み スクリプトをアセンブリと呼ぶようです)は、スクリプトの 実行終了後も永遠にメモリ内に残るため、コンパイル&実行を 繰り返しているうちに、アプリケーションの使用メモリが 際限なくふくらんでしまうという現象です。
メモリリークを防ぐには†
これを回避するにはアプリケーション自体を実行している環境 (AppDomainと呼ばれるようです)とは別に、スクリプトの コンパイル&実行のための専用の実行環境を用意して、そこで 実行後、スクリプトを使い終わったら実行環境もろとも破棄 (Unload)するのが常套手段なのだそうです。
見つけた中ではこの記事がもっとも詳しそうでした。
Customizing the .NET Common Object Runtime - Part 2
http://www.setfocus.com/technicalarticles/customizingclrpart2.aspx
コンパイル&実行するコードをDLLに置く†
アプリケーション内部でスクリプトをコンパイルすると、 その時点でアセンブリがアプリケーションの実行環境に ロードされてしまうため、スクリプトのコンパイル自体を 別環境で行わなければなりません。
このため、スクリプトのコンパイルを行う部分のコードを DLL(ダイナミックリンクライブラリ)に切り出して、 次の手順で動作させることになります。
- DLLをロード
- DLLにスクリプトを渡してコンパイル
- DLL経由でスクリプトを実行
- DLLをアンロード(この時点でスクリプトも解放される)
上記コードをDLLに入れる†
[ファイル]-[新しいプロジェクト] から [クラスライブラリ] を 選んで ScriptRunner と名付けます。
表示される Class1.cs というファイルに以下を入力して、 ScriptRunnerLibrary.cs として保存します。
注意点として、別環境にあるクラスをアプリケーション側から アクセス可能にするため、Script クラスは MarshalByRefObject を 継承しています。
LANG:C# using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Linq; using System.Text; using Microsoft.CSharp; using System.CodeDom.Compiler; using System.Reflection; namespace ScriptRunnerLibrary { public class NoExecutableAssemblyError: Exception { }; public class NoSuchClassNameError: Exception { }; public class NoSuchClassFunctionError: Exception { }; /// <summary> /// コンパイル済みスクリプトを表すクラス<br/> /// 別 AppDomain で動作させることを前提に MarshalByRefObject を継承している。 /// </summary> public class Script: MarshalByRefObject { CompilerResults compilerResults = null; /// <summary> /// デフォルトのアセンブリ参照を使ってスクリプトをコンパイルする /// <code> /// Compile(script, /// new string[]{ /// "System.dll", /// "System.Data.dll", /// "System.Deployment.dll", /// "System.Drawing.dll", /// "System.Windows.Forms.dll", /// "System.Xml.dll", /// "mscorlib.dll" /// }); /// </code> /// と同等の動作。 /// </summary> /// <param name="script">スクリプトソース</param> /// <returns>成功したら true</returns> public bool Compile(string script) { return Compile(script, new string[]{ "System.dll", "System.Data.dll", "System.Deployment.dll", "System.Drawing.dll", "System.Windows.Forms.dll", "System.Xml.dll", "mscorlib.dll" }); } /// <summary> /// アセンブリ参照名を指定してスクリプトをコンパイルする /// </summary> /// <param name="script">スクリプトソース</param> /// <param name="assemblyNames"> /// アセンブリ参照名 /// <code> /// new string[]{"System.dll", "System.Windows.Forms.dll"} /// </code>のようにして与える。 /// </param> /// <returns>成功したら true</returns> public bool Compile(string script, string[] assemblyNames) { // コンパイル時のオプション設定 CompilerParameters param = new CompilerParameters(assemblyNames); param.GenerateInMemory = true; // exe を作らない param.IncludeDebugInformation = false; // デバッグ情報を付加しない // コンパイルする CSharpCodeProvider codeProvider = new CSharpCodeProvider(); compilerResults = codeProvider.CompileAssemblyFromSource(param, script); // エラーメッセージが無ければ成功 return compilerResults.Errors.Count == 0; } /// <summary> /// 直前のコンパイルで生じたエラーメッセージを返す。 /// </summary> /// <returns>エラーメッセージ</returns> public String ErrorMessage() { if (compilerResults == null) return ""; string result = ""; for (int i = 0; i < compilerResults.Errors.Count; i++) result += compilerResults.Errors[i].Line.ToString() + ":" + compilerResults.Errors[i].ErrorText + "\r\n"; return result; } // 内部で使う private Type getClassReference(string ClassName) { if (compilerResults == null || compilerResults.CompiledAssembly == null) throw new NoExecutableAssemblyError(); return compilerResults.CompiledAssembly.GetType(ClassName); } /// <summary> /// クラス関数を呼び出す。 /// </summary> /// <param name="ClassName">クラス名</param> /// <param name="FunctionName">クラス関数名</param> /// <param name="Parameters">パラメータ</param> /// <returns></returns> public object InvokeClassFunction(string ClassName, string FunctionName, object[] Parameters) { // 渡された引数の型をチェック Type[] argumentTypes = new Type[Parameters.Length]; for (int i = 0; i < Parameters.Length; i++) argumentTypes[i] = Parameters[i].GetType(); // クラスリファレンスを取得 Type type = getClassReference(ClassName); if (type == null) throw new NoSuchClassNameError(); // クラス関数を取得 MethodInfo mi= type.GetMethod(FunctionName, argumentTypes); if (mi == null) throw new NoSuchClassFunctionError(); // 呼び出し return mi.Invoke(null, Parameters); } /// <summary> /// クラスのインスタンスを作成する。 /// </summary> /// <param name="ClassName">クラス名</param> /// <param name="Parameters">コンストラクタに渡すパラメータ</param> /// <returns>クラスのインスタンス</returns> public object CreateInstance(string ClassName, object[] Parameters) { // 渡された引数の型をチェック Type[] argumentTypes = new Type[Parameters.Length]; for (int i = 0; i < Parameters.Length; i++) argumentTypes[i] = Parameters[i].GetType(); // クラスリファレンスを取得 Type type = getClassReference(ClassName); if (type == null) throw new NoSuchClassNameError(); // コンストラクタの取得 ConstructorInfo constructorInfo = type.GetConstructor(argumentTypes); if (constructorInfo == null) throw new NoSuchClassFunctionError(); // 呼び出し return constructorInfo.Invoke(Parameters); } /// <summary> /// オブジェクトのメンバ関数を呼びだす。 /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FunctionName">関数名</param> /// <param name="Parameters">パラメータ</param> /// <returns></returns> public object InvokeFunction(object Object, string FunctionName, object[] Parameters) { // 渡された引数の型をチェック Type[] argumentTypes = new Type[Parameters.Length]; for (int i = 0; i < Parameters.Length; i++) argumentTypes[i] = Parameters[i].GetType(); // 型情報を取得 Type type = Object.GetType(); // メンバ関数の取得 MethodInfo methodInfo = type.GetMethod(FunctionName, argumentTypes); if (methodInfo == null) throw new NoSuchClassFunctionError(); // 呼び出し return methodInfo.Invoke(Object, Parameters); } /// <summary> /// オブジェクトのフィールドに値を代入する /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">フィールド名</param> /// <param name="Value">値</param> public void SetField(object Object, string FieldName, object Value) { Type type = Object.GetType(); FieldInfo fieldInfo = type.GetField(FieldName); fieldInfo.SetValue(Object, Value); } /// <summary> /// オブジェクトのフィールドから値を読み出す /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">フィールド名</param> /// <returns>値</returns> public object GetField(object Object, string FieldName) { Type type = Object.GetType(); FieldInfo fieldInfo = type.GetField(FieldName); return fieldInfo.GetValue(Object); } /// <summary> /// オブジェクトのプロパティに値を代入する /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">プロパティ名</param> /// <param name="Value">値</param> public void SetProperty(object Object, string PropertyName, object Value) { Type type = Object.GetType(); PropertyInfo propertyInfo = type.GetProperty(PropertyName); propertyInfo.SetValue(Object, Value, null); } /// <summary> /// オブジェクトのプロパティから値を読み出す /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">プロパティ名</param> /// <returns>値</returns> public object GetProperty(object Object, string PropertyName) { Type type = Object.GetType(); PropertyInfo propertyInfo = type.GetProperty(PropertyName); return propertyInfo.GetValue(Object, null); } } }
F6でビルドすると .\bin\Release ディレクトリに ScriptRunner.dll が 作成されます。
DLLを呼び出してスクリプトをコンパイル・実行するためのクラス†
Script クラスは MarshalByRefObject を継承しているので、 アプリケーションからはほとんど透過的にアクセスすることが 可能です。
手順としては
- ドメインを作成(AppDomain.CreateDomain)
- Script クラスのインスタンスを作成して、同じ Script 型の変数に格納 (CreateInstanceAndUnwrap)
- Script 型変数を通じて自由に操作を行う
- ドメインの解放 (AppDomain.Unload)
となり、コードとしては
LANG:C# appDomain = AppDomain.CreateDomain(domainName); script = (ScriptRunnerLibrary.Script) appDomain.CreateInstanceAndUnwrap( "ScriptRunner", "ScriptRunnerLibrary.Script"); script.??? // script へのアクセス AppDomain.Unload(appDomain);
になります。これをクラスにまとめたのが次のソースです。
DLL内で作った ScriptRunnerLibrary.Script 型の変数を アプリケーション側で受け取り、操作するためには、 アプリケーション自体も ScriptRunnerLibrary.Script を知っている 必要があります。
そこで、ScriptRunnerLibrary.cs をプロジェクトに含めてしまうと 同じソースに対するアセンブリがアプリケーション側とDLL側の 両方に含まれてしまうため、うまく行きません。
アプリケーションにはソースファイルではなく、コンパイル済みの アセンブリである "ScriptRunner.dll" から ScriptRunnerLibrary.Script に関する情報を得てもらうことになります。
手順は、[プロジェクト]-[参照の追加] を使って "ScriptRunner.dll" を 指定することになります。
ScriptRunner.cs
LANG:C# using System; using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.CSharp; using System.CodeDom.Compiler; using System.Reflection; namespace ScriptRunnerLibrary { /// <summary> /// DLLに格納された ScriptRunnerLibrary.Script クラスを /// 使って文字列として渡されたC#コードをコンパイルし、 /// 実行するためのクラス /// </summary> public class ScriptRunner { static Random rand = new Random(); AppDomain appDomain = null; ScriptRunnerLibrary.Script script = null; private void unloadLibrary() { // アプリケーション終了時に暗黙的に解放される // ことがあるため、エラー回避用に catch している if (appDomain != null) { try { AppDomain.Unload(appDomain); appDomain = null; } catch (CannotUnloadAppDomainException) { ; // すでにアンロードされていた } } } private void loadLibrary() { // ドメインには乱数を使って一意の名前を付ける string domainName = Convert.ToString(rand.Next(), 16) + Convert.ToString(rand.Next(), 16) + Convert.ToString(rand.Next(), 16) + Convert.ToString(rand.Next(), 16); appDomain = AppDomain.CreateDomain(domainName); script = (ScriptRunnerLibrary.Script) appDomain.CreateInstanceAndUnwrap( "ScriptRunner", "ScriptRunnerLibrary.Script"); } /// <summary> /// 与えられたアセンブリを参照しつつスクリプトをコンパイルする /// </summary> /// <param name="scriptSource">C#スクリプト</param> /// <param name="assemblyNames">参照するアセンブリの名前リスト</param> public ScriptRunner(string scriptSource, string[] assemblyNames) { loadLibrary(); script.Compile(scriptSource, assemblyNames); } ~ScriptRunner() { // メモリを開放する unloadLibrary(); } /// <summary> /// コンパイルが成功したかどうかを確認する。 /// </summary> /// <returns>コンパイルに成功していれば true</returns> public bool Ready() { return script.ErrorMessages().Length == 0; } /// <summary> /// コンパイル時に生じたエラーメッセージを返す。 /// </summary> /// <returns>エラーメッセージ</returns> public string ErrorMessage() { return script.ErrorMessage(); } /// <summary> /// 標準のアセンブリを参照しつつスクリプトをコンパイルする /// </summary> /// <param name="scriptSource">C#スクリプト</param> public ScriptRunner(string scriptSource) { loadLibrary(); script.Compile(scriptSource); } /// <summary> /// クラス関数を呼び出す /// </summary> /// <param name="ClassName">クラス名</param> /// <param name="FunctionName">クラス関数名</param> /// <param name="Parameters">クラス関数への引数</param> /// <returns></returns> public object InvokeClassFunction(string ClassName, string FunctionName, object[] Parameters) { return script.InvokeClassFunction(ClassName, FunctionName, Parameters); } /// <summary> /// クラス名を指定してオブジェクトを作成する /// </summary> /// <param name="ClassName">クラス名</param> /// <param name="Parameters">コンストラクタへの引数</param> /// <returns></returns> public object CreateInstance(string ClassName, object[] Parameters) { return script.CreateInstance(ClassName, Parameters); } /// <summary> /// オブジェクトのメンバ関数を呼び出す /// </summary> /// <param name="Object"><see cref="CreateInstance"/> /// で作ったオブジェクト</param> /// <param name="FunctionName">メンバ関数名</param> /// <param name="Parameters">関数への引数</param> /// <returns></returns> public object InvokeFunction(object Object, string FunctionName, object[] Parameters) { return script.InvokeFunction(Object, FunctionName, Parameters); } /// <summary> /// オブジェクトのフィールドに値を代入する /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">フィールド名</param> /// <param name="Value">値</param> public void SetField(object Object, string FieldName, object Value) { script.SetField(Object, FieldName, Value); } /// <summary> /// オブジェクトのフィールドから値を読み出す /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">フィールド名</param> /// <returns>値</returns> public object GetField(object Object, string FieldName) { return script.GetField(Object, FieldName); } /// <summary> /// オブジェクトのプロパティに値を代入する /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">プロパティ名</param> /// <param name="Value">値</param> public void SetProperty(object Object, string PropertyName, object Value) { script.SetProperty(Object, PropertyName, Value); } /// <summary> /// オブジェクトのプロパティから値を読み出す /// </summary> /// <param name="Object">対象となるオブジェクト</param> /// <param name="FieldName">プロパティ名</param> /// <returns>値</returns> public object GetProperty(object Object, string PropertyName) { return script.GetProperty(Object, PropertyName); } } }
例外の補足†
スクリプト内で発生した例外は、呼び出し側には TargetInvocationException として 通知されます。
実際に発生した例外の情報にアクセスするには、InnerException プロパティを 使います。
LANG:C# try { /* スクリプトの呼び出し */ } catch (System.Reflection.TargetInvocationException ex) { // 例外からデータを取り出して表示 string message = ""; message += ex.InnerException.Message + "\r\n"; message += ex.InnerException.StackTrace + "\r\n"; message += "追加データ:\r\n"; System.Collections.IEnumerator i = ex.InnerException.Data.Keys.GetEnumerator(); while (i.MoveNext()) message += i.Current.ToString() + " : " + ex.InnerException.Data[i.Current] + "\r\n"; MessageBox.Show(message, "例外発生"); }
使用例†
たとえば、次のようにして動作を確認することができます。
LANG:C# static string cs = @" public class HelloWorldScript: System.MarshalByRefObject { public static void class_hello() { System.Windows.Forms.MessageBox.Show(""Hello, world! #1""); } public static void class_hello(string a) { System.Windows.Forms.MessageBox.Show(a); } public string message= """"; public HelloWorldScript() { } public HelloWorldScript(string msg) { message= msg; } public void obj_hello() { System.Windows.Forms.MessageBox.Show(""Hello, world! #3""); } public void obj_show() { System.Windows.Forms.MessageBox.Show(message); } public void obj_show(string a) { System.Windows.Forms.MessageBox.Show(a); } public string Message { set { message = value; } get { return message; } } public void obj_exception() { System.Exception e= new System.Exception(); e.Data[""message""]= ""Hello, world! #9""; throw e; } }"; private void button1_Click(object sender, EventArgs e) { try { // スクリプトをコンパイル ScriptRunnerLibrary.ScriptRunner runner= new ScriptRunnerLibrary.ScriptRunner(cs); // クラス関数呼び出し runner.InvokeClassFunction( "HelloWorldScript", "class_hello", new object[]{}); // クラス関数に引数を与えて呼び出し runner.InvokeClassFunction( "HelloWorldScript", "class_hello", new object[] { "Hello, world! #2" }); // オブジェクトの作成 object obj; obj= runner.CreateInstance("HelloWorldScript", new object[] { }); // オブジェクトのメンバ関数呼び出し runner.InvokeFunction(obj, "obj_hello", new object[] { }); // オブジェクトのメンバ関数に引数を与えて呼び出し runner.InvokeFunction(obj, "obj_show", new object[] { "Hello, world! #4" }); // オブジェクトのフィールドにデータを格納 runner.SetField(obj, "message", "Hello, world! #5"); runner.InvokeFunction(obj, "obj_show", new object[] { }); // オブジェクトのフィールドからデータを読み出す runner.SetField(obj, "message", "Hello, world! #6"); System.Windows.Forms.MessageBox.Show( runner.GetField(obj, "message") as string); // オブジェクトのプロパティにデータを格納 runner.SetProperty(obj, "Message", "Hello, world! #7"); runner.InvokeFunction(obj, "obj_show", new object[] { }); // オブジェクトのプロパティからデータを読み出す runner.SetProperty(obj, "Message", "Hello, world! #8"); System.Windows.Forms.MessageBox.Show( runner.GetProperty(obj, "Message") as string); // 例外の補足 runner.InvokeFunction(obj, "obj_exception", new object[] { }); } catch (System.Reflection.TargetInvocationException ex) { // 例外からデータを取り出して表示 string message = ""; message += ex.InnerException.Message + "\r\n"; message += ex.InnerException.StackTrace + "\r\n"; message += "追加データ:\r\n"; System.Collections.IEnumerator i = ex.InnerException.Data.Keys.GetEnumerator(); while (i.MoveNext()) message += i.Current.ToString() + " : " + ex.InnerException.Data[i.Current] + "\r\n"; MessageBox.Show(message, "例外発生"); } }
Hello, world! と書かれたメッセージボックスが9回表示されればOKです。
- スクリプト側で定義されたクラス関数の呼び出し
- スクリプト側で定義されたクラスのインスタンスの作成
- スクリプト側で定義されたクラスのインスタンスに対するメンバ関数の呼び出し
- これらすべてでオーバーロードされた(引数だけの異なる)関数の正しい呼び出し
- フィールドへのアクセス
- プロパティへのアクセス
- 例外の補足
ができていることが分かります。
注意点として、別環境で作ったオブジェクトにアプリケーション環境から アクセスするためには、そのオブジェクトのクラスが System.MarshalByRefObject を 継承するなどの形でシリアライズ可能になっている必要があります。
上の例でも HelloWorldScript クラスは System.MarshalByRefObject を 継承しています。
IDisposable を使うべき†
(2010/06/02 追記)
この記事を書いた時点ではまだC#は初心者だったので気づかなかったのですが、
LANG:c# ~ScriptRunner() { // メモリを開放する unloadLibrary(); }
の部分はデストラクタでなく IDisposable の Dispose で行うべきですね。
後で時間を見つけて直そうと思います。