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ライフが送れるようになりました!

Register-ArgumentCompleter で 入力補完機能を自作しよう!

これは、PowerShell Advent Calendar 2015 9日目の記事です。

atnd.org

目次

はじめに

以前、TabExpansion++ というモジュールを使うと、自前の入力補完機能を簡単に作ることが出来る、という内容の記事を書きました。

pierre3.hatenablog.com

この入力補完のカスタマイズ機能ですが、PowerShell v5.0 から標準で使えるようになり、より手軽に使用できるようになっていました。
それに伴い、Register-ArgumentCompleterというコマンドレットが追加されていて、このコマンドレットを実行するだけで、自前の入力補完機能が登録できるようになったようです。

今回は、このRegister-ArgumentCompleterコマンドレットを使って、自前の入力補完機能を実装してみたいと思います。

Register-ArgumentCompleter コマンドレット

まずは、Register-ArgumentCompleter コマンドレットに渡すパラメータを確認してみましょう。

Register-ArgumentCompleter `
    -CommandName (ターゲットコマンド名) `
    -ParameterName (ターゲットパラメータ名) `
    -ScriptBlock (入力候補一覧を作成するスクリプトブロック)
  • CommandName パラメータに、補完機能のを登録するコマンドの名前を指定します。
  • ParameterName パラメータに、補完対象となるパラメータの名前を指定します。
  • ScriptBlock パラメータに、独自の入力候補一覧を作成する処理を記述したスクリプトブロックを指定します。(詳細は後述します)

入力候補の一覧を生成するスクリプトブロック

ScriptBlock パラメータには、System.Management.Automation.CompletionResult オブジェクトをコレクションで返すスクリプトブロックを指定します。
また、スクリプトブロックには、5つのパラメータが渡ってきて、入力途中の文字列などが取得できるようになっています。

スクリプトブロックの概要をざっくりと書くと以下の様になります。

$createCompletionResults = {
    # 以下の5つのパラメータを受け取る。入力中の文字列は $wordToComplete に入ってくる。
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
    
    $items = (入力候補のソースを取得)    

    # 入力候補の数だけ値を出力する
    foreach($item in $items)
    {
        $completionText = (決定時にパラメータ値として挿入される文字列);
        $listItemText = (入力候補の一覧に表示される文字列);
        $resultType = (入力候補のタイプ。候補の左側に表示されるアイコンを指定できる);
        $toolTip = (ツールチップに表示される文字列);
        
        # CompletionResult オブジェクトとして出力する
        [System.Management.Automation.CompletionResult]::new(
            $completionText,
            $listItemText,
            $resultType,
            $toolTip);   
    }
}

それでは、これを踏まえて実際に入力補完機能を作ってみましょう。

Invoke-Item コマンドレットの入力候補に「最近使った項目」の一覧を出す

Invoke-Item コマンドレットのPathパラメータ入力時に「最近使った項目」の一覧を出して、最近使ったファイルに素早くアクセスできるようにしたいと思います。

  • Invoke-Itemコマンドレットは、指定したファイルを実行したり、フォルダを開いたりするのに使用するコマンドレットです。
    エクスプローラ上で任意のファイルやフォルダをダブルクリックした時と同様の動作をします。

  • 「最近使った項目」は、%AppData%\Microsoft\Windows\Recent フォルダにショートカットとして保存されます。スタートメニューやタスクバーアイコンを右クリックすると表示される「最近使ったもの」一覧で利用されます。

f:id:pierre3:20151203225018p:plain

実装

実装したコードは、以下の通りです。
細かな部分はコード内のコメントと、後述の補足説明をご確認ください。

「最近使った項目」フォルダからショートカットファイルの一覧を取得する

「最近使った項目」(Recent)フォルダは、[System.Environment]::GetFolderPath() メソッドで取得できます。
取得したフォルダ以下のファイルは Get-ChildItem コマンドで取得しています。フォルダ名の下に\*.lnk を付けることで、ショートカットファイル(.lnk)のみを取得するようにしています。
取得したファイルの一覧は、更新日時の新しい順にソート後、先頭から表示したい件数分(ここでは50件)を取得しています。

# Recentフォルダから、最新50件のショートカット(*.lnk)ファイルを取得
$recentFolder = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::Recent);
$items = Get-ChildItem "$($recentFolder)\*.lnk" |
    Sort-Object -Property LastWriteTime -Descending | 
    Select-Object -First 50;

Pathを直接入力する場合にも対応する

「最近使った項目」の一覧から選択だけでなく、Pathを直接入力したい場合にも対応するため、スクリプトブロックの冒頭で以下のコードを入れています。

# "\" が含まれる場合は、通常のPath入力とみなし、独自の入力補完は行わない
# 例えば"C:\" と入力した場合など
if(([string]$wordToComplete).Contains("\") )
{ 
    return; 
}

こうすることで、".\" や、"C:\" などと入力した場合は 通常のPathを補完するダイアログが表示されるようになります。
スクリプトブロックが、なにも返さなかった場合は、通常の補完機能が動作することを利用しています。)

入力済みの文字列がマッチする項目だけを一覧に含める

パラメータに入力途中の文字がある状態で、補完機能が実行された場合、「補完一覧に表示する文字列」に「入力済みの文字列」がマッチする項目のみを一覧に含めるようにしています。

# 一覧に表示する文字列
$listItemText = Split-Path $shortcut.TargetPath -Leaf;
# 入力中の文字列$wordToCompleteがマッチしない項目はスキップ
if($listItemText -notmatch $wordToComplete)
{
    continue;
}

ショートカットの情報はWSHのCreateShortCut()で取得

どうやらショートカットファイルを操作するPowerShellのコマンドはないようなので ショートカットの参照元を取得するために、WSHの機能を使用しています。

$wsh = new-object -comobject WScript.Shell
$shortcut = $Wsh.CreateShortcut("path\to\shortcut");
# 参照元のパス
$shortcut.TargetPath;

実行結果

Invoke-Item の第1引数(-Path)で [Ctrl]+[Space](「最近使った項目」の一覧を表示)

f:id:pierre3:20151207220053p:plain

"ab" を入力後 [Ctrl]+ [Space] (「最近使った項目」で名前に "ab" を含むファイルのみ表示)

f:id:pierre3:20151207220232p:plain

"c:\" を入力 (c ドライブのアイテム一覧を表示)

f:id:pierre3:20151207220256p:plain

ちゃんと、期待通りの動きになっているようです。

まとめ

Register-ArgumentCompleterコマンドレットを使う事で、自前の入力補完機能が簡単に実装できることが分かりました。
この機能が必要となるケースはそれほど多くはないかもしれませんが、使い方によっては非常に有用な機能となり得るのではないでしょうか。

おまけ

Invoke-Item でファイルを開いても、「最近使った項目」が更新されない問題が発覚しました。
どうやら、エクスプローラや標準のファイル選択ダイアログを使わないと「最近使った項目」は更新されないようです。

これはあまり嬉しくないので、Invoke-Item コマンドを実行後に、「最近使った項目」を更新する処理を追加したコマンドレットを作って、これを使う様にしました。

function Invoke-ItemEx
{
    [CmdletBinding()]
    [Alias("iix")]
    Param
    (
        [Parameter(Mandatory=$true, Position=0)]
        [string]
        $Path
    )

    if(-not(Test-Path $Path))
    {
        Write-Error -Exception ([System.ArgumentException]::new(
            "存在するファイル名またはフォルダ名を指定してください","Path"));
        return;
    }

    Invoke-Item -Path  $Path;
    
    # ファイルのみ    
    if(Test-Path $Path -PathType Leaf)
    {
        $Shell = New-Object -ComObject Shell.Application;
        $Shell.AddToRecent($Path);
    }
}

Register-ArgumentCompleter -CommandName Invoke-ItemEx -ParameterName Path `
    -ScriptBlock $recentItemsCompletion
  • 「最近使った項目」への登録は Shell.Application の AddToRecent()を使います。
  • AddToRecent()に、アクセスしたファイルのPathを渡して実行すると、Recentフォルダに以下のショートカットが登録されます。
    • 引数に渡したファイルのショートカット
    • そのファイルを格納しているフォルダのショートカット
  • AddToRecent()にフォルダのPathを渡すと、その1階層上のフォルダのショートカットが登録されてしまいます。
    • これは期待する動作ではないので、AddToRecent()にはファイルのPathのみを渡すようにしました。

