きり丸の技術日記

技術検証したり、資格等をここに残していきます。

JavaのJacksonライブラリのCsvMapperでCSVを読み込む

JavaでCSVを読み込む方法を調べました。

最初は、SpringBootに組み込まれているObjectMapperで読み込めると思いましたが、ObjectMapperではCSV読み込みはできないようです。ですので、意図的にJacksonライブラリのCsvMapperを依存関係に含める必要があります。

この記事では、CsvMapperを利用してCSVファイルを読み込めるようにします。

ユースケース

  • 巨大なデータの登録
  • APIがJSONではなくCSV形式で提供している場合

環境

  • Java
    • 15
  • org.springframework.boot:spring-boot-starter-test
    • 2.4.1
  • com.fasterxml.jackson.dataformat:jackson-dataformat-csv
    • 2.11.3
  • Lombok
  • Gradle

やりたいこと

  • CSVファイルを読み込む

今回用意したCSVファイルは、気象庁のデータを使用します。いくつか処理がしやすいようにファイルを加工しています。

  • 選択地点
    • 海老名
  • 出力項目
    • 日付
    • 日平均気温
    • 日最低気温
    • 日照時間
    • 降水量の日合計
  • 出力期間
    • 2014年1月1日から2015年1月1日まで

www.data.jma.go.jp

事前準備

build.gradleに依存関係を追加する。

implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv'

CSVファイルの準備

気象庁から出力されたヘッダー名は日本語ですが、後でマッピングしたいので英語に変換しています。日本語でもマッピングできるらしいのですが、記号が上手くマッピングできないようだったので英語に変換しました。

date,temperatureAverage,temperatureHigh,temperatureLow,daylightHours,precipitationAmount
2014/1/1,7.1,15.0,-2.5,8.5,0.0
2014/1/2,5.4,12.8,-1.8,8.9,0.

読み込み用のDTOを作成する

CSVファイルを読み込んだ後にエンティティを生成するためのDTOを用意します。

  • 日付
    • LocalDate
  • 日平均気温
    • BigDecimal
  • 日最低気温
    • BigDecimal
  • 日照時間
    • BigDecimal
  • 降水量の日合計
    • BigDecimal

JsonPropertyOrderでCSVファイルを読み込む順番を指定します。また、JsonPropertyの名前がヘッダー名と一致するようにします。

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonPropertyOrder({
    "date",
    "temperatureAverage",
    "temperatureHigh",
    "temperatureLow",
    "daylightHours",
    "precipitationAmount"
})
public class Weather {
  @JsonProperty("date")
  private LocalDate date;
  @JsonProperty("temperatureAverage")
  private BigDecimal temperatureAverage;
  @JsonProperty("temperatureHigh")
  private BigDecimal temperatureHigh;
  @JsonProperty("temperatureLow")
  private BigDecimal temperatureLow;
  @JsonProperty("daylightHours")
  private BigDecimal daylightHours;
  @JsonProperty("precipitationAmount")
  private BigDecimal precipitationAmount;
}

ヘッダーありCSVファイルを読み込む

パラメータとして、CSVファイルのパスを渡します。

CsvMapperを生成し、CsvMapperからCSVのヘッダーを識別できるようにCsvSchemaを生成します。

CsvMapperのreaderForメソッドで戻り値の型指定、withメソッドでCsvSchemaの指定、readValuesメソッドでCSVファイルを読み込ませます。

CSVファイルを読み込んだ結果はIteratorとして読み込むので、Whileでリストとして読み込むようにします。

  public List<Weather> readCsc(Path path) throws IOException {
    CsvMapper csvMapper = new CsvMapper();

    CsvSchema csvSchema = csvMapper
        .schemaFor(Weather.class)
        .withHeader();
    List<Weather> rtn = new ArrayList<>();

    MappingIterator<Weather> objectMappingIterator =
        csvMapper.readerFor(Weather.class)
            .with(csvSchema)
            .readValues(path.toFile());

    while (objectMappingIterator.hasNext()) {
      rtn.add(objectMappingIterator.next());
    }

    return rtn;
  }

ヘッダー無しのファイルを読み込む場合

CSVファイルのヘッダーが無い場合は、CsvSchema生成時にwithHeaderメソッドを呼ばないだけで読み込めます。

  public List<Weather> readCsvNoHeader(Path path) throws IOException {
    CsvMapper csvMapper = new CsvMapper();

    CsvSchema csvSchema = csvMapper
        .schemaFor(Weather.class);
    List<Weather> rtn = new ArrayList<>();

    MappingIterator<Weather> objectMappingIterator =
        csvMapper.readerFor(Weather.class)
            .with(csvSchema)
            .readValues(path.toFile());

    while (objectMappingIterator.hasNext()) {
      rtn.add(objectMappingIterator.next());
    }

    return rtn;
  }

LocalDateとして読み込む

デフォルトではCSVファイルをLocalDateとして読み込むことはできません。

ですので、CsvMapperにLocalDateを読み込ませるようにします。日付フォーマットも正しく渡してあげる必要があります。

    JavaTimeModule javaTimeModule = new JavaTimeModule();
    javaTimeModule.addDeserializer(
        LocalDate.class,
        new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy/M/d"))
    );
    csvMapper.registerModule(javaTimeModule);

対応しなかった時のエラーメッセージ。

Cannot construct instance of `java.time.LocalDate` (no Creators, like default constructor, exist): no String-argument constructor/factory method to deserialize from String value ('2014/1/1')

この変換が面倒であれば、CSVファイルを読み込むときは一旦String型で読み込むのも1つの手段です。

テストを書く

これを検証した時のテストコードも載せます。

テスト前にファイルを生成、テスト後にファイルを削除するようにします。

後は、生成したファイルのパスを渡して、読み込めることの確認をします。

  WeatherDomainService target = new WeatherDomainService(new CsvMapper());

  private final Path path = Path.of("/tmp/2014ebina.csv");

  @BeforeEach
  void setup() throws IOException {
    FileUtils.copyToFile(
        getClass().getResourceAsStream("/jp/co/kelly/2014ebina.csv"),
        path.toFile()
    );
  }

  @AfterEach
  void teardown() throws IOException {
    FileUtils.deleteQuietly(
        path.toFile()
    );
  }

    @Test
    void test_01() throws IOException {
      var weathers = target.readCsc(path);
      assertThat(weathers).hasSize(365);
      assertThat(weathers).contains(
          Weather.builder()
              .date(LocalDate.of(2014, 1, 1))
              .temperatureAverage(BigDecimal.valueOf(7.1))
              .temperatureHigh(BigDecimal.valueOf(15.0))
              .temperatureLow(BigDecimal.valueOf(-2.5))
              .daylightHours(BigDecimal.valueOf(8.5))
              .precipitationAmount(BigDecimal.valueOf(0.0))
              .build()

      );
    }

ソースコード

終わりに

巨大なCSVファイルデータを処理したい、というケースではもっと最適なライブラリがあります。それは、SpringBatchのFileItemReaderです。

SpringBatchであれば、トランザクションやリトライ等も管理できるので便利です。ただ、SpringBatchは準備が面倒だという点から、自分はまだ検証できてません。

正直、サクっとCSVを処理したければStringとして読み込みつつ、Splitメソッドでカンマごとに分解でもいいのでこのライブラリを使う機会は少ないかもしれないです。地味に仕様把握に時間かかってしまったので、この記事を見たことでサクっと使えました!という声を聞けることを願っています。

参考