C#スクリプト エディタに入力補完機能を実装する

この記事は、以下の記事の続きとなります。

この記事のサンプルコードはこちら github.com

これまでは、読み込んだ設定スクリプトをTextBoxコントロールに表示していたのですが、
今回、もう少し"コードエディタっぽい" エディタに変更して、ソースコードの表示・編集が出来るようにしたいと思います。

目次

C#スクリプトエディタにAvalonEditを使う

AvalonEditは、WPF用の高機能テキストエディタライブラリで、ソースコードシンタックスハイライトや入力補完機能を備えています。
今回は設定スクリプトの表示、編集用にこのライブラリを使用したいと思います。

AvalonEdit by icsharpcode

NuGet からインストール可能です。

PM> Install-Package AvalonEdit

XAML での使用例は以下の通りです。
AvalonEdit:TextEditor というコントロールを配置します。シンタックスハイライトや、行番号の表示有無などが指定可能です。

<Window x:Class="CsvEditor.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:CsvEditor"
        xmlns:avalonEdit="http://icsharpcode.net/sharpdevelop/avalonedit"
        mc:Ignorable="d"
        Title="MainWindow" Height="580" Width="960">
    <Grid>
        <!-- 中略 -->
        <avalonEdit:TextEditor 
            x:Name="configEdit"
            Document="{Binding ConfigurationDoc,Mode=TwoWay}"
            SyntaxHighlighting="C#"
            ShowLineNumbers="True"
            FontFamily="Consolas" FontSize="10Pt">
        </avalonEdit:TextEditor>
        <!-- 中略 -->
    </Grid>
</Window>

C#スクリプトエディタに入力補完機能を付ける

AvalonEdit の入力補完ウィンドウ

AvalonEdit には、入力補完ウィンドウを表示する仕組みが備わっています。

公式ドキュメント(http://avalonedit.net/documentation/ の"Code Completion")のサンプルを参考に以下のような記述を行うだけで、補完ウィンドウの表示自体は簡単に実装することが可能です。

//MainWindowのコードビハインド
public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;

    public MainWindow()
    {
        InitializeComponent();
        //イベントハンドラを登録
        configEdit.TextArea.TextEntered += TextArea_TextEntered;
        configEdit.TextArea.TextEntering += TextArea_TextEntering;
    }
    //文字入力中
    private void TextArea_TextEntering(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        //補完Windowが開いている間
        if (e.Text.Length > 0 && completionWindow != null)
        {
            //英数字以外が入力された場合
            if (!char.IsLetterOrDigit(e.Text[0]))
            {
                //選択中のリストの項目をエディタに挿入する                
                completionWindow.CompletionList.RequestInsertion(e);
            }
        }
        // HandledはTrueにしない
        // e.Handled=true;
    }
    //文字入力後
    private async void TextArea_TextEntered(object sender, System.Windows.Input.TextCompositionEventArgs e)
    {
        // ピリオドを入力
        if (e.Text == ".")
        {
            //入力補完Windowを生成
            completionWindow = new CompletionWindow(configEdit.TextArea);
            //補完リストに表示するアイテムをコレクションに追加する
            //---> ここは、編集内容に応じて適切な入力候補を追加するように書き換える!
            IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;
            data.Add(new CompletionData("item1"));
            data.Add(new CompletionData("item2"));
            data.Add(new CompletionData("item3"));
            //<---
            //Windowを表示
            completionWindow.Show();
            completionWindow.Closed += delegate
            {
                completionWindow = null;
            };
        }
    }
}
  • 入力候補一覧には、ICompletionData を実装したクラスのインスタンスを追加してあげる必要があります。
public class CompletionData : ICompletionData
{
    //入力候補一覧(UI)に表示する内容
    public object Content { get; set; }
    //ツールチップに表示する説明文
    public object Description { get; set; }
    //入力候補の左側に表示するアイコン
    public ImageSource Image { get; set; }
    //表示順の優先度?
    public double Priority { get; set; }
    //アイテム選択時に挿入される文字列
    public string Text { get; set; }
    //アイテム選択後の処理
    public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs)
    {
        textArea.Document.Replace(completionSegment, Text);
    }
}

Roslyn Workspaces API の Recommender を利用して入力候補一覧を取得する

AvalonEdit を利用することで、入力補完Window 自体を表示することができるのは分かったのですが、肝心の入力候補の一覧はどうやって作ればよいのでしょうか?

Roslyn の Workspaces API 辺りにコード補完の機能があったような。。。
と調べてみると、どうやら Microsoft.CodeAnalysis.Recommendations.Recommender クラスのGetRecommendedSymbolsAtPositionAsync というメソッドが使えそうだという事が分かりました。

Roslyn Workspases API もNuGetで追加します。

PM> Install-Package Microsoft.CodeAnalysis.Workspaces

Recommender.GetRecommendedSymbolsAtPositionAsync()

(このメソッドに関する情報が非常に少ないため、メソッド名と戻り値等から予測すると)
このメソッドは、ソースコード上の指定した場所(position)におけるコード補完候補の一覧を IEnumerable<ISymbol> で返してくれるメソッドのようです。

シグネチャは以下の通り。引数にはposition のほかに、SemanticModel と Workspace を渡す必要があるようです。

public static Task<IEnumerable<ISymbol>> GetRecommendedSymbolsAtPositionAsync(
    SemanticModel semanticModel,
    int position,
    Workspace workspace,
    OptionSet options = null,
    CancellationToken cancellationToken = default(CancellationToken));
(引数1) semanticModel

SemanticModelは、C#スクリプトを実行する際に作成する Script<T>オブジェクトから取得できるCompilation オブジェクトから取得可能なので、これを指定すれば良さそうです。

private void CompileScript(string code)
{
    Script<object> script = CSharpScript.Create(code, ScriptOptions.Default
        .WithReferences(
            typeof(object).Assembly,
            typeof(CsvReader).Assembly,
            typeof(ICsvEditorConfigurationHost).Assembly)
        .WithImports(
            "System",
            "System.Collections.Generic",
            "System.Linq"),
        typeof(ICsvEditorConfigurationHost));
    //SemanticModel の取得
    var compilation = script.GetCompilation();
    var syntaxTree = compilation.SyntaxTrees.First();
    var semanticModel = compilation.GetSemanticModel(syntaxTree);
    //Workspaceの作成
    //...(略)
}
(引数2) positon

ソースコード上の位置(エディタで補完機能を呼び出した際のキャレット位置)を指定すれば良さそう。

(引数3) workspace

ソースコードはWorkspace の形で渡してあげる必要があります。

Workspace を一から作成するには AdhocWorkspace クラスを使用します。

//Workspaceの作成
Workspace workspace = new AdhocWorkspace();
//空のprojectを作成
string projectName = "CsvEditorConfig";
ProjectId projectId = ProjectId.CreateNewId();
VersionStamp versionStamp = VersionStamp.Create();
ProjectInfo projectInfo 
    = ProjectInfo.Create(projectId, versionStamp, projectName, projectName, LanguageNames.CSharp);
Project project = workspace.AddProject(projectInfo);
//Documentを追加
SourceText sourcetext = Microsoft.CodeAnalysis.Text.SourceText.From(code);
workspace.AddDocument(project.Id, "Config.csx", sourcetext);

