CSV,TSVなどの文字区切りテキストをパースする

連載記事C#でCSVファイル操作ライブラリ の最初の記事です。

C#CSVをパースする処理を自作してみます。

動作的には、 TextFieldParser クラス (Microsoft.VisualBasic.FileIO) のReadFields()メソッドに準じますが、TextFieldPaser にはC# で CSV を扱うのに CsvHelper を使う - dunno logs に記載されているような問題があるようですので、このあたりの改良も含め、以下の仕様で実装してみたいと思います。

仕様

  • 1レコード(行)は改行文字(※1)で区切られる
  • フィールドの区切り文字には1文字以上の文字列を複数指定できる
    • { "," } (カンマ区切り)
    • { "\t", " " } (タブとスペースが混在)
    • { "::" } (コロン2つで区切る) など
  • フィールドに以下の文字を含む場合はダブルクオーテーションで囲む
    • 区切り文字に指定した文字列
    • 改行文字(※1)
    • ダブルクオーテーション
    • フィールド前後の空白(※2)
  • 上記以外のフィールドの場合でもダブルクオーテーションで囲んでいる場合がある
  • フィールド内のダブルクオーテーションは2つ重ねてエスケープする
  • フィールド前後の空白(※2)は削除する

(※1) 今回はTextReader.ReadLine()等で読み取った1行を解析するため、改行文字は使用するTextReaderの実装によります。System.IO.StreamReaderでは'\r', '\n', '\r\n' を行区切りと認識します。
(※2) ここで認識される空白文字はchar.IsWhitespace(char)でtrueを返す文字です。半角、全角スペース、タブなどを含みます。 )

Parseメソッド

1行分の文字列を受け取ってフィールドに分割し、文字列のシーケンスとして返すメソッドを定義します。

class XsvReader
{
    public static IEnumerable<string> Parse(string line, 
        ICollection<string> delimiters, Func<string> followingLineSelector);
}
  • line パラメータには、処理したい1行分の文字列を渡します。
  • delimiters パラメータで、区切り文字を指定します。
  • followingLineSelector パラメータには、次の1行を提供する処理を記述します。(フィールド内に改行を含む場合に、後続の行が必要となります。)

これを使用する側の実装は次のようになります。

var csvData = new List<string[]>();
using(var reader = new StreamReader("filename.csv"))
{
    while(!reader.EndOfStream)
    {
        var row = XsvReader.Parse(reader.ReadLine(), new []{","}, ()=> reader.ReadLine());
        csvData.Add(row.ToArray());
    }
}

この例ではSystem.IO.StreamReaderを使用し、 ReadLine()メソッドで読み込んだ1行分の文字列を第1引数に渡しています。 さらに、第3引数に渡すデリゲート内で追加の1行をReadLine()する処理を記述しています。

ソースコード

以下、Parseメソッドの全容です。
処理の概要は、ソースコード内のコメントを参照ください。割とオーソドックスな?実装になっていると思います。

※ 実行速度についてはあまり考慮していません。簡単に計測した結果では、TextFieldPaser の1.5倍~2倍の実行時間でした。
実用に耐えるレベルではあると思いますが、これは今後の課題にしておきます。

実行してみる

次のテキストを使って動作確認をします。

No., Name, Price, Comment<n>
001, りんご, 98円, 青森産<n>
002, バナナ, 120円, "    とっても!
お,い,し,い,よ!"<n>
<n>
004, "うまい棒""めんたい""", 10円,<n>
005, バナメイ海老, 800円, "300g

エビチリに"

(※ `<n>` は、レコード終わりの改行を分かりやすくするために付加したマークです。
実際に使用するテキストには含まれません。)

比較のために、TextFieldParser クラス (Microsoft.VisualBasic.FileIO)ReadFields()メソッドでの実行結果も見てみます。

実行結果

まずは、TextFieldParser の出力結果

フィールド前後の空白を削除する/しないを設定するTextFieldParser.TrimWhiteSpac プロパティの値によって、出力結果が異なりますので両方で実行してみます。

TextFieldParser で問題とされている、以下の2点に注意して出力結果を見てみましょう。

  • 空の行が削除されてしまう
  • ダブルクオーテーションで囲んだフィールド内の空白や改行が削除されてしまう。

前後の空白を削除するTrimWhiteSpace=true の場合

<< TextFieldParser (TrimWhiteSpace=true) >>
No.;Name;Price;Comment<n>
001;りんご;98円;青森産<n>
002;バナナ;120円;とっても!
お,い,し,い,よ!<n>
004;うまい棒"めんたい";10円;<n>
005;バナメイ海老;800円;300g
エビチリに<n>
  • 区切り文字後の空白が削除されている
  • "で囲まれたフィールド内の空白(とっても!の前)まで削除されている
  • "で囲まれたフィールド内の空白行(エビチリにの上の行)が削除されている
  • No.003に相当する空の行が削除されている

続いて、前後の空白を削除しない TrimWhiteSpace=false の場合

<< TextFieldParser (TrimWhiteSpace=false) >>
No.; Name; Price; Comment<n>
001; りんご; 98円; 青森産<n>
002; バナナ; 120円;    とっても!
お,い,し,い,よ!<n>
004;うまい棒"めんたい"; 10円;<n>
005; バナメイ海老; 800円;300g
エビチリに<n>
  • 区切り文字後の空白が削除されていない
  • "で囲まれたフィールド内の空白も削除されていない
  • "で囲まれたフィールド内の空白行(エビチリにの上の行)が削除されている
  • No.003に相当する空の行が削除されている
作成したParseメソッドの結果

では、今回作成したParseメソッドの結果を確認してみましょう。

<< XsvReader.Parse() >>
No.;Name;Price;Comment<n>
001;りんご;98円;青森産<n>
002;バナナ;120円;    とっても!
お,い,し,い,よ!<n>
<n>
004;うまい棒"めんたい";10円;<n>
005;バナメイ海老;800円;300g

エビチリに<n>
  • 区切り文字の後ろの空白は削除されている。
  • "で囲まれたフィールド内の空白(とっても!の前)は削除されていない
  • "で囲まれたフィールド内の区切り文字(,)がそのまま残されている
  • "で囲まれたフィールド内のダブルクオーテーション("")が正しく処理されている
  • "で囲まれたフィールド内の改行が削除されていない。
  • 空白行(No.003に相当する行)では要素0のIEnumerable<string>を返している

ちゃんと、意図した通りの動作になっているようです。

テスト用コード

最後に、上記の確認用に使用したテストコードを掲載しておきます。