CSVのクラスマッピングの定義をC#スクリプトで記述する (その2:Converter)
この記事は、以下の記事の続きです。
- この記事のサンプルコードはこちら
目次
前回は、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
このデータを読み込んで生成したオブジェクトをそのまま表示すると以下のようになります。
期待する表示内容とは異なる表示になっている箇所がいくつかあります。
- カラムヘッダがデータ格納クラスのプロパティ名になっている
⇒ 読み込んだ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; } }
- カラムヘッダには
Names
とNameIndex
が使えそうです。 - データの変換処理は、
TypeConverter
で行います。これは(特に指定しなければ)対応するデータの型に応じたコンバータが自動で割り当てられます。- CsvHelperで実装されているコンバータは以下を参照ください。CsvHelper/src/CsvHelper/TypeConversion at master · JoshClose/CsvHelper · GitHub
- 文字列のフォーマットやカルチャの設定等は
TypeConverterOptions
に格納されます。これはClassMap 登録時に.TypeConverterOption()
メソッドで設定します。
CsvClassMapのTypeConverter
(CsvHelper)を IValueConverter
(WPF) にラップして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]を押して読み込んだ結果
- ちゃんと元データのカラム名が表示されています。
- 「生年月日」が
yyyy/M/d
フォーマットで表示されています。 - 「お小遣い」に
¥
マークがついています。(int.ToString("C")
のフォーマット)
いい感じです。 では、変換できない文字を入力した場合はどうなるか見てみましょう。
ちゃんと 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}" />
これで以下の様に読み込んだデータが一覧表示されるようになります。
CsvHelperにCSVデータの読み込みとクラスにマップをお任せする
CSVデータの読み込み処理と、クラスへマッピングしてオブジェクトを生成する処理は、CsvHelper を使用すると便利です。
以下に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#で記述されたスクリプトをアプリケーション内で実行することが可能となります。
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に上げておきました。
スクリプトを実行した後の実際の読み取り処理などは、こちらのコードをご確認ください。
実行例
今回は動作確認のため、ウインドウの右側のテキストボックスに読み込んだスクリプトを表示して編集できるようにしました。
[Read]ボタンをクリックすると、スクリプトの実行とCSVファイルの読み取りを行います。
[Read]ボタン押下後。読み込んだ結果がDataGridに表示されています。
これだけでは、スクリプトで動的に実行されているかよくわからないので、少し手を加えてみます。
プロパティに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]ボタンをクリックします。
ちゃんと”年齢”カラムが追加されて計算で求めた年齢が入っています。
一覧のフィルタ、ソートをスクリプトで実行してみる
ついでに、表示する一覧を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));
書いた通りにフィルタ、ソートが実行されて表示されました。
編集と保存
セルを編集して保存することも可能です。
[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人選び、表示します。
- それを5回繰り返して、1人も重複していなければ成功です。「5人揃ってゴレンジャイ!」と表示して終了します。
- メンバーが重複した時点で失敗とします。1人目からやり直してください。
- 成功するまで繰り返し行います。
(元ネタはこちら)
私の回答
やり方はいろいろあると思いますが、今回は「成功するまで繰り返す」部分で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で試してみました。
Azure App Service と Visual Studio 2015 を使用してLINE BOTを作成する手順をまとめてみたいと思います。
今回はひとまず、ユーザーからのメッセージを受け取れることと、BOT側のメッセージをユーザーに返信出来ることが確認できるまでを目標とします。
目次
- 目次
- はじめに
- Visual Studio で ASP.NET WebAppプロジェクトを作成する
- LINE ビジネスアカウントの設定を行う
- ユーザーのメッセージを受け取るAPIを作成する。
- Webアプリケーションをデプロイする
- 実際にメッセージを送ってみよう
- Azure に配置したWebアプリをリモートデバッグする。
- まとめ
はじめに
用意するもの
LINE ビジネスアカウント
- Line Business Centerから無料で登録できます。
- LINE BOT API Trial への申し込みは先着1万名までだそうです。既に締め切られてしまっていたらごめんなさい。
Microsoft Azure アカウント
- 無料で登録可能。Webアプリも無料プランで10個まで作成できます。
- 詳しくはこちらを https://azure.microsoft.com/ja-jp/free/
Visual Studio 2015
- 無償版の Visual Studio Community 2015 が使用できます。
Visual Studio で ASP.NET WebAppプロジェクトを作成する
- プロジェクトの新規作成ダイアログで「ASP.NET Webアプリケーション」を選択してプロジェクトを作成します。
- ASP.NET 4.6 テンプレートの「Empty」を選択します。
- 「フォルダーおよびコア参照を追加する」 で「Web API」のみにチェックします。
- Microsoft Azure クラウドにホストするをチェックし、コンボボックスでWebアプリを選択します。
- [OK]をクリックすると、Azure 関連の設定画面に進みます。
- Azureに作成するWebアプリの名前を入力します。
- App Serviceプラン、リソースグループは、既存のものから選択するか、新規に作成します。
- この画面の操作に関する手順は、Azure Web App のチュートリアルが詳しいのでこちらを参考にしてください。
https://azure.microsoft.com/ja-jp/documentation/articles/web-sites-dotnet-get-started/
- ここで入力したWebアプリ名に 「.azurewebsites.net」ドメイン名を付加したものがこのアプリのURLになります。
- http(s)://linebotapp.azurewebsites.net
- 各項目を入力後、[OK]をクリックすると WebAPIのプロジェクトが作成されます。
LINE ビジネスアカウントの設定を行う
LINE ビジネスアカウントで LINE Developpers にログインすると、作成したアカウントの詳細が「Channels」で確認できます。
- このページの下部にある[Edit]ボタンをクリックして編集画面を開きます。
BOT APIのCallBack URLを入力する。
- アカウント名や、アイコン画像はいつでも変えられるようですので、最初は適当な名前でもOKです。
- CallBack URL に、先ほど作成したWebAppのURLを入力します。
https://linebotapp.azurewebsites.net:443/api/callback
サーバーの送信IPアドレスをWhite Listに登録する
Server IP Whitelist にIPアドレスを登録する必要があります。
IPアドレスの確認方法
Azureポータルから確認できます。
ポータルにログインし、App Service > LineBotApp(アプリ名) > 設定 > プロパティ の「送信IPアドレス」欄に記載されているIPアドレス(複数記載されていますが、全て登録します。)
※ 送信IPアドレスについては、こちらのサイトの説明が参考になります。 http://cloudsteady.jp/faq/2271.html/
ユーザーのメッセージを受け取るAPIを作成する。
作成したWebAppプロジェクトにコントローラを追加します。
- Visual Studio のソリューションエクスプローラで 「Controller」フォルダを右クリックして[追加]-[Controller...]を選択します。
- ダイアログで「Web API 2 コントローラ -空」を選択後、コントローラ名を「CallBackController」として[追加]をクリックします。
- 作成されたソースファイルの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の発行ウィザードが表示されます。
- Web発行ウィザードでは、特に内容を変更せずに[次へ>]で進んでOKです。
- ブラウザが立ち上がり、以下のページが表示されたらデプロイ成功です。
実際にメッセージを送ってみよう
では、実際にLINEアプリに作成したBOTを友達登録して、動作確認をしてみましょう。 (LINE ビジネスアカウントの Channels-Basic information ページに表示されるQRコードを 読み取ることで友達登録できます。)
送ったメッセージがちゃんと帰ってきましたね。とりあえず、最低限のやり取りが出来ることは確認できました。
Azure に配置したWebアプリをリモートデバッグする。
Azure上のWebアプリのリモートデバッグもVisual Studioから簡単に行えます。
まとめ
Atomプラグイン plantuml-viewer で大きなUMLを扱った場合の問題と対処法
以前に紹介したAtomプラグインのplantuml-viewerについて。使っていて少々気になる点がありました。
概ね問題なく快適に使えているのですが、変換するUMLのサイズが大きくなってくると
極端にレスポンスが悪くなったり、最後の入力結果が反映されなかったりすることがあります。
plantumlによる変換処理は、子プロセスに投げられてバックグラウンドで処理されるのですが、この部分の処理が以下の様になっているために、前述の問題が起きると考えられます。
- 1文字入力する度に変換処理が走る(その結果、同時に実行されるプロセスが多くなる)
- 結果が表示されるのは、処理の実行順ではなく完了した順番
シーケンス図で表すとこんな感じでしょうか
実際問題、プレビューを画像をここまで頻繁に更新する必要はないと思うので、「一定時間入力が無かったら更新する」といった、所謂 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日目の記事です。
目次
はじめに
以前、TabExpansion++ というモジュールを使うと、自前の入力補完機能を簡単に作ることが出来る、という内容の記事を書きました。
この入力補完のカスタマイズ機能ですが、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
フォルダにショートカットとして保存されます。スタートメニューやタスクバーアイコンを右クリックすると表示される「最近使ったもの」一覧で利用されます。
実装
実装したコードは、以下の通りです。
細かな部分はコード内のコメントと、後述の補足説明をご確認ください。
「最近使った項目」フォルダからショートカットファイルの一覧を取得する
「最近使った項目」(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](「最近使った項目」の一覧を表示)
"ab" を入力後 [Ctrl]+ [Space] (「最近使った項目」で名前に "ab" を含むファイルのみ表示)
"c:\" を入力 (c ドライブのアイテム一覧を表示)
ちゃんと、期待通りの動きになっているようです。
まとめ
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>
試した環境
- Windows 10 Pro 64bit
- Microsoft Visual Studio Enterprise 2015 Version 14.0.23107.0 D14REL
- Microsoft .NET Framework Version 4.6.00079
問題の起きるサンプルコードを以下に置いておきます
https://github.com/pierre3/UWPTestApp