C#
Roslyn
RoslynPad

C#アプリにスクリプティング機能を追加してみる

こんなものを作ります

sc1.png
C#スクリプトをアプリケーションに組み込むだけでなく、
上の画像のようにインテリセンスやハイライトが効くエディタを組み込んでみます。

使用するもの

Roslyn(ロズリン)とは?

C#/VBのコーディングのサポート(インテリセンス、参照の検索等)や、コードを解析してコンパイルを行うAPIの総称です。Visual Studioの内部でも使用されており、さらにオープンソースで公開もされています。
ただ、Scripting APIの仕様がバージョンアップで変わっているため、数年前の記事で紹介されている方法では動かないことがありました。
今回この記事を書こうと思った理由の1つです。

RoslynPad

内部でRoslynを使用しているコードエディタです。単体のアプリとして公開されている他、WPFに組み込めるようにNugGetでも公開されています。しかし、これに関する日本語の記事が全く見当たらない上、サンプルが少し複雑で1行ごとにコンパイルしていくような汎用性がない?ものでした。本記事では最低限のサンプルも紹介しようと思います。

C#スクリプト!?

C#をスクリプトのように実行できるようにした代物です。2015年末に発表されていますがあまり盛り上がってない気がします。試すだけなら、Visual Studioの上部メニューの表示→その他のウィンドウ→C# インタラクティブを選択することでインタラクティブなC#を使用できます。

RoslynPadをWinFormsで使用しようとしたところ...

RoslynPadのコントロールはWPF仕様なので基本的にWPFのアプリにしか組み込むことはできません。
しかし、WinFormsにはWPFコントロールを組み込めるElementHostなるものが存在します。
コントロールのみであれば表示することができましたが、インテリセンスの機能を初期化する際にエラーが出でしまいました。

同じパッケージを入れているはずなのにと不思議に思いつつ、検索しても情報はなく、
該当箇所のソース(Roslyn内部)を見て、呼び出しを遡ってみましたが手がかりは得られませんでした。
箇所がそもそもinternalクラスでRoslynPadが直接呼び出しているようなものではなかったのも一因です。