ICompletionData のコレクションを作成する

AvalonEdit の CompletionWindow に渡す入力候補ICompletionData インターフェースを実装したオブジェクトである必要がありました。

次は、GetRecommendedSymbolsAtPositionAsync() メソッドから取得したISymbolのコレクション を ICompletionData のコレクションに変換して返すメソッドを作ります。

public class MainWindowViewModel : BindableBase
{
    private Script<object> script;
    private SemanticModel semanticModel;
    private AdhocWorkspace workspace = new AdhocWorkspace();
    
    public async Task<IEnumerable<CompletionData>> GetCompletionListAsync(int position,string code)
    {
        CompileScript(code);
        if (semanticModel == null || workspace == null)
        {
            return Enumerable.Empty<CompletionData>();
        }
        var items = await Recommender.GetRecommendedSymbolsAtPositionAsync(
            semanticModel, position, workspace);
        return items.Select(symbol => new CompletionData()
        {
            //ListBoxに表示する内容
            Content = symbol.Name,
            //エディタに挿入する文字列
            Text = symbol.Name,
            //ツールチップに表示する文字列
            Description = symbol.ToMinimalDisplayString(semanticModel, position)
        });
    }
}

これをViewModelに実装して、View側 (コードビハインド) に記述した TextArea_TextEntered イベントハンドラで呼び出すようにします。
また、ピリオド(.)のほかに[Ctrl]+[Space]キーでも入力補完ウィンドウを表示するようにOnKeyDown() メソッドをオーバーライドします。

public partial class MainWindow : Window
{
    private CompletionWindow completionWindow;
    private MainWindowViewModel VM { get { return DataContext as MainWindowViewModel; } }

    private async void TextArea_TextEntered(object sender, 
        System.Windows.Input.TextCompositionEventArgs e)
    {
        if (VM == null) { return; }
        // Open code completion after the user has pressed dot:
        if (e.Text == ".")
        {
            await ShowCompletionWindow();
        }
    }

    protected override async void OnKeyDown(KeyEventArgs e)
    {
        base.OnKeyDown(e);
        //Ctrl+Space でも表示するように
        if (e.Key == Key.Space && e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control))
        {
            e.Handled = true;
            await ShowCompletionWindow();
        }
    }    

    private async Task ShowCompletionWindow()
    {
        if (VM == null) { return; }
        completionWindow = new CompletionWindow(configEdit.TextArea);
        IList<ICompletionData> data = completionWindow.CompletionList.CompletionData;

        var completionItems = await VM.GetCompletionListAsync(
            configEdit.TextArea.Caret.Offset + 1,
            configEdit.Document.Text + " ");

        foreach (var item in completionItems)
        {
            data.Add(item);
        }
        completionWindow.Show();
        completionWindow.Closed += delegate
        {
            completionWindow = null;
        };
    }
}

実行してみよう

長くなってしまいましたが、これで一通りの実装が完了しました。
それでは、実行結果を見てみましょう。

f:id:pierre3:20160727223655g:plain

  • ソースコードの編集箇所に応じた入力候補がちゃんと表示されています。
  • デリゲートの中の引数でも補完が利くようです。

中々いい感じに動いていますね。

※ あとは、以下の2点を何とかすれば完璧なのですが、今回はあきらめました。

まとめ

  • AvalonEdit は 高機能なテキストエディタを簡単に実装できていいぞ!
  • コンテキストに応じた入力候補の取得には Microsoft.CodeAnalysis.Recommendations.Recommender が使えるよ!

CSVのクラスマッピングの定義をC#スクリプトで記述する (その3: ValidationRule)

この記事は、以下の記事の続きです

github.com

目次

入力可能なデータの条件を指定できるようにする

前回は、CsvHelperのテキスト変換処理(TypeConverter)ラップしたConverterを設定することで、DataGridColumn の変換処理をカスタマイズしました。
今回は、DataGridColumnの検証(Validation)機能を利用して、カラム毎に入力値の制限を付けられるようにしたいと思います。

C#スクリプト側の記述

スクリプト側に公開するインターフェースに以下のメソッドを追加して、カラム(データ格納クラスのプロパティ)毎に入力制限を設定できるようにします。

public interface ICsvEditorConfigurationHost
{
    void AddValidation<TType,TMember>(
        Expression<Func<TType, TMember>> memberSelector,
        Func<TMember, bool> validation,
        string errorMessage);
}
  • 第1引数: 式木(デリゲート)でデータ格納クラスの対象プロパティを指定
  • 第2引数:「対象プロパティの値を受け取りboolを返すデリゲート」で、入力可能な値の条件を指定
  • 第3引数: 入力制限に引っかかった場合のエラーメッセージを指定

スクリプト内での記述例は以下の通り。(前回の記事と同じサンプルデータを使用)

//生年月日は今日以前のみ
AddValidation<Person, DateTime>(prop => prop.Birthday,
    m => m <= DateTime.Today,
    "未来の日付は入力できません");

//お小遣いは ¥0 以上 ¥10,000 以下
AddValidation<Person, int>(prop => prop.PocketMoney,
    m => (m >= 0) && (m <= 10000),
    "入力可能な範囲は ¥0~¥10,000 です。");

AddValidation メソッドの実装は以下の通りです。 引数に渡した設定値をプロパティ名をキーとした辞書に登録します。

public class CsvEditorConfigurationHost
{
    public IDictionary<string, ColumnValidation> ColumnValidations { get; } 
        = new Dictionary<string, ColumnValidation>();

    public void AddValidation<TType, TMember>(Expression<Func<TType, TMember>> memberSelector, 
        Func<TMember, bool> validation, string errorMessage)
    {
        //式木からプロパティ名を取得
        MemberExpression memberExpression = null;
        if (memberSelector.Body.NodeType == ExpressionType.Convert)
        {
            var body = (UnaryExpression)memberSelector.Body;
            memberExpression = body.Operand as MemberExpression;
        }
        else if (memberSelector.Body.NodeType == ExpressionType.MemberAccess)
        {
            memberExpression = memberSelector.Body as MemberExpression;
        }
        if (memberExpression == null)
        {
            throw new ArgumentException("Not a member access", nameof(memberSelector));
        }
        //プロパティ名をキーにして辞書に登録
        ColumnValidations.Add(memberExpression.Member.Name, 
            new ColumnValidation(m => validation((TMember)m), errorMessage));
    }
}
//設定値格納用
public class ColumnValidation
{
    public Func<object, bool> Validation { get; }
    public string ErrorMessage { get; }
    public ColumnValidation(Func<object, bool> validation, string errorMessage)
    {
        Validation = validation;
        ErrorMessage = errorMessage;
    }
}

DataGridColumn に ValidationRuleを設定する

スクリプトで指定された条件からDataGridColumnバインドするValidationRule クラスを作成します。

public class DataGridColumnValidationRule : ValidationRule
{
    private Func<object, bool> isValidate;
    private object errorContent;

    public DataGridColumnValidationRule(Func<object, bool> isValidate, object errorContent)
    {
        if (isValidate == null) { throw new ArgumentNullException(nameof(isValidate)); }
        if (errorContent == null) { throw new ArgumentNullException(nameof(errorContent)); }

        ValidationStep = ValidationStep.ConvertedProposedValue;
        this.isValidate = isValidate;
        this.errorContent = errorContent ?? "invalid value";
    }

