真・Roslyn for Scripting! あなたのアプリケーションにもC#スクリプトを!!

roslynこの記事は「C# Advent Calendar 2015」の18日目の記事です(遅刻しました (;´Д`))。
そして、この記事は以前に書いた「Roslyn for Scriptingで、あなたのアプリケーションにもC#スクリプトを!!」の改訂版なので、先にこちらを眺めてもらえるとスムーズに理解できると思います。


Roslyn for Scriptingの正式版が公開されました!

前記事ではまだ正式版ではありませんでしたが、とうとう正式版が公開されました。この記事では、以前のバージョンと正式版との違いについて焦点を当てたいと思います。

背景

前回の記事で紹介したNuGetのパッケージは、Visual Studio 2015リリースに向けての追い込みの時期に公開されたものです。バージョンは1.0.0-rc2なので、Visual Studio 2015に合わせて正式リリースするつもりであるように見えました。ただ、バックログを見ても色々課題が残っており、本当にあと少しで間に合うのかという不安もあったのです。

すると、何とRoslynのScripting部分はキャンセルされてしまいました!! rc3でそれらのコードはごっそり削除、NuGetの更新も途絶えてしまったのです。

私が知っている(issueにも挙げた)課題としては、以下のようなものがありました。

  • 外部から与えるグローバルインスタンスがDynamicObjectの場合でも、スクリプト環境はそれを認識できない。
  • メソッドの即時実行を行った場合に、非同期メソッドであった場合は、グローバルなクロージャが非同期コンテキストを認識できない。
  • スクリプティング環境では、Roslyn自体は内包されていてアクセス出来ない。IntelliSenseみたいな事をしたくても出来ない

# グロサミで私の写真がちらっと出た時はびっくりしました。ちょっと嬉しかった

これらがどのように解決されたのかも検証したいと思います。


インターフェイスの変更

正式版のバージョンは1.1.1です。これは、Visual Studio 2015 Update1とともにリリースされました。ただし、RoslynやScriptingのNuGetパッケージは独立しているので、VSのバージョンと関係なく個別に使用することができます。出来るのですが… 実際には.NET Framework 4.6以上が必要です。NuGetのパッケージのターゲットフレームワークが「dotnet」となっており、いわゆる「netcore5」に対応するのですが、従来の環境では4.6以上が必要になります。

実現内容を考えると、もっと古いターゲットでも動作させられる可能性はあると思うのですが、netcore5から導入されたライブラリのNuGet細分化を前提としているため、移植は別の意味で面倒かもしれません。

パッケージ名は「Microsoft.CodeAnalysis.CSharp.Scripting」です。NuGetでサクッとインストールしましょう。

PM> Install-Package Microsoft.CodeAnalysis.CSharp.Scripting

さて、正式版で変更を受けたポイントは2つあります。

  • 時間のかかる処理は、すべて非同期(Task)ベースとなりました。
  • デフォルトオプションは完全に「空」になりました。

時間のかかる処理とは、Scriptingで言うならRunメソッドです。rc2ではRunメソッドを呼び出すと、スクリプトが実行されて結果が返却されました。この部分がTaskベースの非同期処理となり、名前も「RunAsync」メソッドとなりました。後方互換性は維持されていないため、正式版を使用する場合は修正が必要です。とは言っても、難しいことは無いでしょう。

// 指定されたスクリプトを非同期で実行する
public static async Task ExecuteScriptAsync()
{
	Trace.WriteLine("Start script...");
	
	var script = CSharpScript.Create("Console.WriteLine(\"Hello C# scripting!\");");
	await script.RunAsync();

	Trace.Write("Finished script");
}

上の例のように、RunAsyncメソッドをawaitで待機します。あるいは、戻り値のTaskを使ってゴニョゴニョすればOKです。合理的な変更だと思います。また、Createメソッドはジェネリックバージョンもあり、RunAsyncの戻り値を型指定で伝搬させ、スクリプトの式評価の結果をタイプセーフに得る事もできます。この辺りも「こうなればいいのに」と思っていたことが実現されていて好感が持てます。

consolenotfoundさて、実は上のコードは動作しません。RunAsync呼び出し時に例外が発生します。

