2010/11/01

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

行ベースのパーサができれば、改行文字を一つ加えるぐらい簡単だろうと思っていると、案外はまるものです。

実際にところ最初から改行を含める事ができるCSVパーサをなんとなく作っていて、その後で行ベースのCSVパーサを考えて、今に至っています。

ちゃんと(?)設計したことで改行を加えて破綻するような処理にすることなく、それなりに動くものができたようです。

行ベースから改行を含むCSVパーサへの変更

よくよく行ベースのEBNFルールをみると行末を示す改行文字がどこにも入っていませんでした。

もう少し詳細に検討すると、今回は次のようなEBNF文法を満たすような入力をword単位で処理したいという事になります。

file = { block } ;
block = word , { "," , word } , "\n" ;
word = raw string | quoted string ;
raw string = { "x" } ;
quoted string =  """, { "x" | """" | "," | "\n" } , """;
  • Note #1: ダブルクォートを2つ連結した文字は""","""と書かずに""""とする
  • Note #2: "x"は 改行"\n" , 空文字列"", カンマ","、ダブルクォート"""を含まない1文字とする

考えを整理するために、前回までは暗黙的に存在していたfileを持ち出しつつ、lineをblockに置き換えて、fileは(空を含む)blockの集合と定義しました。 前回もblockのようにlineを定義するべきだったかなと思っています。

今回はquoted stringの中に改行記号を加えました。 そうすると従来lineと読んでいた文字列に改行が含まれてしまい名前と実体が、一致しない状態になってしまいます。 そこで今回はlineをblockと呼ぶことにしました。

がんばって文法をちゃんと定義してみたところで、改行記号とカンマで区切って処理しようというのですから、直接的にblockやwordを単位として処理することは、やっぱりできません。

前回と同じようにwordを構成するraw stringとquoted stringを取り出すための処理を考えてみます。

  • lineを","で分割し raw stringquoted stringの断片 となった文字列をitem変数に格納する
  • item変数に次のルールを適用し、raw stringを認識する
    • 条件0 isQuoteBlock==false かつ { "x" } で構成されている文字列
  • item変数に次のルールを適用し、quoted stringを認識する
    • 条件1 完結したquoted string: isQuoteBlock==false かつ """, { """" | "x" } , """ ;
    • 条件2 quoted stringの開始要素: isQuoteBlock==false かつ """, {"x" | """" } ; isQuoteBlock = true.
    • 条件3 quoted stringの中間要素: isQuoteBlock==true かつ { "x" | """" } ;
    • 条件3' quoted stringの中間要素': isQuoteBlock==true かつ cScanner.hasNext() == false; (つまり行末)
    • 条件4 quoted stringの終了要素: isQuoteBlock==true かつ { "x" | """" }, """ ; isQuoteBlock = false.
      • 条件1であればそのままquoted stringとして認識
      • 条件2であればitemに、続く条件3の要素を","を加えて連結して、条件3'であれば"\n"を加えて連結し、最後に条件4を","を加えて連結してquoted stringと認識

結局のところ前回でエラー状態だといったisQuoteBlockがtrueのまま、ループが終了した状態を今回は条件3'(C3')として利用することにしました。

行ベースCSVパーサに加える変更

isQuoteBlock==trueのままループを抜けた場合の処理は、次の行を読み込んでcScannerを再度生成する事にあります。

genRow()を再帰的に呼び出す事を考えましたが、やりたい事は条件4になるまで行を読み込んで処理をする事なので再帰的な処理はしませんでした。

もし再帰的にメソッドを呼び出す場合には、いくつかのフラグを適切なスコープに入れて戻ってきたRow型オブジェクトから要素を取り出してtmpItemに連結、残りはそのままrowオブジェクトにコピーする処理が必要になります。

do - whileループによる次行の読み込み

isQuoteBlockがtrueの間は行を次々に読み込むように処理を変更し、次のようなdo - whileループにしました。

genRow(String line)メソッドの変更した処理抜粋

    private Row genRow(String line) {
        if (line.isEmpty()) {
            return null;
        }
        CSVRow row = new CSVRow();
        this.isQuoteBlock = false;
        String tmpItem = "";
        boolean isC3d = false;
        do {
          if (line.startsWith(itemDelimiter)) {
            // 行頭にカンマがある場合の特別処理
          }
          while (cScanner.hasNext()) {
            // 1行の分割処理
          }
          if (line.endsWith(itemDelimiter)) {
            // 行末にカンマがある場合の特別処理
          }
          if (isQuoteBlock) {
            // line変数に次行を読み込む処理
          }
        } while (isQuoteBlock);
        return (Row) row;
    }
新たに加わった状態遷移

quoted stringを認識するための条件0, 1については、今回の変更によってバグが入り込んでいなければ特別な配慮は必要ないはずです。