    public override ValidationResult Validate(object value, CultureInfo cultureInfo)
    {
        return (isValidate(value)) ?
            ValidationResult.ValidResult :
            new ValidationResult(false, errorContent);
    }
}

前回のConverterの指定と同様にAutoGeneratingColumnイベントのハンドラ内でDataGridColumnのBindingオブジェクトにValidationRuleを追加します。

public class MainWindow
{
    private void DataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e)
    {
        if (VM == null) { return; }

        var converter = VM.GetDataGridColumnConverter(e.PropertyName);
        if (converter == null) { return; }

        if (!string.IsNullOrEmpty(converter.HeaderName))
        {
            e.Column.Header = converter.HeaderName;
        }

        var textColumn = e.Column as DataGridTextColumn;
        if (textColumn != null)
        {
            textColumn.EditingElementStyle = (Style)Resources["textColumnStyle"];
        }

        var binding = (e.Column as DataGridBoundColumn)?.Binding as Binding;
        if (binding != null)
        {
            binding.Converter = converter;
            //VMからValidationRuleを取得してBindingに設定        
            var validationRule = VM.GetDataGridColumnValidation(e.PropertyName);
            if (validationRule != null)
            {
                binding.ValidationRules.Add(validationRule);
            }
        }
    }
}

public class MainWindowViewModel
{
    public DataGridColumnValidationRule GetDataGridColumnValidation(string propertyName)
    {
        ColumnValidation columnValidaiton;
        if (host.ColumnValidations.TryGetValue(propertyName, out columnValidaiton))
        {
            return new DataGridColumnValidationRule(columnValidaiton.Validation, columnValidaiton.ErrorMessage);
        }
        return null;
    }
}

実行例

「生年月日」と「お小遣い」に入力可能範囲外の値を入力してみます。

f:id:pierre3:20160722230823p:plain

期待通りセルの背景が赤くなり、ツールチップには指定したエラーメッセージが表示されています。

まとめ

これで入力ミスも柔軟にチェックすることが出来るようになりました。

CSVのクラスマッピングの定義をC#スクリプトで記述する (その2:Converter)

この記事は、以下の記事の続きです。

pierre3.hatenablog.com

  • この記事のサンプルコードはこちら

github.com

目次

前回は、CSVデータのクラスマッピングC#スクリプトで記述してC#のオブジェクトに格納するところまでやりました。
そのオブジェクトをWPFのDataGridコントロールバインドして、読み取ったデータが表示されることも確認できました。

ただ、このままではDataGridのセルに表示されるデータが元のフォーマットと異なって表示されてしまう場合があります。

DataGridの表示フォーマットを何とかしたい

今回は、以下のCSVデータを使用します。

名前,生年月日,配偶者,性別,お小遣い
佐藤,1986/9/2,なし,男性,\1000
鈴木,2001/8/10,なし,男性,\500
高橋,1974/6/7,あり,女性,\300
田中,1990/11/20,なし,男性,\1000
伊藤,1986/10/10,なし,男性,\2000
渡辺,1968/5/5,なし,女性,\800
山本,1970/9/3,あり,女性,\9000
中村,1984/3/15,なし,男性,\10000
小林,2005/1/2,なし,男性,\100
加藤,1955/6/10,あり,男性,\0
吉田,1986/9/12,あり,男性,\800
山田,1996/3/25,なし,女性,\500
佐々木,1986/8/3,なし,男性,\500
山口,1986/12/6,あり,女性,\6000
松本,1980/2/2,あり,男性,\8000
井上,1992/7/17,あり,女性,\9000
木村,1989/11/6,なし,男性,\10000
林,1999/10/2,あり,男性,\200
斎藤,2010/4/21,なし,女性,\1000
清水,1980/9/7,あり,男性,\600

このデータを読み込んで生成したオブジェクトをそのまま表示すると以下のようになります。

f:id:pierre3:20160712223559p:plain

期待する表示内容とは異なる表示になっている箇所がいくつかあります。

  • カラムヘッダがデータ格納クラスのプロパティ名になっている
    ⇒ 読み込んだCSVファイルのヘッダ名を表示したい
  • 「生年月日」に時刻が含まれています。年月日の並びもおかしい
    yyyy/M/d のフォーマットで表示したい
  • 「お小遣い」の¥(\)マークが表示されない
    ¥10,000 の様に表示させたい

CsvClassMap で指定した文字列のフォーマットや変換処理をDataGridの表示に流用する

CSV読み書き時に使用するCsvClassMap<T>C#スクリプトで以下のように記述しました。

RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("名前");
    classMap.Map(m => m.Birthday).Name("生年月日")
        .TypeConverterOption("yyyy/M/d");
    classMap.Map(m => m.Married).Name("配偶者")
        .TypeConverterOption(true, "あり")
        .TypeConverterOption(false, "なし");
    classMap.Map(m => m.Sex).Name("性別");
    classMap.Map(m => m.PocketMoney).Name("お小遣い")
        .TypeConverterOption(NumberStyles.Currency)
        .TypeConverterOption("C");
});

DataGridに表示される形式は、CSVファイルに保存される形式と合わせたいのですが、 DataGridの表示フォーマットのために上記のような記述を2度も書くのはいやですよね。

ここはどうにかして、CsvClassMap の設定値を使いまわす方法を考えたいと思います。

CsvClassMap を覗いてみる

CsvClassMap のメンバーを確認してみます。

カラムごとの設定は PropertyMaps というコレクションに入っているPropertyMapクラスのDataプロパティに格納されているようです。

var propertyMap = csvClassMap.PropertyMaps
    .FirstOrDefault(m.Data.Property.Name == propertyName);
var propertyMapData = propertyMap.Data;

このプロパティはCsvPropertyMapData というクラスで、以下のようなメンバーを持ちます(抜粋)。

//Property(カラム)毎のマッピング設定を格納するクラス
public class CsvPropertyMapData
{
    //プロパティ情報
    public virtual PropertyInfo Property { get; }
    //カラムヘッダ名(コレクション)
    public virtual CsvPropertyNameCollection Names { get; }
    //ヘッダ名のインデックス        
    public virtual int NameIndex { get; set; }
    //カラムのインデックス
    public virtual int Index { get; set; }
    //テキスト⇔オブジェクト コンバータ
    public virtual ITypeConverter TypeConverter { get; set; }
    //コンバータでの変換時に使用するオプション
    public virtual TypeConverterOptions TypeConverterOptions { get; }
    //変換できなかった場合の規定値
    public virtual object Default { get; set; }
}
//テキスト⇔オブジェクト間の変換で使用するオプション
public class TypeConverterOptions
{
    public CultureInfo CultureInfo { get; set; }
    public DateTimeStyles? DateTimeStyle { get; set; }
    public TimeSpanStyles? TimeSpanStyle { get; set; }
    public NumberStyles? NumberStyle { get; set; }
    public List<string> BooleanTrueValues { get; }
    public List<string> BooleanFalseValues { get; }
    public string Format { get; set; }
}
  • カラムヘッダには NamesNameIndex が使えそうです。
  • データの変換処理は、TypeConverter で行います。これは(特に指定しなければ)対応するデータの型に応じたコンバータが自動で割り当てられます。
  • 文字列のフォーマットやカルチャの設定等は TypeConverterOptions に格納されます。これはClassMap 登録時に.TypeConverterOption() メソッドで設定します。

