CSVパーサ

これは何?

Javaで書いたCSVパーサのプログラムです。

GitHubにも置いてあります。

https://github.com/kmaebashi/csvparser

使い方

try (var parser = CsvParser.newInstance("data.csv")) {
    String[] fields;
    while ((fields = parser.readLine()) != null) {
        // ここで、fieldsには、1行分のデータをカンマで分割したものが格納されている。
    }
}

ソース

ディレクトリ構造

com.kmaebashi以下
├─csvparser
│      CsvParseException.java … パース時に発生する例外
│      CsvParser.java         … CSVパーサのインタフェース
│
└─csvparserimpl
        CsvParserImpl.java     … CSVパーサの実装

私が作るJavaのプログラムはたいていそうなのですが、利用者に対して公開するソースと、公開しないソースとでパッケージを分けています。

この例では、利用者に公開するパッケージはcom.kmaebashi.csvparserで、その中にinterfaceであるCsvParser.javaと例外クラスがいます。CsvParser.javaを実装したクラスであるCsvParserImpl.javaはcsvparserimplパッケージにいます。こうして、公開するインタフェースと実装を分離しています。

内容

まずは利用者に公開するインタフェースであるCsvParser.javaです。

CsvParser.java:

package com.kmaebashi.csvparser;

import java.io.*;
import java.nio.charset.StandardCharsets;

import com.kmaebashi.csvparserimpl.CsvParserImpl;

public interface CsvParser extends AutoCloseable {
    public static CsvParser newInstance(String path)
            throws FileNotFoundException, IOException {
        Reader reader = new BufferedReader(new InputStreamReader(
                new FileInputStream(path), StandardCharsets.UTF_8));
        return new CsvParserImpl(reader);
    }

    public static CsvParser newInstance(Reader reader) {
        return new CsvParserImpl(reader);
    }

    abstract public String[] readLine() throws IOException, CsvParseException;
    abstract public void close() throws IOException;
}

Javaでもinterfaceにデフォルト実装やらstaticメソッドやら書けるようになった話は聞いていた気がしますが、Java8からできるとは知らなかった。なら使うべきですね。

newInstance()でインスタンスを作ったら、readLine()で1行分ずつデータを取得します。パースできない場合はCsvParseExceptionを投げます。例外クラスの中身はまあ予想通りかと思います(作者である自分への縛りとして、messageなしでのインスタンス化は認めていません)。

CsvParseException.java

package com.kmaebashi.csvparser;

public class CsvParseException extends Exception {
    public CsvParseException(String message) {
        super(message);
    }
}

次がCSVパーサの実装であり、本体です。

CsvParserImpl.java

package com.kmaebashi.csvparserimpl;

import com.kmaebashi.csvparser.CsvParser;
import com.kmaebashi.csvparser.CsvParseException;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;

public class CsvParserImpl implements CsvParser {
    Reader reader;
    public CsvParserImpl(Reader reader) {
        this.reader = reader;
    }

    /*
     * このCSVパーサは、入力を1文字ずつ読み込みながら、「状態」を変えつつ
     * 入力を解析していきます。取りうる状態を表すのが列挙型Stateです。
     * AFTER_COMMA…カンマを読み込んだ直後の状態。初期状態もこれと同じなので
     *   代用しています。
     * IN_FIELD…ダブルクォートに囲まれていないフィールドの読み込み中です。
     *   カンマが来たらAFTER_COMMAに、改行が来たら
     * IN_QUOTE…ダブルクォートで囲まれたフィールドの読み込み中です。
     * QUOTE_IN_QUOTE…IN_QUOTEの最中に「"」が現れた状態です。
     * AFTER_CR…改行コードがCR+LFの場合、CRが来た状態。
     */
    private enum State {
        AFTER_COMMA,
        IN_FIELD,
        IN_QUOTE,
        QUOTE_IN_QUOTE,
        AFTER_CR
    }

