*[hatefu:labs.yaneu.com/20101017/] C#をスクリプト言語として使う


C#で開発している場合、外部ファイルにしたいスクリプト用の言語として、わざわざLuaのようなC#とは異なる言語を使う必要は無いだろう。

スクリプト言語には素直にC#を採用すれば良い。これにより、いつでもスクリプトから本体のコードへと昇格が可能になる。逆に本体のコードからスクリプト用のコードへの降格も可能になる。

「ああ、なんだ。CSharpCodeProviderの話か、それなら知ってるよ」と思った人も、以下ではいろんなアイデアを書いてあるので最後まで目を通して損は無いと思う。


* CSharpCodeProviderについてのおさらい


まずCSharpCodeProviderについておさらいしておこう。


# CSharpCodeProvider
CSharpCodeProvider
http://dobon.net/vb/dotnet/programing/eval.html


CSharpCodeProviderは、実行時にC#のコードをコンパイルして実行することが出来る。それ自体は何も難しいことではない。上のリンク先を見れば5分で理解できる。


* 何故、型推論のためのvarが使えないのか?


スクリプト言語用のコードはテキストエディタで書かれることが多い。というのもスクリプト言語は現場作業者がちょっとしたカスタマイズをしたりするために使われるのだが、現場作業者はVisual Studioの使い方を知らなかったりするためである。

テキストエディタを用いて、プログラミングに対して専門的な知識が無い人が書くのに使う以上、

>>
List<List<string>> csv = ReadCsv("myData.csv");
<<

などと型名を正確に書くのは至難の業である。ここは無論、次のように書きたい。C# 3.0ならばそれが可能なはずだ。

>>
var csv = ReadCsv("myData.csv");
<<

ところが、CSharpCodeProviderでは何も設定しない場合、C# 2.0準拠のコードしかコンパイルできない。.NET Framework 3.5や4.0をターゲット環境にしているにもかかわらず、である。

これを回避するには、コンパイラのバージョンを指定してやる必要がある。裏技っぽいのだが、次のようにコンストラクタでバージョン指定をしてやれば良い。