CsvClassMapのTypeConverter(CsvHelper)を IValueConverterWPF) にラップしてDataGridColumn に設定する。

DataGridのカラムごとの変換処理は、 DataGridColumn のBindingプロパティに指定するコンバータを置き換えることで変更可能です。

コンバータはIValueConverter を継承したクラスである必要があります。

以下に作成したコンバータを示します。中身は、CsvClassMapの TypeConverter をそのまま呼び出すだけのシンプルなものです。

public class DataGridColumnConverter : IValueConverter
{
    private ITypeConverter typeConverter;
    private TypeConverterOptions converterOptions;
    public string HeaderName { get; }
    public DataGridColumnConverter(string headerName, ITypeConverter converter, 
        TypeConverterOptions options)
    {
        typeConverter = converter ?? new DefaultTypeConverter();
        converterOptions = options ?? new TypeConverterOptions();
        HeaderName = headerName;
    }

    public object Convert(object value, Type targetType, 
        object parameter, CultureInfo culture)
    {
        if (targetType != typeof(string)) { return value; }
        try
        {
            return typeConverter.ConvertToString(converterOptions, value);
        }
        catch
        {
            //変換に失敗した場合はDependencyProperty.UnsetValue を返す
            return DependencyProperty.UnsetValue;
        }
    }

    public object ConvertBack(object value, Type targetType, 
        object parameter, CultureInfo culture)
    {
        var oldValue = value as string;
        if (oldValue == null) { return value; }
        try
        {
            return typeConverter.ConvertFromString(converterOptions, oldValue);
        }
        catch
        {
            //変換に失敗した場合はDependencyProperty.UnsetValue を返す
            return DependencyProperty.UnsetValue;
        }
    }
}

コンバータ作成部分の処理は以下のようになります。ClassMapから propertyName に対応する PropertyMap を取得して、そのTypeConverterおよびTypeConverterOptions を用いてコンバータを生成します。

public class MainWindowViewModel : BindableBase
{
    public DataGridColumnConverter GetDataGridColumnConverter(string propertyName)
    {
        var propertyMap = host.ClassMap.PropertyMaps
            .FirstOrDefault(m => m.Data.Property.Name == propertyName);
        if (propertyMap == null) { return null; }

        return new DataGridColumnConverter(
            propertyMap.Data.Names[propertyMap.Data.NameIndex],
            propertyMap.Data.TypeConverter,
            propertyMap.Data.TypeConverterOptions,
            propertyMap.Data.Default);
    }
}

自動生成されるDataGridColumn をカスタマイズする

DataGrid のカラムは、バインドするデータに合わせて自動生成されます。
自動生成されるカラムはAutoGeneratingColumn イベント内でカスタマイズすることができます。

<Grid>
    <DataGrid Grid.Column="0" 
        AutoGenerateColumns="True" 
        AutoGeneratingColumn="DataGrid_AutoGeneratingColumn" 
        ItemsSource="{Binding Items}" >
    </DataGrid>
</Grid>

AutoGeneratingColumn イベントは、DataGrid.ItemsSourceプロパティが変更されて、個々のカラムが自動生成される際に発行されます。

カラムのカスタマイズ処理は、コードビハインドに追加した DataGrid_AutoGeneratingColumn イベントハンドラ内に記述します。

イベント引数 DataGridAutoGeneratingColumnEventArgs e のプロパティを用いて次のように設定します。
(e.Column を強引にキャストしてBindingを取得するなど、少々お行儀が悪いですが。。。)

  • e.PropertyName から 対応するコンバータを取得
  • e.Column.Header に 表示するカラム名を設定
  • e.Column から Binding オブジェクトを取得して、コンバータを設定する
  • e.Column が DataGridTextColumn だったら EditingElementStyle にXAML側で記述した変換エラー時のStyleを設定
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent()
        DataContext = new MainWindowViewModel();
    }
    //AutoGeneratingColumn イベントハンドラ
    private void DataGrid_AutoGeneratingColumn(object sender, DataGridAutoGeneratingColumnEventArgs e)
    {
        var vm = DataContext as MainWindowViewModel;
        if (vm == null) { return; }

        //コンバータを取得
        var converter = vm.GetDataGridColumnConverter(e.PropertyName);
        if (converter == null) { return; }

        if (!string.IsNullOrEmpty(converter.HeaderName))
        {
            //カラムヘッダの文字列を変更
            e.Column.Header = converter.HeaderName;
        }

        var textColumn = e.Column as DataGridTextColumn;
        if (textColumn != null)
        {
            //変換エラー時にToolチップを表示するスタイルを設定
            textColumn.EditingElementStyle = (Style) Resources["textColumnStyle"];
        }

        //コンバータを設定
        var binding = (e.Column as DataGridBoundColumn)?.Binding as Binding;
        if (binding != null)
        {
            binding.Converter = converter;
        }
            
    }
}
<!-- セル編集で変換エラー発生時のスタイル -->
<Window.Resources>
    <Style x:Key="textColumnStyle" TargetType="{x:Type TextBox}">
        <Style.Triggers>
            <Trigger Property="Validation.HasError" Value="True">
                <!-- 背景を赤くする -->
                <Setter Property="Background" Value="Red"/>
                <!-- ツールチップを表示する -->
                <Setter Property="ToolTip">
                    <Setter.Value>
                        <Binding
                                Path="(Validation.Errors)[0].ErrorContent"
                                RelativeSource="{x:Static RelativeSource.Self}"/>
                    </Setter.Value>
                </Setter>
            </Trigger>
        </Style.Triggers>
    </Style>
</Window.Resources>

実行結果

ひとまずこれで準備が整いました。では、実行してみましょう。

  • [Read]を押して読み込んだ結果

f:id:pierre3:20160716161808p:plain

  • ちゃんと元データのカラム名が表示されています。
  • 「生年月日」がyyyy/M/d フォーマットで表示されています。
  • 「お小遣い」に¥マークがついています。(int.ToString("C")のフォーマット)

いい感じです。 では、変換できない文字を入力した場合はどうなるか見てみましょう。

f:id:pierre3:20160716214351p:plain

f:id:pierre3:20160716215040p:plain

f:id:pierre3:20160716214346p:plain

f:id:pierre3:20160716214532p:plain

ちゃんと XAMLで書いたStyle通りにセルの背景が赤くなり、エラーメッセージがツールチップに表示されました!

まとめ

CsvHelperのTypeConverter をラップした IValueConverter を使う事でDataGridの表示形式を元のCSVファイルと合わせることが出来ました。
あとは、カラム毎に「数値の入力可能範囲」や、「入力可能な文字」等の細かなルールを指定できると良いですよね。

という事で、次回はその辺りの実装を考えてみたいと思います。

CSVのクラスマッピングの定義をC#スクリプトで記述する

目次

WPF DataGrid でCSVエディタを作る。

WPF のDataGridコントロールに、読み込んだCSVデータを格納したコレクションをバインドするだけで、CSVエディタのようなものを簡単に作ることが出来ます。

