一般的には","カンマで区切るんだと思います。
でも、ダブルクォート使いたいしー、っていうんで"TAB"タブ区切りにしちゃったり。
さらに、用途によっては改行入らないから、ダブルクォート内での改行処理とかなくてもいいよー、っていうのがよくあるパターンかなー、と思ったりもします。
簡単に整理すると
- カラム区切りはタブ
- カラム内に改行はない
- レコードの区切りは改行
- 1行目はカラム名を指定するヘッダ
- 2行目以降がレコード
このくらいの緩いルールで実装するっていうのを前提にしてみようと思います。
ところで、CSVって1行目からデータ派?それとも1行目はヘッダー派?ヘッダーが入ったらCSVじゃないよー、とかっていう原理主義もあろうかと思うけど、そこは気にせずにぜひ。1カラム目がHならヘッダーレコード、Dならデータレコード、とかっていう構造をもつものはそもそもそれに適したJSONやXMLがいいので、その話も今回はないってことにしてください。
前置きなげー!
本題は、CSVを可変長カラムにするほうがみんな幸せなんじゃない?っていうのとModel Validationで検証するほうが何かと楽ちんじゃない?です。
可変長カラムっていうのはどういうことかっつーと、そもそもCSVで取り込むデータを何らかのPOCOに入れて保持するっていうのを前提に、そのあとデータストアに反映するとか、そのあとの処理に流す、っていうふうに考えると、POCOならコンストラクタで初期値セットするなり、何もしなかったら型の初期値が入ってるから、そのままの値を次の処理に渡せばよろしい、っていう使い方でよければ、そもそもCSV内にそのカラムなくてもいいよね!
で、Model Validationって言ってるのは、POCOにデータいったん入れるんなら、そのタイミングでDataAnnotations使って、モデル検証しちゃえばいいよね!
ビジネスルールを適用して、さらなる検証を行いたい(特定のIDがデータストアに存在するのかとか、外部サービスに依存してるものはModel Validationで行わない)場合は、さらに上位のレイヤで処理すれば、いろいろ疎結合だし、テストも楽ちんでしょーがー。使いまわせるしね。
そんなのそうしてますから!っていういまどきの方々は面白くない話なんですけど、身近なところでは、そーなんだー、という話にもなったので。
なんで、CSVを固定カラムじゃないほうがいいと思ったのか、っていうのは(一般的じゃない理由かもしれないけど)データストアの拡張とか、処理の拡張に対して、CSVフォーマットが紐づいててほしくない、影響範囲に含めたくないから。っていうのと、マルチテナント展開するシステムだと、外部で生成されるCSVを、自システムに取り込む、っていうフローがほとんどだけど、機能拡張のたびに外部での生成方法変えて、とは言いずらい。し、言ってたらペースが外部に依存して、すごく遅くなるから。
CSV作る人(そこをシステム化してるかどうかはあまり重要じゃないですね)と、そのCSVを自システムに処理させる、っていう業務を、いろんな意味で疎結合。の、イメージす。
んー、うまく言えてないすね。さーせん。ようするにフォーマットが疎じゃないと不便だったから、です。んじゃ、CSVにするなよ!みたいなね。でも、Excelでデータつくるじゃん!
ここまでで、もうイメージできた人も多いと思うけど、せっかくなので、コードでどう表現するのかを書いてみます。
これってコードにすると、
var columns = line.Split(splitChar);
とか、ですね。
1行目ならそこにはカラム名(これの一覧はPOCOにセットするために保持しとく)が入ってて、1行目以降ならそれはデータ。っていうのを、上位層で判定。
これで、レコードをカラムに分割できました。
今度はカラム名の一覧と、モデルクラス(ジェネリックでしょうね)のプロパティをマッピング。
var properties = TypeDescriptor.GetProperties(type);
これで一応POCOのプロパティ一覧(PropertyDescriptorCollection)は取得できましょう。キャッシュするかどうかはお好きに。
あとは、もう単純にくるくるループ回していけば、いいですよね!PropertyDescriptorがあれば、Converter.ConvertFromStringでプロパティにセットできるか判定もね。
後は、DataAnnotationsを指定してるかもしれないから、Validator.TryValidateObjectで、モデル検証。
public static IList
{
var lineNumber = 0;
var mappings = new ColumnMapping[] { };
var result = new List
foreach (var line in LoadLine(text))
{
var rowErrors = new List
var columns = ParserHelper.SplitLine(line, SplitChar);
lineNumber++;
if (lineNumber == 1)
{
// 1行目のヘッダからマッピングカラム判定
mappings = GenerateColmunMappings
continue;
}
var model = new T() { IsValid = false, LineNumber = lineNumber - 1 };
result.Add(model);
// マッピングと数が一致してない行はエラー
if (columns.Length != mappings.Length)
{
rowErrors.Add("ヘッダのカラム数と一致しません");
continue;
}
// カラム値をマッピング
foreach (var map in mappings)
{
var value = columns[map.Index];
// 無効カラムはスキップ
if (!map.IsValid)
continue;
// 値の正規化
value = ParserHelper.NormalizeString(map.PropertyDescriptor, value);
// 値の型変換可能かチェック
if (!ParserHelper.IsValidValue(map.PropertyDescriptor, value))
{
rowErrors.Add("カラムの型が適切ではありません(" + map.HeaderName + ")");
}
else
{
map.PropertyDescriptor.SetValue(model, ParserHelper.ConvertFromString(map.PropertyDescriptor, value));
}
}
// DataAnnotationでモデル検証
var validationResults = new List
ModelValidator.Validation(model, validationResults);
rowErrors.AddRange(validationResults.Select(vr => vr.ErrorMessage));
model.IsValid = !validationResults.Any();
if (!model.IsValid)
{
model.ErrorMessage = string.Join("\n", validationResults.Select(vr => vr.ErrorMessage));
}
// なんかやりたかったらどうぞ
if (action != null)
{
action(model);
}
}
return result;
}
で、これのいいところはヘッダにカラム名してして、その名前をキーにプロパティにセットするから、CSVのフォーマットとして並び順が全く重要じゃなくなる。悪いところ?まぁ、いいや。いいように考えましょう。
あと、今回のコードではPOCO中心でCSVファイルのカラム有無を判定するような処理いれてないす。POCOの値だけを頼りにカラムの更新を判断して処理する場合、たとえばデータストアに反映するなんてときに特定カラムのみ更新する、とか、値をNullにするのか無指定なのか判断できないとちょっと不便かもー?
takepara/ModernCsv
あと、他にも変な機能もあるけど、興味があるなら...。