>>
			using (var cscp = new CSharpCodeProvider(new Dictionary<string, string> { { "CompilerVersion", "v3.5" } }))
			{
<<

これでvarが使えるようになった。


* 参照アセンブリを動的に指定する


参照アセンブリ自体は、CompilerParameters.ReferencedAssembliesにどんどん追加して行けば良い。

>>
				var cps = new CompilerParameters();
				cps.ReferencedAssemblies.Add(path);

				CompilerResults cr = cscp.CompileAssemblyFromSource(cps, source);
<<

いま、スクリプト言語では、次のusingをしたいとする。

>>
using System;
using System.IO;
using System.Collections.Generic; // List,Dictionary,…,
using System.Runtime.Serialization.Formatters.Binary; // Serializer
using System.Text.RegularExpressions; // Regex
using System.Windows.Forms; // MessageBox,HtmlDocument
using System.Linq;
using System.Drawing; // Bitmap
<<

そのためには次のアセンブリ参照が必要である。

>>
			cps.ReferencedAssemblies.Add("System.dll"); // Regex
			cps.ReferencedAssemblies.Add("System.Windows.Forms.dll"); // MessageBox,HtmlDocument
			cps.ReferencedAssemblies.Add("System.Core.dll"); // Extensions
			cps.ReferencedAssemblies.Add("System.Drawing.dll"); // Bitmap
<<

また、自作のアセンブリを参照したい場合はそのdllファイルをfull pathで指定すると良い。

>>
			cps.ReferencedAssemblies.Add(
				Path.Combine(Application.StartupPath,"YaneGameSdk.dll")
			);
<<

これぐらいのアセンブリ参照は事前にやっておけばいいとは思うが、スクリプト言語として使い倒してくると使いもしないアセンブリ参照までずらずらと指定することになり、コンパイル~実行までの時間が長くなると思われる。(測定したわけではないが)

また、スクリプト言語側で新たに新しいアセンブリを参照したい場合、アセンブリ参照を追加するように本体(スクリプトを動的にコンパイルして呼び出す側のプログラム。以下で「本体」と書いてあるのはこの意味)のコードを修正しなければならないようでは、スクリプト言語としてのお手軽さが失われてしまうだろう。

そこで、アセンブリ参照をスクリプト側から動的に追加できる仕組みが必要だ。まずは、動的にアセンブリを追加するコードから。

>>
				// 他に追加アセンブリが指定されているならそれも追加する。
				foreach (var ra in referencedAssemblies)
				{
					// これシステム関係
					if (ra.StartsWith("System."))
					{
						// path指定せずに単にアセンブリファイル名のみを指定。
						cps.ReferencedAssemblies.Add(ra);
					}
					else
					{
						cps.ReferencedAssemblies.Add(
							Path.Combine(Application.StartupPath, ra)
							);
					}
				}
<<

* using命令の拡張


以上で準備は整った。

スクリプト側からアセンブリ参照を指定してやるには、using命令を拡張するのがお手軽だろう。

>>
using YaneGameSdk in "lib/YaneGameSdk.dll";
<<

のようにスクリプト言語側で表記すれば、実行ファイルの存在するディレクトリ配下のlibフォルダにあるYaneGameSdk.dllへの参照を自動的に追加してくれると助かる。

これは難しいことではない。using命令は独立した行なので、単なるパターンマッチングで済む。

>>
			// using XXX in "lib/XXX.dll"; みたいな形があるなら、これは"using XXX;"に書き換えてかつそれを
			// reference assemblyに追加しなくてはならん。

			var referencedAssemblies = new List<string>(); // 追加のreference assembly
			// どうせ最初の200行ぐらいまでにしかusingは来ない。
			{
				var regex = new Regex(@"(using [a-zA-Z\.]+)[ \t]+in[ \t]*\""(.+)\"".*;");
				for (int i = 0; i < 200 && i < sourceLines.Count; ++i)
				{
					var match = regex.Match(sourceLines[i]);
					if (match.Success)
					{
						// 一致したので、参照アセンブリに追加して、元のソースを変更する。
						referencedAssemblies.Add(match.Groups[2].Value);
						sourceLines[i] = match.Groups[1].Value + ";";
					}
				}
			}
<<

* #include命令が欲しい


スクリプト言語として使うならばC/C++の#include命令に相当する命令が欲しいところだ。
これについてはあとで考えよう。


* 重複usingの除去


スクリプト言語として使う上で是非やっておきたいのが重複usingの除去だ。何故これが必要になるかと言うと、#include命令によってスクリプトをいくつかのファイルに分割したとき(クラスやモジュールの単位で分割する)、それぞれのスクリプトでusingをすることがあるからだ。重複usingの除去自体は難しくない。


>>
			var sourceLines = new List<string>(source.Split(new[] { "\r\n" }, StringSplitOptions.None));

			// 重複usingの除去。
			{
				var usingDic = new Dictionary<string, object>();
				var regex = new Regex(@"(using [a-zA-Z\.]+)[ \t;]");

				// どうせ最初の200行ぐらいまでにしかusingはこない。
				for (int i = 0; i < 200 && i < sourceLines.Count; ++i)
				{
					var line = sourceLines[i];
					var match = regex.Match(line);
					if (match.Success)
					{
						// usingであった。
						var using_line = match.Groups[1].Value;
						if (usingDic.ContainsKey(using_line))
						{
							// 重複

							// この行は削除が決定
							sourceLines[i] = ""; // 行数を減らすのが面倒なのでこれでいいや。
						} else
						{
							// 未出
							usingDic.Add(using_line,null);
						}
					}
				}
			}
<<

* #include命令の実装


include命令を実装する場合の注意点として、include先のファイルでコンパイル時のエラーが起きたときにそのinclude先のファイル名とその行数が表示されて欲しいということだ。そのためには、includeしたときに元のファイル名と行数を記憶しておく必要がある。


>>
		// includeした元のソースの名前と行番号を保持する
		private class SourceNameAndLine
		{
			public string SourceName;
			public int lineNo;
		}
<<

ソースファイル名とその何行目から何行目というように区間を持っておけばデータサイズ的には効率が良くなるが、実装のためのコードが複雑になるし、スクリプトファイル自体そんなに行数が多くなるものでもないので今回のものは少し手抜き実装である。

>>
			var snal = new List<SourceNameAndLine>();

			for (int i = 0; i < sourceLines.Count; ++i)
				snal.Add(new SourceNameAndLine() { SourceName = path, lineNo = i + 1 });
			// 以下、SourceLinesをいじるごとにそれと整合性がとれるように↑を調整する。
			// もう少しまともに実装できるけど、どうせスクリプトって100行程度だしな…。

			// includeしてたらそこでファイルから読み込むか。
			{
				var include_count = 0; // includeした回数
				var regex = new Regex(@"#include[ \t]+""(.*?)""");
				for (int i = 0; i < sourceLines.Count; ++i)
				{
					var result = regex.Match(sourceLines[i]);
					if (result.Success)
					{
						if (++include_count == 1000)
							throw new Exception("最大include回数に達しました");

						// 一致したので、この行は置換。
						var filename = result.Groups[1].Value;
						var includeFile = Read(filename);
						var includeLines = includeFile.Split(new[] { "\r\n" }, StringSplitOptions.None);
						// これappendしとこう。
						sourceLines.RemoveAt(i);
						snal.RemoveAt(i);

						sourceLines.InsertRange(i, includeLines);
						for (int j = 0; j < includeLines.Length; ++j)
							snal.Insert(i + j, new SourceNameAndLine() { SourceName = filename, lineNo = j + 1 });

						// 二重includeはありうるのだが、循環includeはまずいよなぁ…。
						// includeは1000回まで!!という制限をつけるか。
					}
				}
			}
<<

もちろんinclude先のスクリプトファイルでさらに別のスクリプトファイルをincludeしたり出来る。

Readはファイルから読み込んでそれを丸読みしてstringを返すFunc<string /*filename*/ , string /* result */>である。なぜこんなものを通しているかというと、後述するようにスクリプトファイルをリリース時には隠匿したい場合、ファイル自体に何らかの暗号化をほどこすためである。


* 拡張メソッドの実装


スクリプトファイル側で毎回usingを書いたり、拡張メソッドをusingしたりするのは嫌なので、ソースの前後に別のスクリプトを差し込めるようにしておく。

>>
		// usingなどheader部分に外部からinjectするためのソース。
		public string HeaderSource = "#include \"system/header_script.txt\"\r\n";

		// partial class Programとして外部からinjectするためのソース。
		public string InjectionSource = "#include \"system/injection_script.txt\"\r\n";

		// 拡張メソッドとして外部からinjectするためのソース。
		public string FooterSource = "#include \"system/footer_script.txt\"\r\n";
<<

>>
			var source =
				HeaderSource +    // headerとして毎回usingしたいものなどはここに書く。
				Read(path) +      // メインのスクリプト patial class Program { public static void Main() { ... } }
				InjectionSource + // partial class Program { }はここに書く。
				FooterSource      // 拡張メソッドここに置く。
			;
<<

としておき、system/header_script.txt には

>>
using System;
using System.IO;
using System.Collections.Generic; // List,Dictionary,…,
using System.Runtime.Serialization.Formatters.Binary; // Serializer
using System.Text.RegularExpressions; // Regex
using System.Windows.Forms; // MessageBox
using System.Linq;
using System.Drawing; // Bitmap
<<

と書いておけば、毎回usingする必要は無くなる。

extensionメソッドは system/footer_script.txt に書く。例えば、文字列操作用のextensionとして、"ABCDE".Left(3)のように書けば"ABC"という文字列が返ってくるようなメソッドを次のようにして追加しておく。

>>
public static class Extensions
{
	// C#のSubstringが使いにくいのでVB.NETみたいなLeft,Right,Midを追加。
	public static string Left(this string str, int length)
	{
		return str.Substring(0, Math.Min(length, str.Length));
	}
	public static string Right(this string str, int length)
	{
		if (length >= str.Length)
			return str; // ちょんぎる必要がない。

		return str.Substring(str.Length - length, length);
	}
	public static string Mid(this string str, int position, int length)
	{
		if (position >= str.Length || length < 0)
			return ""; // ちょんぎれない。
		if (position + length > str.Length)
			length = str.Length - position;

		return str.Substring(position, length);
	}
}
<<

* include命令を使うメリット


さきほどソースプログラムは、以下のように
** 1) ヘッダー
** 2) スクリプト本体
** 3) class Programの追加部分
** 4) 拡張メソッド
の4つの部分を繋ぎ合わせて作られていた。