例えば、以下のようなCSVデータを読み取る場合、

名前,生年月日,配偶者,性別
佐藤,1986/9/2,なし,男性
鈴木,2001/8/10,なし,男性
高橋,1974/6/7,あり,女性
田中,1990/11/20,なし,男性
伊藤,1986/10/10,なし,男性
渡辺,1968/5/5,なし,女性
山本,1970/9/3,あり,女性
中村,1984/3/15,なし,男性
小林,2005/1/2,なし,男性
加藤,1955/6/10,あり,男性
吉田,1986/9/12,あり,男性
山田,1996/3/25,なし,女性
佐々木,1986/8/3,なし,男性
山口,1986/12/6,あり,女性
松本,1980/2/2,あり,男性
井上,1992/7/17,あり,女性
木村,1989/11/6,なし,男性
林,1999/10/2,あり,男性
斎藤,2010/4/21,なし,女性
清水,1980/9/7,あり,男性

データ格納用のクラスを用意して、DataGridにバインド

//データ格納クラス
class Person
{
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public bool Married { get; set; }
    public Sex Sex { get; set; }
}

enum Sex
{
    男性,
    女性
}

class MainWindowViewModel
{
    //バインディングソース
    public ObservableCollection<Person> Items{ get; set; }
    
    public void Read()
    {
        //データ読み込みとクラスへのマッピングはライブラリ任せにする(後述します)
        using (var baseReader = StreamReader("test.csv"))
        using (var reader = new CsvReader(baseReader))
        {
            Items = new ObservableCollection<Person>(reader.GetRecords<Person>());
        }
    }   
}
<DataGrid AutoGenerateColumns="True" ItemsSource="{Binding Items}" />

これで以下の様に読み込んだデータが一覧表示されるようになります。

f:id:pierre3:20160625214742p:plain

CsvHelperにCSVデータの読み込みとクラスにマップをお任せする

CSVデータの読み込み処理と、クラスへマッピングしてオブジェクトを生成する処理は、CsvHelper を使用すると便利です。

joshclose.github.io

以下にCsvHelperを使用したクラスマッピングの例を示します。
クラスマッピングには、CsvClassMap<T> を継承したクラスを用いて行います。

class PersonClassMap:CsvClassMap<Person>
{
    //コンストラクタにマッピング方法を記述
    public PersonClassMap()
    {
        Map(m => m.Name).Name("名前");
        Map(m => m.Birthday).Name("生年月日")
            .TypeConverterOption("yyyy/M/d");
        Map(m => m.Married).Name("配偶者")
            .TypeConverterOption(true, "あり")
            .TypeConverterOption(false, "なし");
        Map(m => m.Sex).Name("性別");    
    }
}

class MainWindowViewModel
{
    public void Read()
    {
        using (var baseReader = StreamReader("test.csv"))
        using (var reader = new CsvReader(baseReader))
        {
            //Class Map 登録
            reader.Configuration.RegisterClassMap<PersonClassMap>();

            Items = new ObservableCollection<Person>(reader.GetRecords<Person>());
        }
    }   
}

読み込むデータに応じて、クラスマッピングの定義を動的に行いたい

CsvHelperを使用することで、簡単にCSVデータを型付きのオブジェクトとして扱う事が可能となりました。
ただ、上記のような実装方法では「どのようなCSVデータを読んで、どんなクラスにマップするか」をアプリケーション実装時に知っている必要があります。

つまり、アプリケーションが対応しているフォーマットのCSVデータしか扱えない という事です。

できれば
CSVデータのフォーマットが変わる度にアプリケーションを変更したくない」ですし、「どのようなデータも柔軟に扱えるようにしたい」のです。

ならば、格納クラスの定義とマッピングの設定を外部から設定すればいいのでは? そこで「Roslyn Scripting」です。

C# スクリプトCSVエディタの設定を記述する

Roslyn Scripting

Roslyn Scripting を利用することで、C#で記述されたスクリプトをアプリケーション内で実行することが可能となります。

ufcpp.net

www.kekyo.net

NuGet でインストールして使用します。

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

アプリケーション内でスクリプトを読み込んで実行する

スクリプトの実行方法は簡単で、基本的には以下の様にするだけです。

private CsvEditorCongifurationHost host = new CsvEditorCongifurationHost();

public async Task RunScriptAsync(string code)
{
    try
    {
         var script = CSharpScript.Create(code, ScriptOptions.Default
            .WithReferences(  //参照アセンブリを指定
                typeof(object).Assembly,
                typeof(CsvReader).Assembly)
            .WithImports(  //using する名前空間を指定
                "System",
                "System.Collections.Generic",
                "System.Linq",
                "System.Globalization",
                "System.Text",
                "CsvHelper.Configuration",
                "CsvHelper"),
         //RunAsync()に渡すglobalsオブジェクトの型を指定
         globalsType : typeof(ICsvEditorConfigurationHost));
         
         //RunAsync()にはスクリプト内で参照可能なオブジェクトを渡すことが可能
         scriptContext = await script.RunAsync(globals: host);
    }
    catch (CompilationErrorException e)
    {
        ErrorMessages.Add(e.Message + Environment.NewLine + e.Diagnostics);
    }
}

スクリプト側に公開するメソッドを定義する

アプリケーションとスクリプトとの間のやり取りは、Script.RunAsync() に渡すオブジェクト(以後「Hostオブジェクト」と呼びます)を使って行います。

  • Hostオブジェクトの型はCSharpScript.Create() の引数globalsTypeで指定できます。
    ここには、実際に渡すhostオブジェクトの型だけではなく、Hostオブジェクトの基底クラスや、インターフェースの型を指定しても良いです。

今回は、以下のようなインターフェースを定義して、CSVの読み込みとクラスマッピングの設定に必要なメソッド(およびプロパティ)のみを公開するようにします。

public interface ICsvEditorConfigurationHost
{
    //読み込むCSVテキストのエンコーディングを指定
    Encoding Encoding { get; set; }
    //クラスマッピングの設定(型指定のみ)
    void RegisterClassMap<T>();
    //クラスマッピングの設定(詳細設定あり)
    void RegisterClassMap<T>(Action<CsvClassMap<T>> propertyMapSetter);
    //読み込み設定
    void SetConfiguration(Action<CsvConfiguration> configurationSetter);
}

これを利用したスクリプト側のコードは以下の様になります。

//Encodingの指定。省略した場合は Encoding.Default が使われる
Encoding = Encoding.GetEncoding("shift-jis");

//データ格納用のクラスを定義
class Person
{
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public bool Married { get; set; }
    public Sex Sex { get; set; }
}

enum Sex
{
    男性,
    女性
}

//クラスマッピングの詳細設定
//不要な場合は RegisterClassMap<Person>(); でOK
RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("名前");
    classMap.Map(m => m.Birthday).Name("生年月日")
        .TypeConverterOption("yyyy/M/d");
    classMap.Map(m => m.Married).Name("配偶者")
        .TypeConverterOption(true, "あり")
        .TypeConverterOption(false, "なし");
    classMap.Map(m => m.Sex).Name("性別");
});

//CsvHelperでの読み込み設定
SetConfiguration(conf =>
{
    //先頭に'#'がある行をコメントとしてスキップする
    conf.AllowComments = true;
    conf.Comment = '#';
    //...(必要に応じて設定内容を記述)
    
});

