📝
jackson-dataformat-csvの2と3でCSVヘッダーに関する挙動が違った
環境
- JDK 25
- jackson-dataformat-csv
- 2.20.1
- 3.0.3
やりたいこと
こんな感じで、ヘッダー付きのCSVを読み込む。
product.csv
製品番号,製品名
P00001,ノートパソコン
P00002,デスクトップパソコン
CSVから変換されるクラスはこんな感じ。
Product.java
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
@JsonPropertyOrder({
"製品番号",
"製品名"
})
public record Product(
@JsonProperty("製品番号")
String productId,
@JsonProperty("製品名")
String productName
) {
}
もし、次のようにヘッダー名が異なるCSVが異なる場合、例外InvalidCsvExceptionをスローしたい。
product_invalid_header.csv
商品ID,商品名
P00001,ノートパソコン
P00002,デスクトップパソコン
InvalidCsvException.java
public class InvalidCsvException extends RuntimeException {
public InvalidCsvException(String message, Throwable cause) {
super(message, cause);
}
}
2の場合
jackson-databind-csv 2系の場合は、下記のコードで実現できます。
ProductCsvParser2.java
import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.dataformat.csv.CsvSchema;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class ProductCsvParser2 {
private final CsvMapper csvMapper;
private final CsvSchema csvSchema;
public ProductCsvParser2() {
this.csvMapper = new CsvMapper();
this.csvSchema = CsvSchema.emptySchema()
.withHeader() // ヘッダーあり
.withColumnSeparator(',') // 区切り文字カンマ
.withLineSeparator("\n") // 改行コードLF
.withoutQuoteChar(); // 囲み文字なし
}
public List<Product> parse(Path csvFile) {
try (
BufferedReader br = Files.newBufferedReader(csvFile, StandardCharsets.UTF_8);
MappingIterator<Product> iterator = csvMapper
.readerFor(Product.class)
.with(csvSchema)
.readValues(br)
) {
List<Product> productList = iterator.readAll();
return productList;
} catch (IOException e) {
throw new InvalidCsvException("CSVのパースに失敗しました。", e);
}
}
}
テストクラスはこんな感じ。これでテストはすべてOKになる。
ProductCsvParser2Test.java
class ProductCsvParser2Test {
ProductCsvParser2 productCsvParser2 = new ProductCsvParser2();
@Nested
@DisplayName("parse()")
class Parse {
@Test
@DisplayName("仕様通りのCSVを読み込むと、製品のリストが返る")
void success() {
// テスト用CSVファイルのパスを取得
Path csvFile = Path.of("src/test/resources/product.csv");
// CSVをパース
List<Product> actual = productCsvParser2.parse(csvFile);
// 結果の検証
assertEquals(List.of(
new Product("P00001", "ノートパソコン"),
new Product("P00002", "デスクトップパソコン")
), actual);
}
@Test
@DisplayName("ヘッダーが仕様と異なるCSVを読み込むと、InvalidCsvException")
void invalidHeader() {
// テスト用CSVファイルのパスを取得
Path csvFile = Path.of("src/test/resources/product_invalid_header.csv");
// CSVをパースして例外がスローされることを検証
assertThrows(InvalidCsvException.class, () -> {
productCsvParser2.parse(csvFile);
});
}
}
}
3の場合(うまく行かないケース)
jackson-databind-csv 3系にアップグレードするために、とりあえずパッケージ名のみ変更してテストします。
// パッケージ名を変えるだけ
import tools.jackson.core.JacksonException;
import tools.jackson.databind.MappingIterator;
import tools.jackson.dataformat.csv.CsvMapper;
import tools.jackson.dataformat.csv.CsvSchema;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
// パッケージ名以外はほぼ同じ
public class ProductCsvParser3 {
private final CsvMapper csvMapper;
private final CsvSchema csvSchema;
public ProductCsvParser3() {
this.csvMapper = new CsvMapper();
this.csvSchema = CsvSchema.emptySchema()
.withHeader()
.withColumnSeparator(',')
.withLineSeparator("\n")
.withoutQuoteChar();
}
public List<Product> parse(Path csvFile) {
try (
BufferedReader br = Files.newBufferedReader(csvFile, StandardCharsets.UTF_8);
MappingIterator<Product> iterator = csvMapper
.readerFor(Product.class)
.with(csvSchema)
.readValues(br)
) {
List<Product> productList = iterator.readAll();
return productList;
} catch (JacksonException e) {
throw new InvalidCsvException("CSVのパースに失敗しました。", e);
} catch (IOException e) {
throw new UncheckedIOException("ファイルの読み込みに失敗しました。", e);
}
}
}
テストします。
// 2のテストとほぼ同じ
class ProductCsvParser3Test {
ProductCsvParser3 productCsvParser3 = new ProductCsvParser3();
@Nested
@DisplayName("parse()")
class Parse {
@Test
@DisplayName("仕様通りのCSVを読み込むと、製品のリストが返る")
void success() {
// テスト用CSVファイルのパスを取得
Path csvFile = Path.of("src/test/resources/product.csv");
// CSVをパース
List<Product> actual = productCsvParser3.parse(csvFile);
// 結果の検証
assertEquals(List.of(
new Product("P00001", "ノートパソコン"),
new Product("P00002", "デスクトップパソコン")
), actual);
}
// このテストがNGになる
@Test
@DisplayName("ヘッダーが仕様と異なるCSVを読み込むと、InvalidCsvException")
void invalidHeader() {
// テスト用CSVファイルのパスを取得
Path csvFile = Path.of("src/test/resources/product_invalid_header.csv");
// CSVをパースして例外がスローされることを検証
assertThrows(InvalidCsvException.class, () -> {
productCsvParser3.parse(csvFile);
});
}
}
}
すると、2つ目のテストで例外が全くスローされずNGとなります。
テスト実行結果
org.opentest4j.AssertionFailedError:
Expected com.example.InvalidCsvException to be thrown,
but nothing was thrown.
3の場合(修正版)
ProductCsvParser3.java(修正版、一部)
public ProductCsvParser3() {
this.csvMapper = CsvMapper.builder()
// これを追加!!!
.enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
.build();
// あとは同じ
}
これで想定通り動きました。
Discussion