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にバインドする(型付けされた)オブジェクトを動的に生成することが出来ました。

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