CSV レコードをオブジェクトにマッピングする


CSV はカンマ区切りのテキストファイルで,取扱いの容易さから今でも広く使われるフォーマットです。各行が 1 つのレコードを表し,レコードはカンマで区切られた複数のフィールドからなります。単純に CSV を読んでいけば,レコードは string[] で取得できますが,実際のフィールドのデータ型は必ずしも文字列ではなく,数値や日付を表しているかもしれません。正常な設計であれば CSV の各行でスキームは一貫しているはずなので,レコードを示すオブジェクトにマッピングできるはずです。

例えば次の CSV の各レコードは, Person というクラスのオブジェクトに対応させることができるでしょう。

リュウ,1964-07-21,Male
春麗,1968-03-01,Female
ザンギエフ,1956-06-01,Male
豪鬼,,Male
public class Person
{
	public string Name { get; set; }
	public Nullable<DateTime> Birthday { get; set; }
	public Gender Gender { get; set; }
}

public enum Gender
{
	Unknown, Female, Male,
}

普通に CSV を読んでオブジェクトに変換するだけなら次のようにすることができます。

string[] record = reader.ReadLine().Split(',');
DateTime birthday;
Gender gender;
Person person = new Person()
{
	Name = record[0],
	Birthday = DateTime.TryParseExact(record[1], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out birthday) ?
	           (DateTime?)birthday : null,
	Gender = Enum.TryParse(record[2], out gender) ? gender : Gender.Unknown,
};

しかしながら,これは柔軟性に欠けます。これをメソッドにすると仮定すると,シグネチャは次のようになるでしょう。

public Person ReadRecord(TextReader reader)

もしこれが次のように書けたら便利です。

public T ReadRecord<T>(TextReader reader)

これを実現するためには, string[]T にマッピングするための仕組みを用意してやればよいわけです。

単純なケースを考える

まずは簡単のために,クラスの各プロパティが string である場合を考えます。この場合は,各プロパティが何番目のフィールドで表されるかがわかれば良さそうです。

public class Person
{
	[RecordField(0)]
	public string Name { get; set; }
	[RecordField(1)]
	public string Birthday { get; set; }
	[RecordField(2)]
	public string Gender { get; set; }
}

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public class RecordFieldAttribute : Attribute
{
	private readonly int index;
	public int Index
	{
		get { return index; }
	}

	public RecordFieldAttribute(int index)
	{
		this.index = index;
	}
}

クラスのプロパティに指定された属性を参照することで, CSV の何番目のフィールドがそのプロパティの値であるかを取得することができます。このメタ情報を利用して,空のインスタンスにリフレクションでデータを代入していけば良いです。

string[] record = reader.ReadLine().Split(',');
Type recordType = typeof(T);

var metainfo = from property in recordType.GetProperties()
               let attributes = Attribute.GetCustomAttributes(property, typeof(RecordFieldAttribute))
               where attributes.Length > 0
               let attribute = (RecordFieldAttribute)attributes[0]
               select new { Attribute = attribute, Property = property };

T prototype = Activator.CreateInstance<T>();
foreach (var meta in metainfo)
{
	PropertyInfo property = meta.Property;
	int index = meta.Attribute.Index;
	string value = record[index];
	property.GetSetMethod().Invoke(prototype, new object[] { value });
}

複雑なケースに対応する

さて, Person を最初の形に戻して考えましょう。これはそんなに難しくありません。文字列でのフィールドの値は取得できるのですから,属性で文字列から特定の型へ変換する方法を指定してやれば良いだけです。

public class Person
{
	[RecordField(0)]
	public string Name { get; set; }
	[RecordField(1, ParserType = typeof(BirthdayParser))]
	public Nullable<DateTime> Birthday { get; set; }
	[RecordField(2, ParserType = typeof(GenderParser))]
	public Gender Gender { get; set; }
}

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public class RecordFieldAttribute : Attribute
{
	private readonly int index;
	public int Index
	{
		get { return index; }
	}

	public Type ParserType { get; set; }

	public RecordFieldAttribute(int index)
	{
		this.index = index;
	}
}

public interface IParser
{
	object Parse(string value);
}

IParser の具体的な実装は次のようになります。 RecordFieldAttributeParserType を指定しないときのために,ヌルオブジェクトを用意しておくと便利です。

public sealed class NullParser : IParser
{
	public static readonly NullParser Instance = new NullParser();

	public object Parse(string value)
	{
		return value;
	}
}

public class BirthdayParser : IParser
{
	public object Parse(string value)
	{
		DateTime birthday;
		return DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out birthday) ?
		        (DateTime?)birthday : null;
	}
}

public class GenderParser : IParser
{
	public object Parse(string value)
	{
		Gender gender;
		return Enum.TryParse(value, out gender) ? gender : Gender.Unknown;
	}
}

これを用いて,先ほどのケースに string からの型変換を行う行を追加してやります。

foreach (var meta in metadata)
{
	PropertyInfo property = meta.Property;
	int index = meta.Attribute.Index;
	Type parserType = meta.Attribute.ParserType;
	IParser parser = parserType == null ?
	                 NullParser.Instance : (IParser)Activator.CreateInstance(parserType);
	object value = parser.Parse(record[index]);
	property.GetSetMethod().Invoke(prototype, new object[] { value });
}

まとめと更なる利便に向けて

属性・リフレクションを用いることで, string[] の CSV レコードをオブジェクトにマッピングすることができるようになりました。

今回の例ではフィールドは無視してプロパティしか扱えません。そのため CSV レコードのマッピングに構造体を使うことができません。また, string 型以外の全てのデータ型に対して IParser を実装したクラスを作成しなければなりません。さらに,レコードをオブジェクトにマッピングは行うことができますが,逆にオブジェクトをレコードにマッピングする機能は備えていません。こういった不便な点を改善していくと,より使いやすいものができあがると思います。

修正履歴

  • [2010-11-13 17:25] 複雑なケースの Person クラスのプロパティが string のままだったのを修正。