CsvIterator.java

package myproject.java.utils;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

/**
 * ストリームからCSV形式のデータを読み取り、CSV1行分のデータを返すイテレータを
 * 実装します。
 * <p>
 * Copyright ycookjp
 * https://github.com/ycookjp/
 * </p>
 * <table border='1'><caption>【使用例】</caption><tr><td><pre>
 * import myproject.java.utils.CsvIterator;
 * import java.io.FileInputStream;
 * import java.io.InputStreamReader;
 * import java.io.Reader;
 * import java.io.IOException;
 * ...
 * Reader in = null;
 * try {
 *     in = new InputStreamReader(
 *             new FileInputStream("/path/to/csv"), "UTF-8");
 *     for (List&gt;String&lt; rowdata: new CsvIterator(in)) {
 *         ...
 *     }
 * } catch (IOException ie) {
 *     ...
 * } finally {
 *     if (in != null) {
 *         try {
 *             in.close();
 *         } catch (IOException ie) { }
 *     }
 * }
 * </pre></td></tr></table>
 */
public class CsvIterator implements Iterable<List<String>>, Iterator<List<String>> {

    /**
     * CSVデータを読み込むために、コンストラクタから渡された{@link Reader}より
     * 作成された{@link BufferedReader}のインスタンス。
     */
    private BufferedReader linein = null;

    /**
     * {@link #hasNext()}でストリームの最後かどうかを確認するために先読みした
     * 1行の文字列を保持します。読み込んだ結果を保持しない場合はnullが 設定
     * されます。
     */
    private String strbuf = null;

    /**
     * CSV形式のデータを入力する{@link Reader}を指定して、CSV1行分のデータを
     * 返すイテレータを構築します。
     * @param in CSV形式のデータを入力する{@link Reader}
     */
    public CsvIterator(Reader in) {
        this.linein = new BufferedReader(in);
    }

    /**
     *  外部からのデフォルトコンストラクタ呼び出しを抑止するための
     *  コンストラクタ。
     */
    protected CsvIterator() { }

    /**
     * CSV1行分のCSV項目を格納知った{@link List}のイテレータを返します。
     */
    @Override
    public Iterator<List<String>> iterator() {
        return this;
    }

    /**
     * 反復処理で更にに要素がある場合にtrueを返します。
     * つまり、next()が例外をスローするのではなく要素を返す場合は、trueを
     * 返します。
     * このメソッドを呼び出すと、コンストラクタから渡された{@link Reader}の
     * {@link Reader#read()}メソッドを呼び出し、-1が返された場合はfalseを
     * 返します。そうでない場合は、{@link #strbuf}に読み込んだ文字を追加して
     * trueを返します。
     *
     * @return 次の要素がある場合はtrue、そうでない場合はfalseを返します。
     * @throws RuntimeException 内部で{@link IOException}が発生した場合。
     */
    @Override
    public boolean hasNext() {
        try {
            if (this.strbuf == null) {
                this.strbuf = this.linein.readLine();
            }
            if (this.strbuf == null) {
                return false;
            }
            return true;
        } catch (IOException ie) {
            throw new RuntimeException(ie);
        }
    }

    /**
     * CSV1行分のデータを格納した{@link List}を返します。
     * <p>
     * CSV形式の文字列からCSVの項目を要素とする{@link List}を生成して返却する
     * 処理は以下のとおりである。
     *
     * <ol>
     * <li>
     *   「"」が見つかったら次の「"」が見つかるまでコンマや改行を含めて
     *   読み込んだ文字列を現在処理中の{@link List}項目の文字列に追加する。
     * </li>
     * <li>
     *   カンマが見つかったら、現在処理中の{@link List}項目の文字列をlistに
     *   追加して、次の{@link List}項目の文字列追加処理を開始する。その際
     *   追加された{@link List}項目の文字列の先頭と最後が「"」である場合は、
     *   最初と最後の「"」を除去し、連続する2つの「"」は1つの「"」に変換する。
     * </li>
     * <li>
     *   改行またはストリームの終わりに達したら、現在処理中の{@link List}項目の
     *   文字列から最後の改行コードを除いて{@link List}に追加してそのlistを
     *   返す。なお、追加された{@link List}項目の文字列の先頭と最後が「"」の
     *   場合の扱いは、カンマが見つかった場合と同様である。
     * </li>
     * </ol>
     *
     * @return CSV1行分の各項目が格納された{@link List}を返します。
     * @throws RuntimeException 内部で{@link IOException}が発生した場合。
     */
    @Override
    public List<String> next() {
        try {
            List<String> line = readCsv(this.linein);
            return line;
        } catch (IOException ie) {
            throw new RuntimeException(ie);
        }
    }

