- この記事の背景
- CSVパーサの機能
- CSVパーサの処理ルール
- 区切り文字はカンマとして、必ずしもダブルクォートで囲む必要はない
- 文字としてダブルクォート、カンマ、改行を含む場合には必ずダブルクォートで囲む
- ダブルクォートを文字として含める場合には、ダブルクォートを一つ置いてエスケープする
- カンマで始まる行は空の文字列を直前に含んでいる、カンマだけで終る行は空の文字列を改行前に含んでいる
- 文字コードや改行は厳密には定義しない。でもUTF-8が基本
- CSVパーサが想定する処理方法
- いよいよ文字として改行を許さない場合に限定したCSVパーサを作ってみる
- 作成したファイル群
- とりあえず
この記事の背景
IBMのdeveloperWorksはむかーしからよく読んでいますが、その中に「 今まで知らなかった 5 つの事項: 日常的なJava ツール」という記事がありました。
内容自体はとてもよくまとまっていたのですが、その中でCSVファイルや空白区切りのテキストファイルを対象としてjava.util.Scannerクラスに触れて、「ほとんどの(構文解析)ユーティリティーは機能過剰」、「String.split() では機能不足」な場合にScannerクラスが最適と述べられています。
java.util.Scannerを使いたい場面は「String::split()でも良いんだけど、数値はFloat, Integerと区別して読み込みたい」というシンプルな構文で、かつ型付けのあるケースだと思います。
あるいは「String::split()でも良いんだけど、1行の中に空白を区切りとしてどこかにある整数値を2つ読み込みたい」といった構文が破綻しているケースでしょう。
いずれにしてもString::split()の代りにScannerを使う事でコードがかなりすっきりすると思います。
動機
前置きが長くなりましたが、developerWorksの記事をみて「java.util.Scannerを使ってCSVのパーサを手で書こうとする人っているのだろうか」と思ったのがこの記事の動機です。
始める前から分かっていたのは、行単位で処理しないとドロ沼にはまる可能性が高いことです。
行単位で処理すれば字句解析器は単純で、構文解析もメモリに読み込まれた字句の分析だけで済むため、次の行を先読みする必要もありません。
もしまともに動くものが必要であれば、機能を減らす(制約を増やす)か、自作せずに十分にテストされたCSVパーサを使う事をお勧めします。
参考資料
CSVパーサの機能
CSVパーサは「Comma-Separated Values」の名前の通り、カンマで区切られたフォーマットの総称ですが、人によって、その意味するところには幅があります。
おおまかに「データの受け渡しはCSV形式でいいよね」という約束事をする場合、実際には次のような部分で混乱を招く事になります。
- 1行1レコード? データに改行は入らないの?
- 1レコードに入るデータの区切りはカンマ(,)? 空白( )でもいい?
- 1フィールド(データ)は必ずダブルクォート(")で囲むの?
- やっぱり微妙に形式が違うから、ruby1.9添付のcsv.rbで処理できる形式をCSV形式と呼ぼう ^^;
そこで具体的な例を使って、パーサの機能についてまとめていきます。
CSVパーサの処理ルール
ルールを列挙して、正しい例を挙げていきますが、分かりやすくするために必要なら不正な例も挙げることにします。
区切り文字はカンマとして、必ずしもダブルクォートで囲む必要はない
1,2 "3","4"
これは下記の形式と同じです。
1,2 3,4
文字としてダブルクォート、カンマ、改行を含む場合には必ずダブルクォートで囲む
次に挙げた例の中で、1つめのレコードはaaとb"cという2つのフィールドを持ちます。
2つめのレコードはX,Xというカンマを含むフィールドと'Y
Y'という改行を含めたフィールドの2つから成ります。
aa,"b""c" "X,X","Y Y"
ダブルクォートを文字として含める場合には、ダブルクォートを一つ置いてエスケープする
既に前の例で使っていますが、文字としてダブルクォートを使う場合にはフィールド全体をダブルクォートで囲った上で、ダブルクォートを2つ続けて書く事で、ダブルクォート1文字を表現します。
このルールを加えると、次のような一つのフィードを作る事もできます。
"aa,""b""""c"" ""X,X"",""Y Y"""
カンマで始まる行は空の文字列を直前に含んでいる、カンマだけで終る行は空の文字列を改行前に含んでいる
次の例は2つの空の文字列からなるレコードの例です。
,
カンマで終わる行は RFC4180の要件からは外れるので、次の形式を推奨します。
"",""
このため空の文字列を表わす方法としては、ダブルクォートで囲むのが推奨する方法です。
文字コードや改行は厳密には定義しない。でもUTF-8が基本
文字コードや改行記号については厳密に決めません。処理系に依存させています。
手元の環境はlinuxなので、改行記号は'\n'、文字コードはUTF-8で処理をしています。
CSVパーサが想定する処理方法
ファイル形式に含まれませんが、パーサをどう呼び出すのかは決めておかないと混乱しそうです。 ここは通常のIteratorクラスやjava.util.Scannerをお手本にして、次のようなレコード(Row)単位での処理を考えています。
処理概要の疑似コード
今回は次のようにファイルを入力として、レコード単位で処理するケースを想定しています。
...
CSVScanner scanner = null;
scanner = new CSVScanner(input_file);
while (scanner.hasNext()) {
Row row = scanner.fetchRow();
while (row.hasNext()) {
String field = row.next();
}
}
...
いよいよ文字として改行を許さない場合に限定したCSVパーサを作ってみる
普通に行ベースで処理をすると幸せになれそうなのは分かっているので、まずは改行を文字として含まないケースで考えてみます。
java.util.Scannerでは"\n|,"のような正規表現で区切っていく事も可能ですが、直前にマッチした区切り文字が'\n'なのか','なのかを知ることはできません。この点が状態遷移する処理との組み合せで扱いずらいところです。
処理概要
まずは改行記号を使ってfetchRow()メッドの中でレコード(行)単位に分解します。
次にprivateメソッドでレコードをさらにフィールド(要素、データ)単位に分割していきます。
このために2種類のScannerオブジェクトを準備して、片方はファイル全体を行毎に分割するscannerオブジェクトで、もう一方は使い捨ての各レコードをカンマで分割するcScannerオブジェクトです。
ダブルクォートで囲まれている間はカンマはそのまま出力するため、フラグとしてisQuoteBlock変数を使います。
だいたい、こんなところで処理の流れについての説明は終りです。
java.util.Scannerクラス固有の考慮点
空の文字列をどのように扱うかがポイントになります。
例えば ,,,X ようにカンマが連続する入力を考えた場合に、今回は "","","",X と同じだとしているので、カンマを区切りとして3つの空要素と文字"X"の3つを認識する必要があります。
しかしjava.util.Scannerクラスは最初の空要素を飛して、Xの前に2つの空要素があるという処理をします。 同様に最後に空要素があった場合も飛ばしてしまいます。
次のようなサンプルコード確認できます。
import java.util.Scanner;
public class TestScanner {
public static void main(String[] args) {
Scanner s = new Scanner(",,,X");
s.useDelimiter(",");
int i=0;
while(s.hasNext()) {
String item = s.next();
System.out.println(i + ": " + item);
i++;
}
}
}
これを実行すると次のような出力が得られます。
0: 1: 2: X
連続したカンマの間にある空要素は、空文字列と同値ですが、これは直感的に理解できるので特に混乱することはないでしょう。
作成したファイル群
とりあえず説明が必要なのかどうか、作成したファイル一式を載せてみます。
Mainクラス
/**
* This class is a sample implementation of the main process.
* @version 1.0
* @author Yasuhiro ABE <yasu@yasundial.org>
*/
public class Main {
/**
* This main method is just a sample implementation.
* @param args the command line arguments, currently it's not used.
*/
public static void main(String[] args) {
java.io.File f = new java.io.File("src/test.csv");
if (!f.exists() && args.length > 0) {
f = new java.io.File(args[0]);
}
CSVManager myscanner = null;
try {
myscanner = new CSVManager(f);
} catch (java.io.FileNotFoundException e) {
System.err.println("The given CSV file couldn't be found or couldn't be read.");
System.exit(1);
}
int i = 0;
while (myscanner.hasNext()) {
int j = 0;
Row row = myscanner.fetchRow();
if (row == null) {
System.out.println("!! The row is just new line, then skipped. !!");
continue;
}
while (row.hasNext()) {
String item = row.next();
System.out.println(Integer.toString(i) + ", " + j + ": " + item);
j++;
}
i++;
}
}
}
CSVManagerクラス
/**
* It's an experimental CSV parser using java.util.Scanner.
*
* <p>
* <em>There are some important assumptions;</em>
* <ul>
* <li>Each field doesn't contain the new line character except the end-of-line,
* even though the item is quoted by the double-quote.</li>
* <li>Each field will no be sanitized, it means you can input the incorrect CSV file,
* but the result is not defined.</li>
* </ul>
*
* <p>
* If you have a simply structured text file and each variable has a type, such as integer and String,
* then you can use java.util.Scanner class.
*
* <p>
* If you just want to use java.util.Scanner class for a CSV file and which may be a full featured CSV,
* you should change your mind, I strongly recommended.
*
* @version 1.0
* @author Yasuhiro ABE <yasu@yasundial.org>
* @see <a href="http://download.oracle.com/javase/6/docs/api/java/util/Scanner.html">java.util.Scanner</a>
*/
public class CSVManager {
private String lineDelimiter;
private String itemDelimiter;
private String quoteString;
private java.util.Scanner scanner;
private boolean isQuoteBlock;
private java.io.File csvFile;
/**
* @param file a CSV file.
* @throws java.io.FileNotFoundException
*/
public CSVManager(java.io.File file) throws java.io.FileNotFoundException {
csvFile = file;
lineDelimiter = "\n";
itemDelimiter = ",";
quoteString = "\"";
scanner = null;
isQuoteBlock = false;
this.scanner = new java.util.Scanner(csvFile);
this.scanner.useDelimiter(this.lineDelimiter);
}
/**
* The default delimiter is ",", so you can change that by this method.
* @param itemDelimiter A delimiter string, like ','.
*/
public void changeDelimiter(String itemDelimiter) {
this.itemDelimiter = itemDelimiter;
}
/**
* A user use this method to get a row representation object.
*
* @return Row A user will use this interface object.
* If the row is empty, then the return value will be null.
*/
public Row fetchRow() {
if (this.scanner.hasNext()) {
String rawRow = this.scanner.next();
return genRow(rawRow);
}
return null;
}
/**
* It's a wrapper method for java.util.Scanner::hasNext();
* @return boolean as same as java.util.Scanner::hasNext();
*/
public boolean hasNext() {
return this.scanner.hasNext();
}
/**
* This method prepare the Row object from the given line (row) string.
* @param line A csv row string, such as "a,b,c."
* @return Row object or null if the given line is empty.
*/
private Row genRow(String line) {
if (line.isEmpty()) {
return null;
}
// as example, line is "a,c,b".
java.util.Scanner cScanner = new java.util.Scanner(line);
cScanner.useDelimiter(this.itemDelimiter);
CSVRow row = new CSVRow();
if (line.startsWith(this.itemDelimiter)) {
row.add("");
}
String tmpItem = "";
while (cScanner.hasNext()) {
String item = cScanner.next(); // item is one of; "a c b"
if (this.isQuoteBlock) {
if (item.endsWith(this.quoteString)) {
// case: b"
tmpItem += this.itemDelimiter + item;
row.add(tmpItem);
this.isQuoteBlock = false;
tmpItem = "";
} else {
// case: c
tmpItem += this.itemDelimiter + item;
}
} else if (item.startsWith(this.quoteString)) {
if (item.endsWith(this.quoteString)) {
// spacial case; the line is just a "a","b".
row.add(item);
} else {
// case: "a
this.isQuoteBlock = true;
tmpItem = item;
}
} else {
// special case; the item is not quoted like 123.
row.add(item);
}
}
// To salvage the item in progress which is placed at EOF.
if (!tmpItem.isEmpty()) {
row.add(tmpItem);
} else if (line.endsWith(this.itemDelimiter)) {
row.add("");
}
return (Row) row;
}
}
Rowインタフェース
/**
* This interface provides a record representation of a CSV file.
*
* If you want to use the different implementation of CSVRow class,
* such as using java.util.ArrayList, then implement this interface in your own CSVRow class.
*
* @version 1.0
* @author Yasuhiro ABE <yasu@yasundial.org>
*/
public interface Row {
/**
* <pre>
* It will be used like that;
* while(row.hasNext()) {
* String item = row.next();
* ...
* }
* </pre>
* @return boolean If you will be able to call next(), then true.
*/
public boolean hasNext();
/**
* It return a each separated field item as a String object.
* @return String A column field item.
*/
public String next();
}
CSVRowクラス
/**
* A sample Row implementation class.
*
* @version 1.0
* @author Yasuhiro ABE <yasu@yasundial.org>
* @see Row
*/
public class CSVRow implements Row {
private java.util.LinkedList<String> itemList;
public CSVRow() {
itemList = new java.util.LinkedList<String>();
}
public void add(String field) {
itemList.push(field.replaceAll("^\"", "").replaceAll("\"$", "").replaceAll("\"\"", "\""));
}
public boolean hasNext() {
if (itemList.size() > 0) {
return true;
}
return false;
}
public String next() {
return itemList.pollLast();
}
}
とりあえず
これがちゃんと動くものなのかは、テストケースを準備する必要があるので、とりあえずここまでにして次にテストの方法をまとめていきます。
pythonかrubyのCSVパーサとJavaのCSVパーサを何か使って、比較もしてみる予定です。
2010/10/28追記: テストケースをいろいろ準備して試したところ、バグバグなのが分かったので次の記事でテストケースの作成方法と修正したコードについて載せる予定です。 エラーの処理をちゃんとするのは面倒なので、本気でCSVのパーサが必要な場合は行ベースで処理をせずに一文字づつ読み込みながら状態遷移するオートマトンを設計してください。
0 件のコメント:
コメントを投稿