例外の内容を見ると、「Console」が見つからないと出力されています。rc2では、デフォルトのインポート節(C#で言うところのusing句)として「System」が含まれていたのですが、正式版ではこれが削除されて、何もインポートされていない状態となっています。そのため、自分でインポートを追加する必要があります(または、スクリプトコードにusing句を追加するか、完全限定名でクラスを指定します)。

修正したコードは以下の通りです。

// 指定されたスクリプトを非同期で実行する
public static async Task ExecuteScriptAsync()
{
	Trace.WriteLine("Start script...");
	
	var script = CSharpScript.Create("Console.WriteLine(\"Hello C# scripting!\");").
		WithOptions(ScriptOptions.Default.
			WithImports("System").
			WithReferences(typeof(object).Assembly));
	await script.RunAsync();

	Trace.Write("Finished script");
}

WithOptionsメソッドで、スクリプトにオプションを指定できます。前回の記事ではインポート(名前空間)と参照を加えていましたが、ここの扱いは今回も変わっていません。明示的に”System”を追加してあります。参照も追加してありますが、mscorlibの範疇の型は追加しなくても認識出来るようです。


グローバルコードの扱い

グローバルコードの解釈が練られ直されたようです。

前回紹介したように、スクリプティング環境では、単なる

// 以下はスクリプト:
// いきなりvar
var hoge = 123;

// いきなりメソッド呼び出し
Console.WriteLine("ABC");

のように、名前空間の指定もクラスや構造体もメソッドの定義もない所からの実行可能な式や文を許容します(許容する仕様です)。これが満たされないと、スクリプティングの逐次実行(要するにインタプリタのように使う)が出来ないので、魅力半減です。

このような文は、内部でコード生成された時点で、不透明な外郭クロージャクラスとクロージャメソッドが作られ、その中で文を実行する事になります。

所で、次のようなスクリプトを考えてみます。

// 以下はスクリプト:
using (var httpClient = new HttpClient())
{
	return await httpClient.GetStringAsync("http://www.bing.com/");
}

このコードの戻り値はTask<string>となります。コード中でawaitを使っているからなのですが、これも通常のコードとしてコンパイルする場合は、メソッドシグネチャに「async」と書かないとコンパイルエラーになります。同じRoslynを使ってパースしているのですが、グローバルコードではasyncを書かなくても(書けないが)、awaitを認識して、Taskクラスのインスタンスとして返却するのです。

この仕様はかなり奇妙ですが、コードを書かれるまでは、それが同期的なのか非同期的なのかが分からないので、仕方ないと割り切った感があります(これを許すなら、互換性のためのasync句とは何だったのかという気もしなくもない)。

クロージャーとなる実装がどのようなものかを、簡単に眺めてみます。

public static async Task ExecuteScriptAsync()
{
	Trace.WriteLine("Start script...");

	var script = CSharpScript.Create(
@"var sf = new StackFrame();
var method = sf.GetMethod();
var type = method.DeclaringType;
Console.WriteLine(""{0} - {1}"", type.FullName, method.Name);
").
		WithOptions(ScriptOptions.Default.
			WithImports(
				"System",
				"System.Diagnostics",
				"System.Reflection").
			WithReferences(typeof(object).Assembly));

	await script.RunAsync();
	Trace.Write("Finished script");
}

この結果、コンソールにはこのように表示されました:

Submission#0+<<Initialize>>d__0 - MoveNext

興味深い事です。クロージャーの実装は、”Submission#0″クラスのインナークラス”<>d__0″に作られた”MoveNext”メソッドのようです。これは、C#コンパイラがAwaitableなステートマシンを作るときの形に似ているので、おそらくそのまま再現されているのでしょう。そして、仮にスクリプトブロック内にawaitを使わなかったとしても、初めからasync-awaitが有効なコードブロックとして評価している事が分かります。

しかし、問題はそれだけではありません。「遅延実行」が絡む場合は、いつ実際に評価を行うのかが難しいのです。前回の説明で逐次処理を行う方法を説明しましたが、2つのawaitが分割されて処理されたらどうなるのでしょうか?

// 以下はスクリプト:
// 最初の処理単位(わざとusingを使わない)
var httpClient = new HttpClient();
await httpClient.GetStringAsync("http://www.bing.com/");

// (逐次処理評価で分断)

// 2回目の継続処理単位
await httpClient.GetStringAsync("http://www.google.com/");

なまじ、async-awaitがクロージャーに内包されて実行されている事を知っていると、このコード片がどのように実行されるのかに迷いが生じます。つまり、最初と2回目の継続処理は、単一のクロージャーにまとめられる必要があるのではないかと。しかし、「スクリプトである」と考えれば明確で、逐次処理単位毎に実行されます(つまり書いた通りの感じで逐次実行される)。

逐次処理の検証をしてみます。ScriptStateのAPIも整理されました。RunAsyncの結果がScriptStateなので、このクラスのContinueWithAsyncメソッドを使うと簡単に書けます:

public static async Task ExecuteScriptAsync3()
{
	Trace.WriteLine("Start script...");

	var script1 = CSharpScript.Create(
@"var httpClient = new HttpClient();
await httpClient.GetStringAsync(""http://www.bing.com/"");
").
		WithOptions(ScriptOptions.Default.
			WithImports(
				"System",
				"System.Net.Http").
			WithReferences(
				typeof(object).Assembly,
				typeof(HttpClient).Assembly));

	var scriptState1 = await script1.RunAsync();

	var scriptState2 = await scriptState1.ContinueWithAsync(
@"await httpClient.GetStringAsync(""http://www.google.com/"");
");

	Trace.Write("Finished script");
}

これで、最初のRunAsyncでBingが、次のContinueWithAsyncでGoogleが参照されます。しかし、結果は返されません。「return」句を入れると、スクリプトとしてのコードの性質がはっきりします。

public static async Task ExecuteScriptAsync3()
{
	Trace.WriteLine("Start script...");

	// GetStringAsyncをawait後にreturnする
	var script1 = CSharpScript.Create(
@"var httpClient = new HttpClient();
return await httpClient.GetStringAsync(""http://www.bing.com/"");
").
		WithOptions(ScriptOptions.Default.
			WithImports(
				"System",
				"System.Net.Http").
			WithReferences(
				typeof(object).Assembly,
				typeof(HttpClient).Assembly));

	// 戻り値を得る
	var scriptState1 = await script1.RunAsync();
	var result1 = scriptState1.ReturnValue;

	// GetStringAsyncをawait後にreturnする
	var scriptState2 = await scriptState1.ContinueWithAsync(
@"return await httpClient.GetStringAsync(""http://www.google.com/"");
");
	var result2 = scriptState2.ReturnValue;

	Trace.Write("Finished script");
}

結局、逐次処理の単位毎に、式の結果として評価された値が返される事が分かります。同時に、逐次処理させても、内部のクロージャーは包含された大きなメソッドを作るわけではなく分割され、しかし定義された変数は引き続き使えるようにしているのです。

このような挙動を見ていると、同じC#でRoslynでありながら、直観的なコードはかなり感じが違うなと思いました。勿論、メソッドやクラスを定義する事は可能なので、そこまでやればコンパイル前提のC#のコードと変わりませんが…


DynamicObjectの扱い

ホスト環境との通信として、グローバルにアクセス可能なインスタンスを用意して提供すると、スクリプトからアクセス出来る、という機能がありました。新しいバージョンでも引き続きサポートされていますが、その時こんなことを考えました:

// ホストオブジェクトクラス
public sealed class HostObject
{
	public HostObject()
	{
		this.Target = new ExpandoObject();
		this.Target.Id = 123;
		this.Target.Name = "ABC";
	}

	// ExpandoObjectを使ってダイナミックアクセスを指定するホスト環境のプロパティ
	public dynamic Target
	{
		get;
		private set;
	}
}

public static async Task ExecuteScriptAsync()
{
	Trace.WriteLine("Start script...");

	// HostObject.Targetにアクセスする
	var script1 = CSharpScript.Create(
@"Console.WriteLine(""Id={0}, Name={1}"", Target.Id, Target.Name);
",
		ScriptOptions.Default.
		WithImports(
			"System",
			"System.Diagnostics").
		WithReferences(
			typeof(object).Assembly,
			typeof(Microsoft.CSharp.RuntimeBinder.Binder).Assembly),
		typeof(HostObject));	// ホストオブジェクトの型を指定

	// ホストオブジェクト
	var hostObject = new HostObject();

	await script1.RunAsync(hostObject);

	Trace.Write("Finished script");
}

dynamicidnotfoundRunメソッドからRunAsyncメソッドに変わりましたが、引数にホストオブジェクトを渡すことが可能な点は変わっていません。但し、ホストオブジェクトの型を明示的に指定する必要があります。おそらく、C#スクリプトから見て、「静的」には何の型に見えるのかを明示する必要があるのでしょう。CSharpScript.Createに引数で指定します(例ではオプションの指定も引数に寄せてあります)。

また、Microsoft.CSharp.RuntimeBinder.Binderクラスのアセンブリを参照していますが、これは”Microsoft.CSharp”アセンブリを使うためで、要するにDLR対応です(dynamicキーワードを使う場合に必要)。

rc2では上記のコードを書いた場合、IdとNameプロパティには「アクセス出来ません」でした。issueに上げたのですが、どうやら今回も対応しなかったようです。これが出来ると、ホスト側のアクセス可能な要素を動的に定義できると思ったんですけどね。結構根が深そうです。


AST(抽象構文木)の参照

Roslyn for Scriptingは、当然内部でRoslynを使用しているので、スクリプトのパース結果はRoslynの抽象構文木で表現されているはずです。rc2では一体それがどこからアクセス可能なのか良くわかりませんでしたが、1.1.1ではScriptクラスのGetCompilationメソッドからアクセス出来ます。

public static async Task ExecuteScriptAsync()
{
	Trace.WriteLine("Start script...");

	var script1 = CSharpScript.Create(
@"var target = new
{
	Id = 123,
	Name = ""ABC""
};
Console.WriteLine(""Id={0}, Name={1}"", target.Id, target.Name);
").
		WithOptions(
			ScriptOptions.Default.
			WithImports(
				"System",
				"System.Diagnostics").
			WithReferences(
				typeof(object).Assembly);

	var compilation = script1.GetCompilation();
	var tree = compilation.SyntaxTrees.ElementAt(0);
	var compilationUnitSyntax = (CompilationUnitSyntax)tree.GetRoot();
	var members = compilationUnitSyntax.Members.Select(member => member.GetText().ToString());
	
	Console.WriteLine(string.Join(Environment.NewLine, members));

	Trace.Write("Finished script");
}

ToStringして表示しているだけなので何の捻りもありませんが、パース結果をロジック的に探索出来ます。これを使ってIntelliSenseのような表示の仕掛けも可能でしょう。


各種リゾルバー

スクリプトのコンソールインターフェイスが存在する場合は、ソースコードの参照先パスや、アセンブリの参照先パスを指定して、動的に読み込みをサポートしたくなるかもしれません。これらは通常はScriptOptions.WithFilePathやWithReferencesを使用して指定しますが、スクリプト環境内からの参照解決要求に応答するように実装する事も出来ます。

ScriptOptions.SourceResolverやMetadataResolverがそれに該当し、リゾルバー基底クラスを継承して、独自の参照解決を実装して指定する事が出来ます。

リゾルバーがいつ呼び出されるのか、ですが、Roslyn for Scriptingでは、「REPL」と呼ばれる帯域外コマンドが正式に決定されました。例えば、以下のように「#r」コマンドを使うと、スクリプト内で環境にアセンブリをロードすることが出来ます。

public static async Task ExecuteScriptAsync()
{
	Trace.WriteLine("Start script...");

	// スクリプト内で System.Net.Http アセンブリをロードして使う
	var script1 = CSharpScript.Create(
@"#r ""System.Net.Http""
var httpClient = new System.Net.Http.HttpClient();
return await httpClient.GetStringAsync(""http://www.bing.com/"");
").
		WithOptions(
			ScriptOptions.Default.
			WithImports(
				"System").
			WithReferences(
				typeof(object).Assembly));

	var scriptState = await script1.RunAsync();

	Trace.Write("Finished script");
}

この時、ScriptOptions.MetadataResolverには、RuntimeMetadataReferenceResolverがデフォルトで設定されており、このリゾルバーが標準的な.NETのアセンブリロード処理を行う(この場合は、System.Net.Http.dllをGACから読み取る)ので、#rコマンドが成立するのです。


まとめ

前回と今回の記事で、Roslyn for Scriptingの足掛かりは出来たかなと思います。Roslyn、特にASTの辺りはスクリプティングとは関係なく、これまた膨大なトピックが詰まっていると思います。Roslynで言うならば、上はこのスクリプティングやコンパイラインターフェイス、下はVisual Studio 2015の「アナライザー」が、Roslynへの入り口となると思います。

OSS化され、引き続き改良が進むと思われるため、当分の間はこのインフラが使用される事でしょう。参考になれば幸いです。

それでは。