😸

C# で動的に列が変わる CSV を生成したい

2021/04/30に公開

C# で CSV を生成・読み込みするなら CsvHelper を使うのが楽です。

https://joshclose.github.io/CsvHelper/

大昔ですが、使い方を簡単に書いたことがあります。

https://blog.okazuki.jp/entry/2014/12/26/121954

今回は、以下のような実行時に動的にカラム数が増えるような CSV を生成する場合はどうするのか?というネタを貰ったので考えてみました。例えば以下のような構造のクラスを CSV に出力するケースです。

class Record
{
    public string Id { get; set; }
    public IEnumerable<RecordItem> Items { get; set; }
}

class RecordItem
{
    public string Value1 { get; set; }
    public string Value2 { get; set; }
}

具体例を示すと、以下のようなデータがあるとします。

var records = new[]
{
    new Record
    {
        Id = "record1",
        // 1 件目は Items には 1 件しか入っていない
        Items = new[]
        {
            new RecordItem { Value1 = "a", Value2 = "b" },
        }
    },
    new Record
    {
        Id = "record2",
        // 2 件目は Items には 3 件入ってる
        Items = new[]
        {
            new RecordItem { Value1 = "a", Value2 = "b" },
            new RecordItem { Value1 = "c", Value2 = "d" },
            new RecordItem { Value1 = "e", Value2 = "f" },
        }
    },
    new Record
    {
        Id = "record3",
        // 3 件目は Items にデータが入っていない
        Items = Array.Empty<RecordItem>(),
    },
};

これを、以下のような CSV に出したいといった要望です。

Id,Items0_Value1,Items0_Value2,Items1_Value1,Items1_Value2,Items2_Value1,Items2_Value2
record1,a,b,,,,
record2,a,b,c,d,e,f
record3,,,,,,

わかりやすく表形式で表示すると、こんな感じです。

Items プロパティの件数に応じて列が増える感じです。厄介ですね…。

CsvHelper の基本的な使い方

クラスを CSV に出力するときは大体クラスの 1 プロパティが 1 列になるようにマップします。具体的には ClassMap<T> を継承して以下のようにプロパティごとに列名を指定します。

FooMap.cs
public class FooMap : ClassMap<Foo>
{
    public FooMap()
    {
        Map(m => m.Id).Index(0).Name("id");
        Map(m => m.Name).Index(1).Name("name");
    }
}

このように定義した ClassMap を使って CsvWriter で CSV に出力できます。

using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<FooMap>(); // ここで ClassMap を追加
csv.WriteRecords(records); // CSV に書きだす

今回のような特殊なケースの場合

純粋にクラスの型定義から列のマッピングが出来ない場合ですが、選択肢は 2 つあります。1 つ目は CsvWriter クラスの WriteRecords メソッドを使うのを諦めるです。WriteField メソッドと NextRecord メソッドを使えばカンマなどのエスケープなどを気にせず CSV を生成することが出来ます。

using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.CurrentCulture);

// ヘッダーを書く
csv.WriteField(nameof(Record.Id));
var columnCount = records.Select(x => x.Items?.Count() ?? 0).Max();
foreach (var columnIndex in Enumerable.Range(0, columnCount))
{
    csv.WriteField($"{nameof(Record.Items)}{columnIndex}_{nameof(RecordItem.Value1)}");
    csv.WriteField($"{nameof(Record.Items)}{columnIndex}_{nameof(RecordItem.Value2)}");
}

csv.NextRecord();

// レコードを書いていく
foreach (var record in records)
{
    csv.WriteField(record.Id);
    foreach (var columnIndex in Enumerable.Range(0, columnCount))
    {
        var item = record.Items.ElementAtOrDefault(columnIndex);
        csv.WriteField(item?.Value1);
        csv.WriteField(item?.Value2);
    }

    csv.NextRecord();
}

2 つ目は ClassMap を駆使して頑張る方法です。ClassMap を登録する RegisterClassMap メソッドは ClassMap のインスタンスを受け取るオーバーロードがあります。なので ClassMap を継承して IEnumerable<Record> を受け取って、それを解析して列情報などを組み立ててやります。