サンプルアプリケーション

サンプルコードをGithubに上げておきました。
スクリプトを実行した後の実際の読み取り処理などは、こちらのコードをご確認ください。

github.com

実行例

今回は動作確認のため、ウインドウの右側のテキストボックスに読み込んだスクリプトを表示して編集できるようにしました。
[Read]ボタンをクリックすると、スクリプトの実行とCSVファイルの読み取りを行います。

f:id:pierre3:20160627231205p:plain

[Read]ボタン押下後。読み込んだ結果がDataGridに表示されています。

f:id:pierre3:20160627231211p:plain

これだけでは、スクリプトで動的に実行されているかよくわからないので、少し手を加えてみます。
プロパティにAge を追加して生年月日から計算した値を格納するようにします。

class Person
{
    public string Name { get; set; }
    public DateTime Birthday { get; set; }
    public bool Married { get; set; }
    public Sex Sex { get; set; }
    public ushort Age { get; set; }
}

RegisterClassMap<Person>(classMap =>
{
    classMap.Map(m => m.Name).Name("名前");
    classMap.Map(m => m.Birthday).Name("生年月日")
        .TypeConverterOption("yyyy/M/d");
    classMap.Map(m => m.Married).Name("配偶者")
        .TypeConverterOption(true, "あり")
        .TypeConverterOption(false, "なし");
    classMap.Map(m => m.Sex).Name("性別");
    classMap.Map(m => m.Age).Name("年齢")
        .ConvertUsing(row => {
            var birthday = int.Parse(row.GetField<DateTime>(1).ToString("yyyyMMdd"));
            var today = int.Parse(DateTime.Today.ToString("yyyyMMdd"));
            return (today - birthday) /10000;
        });
});

このようにスクリプトを書き換えて再度[Read]ボタンをクリックします。

f:id:pierre3:20160627232245p:plain

ちゃんと”年齢”カラムが追加されて計算で求めた年齢が入っています。

一覧のフィルタ、ソートをスクリプトで実行してみる

ついでに、表示する一覧をLINQで操作できるようにしてみます。
HostオブジェクトのインターフェースにQuery<T>()メソッドを追加します。

public interface ICsvEditorConfigurationHost
{
    void Query<T>(Func<IEnumerable<T>, IEnumerable<T>> query);
}

テキストボックスを1つ追加して、そこに以下のコードを記述します。

Query<Person>( source => source
    .Where(m => m.Sex == Sex.男性)
    .OrderBy(m => m.Married)
    .ThenBy(m=> m.Age));

f:id:pierre3:20160627233940p:plain

書いた通りにフィルタ、ソートが実行されて表示されました。

編集と保存

セルを編集して保存することも可能です。

f:id:pierre3:20160627234646p:plain

[Write]ボタンをクリックでDataGridの現在の状態がCSV形式で出力されます。

(出力結果)

名前,生年月日,配偶者,性別,年齢
小林,2005/1/2,False,男性,11
鈴木,2001/8/10,False,男性,14
田中,1990/11/20,False,男性,25
木村,1989/11/6,False,男性,26
佐藤,1986/9/2,False,男性,29
伊藤,1986/10/10,False,男性,29
りんな,1998/8/3,False,女性,17
林,1999/10/2,True,男性,16
吉田,1986/9/12,True,男性,29
清水,1980/9/7,True,男性,35
松本,1980/2/2,True,男性,36
加藤,1955/6/10,True,男性,61

CSV形式での出力も CsvHelperを使用しています。出力時もClassMapが使用可能で、今回は読み込み時と同じものを使用しました。
(※ 配偶者 「あり/なし」が「True/False」に変わってしまっているのは想定外でした。bool の変換は読み込み時のみ有効なのだろうか?)

まとめ

ひとまず、クラスマッピングの定義をC#スクリプトで記述して、DataGridにバインドする(型付けされた)オブジェクトを動的に生成することが出来ました。

次は、文字列の表示フォーマットと、セル編集時のバリデーションの設定辺りを検討してみたいと思います。

[Reactive Extensions]お題:5人揃ってゴレンジャイ!

最近 Reactive Extensions に入門した同僚に出したお題です。
ちょっとしたお遊びのつもりで出題したのですが、意外と楽しんでもらえたようです。

目次

お題

Reactive Extensionsを使って、次の処理を実装してください。

  1. ゴレンジャイのメンバー、「赤レンジャイ」「青レンジャイ」「黄レンジャイ」「緑レンジャイ」「桃レンジャイ」からランダムに1人選び、表示します。
  2. それを5回繰り返して、1人も重複していなければ成功です。「5人揃ってゴレンジャイ!」と表示して終了します。
  3. メンバーが重複した時点で失敗とします。1人目からやり直してください。
  4. 成功するまで繰り返し行います。

(元ネタはこちら)


ガキの使い ゴレンジャイゲーム

私の回答

やり方はいろいろあると思いますが、今回は「成功するまで繰り返す」部分でRetry()を使おう、と決めて全体の実装を考えました。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;


class Program
{
  static void Main(string[] args)
  {
      var goren = new[]
      { 
        "赤レンジャイ! ",
        "青レンジャイ! ",
        "黄レンジャイ! ",
        "緑レンジャイ! ",
        "桃レンジャイ! " 
      };
      Observable.Repeat(new Random(), 5)
        .Select(r => goren[r.Next(5)])
        .Scan(new { Value = "", IsOK = true, Buffer = new HashSet<string>() },
          (a, x) =>
          {
            var IsOK = a.Buffer.Add(x);
            if (!IsOK) { a.Buffer.Clear(); }
            return new {
              Value = x,
              IsOK,
              Buffer = a.Buffer
            };
          })   
        .SelectMany(a => a.IsOK
          ? Observable.Return(a.Value + " ")
          : Observable.Return(a.Value + " 被っとるやないかい!\n")
                      .Concat(Observable.Throw<string>(new Exception())))
        .Retry()
        .Subscribe(
          onNext: x => Console.Write(x),
          onCompleted: () => Console.WriteLine("5人揃ってゴレンジャイ!!"));
  }
}

実行結果

青レンジャイ!  青レンジャイ!  被っとるやないかい!
桃レンジャイ!  黄レンジャイ!  青レンジャイ!  桃レンジャイ!  被っとるやないかい!
赤レンジャイ!  桃レンジャイ!  黄レンジャイ!  黄レンジャイ!  被っとるやないかい!
桃レンジャイ!  桃レンジャイ!  被っとるやないかい!
黄レンジャイ!  赤レンジャイ!  赤レンジャイ!  被っとるやないかい!
… (中略)…
青レンジャイ!  赤レンジャイ!  緑レンジャイ!  青レンジャイ!  被っとるやないかい!
青レンジャイ!  赤レンジャイ!  黄レンジャイ!  緑レンジャイ!  青レンジャイ!  被っ とるやないかい!
桃レンジャイ!  赤レンジャイ!  緑レンジャイ!  黄レンジャイ!  緑レンジャイ!  被っ とるやないかい!
赤レンジャイ!  桃レンジャイ!  黄レンジャイ!  桃レンジャイ!  被っとるやないかい!
青レンジャイ!  緑レンジャイ!  赤レンジャイ!  青レンジャイ!  被っとるやないかい!
赤レンジャイ!  緑レンジャイ!  青レンジャイ!  黄レンジャイ!  桃レンジャイ!  5人揃ってゴレンジャイ!!