>>
			var source =
				HeaderSource +    // headerとして毎回usingしたいものなどはここに書く。
				Read(path) +      // メインのスクリプト patial class Program { public static void Main() { ... } }
				InjectionSource + // partial class Program { }はここに書く。
				FooterSource      // 拡張メソッドここに置く。
			;
<<

ところがこのうちのHeaderSource,InjectionSource,FooterSourceは

>>
		// usingなどheader部分に外部からinjectするためのソース。
		public string HeaderSource = "#include \"system/header_script.txt\"\r\n";

		// partial class Programとして外部からinjectするためのソース。
		public string InjectionSource = "#include \"system/injection_script.txt\"\r\n";

		// 拡張メソッドとして外部からinjectするためのソース。
		public string FooterSource = "#include \"system/footer_script.txt\"\r\n";
<<

のようにdefaultでは単にinclude命令で外部ファイルをincludeしているだけだった。何故直接ファイルから読み込んだものをこれらのメンバに設定しないのかと思われるかも知れないが、それをやってしまうとコンパイルエラーが発生したときにどのファイルのどの行なのかを追跡するのが難しくなるのだ。

include命令を使って読み込む限りはエラーが発生してもどのファイルの何行目であるかを逆算することが出来る。それはinclude命令が担保してくれる。

これがinclude命令を実装する最大のメリットなのだ。だから、この手のスクリプト言語では真っ先に実装すべきなのだ。


* Hello! ScriptWorld!!


このスクリプト言語を用いて、さっそくプログラムを書いてみよう。

>>
public partial class Program
{
	public static void Main()
	{
		Show("Hello! ScriptWorld!!");
	}
}
<<

これで、"Hello! ScriptWorld!!"とダイアログメッセージが表示される。

Showメソッドに名前空間やクラスの修飾が無いがどうなっているかと言うとclassがpartialになっているのがミソで、system/injection_script.txt には次のように書かれている。

>>
public partial class Program
{
	// Show("ABC");などとやるだけでダイアログが出てくればデバッグに便利
	public static void Show(string message)
	{
		System.Windows.Forms.MessageBox.Show(message);
	}
	public static void Show(long message)
	{
		System.Windows.Forms.MessageBox.Show(message.ToString());
	}

	// 以下省略。
}
<<

このようにスクリプト側でしょっちゅう使うものは名前空間やクラス名の修飾無しに使えたほうが便利だ。


* アセンブリキャッシュ


コンパイルが成功したものに関しては次のようなアセンブリキャッシュに入れておくとスクリプトからスクリプトを呼び出すときに再コンパイルが不要になるのでいいかも知れない。

>>
		// path→Assemblyへのcache。
		private Dictionary<string, Assembly> assemblyCache;
<<


* コンパイルエラー時の表示


いままで見てきたように、ユーザーの書いたスクリプトの直前にヘッダー用のスクリプトが挿入されることがわかった。コンパイルエラーの際には、このぶんの行数を減算して表示しなければならない。これは例えば次のようなプログラムになる。