ポイントは列のマッピングを行う Map メソッドには Map(x => x.PropertyName) のような式木を受け取るものだけではなく Map() のように何もメンバーを指定しないで作る方法があります。この戻り値にはマッピング情報が何も無い状態なので、この戻り値に対して色々設定をすることで列と値のマッピング方法を細かく制御することが出来るようになっています。

今回の要求を実現するには Map() の戻り値の Data プロパティの WritingConvertExpressionExpression<Func<ConvertToStringArgs<T>, string>> を渡してあげると、その列の値を取得する方法をカスタマイズできます。Map() の戻り値の Name メソッドで列名を指定できます。これを使うと以下のように実装できます。

class RecordMap : ClassMap<Record>
{
    public RecordMap(IEnumerable<Record> records)
    {
        // Id プロパティは普通にマッピング
        Map(x => x.Id);

        // Items プロパティのデータを表示するために必要な列数を計算
        var maxItemsCount = records.Select(x => x.Items?.Count() ?? 0).Max();
        for (int i = 0; i < maxItemsCount; i++)
        {
            var currentIndex = i;

            // 特定のインデックスの Item1 プロパティを取得するマッピング情報を組み立てる
            Expression<Func<ConvertToStringArgs<Record>, string>> writingConvertExpressionForValue1 = x =>
                x.Value.Items.ElementAtOrDefault(currentIndex) == null ?
                    default :
                    x.Value.Items.ElementAtOrDefault(currentIndex).Value1;
            Map().Name($"{nameof(Record.Items)}{i}_{nameof(RecordItem.Value1)}")
                .Data
                .WritingConvertExpression = writingConvertExpressionForValue1;

            // 特定のインデックスの Item2 プロパティを取得するマッピング情報を組み立てる
            Expression<Func<ConvertToStringArgs<Record>, string>> writingConvertExpressionForValue2 = x =>
                x.Value.Items.ElementAtOrDefault(currentIndex) == null ?
                    default :
                    x.Value.Items.ElementAtOrDefault(currentIndex).Value2;
            Map().Name($"{nameof(Record.Items)}{i}_{nameof(RecordItem.Value2)}")
                .Data
                .WritingConvertExpression = writingConvertExpressionForValue2;
        }
    }
}

これを使うと以下のように CSV に書き出せます。

using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.CurrentCulture);

// 今回出力するデータを元に RecordMap を作って登録する
csv.Context.RegisterClassMap(new RecordMap(records));
// CSV に書き出す
csv.WriteRecords(records);

この 2 通りのどちらかになります。

どっちがいいの…

処理時間的に早いのは最初のべた書きです。メソッドに切り出してしまえばいいので、呼び出し側の人からしたら楽です。こういう処理を沢山書かないといけないのなら SourceGenerator を書いてしまうのも 1 つの手かもしれません。

普通のプロパティのマッピングが殆どで、なおかつ Index メソッドや Name メソッドなどを駆使して CSV の出力情報を定義していて、コレクション型のプロパティの数がそんなに多くないならマッピングを定義するのは有りだと思います。他には CSV に出力するクラスが複数個ある状態の場合には、一貫性という観点から ClassMap を使ってマッピングすることを選択すると思います。

1 クラスだけ CSV に吐き出すようなケースで今回の例のような素直にマッピング出来ないクラスに出会ったら、多分 WriteField 作戦で行くと思います。

まとめというか所感

ということで、今回のお題を解くために CsvHelper のコードを結構読んだのですが 1 レコードを出力する処理を組み立てている ObjectRecordWriter クラスの CreateWriteDelegate メソッドで式木をこねくり回しているのを読んで「あー…せやな…」ってなりました。最新の C# 9.0 だとソースジェネレーターが最適解になりそうな案件ですね。

https://github.com/JoshClose/CsvHelper/blob/a9e113d14321efaff9a96e497228039a3221b0ba/src/CsvHelper/Expressions/ObjectRecordWriter.cs#L31

Microsoft (有志)

Discussion