解説

  • Repeat() + Select() => ランダムに5人選ぶ
  • Scan() => 重複チェックのために値をまとめる
  • SelectMany() => 重複していたら 例外を流す
  • Retry() => 例外が流れて来たら、やり直し
  • Subscribe()-onNext => 流れてきた値を表示する。
  • Subscribe()-onCompleted => 無事に5人分流れて来たら完了!「5人揃ってゴレンジャイ!」

重複判定

Scan()のところが少々分かりずらいでしょうか。
ここでは、毎回流れてくる値を、以下をメンバに持つ匿名型にまとめています。

  • string Value: 現在の値
  • bool IsOK : 重複した(false)/していない(true)
  • HashSet<String> Buffer: 値をためておくバッファ(重複判定に使用)

HashSet<T> は重複した値を追加できないため、既に存在する要素を追加しようとすると、追加用メソッドAdd() は失敗しfalse を返してきます。
これを利用して「かぶった」/「かぶっていない」の判定を行っています。

var IsOK = Buffer.Add(x);

失敗したら例外を流す

Retry()は、Rxのストリームに例外が流れてきた場合に動作します。

失敗したら例外を発生させれば良いのですが、ストリーム上に流すためにはObservable.Throw()メソッドを使用してIObservable<T> にラップしてやる必要があります。

ここで、流れる値が IObservable<T>、(つまり IObservable<IObservable<T>>) となるので、通常の値も Observable.Return() を使用してIObservable<T> で流してやる必要があります。
そして、IObservable<IObservable<T>>IObservable<T> に戻すために SelectMany() が使われています。

.SelectMany(a => a.IsOK
  ? Observable.Return(a.Value + " ")
  : Observable.Return(a.Value + " 被っとるやないかい!\n")
              .Concat(Observable.Throw<string>(new Exception())))

(失敗時のメンバーも表示したいので、失敗時の値 と 例外 をConcat()でつなげて流すようにしています。 )

蛇足

失敗したら例外を流す処理 (SelectMany()の部分) は、拡張メソッドに切り出してThrowIfFalse() みたいにしたらもっと分かりやすくなりそうですね。他のケースでも使えそうな気もします。

ThrowIfFalse() 拡張メソッド

という事で、作ってみました。

ThrowIfFalse 条件式がFalseを返したら例外を発行するIObservable<T> 拡張メソッド · GitHub

回答のコードを置き換えてみます。
書いている内容はほとんど変わっていませんが、やりたい事の意図がより伝わりやすくなったと思います。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;


class Program
{
  static void Main(string[] args)
  {
      var goren = new[]
      { 
        "赤レンジャイ! ",
        "青レンジャイ! ",
        "黄レンジャイ! ",
        "緑レンジャイ! ",
        "桃レンジャイ! " 
      };
      Observable.Repeat(new Random(), 5)
        .Select(r => goren[r.Next(5)])
        .Scan(new { Value = "", IsOK = true, Buffer = new HashSet<string>() },
          (a, x) =>
          {
            var IsOK = a.Buffer.Add(x);
            if (!IsOK) { a.Buffer.Clear(); }
            return new {
              Value = x,
              IsOK,
              Buffer = a.Buffer
            };
          })   
        .ThrowIfFalse(a => a.IsOK, new Exception(),
          resultSelectorWhenTrue : a => a.Value + " ",
          resultSelectorWhenFalse : a => a.Value + " 被っとるやないかい!\n")
        .Retry()
        .Subscribe(
          onNext: x => Console.Write(x),
          onCompleted: () => Console.WriteLine("5人揃ってゴレンジャイ!!"));
  }
}

Azure App Service + ASP.NET Web API で LINE BOTを作ってみる

LINE BOT APIのトライアルをAzure + ASP.NET WebAPIで試してみました。

LINE BOT API Trial

Azure App Service と Visual Studio 2015 を使用してLINE BOTを作成する手順をまとめてみたいと思います。

今回はひとまず、ユーザーからのメッセージを受け取れることと、BOT側のメッセージをユーザーに返信出来ることが確認できるまでを目標とします。

目次

はじめに

用意するもの

Visual StudioASP.NET WebAppプロジェクトを作成する

  • プロジェクトの新規作成ダイアログで「ASP.NET Webアプリケーション」を選択してプロジェクトを作成します。

f:id:pierre3:20160411224316p:plain

  • ASP.NET 4.6 テンプレートの「Empty」を選択します。
  • 「フォルダーおよびコア参照を追加する」 で「Web API」のみにチェックします。
  • Microsoft Azure クラウドにホストするをチェックし、コンボボックスでWebアプリを選択します。
  • [OK]をクリックすると、Azure 関連の設定画面に進みます。

f:id:pierre3:20160411220822p:plain

f:id:pierre3:20160411221037p:plain

  • ここで入力したWebアプリ名に 「.azurewebsites.net」ドメイン名を付加したものがこのアプリのURLになります。
    • http(s)://linebotapp.azurewebsites.net
  • 各項目を入力後、[OK]をクリックすると WebAPIのプロジェクトが作成されます。

LINE ビジネスアカウントの設定を行う

LINE ビジネスアカウントで LINE Developpers にログインすると、作成したアカウントの詳細が「Channels」で確認できます。

f:id:pierre3:20160412225914p:plain

  • このページの下部にある[Edit]ボタンをクリックして編集画面を開きます。

f:id:pierre3:20160412230306p:plain

BOT APIのCallBack URLを入力する。

  • アカウント名や、アイコン画像はいつでも変えられるようですので、最初は適当な名前でもOKです。
  • CallBack URL に、先ほど作成したWebAppのURLを入力します。
    • HTTPS とする(BOT サーバーはSSL通信が必須だそうです。幸い、Azure では *.azurewebsites.net ドメインの既定の証明書が使用できるため、特に何もしなくてもHTTPSが有効になっています。)
    • ポート番号を含めて入力します。(ここでは、HTTPS の443を入れておきます。)
    • ユーザーのメッセージを受け取り処理を行うコントローラ名までをURLに含めます。(/api/callback)
https://linebotapp.azurewebsites.net:443/api/callback

f:id:pierre3:20160412230655p:plain

サーバーの送信IPアドレスをWhite Listに登録する

Server IP Whitelist にIPアドレスを登録する必要があります。

f:id:pierre3:20160412234924p:plain

IPアドレスの確認方法

Azureポータルから確認できます。
ポータルにログインし、App Service > LineBotApp(アプリ名) > 設定 > プロパティ の「送信IPアドレス」欄に記載されているIPアドレス(複数記載されていますが、全て登録します。)

※ 送信IPアドレスについては、こちらのサイトの説明が参考になります。 http://cloudsteady.jp/faq/2271.html/

f:id:pierre3:20160412235717p:plain

ユーザーのメッセージを受け取るAPIを作成する。

作成したWebAppプロジェクトにコントローラを追加します。

  • Visual Studio のソリューションエクスプローラで 「Controller」フォルダを右クリックして[追加]-[Controller...]を選択します。
  • ダイアログで「Web API 2 コントローラ -空」を選択後、コントローラ名を「CallBackController」として[追加]をクリックします。