    /**
     * {@link BufferedReader}からCSV1行分のデータを読み込み、CSVの各項目を
     * {@link List}に格納して返します。
     * <p></p>
     * @param in CSV
     * @return CSVの各項目を格納した{@link List}を返します。
     * @throws IOException 入力データの読み込みに失敗した場合
     */
    private List<String> readCsv(BufferedReader in) throws IOException {
        boolean continueReading = true;
        boolean inDquote = false;
        List<String> rowdata = new ArrayList<String>();
        // CSV項目の初期化する
        StringBuilder csvcol = new StringBuilder();

        while (continueReading) {
            String line = readLine();
            if (line == null) {
                if (csvcol.length() > 0) {
                    rowdata.add(trimDoubleQuote(csvcol.toString()));
                }
                break;
            } else if (!inDquote && line.length() == 0) {
                break;
            }
            int index = 0;
            // 1行の文字列を順に調べる
            while (index < line.length()) {
                // ダブルクォートの中である場合
                if (inDquote) {
                    // 次のダブルクォートの出現位置を取得
                    int dqidx = line.indexOf('"', index);
                    // 次のダブルクォートが見つからない場合は改行を含む行末までの
                    // 文字列をセルの文字に追加して、次の行を読み込む
                    if (dqidx < 0) {
                        csvcol.append(line.substring(index));
                        index = line.length();
                    } else {
                        // 次のダブルクォートの文字が見つかったらそこまでの文字列をセルの
                        // 文字に追加して、それ以降の文字を処理する
                        csvcol.append(line.substring(index, dqidx + 1));
                        index = dqidx + 1;
                        inDquote = false;
                    }
                } else {
                    // 次のダブルクォート、カンマの出現位置を取得
                    int dqidx = line.indexOf('"', index);
                    int cmidx = line.indexOf(',', index);
                    if (dqidx >= 0 && (cmidx < 0 || dqidx < cmidx)) {
                        // ダブルクォートの前にコンマが存在しない場合
                        // ダブルクォートまでをセルの文字列に追加する
                        csvcol.append(line.substring(index, dqidx + 1));
                        inDquote = true;
                        index = dqidx + 1;
                    } else if (cmidx >= 0) {
                        // コンマの前にダブルクォートが存在しない場合
                        // コンマの前までの文字列をセルの文字列に追加し、次のセルの処理を開始
                        csvcol.append(line.substring(index, cmidx));
                        rowdata.add(trimDoubleQuote(csvcol.toString()));
                        csvcol.delete(0, csvcol.length());
                        index = cmidx + 1;
                    } else {
                        // コンマもダブルクォートも存在しない場合
                        // 行末までの文字をセルの文字列に追加し、1行分のCSVデータを返す
                        csvcol.append(line.substring(index));
                        index = line.length();
                    }
                }
                if (index >= line.length() && inDquote) {
                    csvcol.append(System.lineSeparator());
                } else if (index >= line.length()) {
                    rowdata.add(trimDoubleQuote(csvcol.toString()));
                    continueReading = false;
                }
            }
        }
        return rowdata;
    }

    /**
     * 文字列の両端がダブルクォートの場合、両端のダブルクォートを削除して
     * 更に「""」を「"」に置換します。
     * @param str 変換元の文字列
     * @return 変換結果の文字列を返します。
     */
    private String trimDoubleQuote(String str) {
        String replaced = str;
        if (str != null && str.length() > 1 && str.charAt(0) == '"'
                && str.charAt(str.length() - 1) == '"') {
            // 文字列の開始、終了文字が共にダブルクォートの場合
            replaced = str.substring(1, str.length() - 1);
            replaced = replaced.replace("\"\"", "\"");
        }
        return replaced;
    }

    /**
     * CSVを入力する{@link Reader}から1行を読み込みます。
     *
     * {@link #hasNext()}で先読みした1行の文字列があればそれを帰します。
     * 先読みしたデータがなければCSVを入力する{@link Reader}から1行を読み込み
     * その文字列を帰します。
     * @return CSVを入力する[@link Reader}から読み込んだ1行の文字列を帰します。
     *      返却される文字列に改行は含みません。
     * @throws IOException
     */
    private String readLine() throws IOException {
        String line = this.strbuf;
        if (line == null) {
            line = this.linein.readLine();
        }
        this.strbuf = null;
        return line;
    }
}