2010/10/27

java.util.ScannerクラスでCSVパーサを作ってみた Vol.1

この記事の背景

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つの空要素があるという処理をします。 同様に最後に空要素があった場合も飛ばしてしまいます。

次のようなサンプルコード確認できます。

TestScanner.javaファイル - java.util.Scannerクラスの特徴的な動き

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クラス

Main.javaファイル

/**
 * 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クラス

CSVManager.javaファイル

/**
 * 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インタフェース

Row.javaファイル

/**
 * 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クラス

CSVRow.java

/**
 * 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 件のコメント: