竹形誠司 ブログ
Java+MySQL+Tomcat    »トピック一覧
掲示板へのスパムが多いため、「ご質問」のコーナーはユーザー登録制とさせていただきました。お手数ですが、上の「新規ユーザーの登録」メニューより登録をお願いします。
帳票Web
アプリケーション

受注開発始めました
詳しくは こちら
竹形 誠司 著/ラトルズ刊
JSP帳票アプリケーション実践開発入門
JSP帳票アプリケーション
実践開発入門

JSP業務アプリケーション短期開発入門
JSP業務アプリケーション
短期開発入門

Java+MySQL+Tomcatで始めるWebアプリケーション構築入門
Java+MySQL+Tomcatで始めるWebアプリケーション構築入門

Java+MySQL+Tomcatで作る掲示板とブログ
Java+MySQL+Tomcatで作る
掲示板とブログ
オートマトンを使った律儀なCSVパーサの作り方
by 竹形 誠司[takegata]
標準のAPIは?
CSV(Comma-Separated Values)は、データの交換によく使われる形式ですがJavaにはCSVを扱う標準のAPIがありません。いろいろな方が独自のAPIを作って公開されていますが、勉強を兼ねて自分で作ってみました。

CSVのフォーマット
Wikipediaによれば、2005年にRFC 4180でCSVの仕様が成文化されたようです。ただ、これを全部読むのは大変なので、次のような簡単なルールで行単位に処理を行うことを目標にしました。