    public String[] readLine() throws IOException, CsvParseException {
        ArrayList<String> fieldList = new ArrayList<String>();
        StringBuilder field = new StringBuilder();
        int ch;
        State state = State.AFTER_COMMA;

        while ((ch = reader.read()) != -1) {
            switch (state) {
                case AFTER_COMMA:
                    if (ch == '\"') {
                        state = State.IN_QUOTE;
                    } else if (ch == ',') {
                        fieldList.add(field.toString());
                        field = new StringBuilder();
                    } else if (ch == '\r') {
                        state = State.AFTER_CR;
                    } else if (ch == '\n') {
                        fieldList.add(field.toString());
                        return fieldList.toArray(new String[0]);
                    } else {
                        field.append((char) ch);
                        state = State.IN_FIELD;
                    }
                    break;
                case IN_FIELD:
                    if (ch == ',') {
                        fieldList.add(field.toString());
                        field = new StringBuilder();
                        state = State.AFTER_COMMA;
                    } else if (ch == '\r') {
                        state = State.AFTER_CR;
                    } else if (ch == '\n') {
                        fieldList.add(field.toString());
                        return fieldList.toArray(new String[0]);
                    } else {
                        field.append((char) ch);
                    }
                    break;
                case IN_QUOTE:
                    if (ch == '\"') {
                        state = State.QUOTE_IN_QUOTE;
                    } else {
                        field.append((char) ch);
                    }
                    break;
                case QUOTE_IN_QUOTE:
                    if (ch == '\"') {
                        field.append((char)ch);
                        state = State.IN_QUOTE;
                    } else if (ch == ',') {
                        fieldList.add(field.toString());
                        field = new StringBuilder();
                        state = State.AFTER_COMMA;
                    } else if (ch == '\r') {
                        state = State.AFTER_CR;
                    } else if (ch == '\n') {
                        fieldList.add(field.toString());
                        return fieldList.toArray(new String[0]);
                    } else {
                        throw new CsvParseException("ダブルクォートを閉じた後がカンマでも改行でもありません。");
                    }
                    break;
                case AFTER_CR:
                    if (ch == '\n') {
                        fieldList.add(field.toString());
                        return fieldList.toArray(new String[0]);
                    } else {
                        throw new CsvParseException("CRの後にLF以外の文字が来ました。(" + (char)ch + ")");
                    }
            }
        }
        if (field.length() > 0 || fieldList.size() > 0) {
            fieldList.add(field.toString());
            return fieldList.toArray(new String[0]);
        } else {
            return null;
        }
    }

    @Override
    public void close() throws IOException {
        this.reader.close();
    }
}

コメントに全部書いてあるのでここで書き足すこともないかと思いますが、このCSVパーサの実装は典型的な有限状態マシン(有限オートマトン)です。入力を1文字ずつ読み込みながら、「状態」を変えつつ解析していきます。その状態の一覧が列挙型Stateで(27行目以降)、状態を保持する変数がローカル変数state(39行目)です。

たとえば、ダブルクォートで囲まれないフィールドは、以下の順序で読み込まれます。

  1. 初期状態は「AFTER_COMMA
  2. AFTER_COMMAのところにダブルクォートでもカンマでも改行でもない「普通の文字」が来ると、54行目のelseに入って状態がIN_FIELDに遷移する。この時の文字はフィールドの最初の1文字なので、StringBuilderfieldに保持する。
  3. 状態がIN_FIELDのところに普通の文字が来る間は、69行目のelseを通り続けてフィールドの文字を蓄積していく。
  4. 状態がIN_FIELDのところにカンマが来たら、fieldtoString()してフィールドの一覧であるfieldListに突っ込んで、fieldをクリアしたうえで状態をAFTER_COMMAにする。これにより、最初の状態に戻る。
  5. 状態がIN_FIELDのところに改行(\n)が来たら、現状のfieldfieldListに突っ込んでリターンする。

改行が\r\nの場合や、フィールドがダブルクォートで囲まれている場合も同様に読めますので、読んでみてください。

公開日: 2024/01/06


不具合等ありましたら、掲示板にご連絡願います。

ひとつ上のページに戻る