概要
- 値として改行や引用符を含むCSVファイルを解析するサンプルです。
- CSVファイルへの出力は比較的簡単に実現できますが、読み取り・解析の難易度は高くなるので、その参考です。
- 業務要件やその変化に応じて柔軟に仕様を変更したい場合や、セキュリティ上の理由でサードパーティーのパッケージの利用が難しい、等を想定しています。
- 使用環境は次の通りです。
OS Windows 10(64ビット) IDE Microsoft Visual Studio Community 2022(17.6.0) 言語 C#(10.0) + .NET6 パッケージ (追加のパッケージは不要)
動作仕様
- 次のように、値としてカンマ、改行、引用符(")を含むCSVファイルの読み取りを想定しています。
列1,列2,列3 値1A,記号"",値1C 値2A,"値2B-1,値2B-2","値2C-1 値2C-2"- 値にカンマや改行コードを含める場合、引用符(")で囲うものとします。
- 引用符(")として読み取る場合、「""」と定義するものとします。
- 読み取った行・列値は、List<string[]>に変換します。(読み取った列値はstring型とし、1行分の列値はstring[]に格納します。)
- 空行(長さ0の行)を読み取った場合、string[0]ではなく、string[]{""}とします。
サンプルコード
TextReaderとして引き渡されたCSVファイルを、List<string[]>として解析します。
単体テストコードを含む完全なソースコードはgithubで公開しています。
public static List<string[]> Parse(TextReader reader)
{
var rowList = new List<string[]>();
string val; int deli;
do
{
// 1行分のカラム値を取得
var colList = new List<string>();
do
{
// 区切り文字・行終端(または引用符開始)までの値を取得
(val, deli) = ScanValue(reader, c => c == ',' || c == '"' || c == '\r' || c == '\n');
// 引用符だった場合、次の引用符までの値を取得
if (deli == '"')
{
// 次の引用符までを値とする。
// 終端の引用符が存在しない場合は例外とする。
(val, deli) = ScanValue(reader, c => c == '"');
if (deli != '"') throw new FormatException("no ending quotation");
// 終端となる引用符の次にある区切り文字まで読み飛ばす
(_, deli) = ScanValue(reader, c => c == ',' || c == '\r' || c == '\n');
}
// 取得した値をカラム値リストに追加
colList.Add(val);
} while (deli == ','); // 区切り文字の場合、次のカラム値を取得
// 改行読み飛ばし(\r\n時は追加で\nを読み飛ばし)
if (deli == '\r' && reader.Peek() == '\n') deli = reader.Read();
rowList.Add(colList.ToArray());
} while (deli >= 0); // ファイル終端(-1)でない場合は繰り返し
return rowList;
}
private static (string, int) ScanValue(TextReader reader, Func<int, bool> stopFunc)
{
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = reader.Read()) >= 0)
{
// 連続する引用符は単一の引用符と解釈
if (ch == '"' && reader.Peek() == '"')
{
ch = reader.Read();
sb.Append((char)ch);
continue;
}
if (stopFunc(ch)) break;
sb.Append((char)ch);
}
return (sb.ToString(), ch);
}- 基本的な考えとして、カンマ・引用符・改行コード等の特定の区切り文字までを読みとり、その区切り文字の種類に応じて、通常の列値の取得、引用符付きの列値の取得、処理行の変更、等を行います。
- カンマ、改行等の区切り文字の直前までの文字を列値として読み取ります。読み取った区切り文字がカンマの場合、「列値がまだ残っている」と判断し、同様に列値(string)を読み取ります。
- 読み取った区切り文字が改行の場合、「行の終端」と判断し、それまでに読み取った列値群(string[])を、行リスト(List<string[]>)に追加します。
- 読み取った区切り文字が引用符の場合、「引用符付きの列値の始まり」と判断し、終了となる引用符までの文字を列値として読み取ります。また、カンマ・改行等の区切り文字まで読み飛ばします。(開始の引用符の前にあった文字列、終了の引用符から区切り文字までにあった文字列が読み取られていますが、これらは全て無視する前提です。)
- ScanValue()に関して、読み取りを停止する区切り文字配列を引き渡しても良かったのですが、性能や停止条件の柔軟な定義を可能とするためにデリゲート(ラムダ式)を使用しています。
業務利用での補足
- 業務用のモデルクラスへのデシリアライズ
- 実際の業務処理では、CSVファイルの内容を業務用モデルクラスに格納して、業務処理を行うと思います。そのため、次のように仮引数で指定したモデルクラスに格納して返却する仕組みの方が便利です。
public class MyModel { [CsvColumn(1, "列1")] // 対応するCSV列のインデックス番号や列名 public string MyProperty { get; set; } ... } List<MyModel> result = CsvFileUtils.Parse<MyModel>(reader); - 入力検証や変換等を実装する場合、前述と同様に属性を使用した実装方法が考えられます。検証失敗時のメッセージや行番号を纏めて返却するような仕組みも必要になります。
- 業務の要件に合わせて、より高度な機能をもった共通部品を実現することもできますが、開発側の負担(工数)が増大するので、どこまで対応するかは要検討です。このような高機能を求めるのなら、サードパーティーのパッケージを使用した方が手っ取り早いです。
- 実際の業務処理では、CSVファイルの内容を業務用モデルクラスに格納して、業務処理を行うと思います。そのため、次のように仮引数で指定したモデルクラスに格納して返却する仕組みの方が便利です。
- その他の典型的な検討項目は次の通りです。
- 対応するエンコーディング(UTF-8, Shift_JIS等)
- ヘッダ行の有無(ヘッダ行なし、ヘッダ行はスキップ、等)
- 列値の空白・タブのトリム有無
- 想定する改行コード(\r\nのみ、混在等)
- 引用符付きの列値に含まれる改行コードの変換や統一
- 空行やコメント行の扱い(空行は許容しない、空行はスキップ、等)
- 最終行の改行有無の扱い(直感的に気付きづらいが、最終行に改行含まれると実際の最終行は空行になってしまう。このような空行を無視する、エラーにする、等。この違いを意識している処理系は少ない。)
- 入力ファイルの制限
- ユーザがアップロードしたCSV、外部システムから連携されたCSVファイル等の場合、セキュリティ対策としてファイルサイズ上限を設けることをお薦めします。
- なお、CSVファイルの上限行数を設けても不十分であり、ファイルサイズをチェックすることをお薦めします。(例えば1行が数GBになるようなものをチェックできない。)