正直、高校生の自分にはこれが限界です。
既存のWinFormsアプリに組み込むなら、別の実行ファイルにして連携するしか方法はないのかもしれません。
最も、新規で作るならそんなゴリ押ししないでWPFで作れという話ですが(^^;
(何か情報があれば教えてもらえるとうれしいです)

とりあえずスクリプト機能をアプリに組み込んでみる

前置きが長くなりましたが、初めは単純にTextBoxにC#スクリプトを書き、それを実行できるアプリを作ってみます。

NuGetからインストール

sc2.png
「CSharp Scripting」で検索すると出てきます。
これ一つをインストールするだけで依存している大量のライブラリがインストールされます^^;
また、バージョンによって扱い方が異なるので注意してください。
(執筆時のバージョンは2.6.1)

コード

sc10.png

FormsでもWPFでもいいのでテキストボックスとボタンを用意し、ボタンのクリックイベントに以下を貼り付けます

Form1.cs
private void button1_Click(object sender, EventArgs e)
{
    try
    {
        var script = CSharpScript.Create(textBox1.Text);
        script.RunAsync();
    }
    catch (CompilationErrorException ex)
    {
       MessageBox.Show(ex.Message, "コンパイルエラー");
    }
    catch (Exception ex)
    {
       MessageBox.Show(ex.Message, "エラー");
    }
}

最低限これだけあれば動きます。

スクリプトを実行してみる

テキストボックスに

script.csx
using System;
Console.WriteLine("Hello Roslyn");

と入れてボタンを押してみましょう。
sc3.png
...でました、がかなり地味ですね。

毎回最初にusingを描くのはナンセンス?

使いそうなものは予めusingしておくことができます。
先程のコードを以下のように修正してください。

Form1.cs
ScriptOptions options = ScriptOptions.Default
    .WithImports("System");
var script = CSharpScript.Create(textBox1.Text,options);
script.RunAsync();

スクリプトを作成する時に実行時のオプションを指定できる、ScriptOptionsを一緒に渡すことができます。
ScriptOptions.Defaultでデフォルトを取得し、WithImportsでusingしておくものを指定できます。

メッセージボックスを出してみる

ここで落とし穴があります。
メッセージボックスはSystem.Windows.Formsにありますが、以下のようにしてもエラーが出ます。
(WPFではSystem.Windows.MessageBoxに読み替えてください)

Form1.cs
ScriptOptions options = ScriptOptions.Default
    .WithImports("System","System.Windows.Forms");
script.csx
MessageBox.Show("Hello Roslyn");

sc4.png

スクリプト環境にはSystem.Windows.Formsが参照されていない!

外部ライブラリを使用するとき、プロジェクトにdllの参照を追加すると言った場面があると思います。
普段意識しませんが、WinFormsプロジェクトを作成する際にもVSがSystem.Windows.Forms.dllを自動的に参照に追加しています。

スクリプト環境で自動的に参照してくれるのはSystemのみなので、それ以外を使用するには参照に追加する必要があります。
そのためにはScriptOptionsを以下のようにします。

Form1.cs
ScriptOptions options = ScriptOptions.Default
    .WithImports("System", "System.Windows.Forms")
    .WithReferences(Assembly.GetAssembly(typeof(System.Windows.Forms.MessageBox)));

WithReferencesにdllファイルなどのアセンブリを指定することで参照に追加できます。

System.Windows.Forms.dllアセンブリを取得するにはいくつか方法があると思いますが、
dllのパスや完全名を調べるのは面倒なので以下のようにします。

Assembly.GetAssembly(typeof(System.Windows.Forms.MessageBox))

GetAssemblyは指定したクラスが属しているアセンブリを取得するメソッドです。
なので最後はMessageBoxでもFormでもなんでも良いです。
(もっと気持ちのよい書き方があれば教えてください)

これでようやくメッセージボックスが出せました。
sc5.png
ちなみにScriptOptionsを何も指定しない場合でも以下のようにすることでメッセージボックスは出せます。

script.csx
#r"System.Windows.Forms"
using System.Windows.Forms;
MessageBox.Show("Hello Roslyn");

#rが参照を動的に追加するC#スクリプト独自の方法のようです。

スクリプト側からアプリの操作をする

この記事を読んでいる方の大半は、スクリプトエディタをを作るというより
アプリをスクリプト側から操作できるよう拡張したいと思っていると思います。
では実際にやってみましょう。
具体的には

script.RunAsync(globals);

でスクリプト側で使用したいオブジェクトをglobalsに指定します。
そうすることでそのオブジェクトをグローバルな要素としてアクセスできます。
また、globalsに渡すオブジェクトはpublicである必要があります。
初めに、実行ファイルのパスを返すだけの簡単なクラスを作ります。

MyClass.cs
public class MyClass{
    public string GetCurrentPath()
    {
        return System.IO.Path.GetDirectoryName(
            System.Reflection.Assembly.GetExecutingAssembly().Location);
    }
}

スクリプト実行部分を以下のように変更します。

Form1.cs
var script = CSharpScript.Create(textBox1.Text,options,typeof(MyClass));
script.RunAsync(new MyClass());

CSharpScript.Createの引数3にtypeof(MyClass)が追加されています。
globalsに渡すオブジェクトのTypeを渡します。

この状態でscript側で

script.csx
MessageBox.Show(GetCurrentPath());

として実行すると、実行ファイルのパスが表示されると思います。

地味でつまらない?

上のサンプルではスクリプトで操作した気になれない人は
globalsに自分自身(Form1)を渡してみてください。
そして

Text = "Hello from Script";
Top = 0;

など普通にFormのプロパティを操作する感覚でスクリプトを書いて実行してみてください。
なにが起こるか、想像はつくと思います。

クラスや構造体などを定義しておく

スクリプト内で使用するクラスや構造体を予め定義しておけます。
方法は大きく分けて2つあります。

globalsにわたすクラスの内部に入れ子にする

入れ子にしたものはスクリプト内で使用できます。

別のアセンブリに用意してそれを参照する

別プロジェクトを作成し、dllをビルドし、参照に追加します。

もしくは使いたいクラスがアプリ内にある場合、
アプリ自身を参照に追加することでも同じことが実現できますが、
アプリ内にある全てのpublicなクラスがスクリプト側で操作できてしまう危険があります。
例えばオプションに次のように指定します。

ScriptOptions options = ScriptOptions.Default
     .WithImports("System", "System.Windows.Forms","ScriptingTest")
     .WithReferences(
      Assembly.GetAssembly(typeof(System.Windows.Forms.MessageBox)),
      Assembly.GetExecutingAssembly()
      );

WithImportsの最後に自身の名前空間を、

Assembly.GetExecutingAssembly()

で自身を参照に追加しています。
この状態で次のスクリプトを実行すると...

script.csx
new Form1().Show();

今表示されているFormを複製できてしまいます。

入れ子の方法が確実のように思いますが、
私はアセンブリを分離した上で後者をおすすめします。

なぜならRoslynPadの実装時に自作のクラスを予測に表示する際、
自作のクラスが含まれるアセンブリを必ず参照しなければいけないからです。

アプリにglobalsに渡すクラスが含まれていると、globalsのメソッドやメンバを予測に表示するために
アプリのアセンブリを参照することになり、結果的に後者の危険な方法と変わりなくなってしまいます。

とは言ったものの、コードをシンプルにするために
本記事では分離しません。ご了承ください。

最もRoslynPadを使わないのであれば、入れ子が一番ラクで安全です。

RoslynPadを実装してみる

WinFormsのテキストボックスも予約語に色付けをしていけば多少使えるかもしれませんが、
インテリセンスの存在は大きいです。
というわけでここからはWPFのみになりますが実装していきます。
WPFのプロジェクトを作成してください。

NuGetのインストール

sc6.png
「RoslynPad」で検索するといくつか出てきますが、この中で必要なのは画像中の真ん中3つです。
依存関係的に真ん中をインストールすると必要なものはすべてインストールされます。
先程のCSharpScriptもインストールされます。

レイアウト

sc7.png
ボタンとRoslynCodeEditorを適当に配置してください。
xamlの頭に以下を追記する必要があります。

xmlns:editor="clr-namespace:RoslynPad.Editor;assembly=RoslynPad.Editor.Windows"

コード

MainWindow.xaml.cs
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        RoslynHost host;
        public MainWindow()
        {
            InitializeComponent();
            host = new RoslynHost(additionalAssemblies: new[]
            {
                Assembly.Load("RoslynPad.Roslyn.Windows"),
                Assembly.Load("RoslynPad.Editor.Windows"),
            }
            );
        }

        private void RoslynCodeEditor_Loaded(object sender, RoutedEventArgs e)
        {
            roslynCodeEditor.Initialize(host, new ClassificationHighlightColors(), Directory.GetCurrentDirectory(), String.Empty);
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                ScriptOptions options = ScriptOptions.Default
                    .WithReferences(host.DefaultReferences)
                    .WithImports(host.DefaultImports);

                var script = CSharpScript.Create(roslynCodeEditor.Text, options, typeof(MyClass));
                script.RunAsync(new MyClass());
            }
            catch (CompilationErrorException ex)
            {
                MessageBox.Show(ex.Message, "コンパイルエラー");
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "エラー");

            }
        }
    }

