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行目)です。
たとえば、ダブルクォートで囲まれないフィールドは、以下の順序で読み込まれます。
改行が\r\nの場合や、フィールドがダブルクォートで囲まれている場合も同様に読めますので、読んでみてください。
公開日: 2024/01/06
不具合等ありましたら、掲示板にご連絡願います。