←CSVパーサを作る(その1) - 簡易パーサ
CSVの仕様について調べてみると「K3フォーマット」と「RFC4180(日本語訳)」が見つかります。K3フォーマットとCSVの違いについては「桐ver.8活用ガイド」というドキュメントの「読み込み K3フォーマット」と「読み込み CSV」という項目で記述されています。
ここでは前回の記事で作った簡易CSVパーサを発展させて,今回と次回の2回に分けてRFC4180で規定されたCSVが読み込めるCSVパーサを実装してみたいと思います。
【RFC4180で規定されたCSVの仕様】
- レコードの区切りは改行である。改行コードはCRLFだが他の値も考慮すべきである。
- ファイルの末尾に改行はあってもなくても良い。
- ファイルの先頭行にヘッダ行があっても良い。ヘッダ行の有無はMIMEタイプ「text/csv」のパラメータ「header」で示す。header パラメータの値は「present」(存在する),または「absent」(存在しない)のどちらかである。「header」パラメータを使わない場合はヘッダの有無を自ら決める。
- 各行にはカンマ「,」で区切ったフィールド列が必ずある(つまり空行はない)。フィールド数はすべての行で同じ。フィールド中の空白は無視しない。行末のカンマは行の終わりを表さない(その後ろにNULLフィールドがもう1つある)。
- 各フィールドはダブルクォーテーション(二重引用符)「"」で囲んでも良いし囲まなくても良い。
- 改行,ダブルクォーテーション,カンマを含むフィールドはダブルクォーテーションで囲む。
- フィールドデータにダブルクォーテーションを含む場合,ダブルクォーテーションを2つ続けることでエスケープする。
- 文字コードは一般にUS-ASCIIを使う,他の文字コードの使用を明示するにはMIMEタイプ「text/csv」のパラメータ「charset」で指定する。
【参考:K3フォーマットとRFC4180の違い】- フィールドをダブルクォーテーションで,
- RFC4180:「囲んでも囲まなくてもよい」(数値型を囲んでもよい)
- K3フォーマット:「文字列型データは囲む,数値型データは囲まない」(数値型,文字型以外は想定なし)
- 行の先頭が「#」だった場合,
- RFC4180:規定なし。よって通常のレコードとして解釈する。
- K3フォーマット:その行をレコードとして解釈せずに以下のように解釈する:
- 行頭が「#」の場合:制御コマンド記述行
- 行頭が「##」の場合:コメント行
- 行頭が「###」の場合:データ終了。この行以降のデータは取り込まない。
【仕様の考察】
CSVパーサのように外部からデータを取り込んでこれを処理するプログラムの場合,受け取ったデータがRFC4180の仕様に沿っていた場合に正常に処理できることはもちろん,仕様とは異なるデータを受け取った場合にどのように処理するかを決める必要があります。CSVパーサの実装を検討する上で考慮すべき事項として以下のようなものが考えられます。
- 画像データやPDFなど,CSVではないデータを受け取った場合にどうするか。
- RFC4180の仕様から若干外れたCSVデータ,たとえば次のようなデータを受け取った場合にこれをどのように処理するか:
- CRLF以外の改行コードを受け取ったらどうするか。複数の種類の改行コードが混在していたらどうするか。
- 仕様では「空行はない」とされているが,空行があった場合はどうするか。
- 仕様では「フィールド数はすべての行で同じ」となっているが,フィールド数にばらつきがあった場合はどうするか。
- 仕様で「改行,ダブルクォーテーション,カンマを含むフィールドはダブルクォーテーションで囲む」とあるが,囲まれていない場合はどうするか。フィールド中の改行とカンマについてはダブルクォーテーションなしでは判定は不可能だが,ダブルクォーテーションについてはエスケープされているかどうかで判定できる可能性がある。
- ダブルクォーテーションで囲まれていないフィールドの途中にエスケープされていないダブルクォーテーションが出現した場合はどのように解釈するか。
- 行頭が「#」の場合どうするか。RFC4180では行頭が「#」の場合については決められていないが,K3フォーマットではこの行はレコードとして解釈せずにコメント行または制御行として扱う。
- 文字コードはどうするか。RFC4180ではUS-ASCIIがデフォルトだが,これだと日本語が扱えないのでデフォルトを変えるべきではないか。
これらを考慮する場合に重要なのが「リーズナブル」という考え方です。これは具体的には以下のような考え方です。
- なるべく少ないコードでより多くの要求を満たす実装方法を選択する。
- 重要ではない小さな要求のために大量のコードが必要になる実装方法は避ける。
- 何かに対応するために大量のコードが必要になったら「この実装方法には何か根本的な問題があるのではないか?」と考える。
- 実現不可能なもの,実現が困難なものはどこかで諦める。その場合はユーザが納得できる代替案を探す。
以上を踏まえて,CSVパーサを実現するためにRFC4180に対して以下のように追加修正を行います。
- CSVパーサの実装において,CSV以外のデータを受け取った場合の処理については特に考慮しません。この問題は,CSVパーサが呼ばれる以前に,たとえばファイル名の拡張子でエラー判定するなどして解決するものとします。
- フィールドに含まれる空白のうち,フィールドの先頭と末尾の空白は無視(削除)できることにします。これは特に数値を数値データとして取り込む場合に必要です。
- MIMEタイプのパラメータは読み込んだCSVデータの中で判断できないため,パーサでは対応しません。
- 行頭に「#」がある行はコメント行とします。ヘッダ行はこのコメント行で実現するものとします。対応すべき制御コマンドもないので「#」1つでもコメント行とします。
- フィールド内にエスケープされたダブルクォーテーションが含まれる場合の動作は次のようにします。
- エスケープされたダブルクォーテーションの前後にデータがある場合は,フィールド全体を囲むダブルクォーテーションはあってもなくても良い。
- フィールド内のデータがエスケープされたダブルクォーテーションだけの場合,全体を囲むダブルクォーテーションが必須。(つまり連続する4つのダブルクォーテーションが必要。)
- 逆にフィールド内のデータがダブルクォーテーションの1ペアだけだった場合,パーサはこれをダブルクォーテーションで囲んだNULLフィールドとして扱う。
- レコードの区切りは改行(またはファイル末尾)ですが,ダブルクォーテーションで囲んだフィールドに改行が入っていることがあるため,改行がレコードの区切りとは限りません。
- フィールド数は固定であることを期待しません。また空行もありうるものとします。空行はスキップします。フィールドが多いときは余りは無視します。フィールドが足りないときはNULLフィールドで補うものとします。
- ダブルクォーテーションを2つ続けることによりダブルクォーテーションをエスケープする仕様のため,ダブルクォーテーションは1フィールド及び1レコード内で必ず偶数になります。
- 文字コードはUS-ASCIIではなく「Windows-31J/MS932」とします。これはExcelで開けるCSVの文字コードに合わせています。
【実装の考察】
処理手順としては,入力したテキストデータを先頭から1レコードづつ切り出し,このレコードをフィールドに分割する,これを入力テキストの最後まで繰り返す,という流れになりますが,処理手順を検討した結果,以下の流れで処理を行うことにします。
- レコードの確定
入力テキストを1行づつ取り出し,ダブルクォーテーションのペアをトレースしてレコードの末尾を確定し,1レコードを確定する。- 取り出した1行分の入力テキストに含まれるダブルクォーテーションの数がゼロまたは偶数ならば,その1行を1レコードとして確定する。
- 取り出した1行分の入力テキストに含まれるダブルクォーテーションの数が奇数ならば,ダブルクォーテーションで囲まれたフィールドの途中と見なして次の1行分の入力テキストを取り出し前の行と連結する。これをダブルクォーテーションが偶数になるまで(または入力テキスト末尾になるまで)繰り返す。
- フィールドの分割
レコードに対して,最初にレコード全体をカンマで分割した後で,ダブルクォーテーションをヒントに- 文字列フィールドのカンマによる分割の再結合
- 文字列フィールドを囲むダブルクォーテーションの削除
- ダブルクォーテーションが2つ連続した場合のエスケープ処理
を行う。 - 1と2を入力テキスト末尾まで繰り返す。
以上のような処理になりますが,ソースコードを簡潔にするために上記「レコードの確定」と「レコードのフィールドへの分割」を別メソッドにして実装することにします。
このメソッドを実装する前に,
前回の記事で作った「簡易CSVパーサ」を,上記メソッドを呼び出すように書き換えたコードを先に作ってしまいます。
【簡易パーサの修正】
「レコードの確定」「レコードのフィールドへの分割」を行うメソッドを以下のように規定します。
/**
* BufferedReaderから1レコード分のテキストを取り出す。
* @param reader 行データを取り出すBufferedReader。
* @return 1レコード分のテキスト。
* @throws IOException 入出力エラー
*/
private String buildRecord (BufferedReader reader);
/**
* レコードデータsrcを分割してフィールドの配列にする。
* @param src レコードデータ。
* @param dest レコードデータからフィールドの配列を取り出してリストに加える。
*/
private void splitRecord (String src, LinkedList dest);
|
このメソッドを使って前回の記事の「簡易CSVパーサプログラム」を書き換えると次のようになります。
注:行頭が「#」の行はコメント行と見なしてスキップするコードも追加しています。
//------------------------------------------------------------------
/**
* CSVファイルの読み込み。
* @param stream 入力ストリーム。FileInputStream,ByteArrayInputStreamなど。
*/
public void read (
InputStream stream)
{
LinkedList columns = new LinkedList();
InputStreamReader reader = null;
BufferedReader buff = null;
try {
reader = new InputStreamReader(stream, "MS932");
buff = new BufferedReader(reader);
String record;
int lineNum = 0;
while ((record = buildRecord(buff)) != null) {
lineNum++;
if (record.length() <= 0)
continue;
if (record.startsWith("#"))
continue;
splitRecord(record, columns);
if (0 < columns.size()) {
readColumns(columns, lineNum);
columns.clear();
}
}
}
catch (Exception ex) {
ex.printStackTrace();
}
finally {
columns.clear();
try {
if (buff != null) {
buff.close();
}
if (reader != null) {
reader.close();
}
stream.close();
}
catch (IOException ex) {
ex.printStackTrace();
}
}
}
//------------------------------------------------------------------
/**
* 読み込んだ1レコード分のデータをDBに取り込む。
* 実際の処理は派生クラスで実装。
* @param columns 1レコードをフィールドに分割した文字列リスト。
* @param lineNum CSVの行番号(エラーが発生したときの行番号記録用)。
*/
abstract protected void readColumns (LinkedList columns, int lineNum);
|
今回はここまで。
次回は,「レコードの確定」と「レコードのフィールドへの分割」を行う「buildRecord」と「splitRecord」両メソッドの実装を行い,CSVパーサを完成させます。
CSVパーサを作る(その3) - RFC4180対応 後編→
※上記コードでは,整形のため全角スペースを使用している部分があります。
【著作権表記】上記コードを含む本ブログのプログラムコードは,私的利用可,商用利用可,改変しての利用可です。利用の際に作者に許諾を得る必要はありません。
■関連情報
CSVの仕様:
RFC4180(
日本語訳)(
Wikipedia)
桐ver.8活用ガイド(K3フォーマットとCSVの違いについての解説あり)
Microsoftコードページ932(
Wikipedia)
WebObjects:
CSVレスポンスの実装
■関連書籍をAmazonで検索:[
Java]
●
Effective Java 第2版 (The Java Series)
←この記事が役に立ったという方はクリックお願いします。
▼CSVパーサを作る[
その1][その2][
その3]