2014年5月1日木曜日

CSVを今っぽく

CSVってカンマなのかコモンなのか、っていう話ではなくてですね。
一般的には","カンマで区切るんだと思います。
でも、ダブルクォート使いたいしー、っていうんで"TAB"タブ区切りにしちゃったり。
さらに、用途によっては改行入らないから、ダブルクォート内での改行処理とかなくてもいいよー、っていうのがよくあるパターンかなー、と思ったりもします。

簡単に整理すると

  1. カラム区切りはタブ
  2. カラム内に改行はない
  3. レコードの区切りは改行
  4. 1行目はカラム名を指定するヘッダ
  5. 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でデータつくるじゃん!

ここまでで、もうイメージできた人も多いと思うけど、せっかくなので、コードでどう表現するのかを書いてみます。暇だから。まずは、データのパース。ルールが単純なので、タブやカンマでSplitするだけです。ダブルクォートのエスケープとか、改行込のデータがあるなら、そこは頑張って!

これってコードにすると、

var columns = line.Split(splitChar);

とか、ですね。
1行目ならそこにはカラム名(これの一覧はPOCOにセットするために保持しとく)が入ってて、1行目以降ならそれはデータ。っていうのを、上位層で判定。

これで、レコードをカラムに分割できました。
今度はカラム名の一覧と、モデルクラス(ジェネリックでしょうね)のプロパティをマッピング。

var properties = TypeDescriptor.GetProperties(type);

これで一応POCOのプロパティ一覧(PropertyDescriptorCollection)は取得できましょう。キャッシュするかどうかはお好きに。

あとは、もう単純にくるくるループ回していけば、いいですよね!PropertyDescriptorがあれば、Converter.ConvertFromStringでプロパティにセットできるか判定もね。

後は、DataAnnotationsを指定してるかもしれないから、Validator.TryValidateObjectで、モデル検証。

        public static IList Parse(string text, Action action = null) where T : ModelBase, new()
        {

            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(columns);
                    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

あと、他にも変な機能もあるけど、興味があるなら...。

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。