>>
				CompilerResults cr = cscp.CompileAssemblyFromSource(cps, source);

				// HeaderSourceの行数をカウントしておき、コンパイルエラーのときの行数を調整する。
				// 改行文字列(2文字)→1文字に置換すれば、その置換された回数が出現回数である。
				headerLineCount = HeaderSource.Length - HeaderSource.Replace("\r\n", " ").Length;

				if (cr.Errors.Count > 0)
				{
					string errors = Path.GetFileName(path) + "のコンパイル中にエラーが発生しました。\n";
					foreach (CompilerError err in cr.Errors)
					{
						int line = err.Line - headerLineCount - 1; // err.Lineは1-originなので。
						string fn = "";
						if (0 <= line && line < snal.Count)
							fn = Path.GetFileName(snal[line].SourceName) + "の" + snal[line].lineNo;
						else
							fn = line.ToString(); // 範囲外なのでfooter scriptか何かでエラーになったんだろ。
						errors += fn + "行目の" + err.Column + "文字目 → " + err.ErrorText + "\n";
					}

					throw new ScriptCompileErrorException(errors);
				}
<<

挿入されるヘッダースクリプトの行数をカウントしなければならないが、上のソースでは改行コード(2文字)をReplaceで1文字に置換して、そのLengthを調べることで改行コードの出現回数をカウントするというテクニックを用いている。


* 実行時の捕捉されなかった例外の場所を表示する


実行時に捕捉されない例外が出ることもある。このソース上の行数をユーザーのために表示したい。しかし、これが一筋縄ではいかない。


>>
				var cps = new CompilerParameters();

				// デバッグ情報を生成するためのテクニック
				cps.IncludeDebugInformation = true; // デバッグ情報が無いと例外が出たときにStackTraceに行数が出ない。
				cps.GenerateInMemory = false; // メモリ上だと例外が発生したときにStackTraceが出せない。
<<

こう指定すればデバッグ情報(拡張子が".pdb"のファイル)が出力される。そうすれば実行時の例外を本体のほうで捕捉して、そのStackTraceを調べればスクリプトの行番号ぐらいわかりそうなものである。ところが、そう簡単でもない。

** 1) pdbがlockされたままになる問題

まず、ひとつ目の問題として、この方法でpdbファイルは確かに出来るのだが、その後、pdbファイルはプログラムの終了までlockされてしまうのである。.NET Framework(バージョンは2.0/3.5/4.0)では一度読み込んだアセンブリを解放できない。将来的には出来るようになるのかも知れないが、いまのところ出来ない。この制約は動的に生成したアセンブリについても同様であり、アセンブリが解放できないということは、いつまでもそのアセンブリは生存していて、いつまでもそのデバッグ情報ファイル(pdbファイル)が必要になるということである。

すなわち、上のようなコンパイルオプションで動的にコンパイルした場合、そのpdbファイルはプログラム終了までlockされており、削除・上書きが出来ない状態になる。これはとても困ったことである。スクリプトだから、少し修正してコンパイルしなおして実行というのを繰り返したいのに、その都度、本体のプログラムもいったん終了させないといけないことになる。

これは耐え難い仕様だ。Windowsのテンポラリフォルダに書き出すのがいいのかも知れないが、本体終了時にもこのファイルを削除することが出来ない。それはこれらのファイルがlockされたままであるからだ。削除されないファイルをテンポラリフォルダに書き出すのは少しお行儀が悪い。

そこで、ここでは次のように自分の実行フォルダ配下に作成する。
test0.dllがlock中ならばtest1.dll、test1.dllがさらにlock中ならばtest2.dll、…。
ここで書き出したファイルは次回の本体起動時にでも削除すればいいだろう。

>>
				for (int i = 0; ; ++i)
				{
					var fullPath = Path.Combine(Application.StartupPath, "temp/" + Path.GetFileName(path) + i);
					try
					{
						File.Delete(fullPath + ".dll");
						File.Delete(fullPath + ".pdb");
						// ファイルが存在しなくとも例外は出ないが、ファイルが存在して権限的に削除できなければ例外が出る
					}
					catch
					{
						continue;
					}
					cps.OutputAssembly = fullPath + ".dll";
					break;
				}
<<

** 別解) AppDomainを作る方法


別のAppDomainを作り、そこにコンパイルしたアセンブリを読み込み、コンパイルエラーが出た場合は、AppDomain.UnloadでAppDomain自体を解放してやると".dll"と".pdb"ファイルに対するlockが解除される。

# 参考
Customizing the .NET Common Object Runtime - Part 2
http://www.setfocus.com/technicalarticles/articles/customizingclrpart2.aspx

しかしコンパイルエラーが出るのは開発時だけだし、アセンブリ自体は上述したように自前でcacheするから実行時に同じスクリプトファイルを二度コンパイルすることは無いと思うので、今回はこの問題にはこれ以上踏み込まない。


** 2) 例外発生時にソースファイル名が取得できない問題


例外発生時にソースファイル名が取得出来ない。事前に取得しておく必要がある。

>>
				CompilerResults cr = cscp.CompileAssemblyFromSource(cps, source);
				scriptTmpFile = cr.TempFiles.BasePath; // スクリプトをコンパイル後の一時ファイル名(拡張子は無し)
<<

これでスクリプトの一時ファイル名はわかる。"c:\\Users\\yaneurao\\AppData\\Local\\Temp\\lft4zo_o"のような文字列が返ってきているはずだ。しかしこれはBasePathであって、実際のファイル名ではない。実際のファイル名はここに ".0.cs" だとか ".1.cs"だとか数字が付与されて、さらに末尾に".cs"の拡張子が付与される。