公式のサンプルを削りに削った結果こうなりました^^;
これで一応動きます。

1.コンストラクタ

RoslynHostというインテリセンスなどの機能を提供するオブジェクトを初期化します。
(冒頭のWinFormsエラーはここで出ました)
additionalAssembliesに既に指定がありますが、これは必須のようです。

2.RoslynCodeEditor_Loaded

エディタコントロールを初期化します。

  • 引数1には1で生成したRoslynHostオブジェクト
  • 引数2にはコードをハイライトするためのクラス
  • 引数3には実行ファイルがあるディレクトリのパス(コード内にファイルへのパスを指定する場面があるとこの設定に沿って相対パス等を補完する目的で使われる?)
  • 引数4には初めに入力されているコードを指定します

※RoslynCodeEditorのLoadedイベントに登録しておいてください。

3.Button_Click

普通にエディタに入力されたテキストからスクリプトを作成、実行しています。
注目してほしいのがScriptOptionsにRoslynHostのDefaultReferencesDefaultImports
が指定されていることです。これが次のキーになります。

実行するとエディタが!

実行するとちゃんとエディタとして機能すると思います。
しかし、ここであることに気づきます。
sc8.png
これを見て何か気づきませんか...?

そうです。
using Systemをしていないのにも関わらずConsoleが使用できています。
実行もできます。これは一体どういうことなのでしょうか?

RoslynHostは必要そうなものを予め追加してくれている

先程のScriptOptionsに次のように指定しました

MainWindow.xaml.cs
ScriptOptions options = ScriptOptions.Default
    .WithReferences(host.DefaultReferences)
    .WithImports(host.DefaultImports);

初めにusing担当のDefaultImportsの中身を見てみましょう。

System
System.Threading
System.Threading.Tasks
System.Collections.Generic
System.Text.RegularExpressions
System.Text
System
System.Linq
System.Collections
System.IO
System.Reflection

これらが初めから追加されていることがわかります。
便利!

DefaultReferencesの中身は上記の他にエディタに必要な大量のdllが指定されていました。
多すぎるので割愛します。

globalsに渡したクラスのメンバを予測に表示する

これには一手間必要です。まず、先程のMyClassを追加しておいてください。
そして、今まで使用してきたRoslynHostクラスに手を加えます。

まずはRoslynHostを継承したCustomRoslynHostを作成します。
そして次のようにします。