UWPのContentDialog や Pageの イベントを {x:Bind } でバインドするとXAML内部エラーになる

UWP のプログラミング中に、以下のエラーに遭遇したのでメモしておきます。

1>C:\Program Files (x86)\MSBuild\Microsoft\WindowsXaml\v14.0\8.2\Microsoft.Windows.UI.Xaml.Common.targets (263,5): Xaml 内部エラー error WMC9999: オブジェクト参照がオブジェクト インスタンスに設定されていません。

ContentDialog の PrimaryButtonClick イベントを{x:Bind }でバインドしたらビルドエラーになった。

次のXAMLで、PrimaryButtonClick="ContentDialog_PrimaryButtonClick" の部分を以下のどちらかに置き換えると、ビルドエラーで上記メッセージが表示されます。

  • PrimaryButtonClick="{x:Bind ViewModel.Execute}" (ViewModel のメソッド)
  • PrimaryButtonClick="{x:Bind ContentDialog_PrimaryButtonClick}" (コードビハインドのイベントハンドラ)

また、これと同じメソッドバインディングを Dialog内に配置したボタンで行った場合は問題ありませんでした。

  • Button Click="{x:Bind ViewModel.Execute}"
<ContentDialog
    x:Class="TestApp1.Views.ContentDialog1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:TestApp1"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Title="TITLE"
    PrimaryButtonText="Button1"
    SecondaryButtonText="Button2"
    PrimaryButtonClick="ContentDialog_PrimaryButtonClick">

    <!-- {x:Bind} を使うと Xaml 内部エラー WMC9999
        PrimaryButtonClick="{x:Bind ViewModel.Execute}"
        PrimaryButtonClick="{x:Bind ContentDialog_PrimaryButtonClick}"
    -->
    <!-- コマンドのバインディングはOK
        PrimaryButtonCommand="{x:Bind ViewModel.TestCommand}"    
    -->

    <Grid>
        <!-- こちらのボタンでは {x:Bind}でも問題なし-->
        <Button Click="{x:Bind ViewModel.Execute}">Content Button</Button>
    </Grid>
</ContentDialog>

また、ContentDialog だけでなく、Pageでも同様のエラーが発生しました。

<Page
    x:Class="TestApp1.Views.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:TestApp1"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    PointerPressed="Page_PointerPressed" >
    <!-- {x:Bind} を使うと Xaml 内部エラー WMC9999
        PointerPressed="{x:Bind Page_PointerPressed}" 
    -->
    
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <!-- こっちの{x:Bind} はOK -->
        <Button Content="Show Dialog" HorizontalAlignment="Center"
                Click="{x:Bind Button_Click}"/>
    </Grid>
</Page>

試した環境

問題の起きるサンプルコードを以下に置いておきます
https://github.com/pierre3/UWPTestApp