実行時に例外を本体のほうで捕捉したら、ex.InnerException.StackTraceを調べるとその1行目に例外が発生した場所が書かれている。

>>
"   場所 Program.Main() 場所 c:\\Users\\yaneurao\\AppData\\Local\\Temp\\lft4zo_o.0.cs:行 15\r\n   "
<<
のような感じである。この右端に書かれている"15"という行番号も直接取得するためのメソッドは用意されていない。自分で文字列をparseする必要がある。例えば次のようになる。「行 15」と書いてある「行」の部分はたぶんローカリゼーションの影響を受けるので正規表現でパターンマッチングを行なうときには気をつけたほうが良い。また今後の.NET Frameworkのバージョンアップなどで形式が変わるかも知れない。困ったものだ。

>>
	catch (Exception ex)
	{
		// Worker用threadなのでthreadを超えて例外を伝播できないため、最後の砦としてここで捕捉する。

		// 例外起こして停止させるときにはエラーメッセージは不要。
		if (!IsStopException(ex))
		{
			if (isFirstError)
			{
				// thread超えの例外かも知れんので、ここで捕捉しとくか。

				// 		ex.InnerException.StackTrace	"   場所 Program.Main() 場所 c:\\Users\\XXX\\AppData\\Local\\Temp\\lft4zo_o.0.cs:行 15\r\n   "
				// この最初の行をsnalに基づいて置換する。

				var error = "";
				var stackTrace = "";
				if (ex.InnerException != null && ex.InnerException.StackTrace != null)
				{
					// ex.StackTraceのほうには記載されてないんだよね。
					var traces = new List<string>( ex.InnerException.StackTrace.Split(new[] {"\r\n"}, StringSplitOptions.None) );
					// これがscriptDllFileに一致するのなら行数を計算する。

					bool isFound = false;
					for (var i = 0; i < traces.Count; ++ i )
					{
						var trace = traces[i];

						// traceの後ろから数字部分。
						var regex = new Regex(@"場所(.*?)場所.*?\\([a-zA-Z0-9_\-]+)(\.[0-9]+)*\.cs.+?([0-9]+)$");
						var match = regex.Match(trace);
						if (match.Success)
						{
							if (match.Groups[2].Value == Path.GetFileName(scriptTmpFile))
							{
								var line = int.Parse(match.Groups[4].Value);
								line = line - headerLineCount - 1; // lineは1-originなので
								if (line < snal.Count)
								{
									// 元のメッセージを置換して表示するか
									
									// ひとつ目のエラーか?
									if (!isFound)
									{
										var source = Path.GetFileName(snal[line].SourceName);
										error = source + "の" + snal[line].lineNo + "行目で捕捉されない例外が発生しました。"
										        + ex.InnerException.Message + "\r\n\r\n";
									}

									var matchLine = "  場所" + match.Groups[1] + "場所 " + runningScriptName + " :行 " + snal[line].lineNo;
									traces[i] = matchLine;
								}
								// ここでbreakせず、すべて置換してしまう。
								isFound = true;
							}
						}
					}
					if (isFound)
					{
						// stack traceの下から何個かはstart upルーチンで、ユーザーにとっては意味のない情報なので削っておく。
						var N = 1;
						if (traces.Count >= N)
							traces.RemoveRange(traces.Count - N, N); // 後ろのN個を削る
						stackTrace = string.Join("\r\n", traces.ToArray());
					}
				}

				MessageBox.Show(error +  stackTrace , "捕捉されない例外が発生しました。");

				// このあと、pdbとか解放したいんだけどどうすればいいのかな…。

				isFirstError = false;
			}
		} else
		{
			// スクリプトの実行が終了したので終了時のハンドラが設定されていれば、それを呼び出す。
			if (ScriptStop!=null)
				ScriptStop(path);
		}
<<


* スクリプト側にデータを渡す


スクリプトはコンパイルするとアセンブリになる。アセンブリなのでリフレクションを用いて自由にstaticメンバ変数の値を変更できる。それを用いてデータを渡しても良い。

もっと単純には、スクリプトを呼び出し時に引数として渡してやることだ。

>>
				var type = asm.GetType("Program"); // クラス名を取得
				var mi = type.GetMethod("StartUp"); // メソッド名を取得

				mi.Invoke(null, new[] { ScriptObj });
<<

渡す型はobject型にしておけば、渡された側はどうとでもcastして使えばいい。例えば、system/injection_script.txt には次のようなStartUpルーチンを仕込んでおくことが出来るだろう。

>>
public partial class Program
{
	public static void StartUp(object scriptObj_) { scriptObj = scriptObj_; Main(); }
	public static object scriptObj; // ScriptData

	public static ScriptData st
	{ get { return scriptObj as YaneuraoGameScript2010.ScriptData; }}

	// 以下省略。
}
<<


* スクリプトの強制停止


スクリプト実行を本体側のUI操作によって途中で停止させたい場合がある。これには例外を用いると良いだろう。

>>
	// スクリプトが外部から強制停止したときに発生する例外。
	public class ScriptStopException : Exception{}
<<

このような例外を本体側で用意しておき、停止ボタンを押したときにこれをthrowすれば、スクリプトはこの例外を捕捉しないまま実行を終了し、本体側でこの例外を捕捉して、スクリプトが強制停止されたことを確認できるはずだ。

問題は、スクリプト側にどうやってそのシグナルを伝達するかだが、何かシグナル伝達用の変数を用意するのが簡単だろう。

>>
	// bool参照を渡すためのテクニック
	bool[] stopFlag = new bool[1];
<<

このような変数を本体側から渡しておき、スクリプト側から呼び出すいくつかのメソッドに仕込んでおくと良いだろう。例えば、system/injection_script.txt に次のようなSleep命令を追加する。

>>
	public static void StopScript()
	{
		throw new YaneuraoGameScript2010.ScriptStopException();
	}
	public static void Sleep(long time)
	{
		while (time >= 1000)
		{
			if (st.ScriptStopFlag[0])
				StopScript();
			time -= 1000;
			System.Threading.Thread.Sleep(1000);
		}
		System.Threading.Thread.Sleep(1000);
	}
<<

これならスクリプト側で長時間のSleepをしていても、その途中で本体側からスクリプトの停止シグナルが来れば例外を投げて抜け出すことが出来る。例外を大域jump代わりに使うというわけだ。


* スクリプトからスクリプトの呼び出し


スクリプトのモジュール化が進んでくると、スクリプトからスクリプトを呼び出したいことがあるかも知れない。ここまで出来ていればこの実装は実に容易だ。本体側にお願いしてコンパイルして実行してもらうだけで良い。私は、system/injection_script.txt に次のように書いてある。このとき受け渡すデータのことをここではBagsと呼ぶことにする。

>>
	public static Dictionary<string,object> bags;
	public static object CallScript(string path)
	{
		return st.CallScript(path , bags);
	}
<<


* InnerExceptionについて詳しく知っておく


さきほど、例外が発生した場所を特定するためにex.InnerException.StackTraceを調べると書いたが、何故、ex.StackTraceでは駄目なのか。

これは、スクリプトをコンパイルして新たなアセンブリを作ってそれを実行しているからで、そこで起きた例外をそのアセンブリの外で捕捉しようと思うと、ex.InnerExceptionに例外が隠匿されるためである。


例えば、
** 本体→スクリプトを実行→(スクリプトのなかからスクリプトを呼び出すために本体にお願い)→本体→別のスクリプトを実行
のように実行して、一番内側(「別のスクリプトを実行」のところで発生した例外を一番外側(1度目の「本体」)で捕捉しようと思うとex.InnerException.InnerException.StackTrace にその正確な例外名が入ってくる。

ゆえに、例えば、停止させるために例外を投げた場合、それがScriptStopExceptionかを判定するためには、InnerExceptionがnullで無い限りは延々と辿っていく必要がある。(次のコード)

>>
		// ScriptStopExceptionかどうかを判定する
		public bool IsStopException(Exception ex)
		{
			while (ex!=null)
			{
				if (ex is ScriptStopException)
					return true;

				ex = ex.InnerException;
				// ここ再帰的に書いてもいいけど・・まあ・・。
			}
			return false; // 違ったのだ
		}

<<

この処理が見苦しいならば、「本体」のほうで例外を捕捉したときに

>>
		if (isHotStart)
		{
			if (ex.InnerException != null)
				throw ex.InnerException; // InnerExceptionを再throwする
			else
				throw; // 単に再throw
		}
		else
			return 1;
<<
のようにInnerExceptionを再throwするというテクニックはある。

また、上のコードでisHotStartというフラグで判定してあるのは、
** スクリプト→「本体」→別のスクリプト
という呼び出しのときを(私は)HotStartと呼び、通常の
** 「本体」→スクリプト
の呼び出し(こちらはColdStartと呼んでいる)と区別するためだ。

何故この区別が必要かと言うと、ScriptStopExceptionを捕捉した場合、それを一番外側の「本体」まで伝播しないといけないためである。すなわちHotStartである限りは「本体」で例外をcatchしてもその例外は再throwされなければならない。


* 別プロセスでスクリプトを呼び出す


別プロセスでスクリプトを呼び出したいことがある。これは、例えば、.NET FrameworkのWebBrowserがリソースリークを防げない構造になっているため、長時間クロールさせているとメモリを使い果たすからだ。ゆえに、こういう部分だけ別プロセスで起動して実行させたいということは多々あるのだ。

別プロセスでスクリプトを呼び出すことはさほど難しくない。そのときにデータを受け渡しできると便利だが、事前に何のデータを受け渡しするかは設計できないことが多いので受け渡し用のデータ(私はBagsと呼んでいる。以下でもそう呼ぶ)としてはobject型の受け渡しが出来るようにしておくのが良いだろう。object型のデータはBinaryFormatterでシリアライズして渡し、受け取る側はコマンドライン引数としてそのファイル名を受け取り、それをデシリアライズしてスクリプトに渡せば良い。

また、このとき呼び出されたスクリプトからさらに別のスクリプトが呼び出されるかも知れないので、この受け渡し用データをシリアライズしてファイルに書き出す時のファイル名には再帰レベルか何かを埋め込み、一意なファイル名にする必要がある。例えば次のようなコードになる。

** 別プロセスでスクリプトを呼び出すCallScriptの実装

>>
		// 子スクリプトを別プロセスで実行
		// 返し値  0 = 正常 , -1 = timeout , 1 = 異常終了。
		// objectはScript側でRETURN文で書いた返し値。
		public object CallScript(string path , System.Collections.Generic.Dictionary<string,object> bags)
		{
			if (ScriptStopFlag[0])
				throw new ScriptStopException();

			int level;
			if (bags.ContainsKey("_level_")) // 再帰レベル
			{
				level = (int)bags["_level_"];
			}
			else
			{
				level = 0;
			}
			bags["_level_"] = (level + 1);

			var bf = new BinaryFormatter();
			var filename = bagPath + "/bags" + level + ".bin";
			using (var fs = new FileStream(filename , FileMode.Create))
			{
				bf.Serialize(fs, bags);
			}

			if (!Directory.Exists(bagsPath))
				Directory.CreateDirectory(bagsPath);

			bags["_scriptName_"] = Path.Combine(Application.StartupPath , path); // 実行スクリプト名を埋め込む。
			// その他のoptionはファイルから読み込むからええやろ…。

			object result = null;

			using (var p = new System.Diagnostics.Process())
			{
				// resultの受け渡し用
				var resultFileName = bagsPath + "/" + Path.GetFileNameWithoutExtension(path) + "Result.bin";
				File.Delete(resultFileName); // まず消しておこう。

				p.StartInfo.FileName = Application.ExecutablePath; // 自分自身を実行
				p.StartInfo.Arguments = "/S" + '"'+ filename + '"'; // bagsのファイル名をコマンドライン引数として渡してやる。 /S[ファイル名]
				p.Start();
				p.WaitForExit(); // 終了を待つ
				int error = p.ExitCode;
				if (error != 0)
				{
					// 非正常終了
					ScriptStopFlag[0] = true;
				}
				else
				{
					// リザルトのファイルをdeserialize
					if (File.Exists(resultFileName))
					{
						using (var file = File.OpenRead(resultFileName))
						{
							result = bf.Deserialize(file);
						}
					}
				}
			}

			// この bagsをデシリアライズして結果を得たほうがいいような気は少しする。

			bags["_level_"] = level;

			return result;
		}
<<

** 別プロセスとして呼び出されたスクリプトから、呼び出し元スクリプトにリザルトを返すためのメソッド。

>>
		// CallScriptの呼び出し側に値を返す。
		public void ReturnToCaller(object obj, System.Collections.Generic.Dictionary<string, object> bags)
		{
			// resultの受け渡し用
			var path = bags["_scriptName_"] as string;
			var bagsPath = Path.Combine(Application.StartupPath, "bags");
			var resultFileName = bagsPath + "/" + Path.GetFileNameWithoutExtension(path) + "Result.bin";
			var bf = new BinaryFormatter();
			using (var fs = new FileStream(resultFileName, FileMode.Create))
			{
				bf.Serialize(fs, obj);
			}
		}
<<

* main threadに実行させる


普通、スクリプトを実行するスレッドはworker threadである。main thread(UI thread)に実行させると、スクリプト側でSleepしたいようなときに困るからである。ゆえにスクリプト側からUI threadに実行させたい時はinvokeしたあとそれを待機するような仕組みが必要になる。以下の例では10秒以内にmain threadから応答がなければtime outという処理になっている。

>>
		/// <summary>
		/// MainThreadにinvokeしてやらせる仕事
		/// </summary>
		/// <returns></returns>
		public int MainThread(Func<int> e)
		{
			if (ScriptStopFlag[0])
				throw new ScriptStopException();

			int? result = null;

			form.Invoke(new MethodInvoker(delegate
			{
				try
				{
					result = e();
					return;
				}
				catch
				{
					// 異常終了
					result = 1;
					return;
				}
				// ここにはやってこない。
			}
			))
			;

			return WaitResult(ref result);
		}

		/// <summary>
		/// 10秒ほど他のスレッドのことを待ってみる。
		/// 常識的には10秒以内に終わるはずなのだが・・。
		/// </summary>
		/// <returns></returns>
		private int WaitResult(ref int? result)
		{
			int counter = 0;
			while (result == null)
			{
				if (ScriptStopFlag[0])
					throw new ScriptStopException();

				if (counter++ == timeout_msec / 100)
					return -1; // だめだこりゃ。

				Thread.Sleep(100);
			}

			return result.Value;
		}
<<

* ユーザーからの入力を受け付ける


スクリプト側で

>>
	var input = Input("何か文字を入れてね");
<<

と書けば入力ダイアログが出てきて文字入力を出来るようにしたいとする。
私は、system/injection_script.txt に次のようなメソッドを書いてあり、これが実現出来ている。

また、入力ダイアログにはWebBrowserが貼りつけてあり、htmlをレンダリングできるようになっている。
ユーザー入力のときに画像を張り付けたりすることが出来て便利である。

>>
// ユーザー入力を受け付ける
public static string Input(){ return Input(null,null);}
public static string Input(string prompt){ return Input(prompt,null);}
public static string Input(string prompt,string html)
{
	string result = null;
	MainThread(
		()=> {
			using (var ui = new YaneuraoGameScript2010.UserInputForm())
			{
				if (prompt!=null)
					ui.Prompt = prompt;
				if (html!=null)
					ui.WebBrowser.DocumentText = html;
				ui.Show();
				while (!ui.InputFinished)
				{
					System.Windows.Forms.Application.DoEvents();
				}
				if (ui.Cancelled)
				{
				//		result = null;
				} else
					result = ui.UserInput;
			}
		}
	);
	if (result == null)
			StopScript();

	return result;
}
<<


* 5分で作れるYaneuraoGameScript2010


さて、ここまで来れば、適当なテキストエディットコントロールをFormに貼りつけて、ファイルの保存やスクリプトの実行~停止が出来るようにするのは簡単である。

# テキストエディットコントロール
TextBoxに関する覚書のすべて
http://d.hatena.ne.jp/yaneurao/20100519


あとは例えばsystem/header_script.txt に

>>
using Yanesdk in "lib/YaneGameSdk2010";
<<

と書くだけでYaneuraoGameSdkがスクリプトから自由に呼び出せる。
名づけてYaneuraoGameScript2010!!
これって、IDE付きゲーム用スクリプト言語に見えなくもない。…見えないか…そうか…。


* 5分で出来るファイルの隠匿


スクリプトファイルが丸見えなのは格好悪い。最終的には、何らかのスクランブルをしたい。かと言って自前で暗号化zipファイルみたいなものを実装するのは大変だ。

よろしい、ファイルをpackする一番簡単な方法をお教えしよう。Dictionary<string,byte[]>にスクリプトファイル一式突っ込んで、BinaryFormatterでシリアライズしてしまうのだ。

>>
		// scriptをpackする
		public void PackScript()
		{
			var scripts = new Dictionary<string, byte[]>();
			// filenameとscriptの中身の対。これをまんま書き出す。

			var files = Directory.GetFiles(
				Path.Combine(Application.StartupPath, "script"), "*.*"
				)
				;
			// scriptフォルダまるごとでいいや…。

			foreach (var file in files)
			{
				var pureFileName = Path.GetFileName(file);
				var bytes = File.ReadAllBytes(file);
				for (int i = 0; i < bytes.Length; ++i)
					bytes[i] ++;
				// xorするなり、1加算するなり何なり暗号化してね。

				scripts.Add(pureFileName, bytes);
			}

			// これシリアライズしてまるまんま書き出しておくか。
			var bf = new BinaryFormatter();
			using (var fs = new FileStream(
				Path.Combine(Application.StartupPath, "script.bin"), FileMode.Create))
			{
				bf.Serialize(fs, scripts);
			}
		}
<<

取り出すときは、この逆の操作をすれば良い。ファイルが要求されるごとにデシリアライズしてたら効率悪いだろうから、起動時にまるごと元のDictionary<string, byte[]>に復元してしまうのが良いと思う。


* 実行行の可視化


ちょっと今回は時間が無かったのだが、スクリプトのコード(C#のコード)を構文解析して、各行に Trace(myFileName,123) のように自分のスクリプトファイル名と行番号を自動的に挿入するようにしておけば、スクリプトの現在実行中のソース行をテキストエディットコントロール上にハイライト表示したりすることが可能になる。

そんなことをして何が面白いのかと言われるかも知れないが、本体とスクリプト言語とで作業を分業するときに、スクリプト言語で書く側の人はプログラミング初心者であることも多々あり、現在の実行中の行が視覚的に確認できると喜ばれることがある。またデバッグするときにTraceOn();と書いたところからTraceOff();と書いたところまでは実行中の行が可視化されるとデバッグに役立つだろう。


* 自作言語からスクリプト言語への変換


自作言語からC#のコードを生成してそれをCSharpCodeProviderを用いてコンパイルする場合にも今回の記事はかなり参考になると思う。自作言語なのに.NET FrameworkのAPIがすべて使えるし、誰かが作ったアセンブリもusingするだけで使える。言語ライブラリの設計に頭を悩ます必要があまり無く、自分の言語の設計に専念できる。これは素晴らしいことだ。


* Linux環境でCSharpCodeProviderを使う


monoでもCSharpCodeProvider相当のことは出来るようだ。ただ、ここで書いてきた話がどこまで通用するのかは調査していないので私にはわからない。

あと、mono projectにはCSharpReplというC#によるinteractive shellがある。たぶんCSharpCodeProviderのような仕組みが使われている。よく知らん。Mono.CSharp.CSharpCodeCompilerがそれなのかな?

# C#によるinteractive shell
CSharpRepl
http://www.mono-project.com/CsharpRepl

[img:Xbyhja.png]


* まとめ


C#のCSharpCodeProvider関連の話題をざっと取り上げた。今回はusing命令の拡張や#include文の追加をしたが、非常にお手軽にC#の構文拡張が出来ることを見ていただけたと思う。もちろん、もっと強力な構文の追加だって可能だ。スクリプト言語は使い捨てという側面があるので、あまり下位互換性を意識しなくてもいいだろう。

あとobject型にextension methodを追加するのは、名前空間の汚染にも似てその副作用を忌避するのが普通だが、スクリプトの場合、効果がスクリプト側に限定されるのでそれほど害があるわけでもない。むしろメリットのほうが大きい。どんどん使えばいいと思う。

それからスクリプト側からusingで他アセンブリを参照できるのは圧巻で、publicなclassでありさえすればそれだけで良い。特別なimport用headerやexporterなど一切不要である。スクリプト言語で書いたソースをコンパイルしたdllもまたアセンブリなのでこのアセンブリ自体を他のスクリプトから直接参照して使うことも出来るし、C#のプロジェクトから参照して使うことも出来る。大変、素晴らしい!

最後に。CSharpCodeProviderは本当に使える。もうLuaとはおさらばだ!


* 更新履歴


2010.10.17 公開