区切り記号にコンマ(,)を使用する。
行末にコンマが現れる場合は末尾に空のカラムを追加する。
行頭にコンマが現れる場合は先頭に空のカラムを追加する。
コンマが連続して現れる場合は、コンマの間に空のカラムを追加する。
データ中にコンマが現れる場合は、カラム全体をダブルクオート(")で囲む。
データ中にダブルクオートが現れる場合は、カラム全体をダブルクオートで囲み、更にダブルクオートをダブルクオートでエスケープする(ダブルクオートを2つ重ねる)。
ダブルクオートで囲まれていないカラムにダブルクオートが現れた場合はエラーとする。
ダブルクオートで始まったカラムがダブルクオートで終っていなければエラーとする。
ダブルクオートで囲まれたカラムであっても、単独のダブルクオートが現れた場合はエラーとする。

このようなルールで、1行を渡すと、文字列の配列を返すstaticメソッドをCSVTokenizerクラスに実装します。

状態の種類
文字列を解釈して、そこからデータを取り出すことを構文解析とかパースとかパーズなどと呼びます。構文解析はプログラミング言語のコンパイラを作る際にも使われる技術でさまざまな方式がありますが、基本的には有限個の「状態」を定義して、次に現れる文字によって別の(または同じ)状態に「遷移」させる、ということをします。1行のCSVを解析するために次の4種類の状態を定義します。
状態説明
INITカラム開始時の初期状態
NOQUOTカラムがダブルクオートで囲まれていない状態
QUOTカラムがダブルクオートで囲まれている状態
QUOT_ESCAPEダブルクオートで囲まれたカラムで
ダブルクオートがエスケープされている状態


状態の遷移
それぞれの状態において、次に現れた文字に応じて行う処理と、その後に遷移する状態を定義します。

INIT
現れた文字処理遷移先
カンマ空のカラムを追加そのまま
ダブルクオートQUOT
上記以外文字をカラムに追加NOQUOT


NOQUOT
現れた文字処理遷移先
カンマカラム内容の確定INIT
ダブルクオートエラー終わり
上記以外文字をカラムに追加そのまま


QUOT
現れた文字処理遷移先
ダブルクオート(次がダブルクオート)QUOT_ESCAPE
ダブルクオート(次がカンマ)NOQUOT
ダブルクオート(上記以外)エラー終わり
上記以外文字をカラムに追加そのまま


QUOT_ESCAPE
現れた文字処理遷移先
ダブルクオートダブルクオートをカラムに追加QUOT
上記以外エラー(ここには来ないはず)終わり


このように有限個の状態を条件によって遷移するような仕組みを「有限オートマトン」と言います(オートマトンを「自動羊肉」と発音してはいけません。危険なオヤジギャグです)。

プログラム
このルールをCSVTokenizerというクラスのparseメソッドに実装します。インスタンスを作らずに呼べるようにstaticにしておきます。
package jp.veltec.util;

import java.util.ArrayList;

public class CSVTokenizer{

    static final int INIT = 0;
    static final int NOQUOT = 1;
    static final int QUOT = 2;
    static final int QUOT_ESCAPE = 3;

    public static String[] parse(String aLine){
        ArrayList<String> record = new ArrayList<String>();
        StringBuilder sb = new StringBuilder();
        int state = INIT;
        for(int i=0;i<aLine.length();i++){
            char c=aLine.charAt(i);
            switch(state){
                case INIT:
                    if(c==','){
                        record.add("");
                    }else if(c=='"'){
                        state=QUOT;
                    }else{
                        state=NOQUOT;
                        sb.append(c);
                    }
                    break;
                case NOQUOT:
                    if(c==','){
                        record.add(sb.toString());
                        sb=new StringBuilder();
                        state = INIT;
                    }else if(c=='"'){
                        throw new IllegalStateException("bad quot at:" + (i+1));
                    }else{
                        sb.append(c);
                    }
                    break;
                case QUOT:
                    if(c=='"'){
                        if(i==aLine.length()-1){
                            state=NOQUOT;
                        }else{
                            if(aLine.charAt(i+1)=='"'){
                                state=QUOT_ESCAPE;
                            }else if(aLine.charAt(i+1)==','){
                                state=NOQUOT;
                            }else{
                                throw new IllegalStateException("bad quot at:" + (i+1));
                            }
                        }
                    }else{
                        sb.append(c);
                    }
                    break;
                case QUOT_ESCAPE:
                    if(c=='"'){
                        sb.append(c);
                        state=QUOT;
                    }else{
                        throw new IllegalStateException("bug! at:" + (i+1));
                    }
                    break;
            }//end switch
        }//end for
        if(state==QUOT){
            throw new IllegalStateException("open quot");
        }
        record.add(sb.toString());
        String[] tokens = new String[record.size()];
        for(int i=0;i<record.size();i++){
            tokens[i]=record.get(i);
        }
        return tokens;
    }
}

呼び出し側のプログラム
このCSVTokenizerの使い方の例を次に示します。
import jp.veltec.util.CSVTokenizer;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.BufferedReader;
import java.io.IOException;

public class CSVTokenizerTest{
    public static void main(String[] args){
        try{
            FileInputStream fis = new FileInputStream(args[0]);
            InputStreamReader isr = new InputStreamReader(fis,"utf-8");
            BufferedReader br = new BufferedReader(isr);
            while(br.ready()){
                String mLine = br.readLine();
                String tokens[] = CSVTokenizer.parse(mLine);
                for(String token:tokens){
                    System.out.println(token);
                }
                System.out.println("-------");
            }
        }catch(IOException e){
            e.getMessage();
        }
    }
}
このテストプログラムでは、csvファイルを1行ずつ読み込んでCSVTokenizerクラスのparseメソッドに渡し、Stringの配列を受け取っています。CSVファイルの内容が次のような場合
aaa,bbb,ccc
"aaa","""bbb""","c""c""c"
"aa,a",bbb,ccc
,bbb,
出力は次のようになります。
aaa
bbb
ccc
-------
aaa
"bbb"
c"c"c
-------
aa,a
bbb
ccc
-------

bbb

-------
状態や処理内容を増やしていくと、独自のプログラミング言語なども作れるわけですが、言語の設計はそんなに簡単な話ではなく、単純にやろうとするとif文の嵐になってしまいます。ただ、この方法を応用して日本語を処理するプログラムを書けば面白ボットが作れるかも知れません。

CSVTokenizerのソースを貼っておきます。
ソースソース
CSVTokenizer.java

投稿:竹形 誠司[takegata]/2011年 05月 07日 19時 06分 /更新:2011年 05月 19日 23時 01分