CustomRoslynHost.cs
    class CustomRoslynHost : RoslynHost
    {

        public CustomRoslynHost(NuGetConfiguration nuGetConfiguration = null,
            IEnumerable<Assembly> additionalAssemblies = null,
            RoslynHostReferences references = null):base(nuGetConfiguration,additionalAssemblies,references)
        {

        }

        protected override Project CreateProject(Solution solution, DocumentCreationArgs args, CompilationOptions compilationOptions, Project previousProject = null)
        {
            var name = args.Name ?? "Program";
            var id = ProjectId.CreateNewId(name);

            var parseOptions = new CSharpParseOptions(kind: SourceCodeKind.Script, languageVersion: LanguageVersion.Latest);

            compilationOptions = compilationOptions.WithScriptClassName(name);

            solution = solution.AddProject(ProjectInfo.Create(
                id,
                VersionStamp.Create(),
                name,
                name,
                LanguageNames.CSharp,
                isSubmission: true,
                parseOptions: parseOptions,
                hostObjectType: typeof(MyClass),
                compilationOptions: compilationOptions,
                metadataReferences: previousProject != null ? ImmutableArray<MetadataReference>.Empty : DefaultReferences,
                projectReferences: previousProject != null ? new[] { new ProjectReference(previousProject.Id) } : null));

            var project = solution.GetProject(id);
            return project;
        }
    }

ここはどんな処理をしているか理解する必要はありません。
重要なのは下から9行目にある

hostObjectType: typeof(MyClass),

という部分です。
ここにglobalsに渡したクラスのTypeを渡します。
これでglobalsにMyClassを指定したとRoslynPadに明示できます。

ただこれだけでは、予測に表示されません。
クラスを含むアセンブリを参照に追加する必要があります。
次に説明します。

自作のクラスを予測に表示する

RoslynHostの初期化部分を以下のように書き換えるだけです。

MainWindow.xaml.cs
host = new CustomRoslynHost(additionalAssemblies: new[]
      {
          Assembly.Load("RoslynPad.Roslyn.Windows"),
          Assembly.Load("RoslynPad.Editor.Windows")
      },
      references: RoslynHostReferences.Default.With(typeNamespaceImports: new[] { typeof(MyClass)})
      );

referencesに表示したいクラスを指定します。
正確にはこれだけでMyClassを含むアセンブリが自動的に参照に追加されます。
そのアセンブリに別のpublicなクラスが含まれていた場合、そのクラスも自動的に使用できるようになります。

また、今までScriptOptionsに参照の追加&usingのオプションを指定していましたが
自動的にRoslynHostDefaultReferencesDefaultImportsに追加されます。
よってScriptOptionsに改めて指定する必要はありません。

これで実行してみると、見事に表示されていることがわかります。
sc12.png

最終的なMainWindow.xaml.csは以下になります。

MainWindow.xaml.cs
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        RoslynHost host;
        public MainWindow()
        {
            InitializeComponent();
            host = new CustomRoslynHost(additionalAssemblies: new[]
            {
                Assembly.Load("RoslynPad.Roslyn.Windows"),
                Assembly.Load("RoslynPad.Editor.Windows")
            },
            references: RoslynHostReferences.Default.With(typeNamespaceImports: new[] { typeof(MyClass)})
            );


        }

        private void RoslynCodeEditor_Loaded(object sender, RoutedEventArgs e)
        {
            roslynCodeEditor.Initialize(host, new ClassificationHighlightColors(), Directory.GetCurrentDirectory(), String.Empty);
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                ScriptOptions options = ScriptOptions.Default
                    .WithReferences(host.DefaultReferences)
                    .WithImports(host.DefaultImports);

                var script = CSharpScript.Create(roslynCodeEditor.Text, options, typeof(MyClass));
                script.RunAsync(new MyClass());
            }
            catch (CompilationErrorException ex)
            {
                MessageBox.Show(ex.Message, "コンパイルエラー");
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "エラー");

            }
    }

注意すべき点としてCustomRoslynHostでMyClassを指定しておきながら、
gloablsに何も渡さなかった場合、予測に表示はされるのに実行時にエラーが出るという状態になってしまいます。

終わりに

RoslynPadの存在を知ることができたのは幸運でした。
が、RoslynPadの日本語の情報がなかったためかなり苦戦しました。
しかし、これで自分のアプリにどんどん利便性の高いスクリプティング機能を実装できそうです。

この記事は英語記事や、サンプルを自分なりに解釈して書いていることが多いので
間違っている内容が含まれている可能性があります。
その場合はどんどん報告していただけると助かります。
では、最後まで読んでいただきありがとうございました。

参考文献