f:id:pierre3:20160413220342p:plain f:id:pierre3:20160413220609p:plain

  • 作成されたソースファイルのCallbackControllerクラスに Postメソッドを追加します。 登録した URL "https://linebotapp.azurewebsites.net/api/callback" にPOSTメソッドでリクエストが来ると、このPostメソッドが実行されます。
  • 今回は、メッセージが送受信できることを確認するのが目的なので、届いたメッセージをほぼそのまま返すだけの処理となっています。
using System;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using Newtonsoft.Json;


namespace LineBotApp.Controllers
{

  public class CallbackController : ApiController
  {
      public async Task<HttpResponseMessage> Post()
      {
        var contentString = await Request.Content.ReadAsStringAsync();
 
        dynamic contentObj = JsonConvert.DeserializeObject(contentString);
        var result = contentObj.result[0];

        var client = new HttpClient();
        try
        {
          client.DefaultRequestHeaders
            .Add("X-Line-ChannelID", Properties.Resources.ChannelID);
          client.DefaultRequestHeaders
            .Add("X-Line-ChannelSecret", Properties.Resources.ChannelSecret);
          client.DefaultRequestHeaders
            .Add("X-Line-Trusted-User-With-ACL", Properties.Resources.MID);
                
               
          var res = await client.PostAsJsonAsync("https://trialbot-api.line.me/v1/events",
              new {
                to = new[] { result.content.from },
                toChannel = "1383378250",
                eventType = "138311608800106203",
                content = new {
                  contentType = 1,
                  toType = 1,
                  text = $"「{result.content.text}」と言ったか にゃ?"
                }
              });
          
          System.Diagnostics.Debug.WriteLine(await res.Content.ReadAsStringAsync());
          return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
       }
       catch (Exception e)
       {
         System.Diagnostics.Debug.WriteLine(e);
         return new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError);
       }
    }
  }
   
}
  • メッセージを送信する際のHeaderには、LINE ビジネスアカウントの Channels-Basic information 画面で確認できる ChannelID、ChannelSecret,MID の値を使用します。
    • X-Line-ChannelID : ChannelID
    • X-Line-ChannelSecret : ChannelSecret
    • X-Line-Trusted-User-With-ACL : MID

Webアプリケーションをデプロイする

Controllerを追加し、ビルドが通ったら後はAzure にデプロイするだけです。

  • ソリューションエクスプローラでプロジェクト名を右クリックし、[公開..]を選択すると、Webの発行ウィザードが表示されます。

f:id:pierre3:20160413230129p:plain

  • Web発行ウィザードでは、特に内容を変更せずに[次へ>]で進んでOKです。

f:id:pierre3:20160413230533p:plain

  • ブラウザが立ち上がり、以下のページが表示されたらデプロイ成功です。

f:id:pierre3:20160413231123p:plain

実際にメッセージを送ってみよう

では、実際にLINEアプリに作成したBOTを友達登録して、動作確認をしてみましょう。 (LINE ビジネスアカウントの Channels-Basic information ページに表示されるQRコードを 読み取ることで友達登録できます。)

f:id:pierre3:20160413232225p:plain

送ったメッセージがちゃんと帰ってきましたね。とりあえず、最低限のやり取りが出来ることは確認できました。

Azure に配置したWebアプリをリモートデバッグする。

Azure上のWebアプリのリモートデバッグVisual Studioから簡単に行えます。

  • サーバーエクスプローラで、[Azure]-[App Service]から発行したWebアプリ名を探し、右クリックメニューから[デバッカ―のアタッチ]を選択します。

f:id:pierre3:20160413233239p:plain

まとめ

これでひとまず、LINE BOT開発の環境が整いました。
さて、どんなBOTを作りましょうか?

Atomプラグイン plantuml-viewer で大きなUMLを扱った場合の問題と対処法

以前に紹介したAtomプラグインのplantuml-viewerについて。使っていて少々気になる点がありました。

atom.io

概ね問題なく快適に使えているのですが、変換するUMLのサイズが大きくなってくると
極端にレスポンスが悪くなったり、最後の入力結果が反映されなかったりすることがあります。

plantumlによる変換処理は、子プロセスに投げられてバックグラウンドで処理されるのですが、この部分の処理が以下の様になっているために、前述の問題が起きると考えられます。

  • 1文字入力する度に変換処理が走る(その結果、同時に実行されるプロセスが多くなる)
  • 結果が表示されるのは、処理の実行順ではなく完了した順番

シーケンス図で表すとこんな感じでしょうか

http://pierre3net.azurewebsites.net/Content/image/plantuml.svg

実際問題、プレビューを画像をここまで頻繁に更新する必要はないと思うので、「一定時間入力が無かったら更新する」といった、所謂 Throttle の動作にした方が良いのでは。。。 などと考えていたら、Githubにそのものズバリのプルリクエストが上がっていました。

[throttle image updates by KylePDavis · Pull Request #10 · markushedvall/plantuml-viewer · GitHub

これはグッジョブ!と、マージされるのを待っていたのですが一向にマージされる気配が無いので、ひとまず自分の環境だけにでも取り込んでしまいましょう!

plantuml-viewer の画像更新をThrottleにする

修正方法は、プルリクエストの内容を参照して頂く方が早いとは思いますが、ここでもざっくりと説明しておきます。

  • 修正するファイルは1つのみで、以下のパスにあります。
%USERPROFILE%\.atom\packages\plantuml-viewer\lib\plantuml-viewer-view.js
  • このファイルの PlantumlViewerView クラス内に、setTimeout()関数を使用して更新頻度を調整するqueueUpdate()関数を追加します。
function PlantumlViewerView (editor) {

  //...(略)...

  var updateImageTimerId = 0
  function queueUpdate () {
    if (updateImageTimerId) return
    updateImageTimerId = setTimeout(function () {
       updateImage()
       updateImageTimerId = 0
    }, 20)
  }
}
  • setTimeout()の第2引数に、待機時間を指定します。待機時間の間入力が無かった場合に更新処理(updateImage())が実行されます。プルリクエストのコードでは20ミリ秒となっていますが、少し短すぎるような気もします。この辺は実際に動かしながら調節すると良いでしょう。
  • 後は、既存のコードにある 画像更新処理関数updateImage() を作成した queueUpdate() 関数に置き換えるだけです。
// ファイル内のすべてのupdateImage()をqueueUpdate()に置き換える
//...(略)...
function attached () {
  disposables = new CompositeDisposable()
  //updateImage() 置き換え
  queueUpdate()
  if (atom.config.get('plantuml-viewer.liveUpdate')) {
    disposables.add(editor.getBuffer().onDidChange(function () {
      if (loading) {
        waitingToLoad = true
        return
      }
      //updateImage() 置き換え
      queueUpdate()
    }))

    interval = setInterval(function () {
      if (panZoom) {
        if (width !== self.width() || height !== self.height()) {
          //updateImage() 置き換え
          queueUpdate()
            width = self.width()
            height = self.height()
          }
        }
      }, 500)
    }
    //...(略)...

これで、より快適なplantumlライフが送れるようになりました!