条件2, 3, 3',4の組み合せが問題で、行ベースで考えれば良い時には「条件2→条件3(繰り返し)→条件4」で完結していました。

今回は「条件2→{条件3 もしくは 条件3'}(繰り返し)→条件4」と開始条件と終了条件は同じですが、いままでは「条件2→3」、「条件2→4」、「条件3→3」、「条件3→4」と変化する場合にカンマ","を加えたり、加えなかったりという事をしていました。

今回は「条件2→3'」、「条件3→3'」、「条件3'→3'」、「条件3'→3」、「条件3'→4」と、2倍以上に条件が増えています。 具体的にどういう事なのか考えていきます。

新たに加わった状態遷移: 条件Xから条件3'への遷移

ある状態'X'からC3'に遷移する状況での処理はどれも同じになります。

X=2であればblockが"x\n"で、itemが "x でループが終った状況です。

X=3であればblockが",x\n"で、itemが x でループが終った状況です。

X=3'であればblockが",\n\n"で、itemが最初の \n でループが終った状況です。

いずれにしても、ある状態からC3'状態に遷移した場合の処理は tmpItem += "\n" になります。

条件3'の中では、どの条件から遷移してきたかは意識する必要はないことになります。

新たに加わった状態遷移: 条件3'から条件Xへの遷移

C3'から他の状態'X'に遷移した場合のアクションはどれも同じですが、同じ3'への移動は既に考えたので今回は遷移先が条件3か4の場合を検討します。

X=3であればblockが"\nx,"で、itemが x を認識した状況です。

X=4であればblockが"\n"で、itemが " を認識した状況です。

行ベースでは tmpItem += "," + item; と処理してきましたが、今回は改行後の最初の要素に対してはカンマ","を加えるのは間違いとなります。

つまり状態3と状態4に移動した時点で、その直前の状態が条件3'であれば","を加えない、条件2や3からの遷移であれば","を加える、という処理をする事が必要です。

この「改行後の最初の要素」を認識するために、isC3d変数を準備することにしました。

最終的に変更したgenRow(String line)メソッド

作成したgenRow(String line)メソッド

    /**
     * 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;
        }
        CSVRow row = new CSVRow();
        this.isQuoteBlock = false;
        String tmpItem = "";
        boolean isC3d = false;
        do {
            java.util.Scanner cScanner = new java.util.Scanner(line);
            cScanner.useDelimiter(this.itemDelimiter);
            if (line.startsWith(this.itemDelimiter)) {
                if (isQuoteBlock) {
                    tmpItem += this.itemDelimiter;
                } else {
                    row.add("");
                }
            }
            while (cScanner.hasNext()) {
                String item = cScanner.next();
                if (this.isQuoteBlock) {
                    // check the condition: C3 or C4
                    if (this.c4Pattern.matcher(item).matches()) {
                        // Condition#4
                        if (isC3d) {
                            isC3d = false;
                            tmpItem += item;
                        } else {
                            tmpItem += this.itemDelimiter + item;
                        }
                        row.add(tmpItem);
                        this.isQuoteBlock = false;
                        tmpItem = "";
                    } else if (this.c3Pattern.matcher(item).matches()) {
                        // Condition#3
                        if (isC3d) {
                            isC3d = false;
                            tmpItem += item;
                        } else {
                            tmpItem += this.itemDelimiter + item;
                        }
                    } else {
                        // error condition
                        System.err.println("error condition#1");
                    }
                } else {
                    if (isC3d) {
                        System.err.println("error condition#4");
                    }
                    // check condtions: Raw String(C0), C1 or C2
                    if (this.c1Pattern.matcher(item).matches() || this.c0Pattern.matcher(item).matches()) {
                        // Condition#0 or #1
                        row.add(item);
                    } else if (this.c2Pattern.matcher(item).matches()) {
                        // Condition#2
                        this.isQuoteBlock = true;
                        tmpItem = item;
                    } else {
                        // error condition
                        System.err.println("error condition#2");
                    }
                }
            }
            if (line.endsWith(this.itemDelimiter) && !line.equals(this.itemDelimiter)) {
                if (isQuoteBlock) {
                    tmpItem += this.itemDelimiter;
                } else {
                    row.add("");
                }
            }
            if (isQuoteBlock) {
                /*
                 * Condition#3' (C3d)
                 * We need to read one more line, because the quote block continue.
                 */
                tmpItem += this.lineDelimiter;
                isC3d = true;
                if (scanner.hasNext()) {
                    line = scanner.next();
                } else {
                    // error condition
                    System.out.println("error condition#5");
                }
            }
        } while (isQuoteBlock);

        if (!tmpItem.isEmpty()) {
            // error condition
            System.err.println("error condition#3");
        }
        return (Row) row;
    }

さいごに

これがちゃんと動くものなのか、という懸念が払拭できていないので次はテストを行なっていきます。

基本的には

0 件のコメント: