CLOVER🍀

That was when it all began.

Jackson Text Dataformats Moduleを使って、CSVファイルの読み書きをしてみる

これは、なにをしたくて書いたもの?

これまでCSVを読み書きするのに、OpenCSV、Super CSVなどを使ってきたのですが、JacksonでもCSVファイルの読み書きが
できることはなんとなく知っていたものの使ったことがありませんでした。

ちょっと試してみようかな、と思います。

どうやら速いみたいですし。

GitHub - uniVocity/csv-parsers-comparison: Comparisons among all Java-based CSV parsers in existence

Jackson Data Format Module

JacksonでCSVファイルを読み書きするモジュールは、Jackson Data Format Moduleのひとつになっています。GitHubリポジトリは、
こちらになります。

GitHub - FasterXML/jackson-dataformats-text: Uber-project for (some) standard Jackson textual format backends: csv, properties, yaml (xml to be added in future)

このリポジトリに含まれているモジュールでは、CSV、properties、toml(2.12.3から)、yamlに対応しています。

ドキュメントは、WikiやCSVモジュール内のREADME.mdを見ることになります。

Home · FasterXML/jackson-dataformats-text Wiki · GitHub

CsvSchema · FasterXML/jackson-dataformats-text Wiki · GitHub

https://github.com/FasterXML/jackson-dataformats-text/blob/jackson-dataformats-text-2.12.3/csv/README.md

あとは、Javadocですね。

Jackson-dataformat-CSV 2.12.3 API

DatabindとCoreにも依存しているので、こちらも見るとよいでしょう。

jackson-databind 2.12.3 API

Jackson-core 2.12.3 API

JacksonでCSVを読み書きする

Jacksonを使ってCSVファイルを読み書きするといっても、基本と成るのはJSONを扱う時に使っていたJacksonです。

CsvMapperというクラスをまずは使うことになるですが、こちらはObjectMapperのサブクラスだったりします。

CsvMapper (Jackson-dataformat-CSV 2.12.3 API)

実際のデータの読み書きに使うのも、Jackson Databindのクラスだったりします。

オブジェクト(POJO)のプロパティの設定に、@JsonFormatなどのアノテーションを使ったりするのも同じです。
JSONではないのですが。

このため、必然的にこのあたりの情報も見ていくことになるでしょう。

Home · FasterXML/jackson-databind Wiki · GitHub

Home · FasterXML/jackson-core Wiki · GitHub

あとは、特徴的なのがCsvSchemaですね。

CsvSchema (Jackson-dataformat-CSV 2.12.3 API)

こちらを使って、CSVのスキーマ定義を行います。スキーマ定義とは、CSVの読み書き時の設定に関する以下のような項目を
指しています。

  • カラム定義(デフォルトは空)
  • ヘッダーの有無(デフォルトはヘッダーなし)
  • 囲み文字の定義(デフォルトは")
  • カラムの区切り文字(デフォルトは`,')
  • 配列要素を区切るための文字(デフォルトは:)
  • 改行文字(デフォルトは\n)
    • CSVファイル作成の時のみ使う項目で、CSVパーサーは\r、\r\n、\nのいずれも扱える
  • エスケープ文字(デフォルトなし)
    • CSVパーサーのみが使う項目で、CSVファイルの書き出し時は囲み文字を二重にして出力する
  • 最初のレコードを無視するかどうか(デフォルトは無視しない)
  • nullを表す文字列(デフォルトは空文字)
  • ヘッダーレコードが存在する場合、スキーマ定義のカラム名がヘッダー名と一致する必要があるか(デフォルトは一致しなくてよい)

個人的には、エスケープ文字が読み込み時にしか効かないことに気づかなくて、けっこうハマりました…。

説明は、このくらいにしていくつかパターンを試していってみましょう。

お題

こんな感じでやってみます。

  • CSVファイルの読み込み
    • 区切り文字,、囲み文字"、エスケープ文字\のCSVを読み込む
      • ヘッダー有り無しの2つのファイルを用意する
    • 読み込み結果は、Stringの配列、Map、自分で定義したクラス、の3つで扱う
  • CSVファイルの書き込み
    • 区切り文字,、囲み文字"のCSVを出力する
      • ヘッダー有り無しの2つのパターンで出力する
    • 書き込みに使用するオブジェクトは、Stringの配列、自分で定義したクラス、の2つで扱う

読み込み時はMapを入れても扱いやすかったので入れましたが、書き込み時はくどくなるのでやめました…。

環境

今回の環境は、こちらです。

$ java --version
openjdk 11.0.11 2021-04-20
OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04)
OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.1 (05c21c65bdfed0f71a2f2ada8b84da59348c4c5d)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-72-generic", arch: "amd64", family: "unix"

Maven依存関係は、こちら。

    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-csv</artifactId>
            <version>2.12.3</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <version>2.12.3</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.7.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.7.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

まずはjackson-dataformat-csvが必要です。こちらで、jackson-databindおよびjackson-core、jackson-annotationsも
推移的に引き込まれます。

また、オブジェクトにマッピングする際にはLocalDateを使うことにしたので、jackson-datatype-jsr310も含めました。

GitHub - FasterXML/jackson-modules-java8: Set of support modules for Java 8 datatypes (Optionals, date/time) and features (parameter names)

動作確認は、テストコードを動かして行うことにしました。…アサーションはしませんでしたけど。

読み込み対象のCSVファイル

読み込み対象のCSVファイルは、こんな感じで用意しました。お題は書籍です。

ヘッダーあり。

src/test/resources/books.csv

isbn,title,price,publishDate
978-4621303252,Effective Java 第3版,"4400","2018-10-30"
978-4798151120,独習Java,3278,2019-05-15
978-4295008477,"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",2860,2020-03-13
"978-4774189093","Java本格入門 \"モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで\"",3278,2017-04-18
978-4774166858,改訂2版 パーフェクトJava,3520,2014-11-01

ヘッダーなし。

src/test/resources/books_headerless.csv

978-4621303252,Effective Java 第3版,"4400","2018-10-30"
978-4798151120,独習Java,3278,2019-05-15
978-4295008477,"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",2860,2020-03-13
"978-4774189093","Java本格入門 \"モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで\"",3278,2017-04-18
978-4774166858,改訂2版 パーフェクトJava,3520,2014-11-01

囲み文字の有無は、適当に仕込んでいます。

マッピング先のクラス

CSVファイルを読み込んだ内容を、自分で作ったオブジェクトにマッピングする場合のクラス。

src/test/java/org/littlewings/jackson/csv/Book.java

package org.littlewings.jackson.csv;

import java.time.LocalDate;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonPropertyOrder({"isbn", "title", "price", "publishDate"})  // for CsvMapper#schemaFor
public class Book {
    String isbn;
    String title;
    int price;
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDate publishDate;

    public static Book create(String isbn, String title, int price, LocalDate publishDate) {
        Book book = new Book();

        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);
        book.setPublishDate(publishDate);

        return book;
    }

    // getter/setterは省略

}

ところどころJacksonのアノテーションが入っていますが、実際の利用時に紹介します。

CSVファイルを読み込む

まずは、CSVファイルを読み込んでみましょう。

テストコードの雛形。

src/test/java/org/littlewings/jackson/csv/JacksonCsvReadTest.java

package org.littlewings.jackson.csv;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.dataformat.csv.CsvParser;
import com.fasterxml.jackson.dataformat.csv.CsvSchema;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;

public class JacksonCsvReadTest {
    // ここに、テストコードを書く
}

ここに、テストコードを埋めていく方針としましょう。アサーションしてませんけど…。

Stringの配列として読み込む(ヘッダーあり)

最初は、CSVファイルの各レコードをStringの配列として読み込みます。

ドキュメントだと、このあたりですね。

CsvSchema / Column definitions / "Unmapped" (no columns)

Data-binding without schema

こんな感じになりました。

    @Test
    public void readCsvWithHeaderAsStringArray() throws IOException {
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .emptySchema()
                        .withoutHeader()
                        .withSkipFirstDataRow(true)  // 1レコード目を飛ばす
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')   // default
                        .withEscapeChar('\\');

        mapper.enable(CsvParser.Feature.WRAP_AS_ARRAY);

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<String[]> iterator =
                    mapper
                            .readerFor(String[].class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                System.out.println(Arrays.stream(iterator.next()).collect(Collectors.joining(", ")));
            }
        }
    }

CsvMapperのインスタンスを作り、それからCsvSchemaの設定を行います。

        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .emptySchema()
                        .withoutHeader()
                        .withSkipFirstDataRow(true)  // 1レコード目を飛ばす
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')   // default
                        .withEscapeChar('\\');

        mapper.enable(CsvParser.Feature.WRAP_AS_ARRAY);

CsvSchema#emptySchemaでデフォルトのスキーマ定義が返るので、これをベースに必要に応じて設定を変えていきます。

読み込むCSVファイルはヘッダーありなのですが、ヘッダーの値を読んでもらっても困るので、ヘッダーなし、
最初のレコードを読み飛ばす設定にしました。

CsvParser.Feature.WRAP_AS_ARRAYは、レコードの構成要素を配列として返すので指定しています。その他、Listにする時などに
便利なようです。

CsvParser.Feature (Jackson-dataformat-CSV 2.12.3 API)

CSVファイルの読み込み、各レコードの取得はこんな感じになります。

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<String[]> iterator =
                    mapper
                            .readerFor(String[].class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                System.out.println(Arrays.stream(iterator.next()).collect(Collectors.joining(", ")));
            }
        }

MappingIteratorは、Jackson Databindのクラスですね。

読み込み結果のClassクラス、スキーマ定義、CSVファイルの読み込み元を指定して、CsvMapperより取得します。

            MappingIterator<String[]> iterator =
                    mapper
                            .readerFor(String[].class)
                            .with(schema)
                            .readValues(reader);

こちらを使って、CSVファイルを1レコードずつ読んでいくようにしました。

実行結果。

978-4621303252, Effective Java 第3版, 4400, 2018-10-30
978-4798151120, 独習Java, 3278, 2019-05-15
978-4295008477, 新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト], 2860, 2020-03-13
978-4774189093, Java本格入門 "モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, 2017-04-18
978-4774166858, 改訂2版 パーフェクトJava, 3520, 2014-11-01
Stringの配列として読み込む(ヘッダーなし)

ヘッダーなしのCSVファイルを読み込み、Stringの配列として受け取る場合。

    @Test
    public void readCsvWithoutHeaderAsStringArray() throws IOException {
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .emptySchema()
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')   // default
                        .withEscapeChar('\\');

        mapper.enable(CsvParser.Feature.WRAP_AS_ARRAY);

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books_headerless.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<String[]> iterator =
                    mapper
                            .readerFor(String[].class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                System.out.println(Arrays.stream(iterator.next()).collect(Collectors.joining(", ")));
            }
        }
    }

先ほどとの違いは、ヘッダーなし、最初のレコードの読み飛ばしを消しただけですね。

そもそも、デフォルトがヘッダーなしなので…。

実行結果は、こちら。

978-4621303252, Effective Java 第3版, 4400, 2018-10-30
978-4798151120, 独習Java, 3278, 2019-05-15
978-4295008477, 新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト], 2860, 2020-03-13
978-4774189093, Java本格入門 "モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, 2017-04-18
978-4774166858, 改訂2版 パーフェクトJava, 3520, 2014-11-01
Mapとして読み込む(ヘッダーあり)

次に、各レコードをMapとして読み込んでみましょう。こうすると、ヘッダーの内容をMapのキーとして扱ってくれます。

こちらの内容ですね。

With column names from first row

    @Test
    public void readCsvWithHeaderAsMap() throws IOException {
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .emptySchema()
                        .withHeader()  // 1レコード目をヘッダーとして使う
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')   // default
                        .withEscapeChar('\\');

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<Map<String, String>> iterator =
                    mapper
                            .readerFor(Map.class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                System.out.println(
                        iterator.next().entrySet().stream().map(e -> e.getKey() + ":" + e.getValue()).collect(Collectors.joining(", "))
                );
            }
        }
    }

なので、ヘッダーを認識するように設定。CsvParser.Feature.WRAP_AS_ARRAYは不要になります。

        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .emptySchema()
                        .withHeader()  // 1レコード目をヘッダーとして使う
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')   // default
                        .withEscapeChar('\\');

CsvMapper#readerForには、MapのClassクラスを指定。

            MappingIterator<Map<String, String>> iterator =
                    mapper
                            .readerFor(Map.class)
                            .with(schema)
                            .readValues(reader);

結果はこちら。

isbn:978-4621303252, title:Effective Java 第3版, price:4400, publishDate:2018-10-30
isbn:978-4798151120, title:独習Java, price:3278, publishDate:2019-05-15
isbn:978-4295008477, title:新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト], price:2860, publishDate:2020-03-13
isbn:978-4774189093, title:Java本格入門 "モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", price:3278, publishDate:2017-04-18
isbn:978-4774166858, title:改訂2版 パーフェクトJava, price:3520, publishDate:2014-11-01
Mapとして読み込む(ヘッダーなし)

今度は、ヘッダーなしのCSVファイルを読み、Mapとして受け取ります。

    @Test
    public void readCsvWithoutHeaderAsMap() throws IOException {
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .builder()
                        .addColumns(List.of("isbn", "title", "price", "publishDate"), CsvSchema.ColumnType.STRING)  // カラムを定義
                        .setColumnSeparator(',')  // default
                        .setQuoteChar('"')  // default
                        .setEscapeChar('\\')
                        .build();

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books_headerless.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<Map<String, String>> iterator =
                    mapper
                            .readerFor(Map.class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                System.out.println(
                        iterator.next().entrySet().stream().map(e -> e.getKey() + ":" + e.getValue()).collect(Collectors.joining(", "))
                );
            }
        }
    }

変わったポイントとしては、CsvSchemaの作り方ですね。ヘッダーがないので、キーの名前をカラムとして定義する必要が
あります。

        CsvSchema schema =
                CsvSchema
                        .builder()
                        .addColumns(List.of("isbn", "title", "price", "publishDate"), CsvSchema.ColumnType.STRING)  // カラムを定義
                        .setColumnSeparator(',')  // default
                        .setQuoteChar('"')  // default
                        .setEscapeChar('\\')
                        .build();

変わったのは、ここくらいですね。

結果。

isbn:978-4621303252, title:Effective Java 第3版, price:4400, publishDate:2018-10-30
isbn:978-4798151120, title:独習Java, price:3278, publishDate:2019-05-15
isbn:978-4295008477, title:新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト], price:2860, publishDate:2020-03-13
isbn:978-4774189093, title:Java本格入門 "モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", price:3278, publishDate:2017-04-18
isbn:978-4774166858, title:改訂2版 パーフェクトJava, price:3520, publishDate:2014-11-01
自分で定義したクラスで読み込む(ヘッダーあり)

読み込み系の最後は、自分で定義したクラスで読み込むようにしてみます。

ソースコードは、こんな感じに。

    @Test
    public void readCsvWithHeaderAsClass() throws IOException {
        CsvMapper mapper = new CsvMapper();
        mapper.registerModules(new JavaTimeModule());  // for LocalDate / LocalDateTime
        CsvSchema schema =
                mapper
                        .schemaFor(Book.class)
                        .withHeader()
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')  // default
                        .withEscapeChar('\\');

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<Book> iterator =
                    mapper
                            .readerFor(Book.class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                Book book = iterator.next();

                System.out.printf("isbn: %s, title: %s, price: %d, publishDate: %s%n", book.getIsbn(), book.getTitle(), book.getPrice(), book.getPublishDate());
            }
        }
    }

いきなり脱線しますが、マッピング先のクラスがこんな感じでLocalDateを使っているので

@JsonPropertyOrder({"isbn", "title", "price", "publishDate"})  // for CsvMapper#schemaFor
public class Book {
    String isbn;
    String title;
    int price;
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDate publishDate;

CsvMapperにJavaTimeModuleを登録します。

        mapper.registerModules(new JavaTimeModule());  // for LocalDate / LocalDateTime

MappingIteratorあたりの使い方は変わりませんが、CsvSchemaの作り方が少し変わります。今回は、CsvMapper#schemaForを
使います。

        CsvSchema schema =
                mapper
                        .schemaFor(Book.class)
                        .withHeader()
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')  // default
                        .withEscapeChar('\\');

これで指定したクラスがスキーマの元になるのですが、対象のクラスには@JsonPropertyOrderアノテーションで
プロパティの順を指定しておく必要があります。

@JsonPropertyOrder({"isbn", "title", "price", "publishDate"})  // for CsvMapper#schemaFor
public class Book {

注意点は、このくらいですね。

結果。

isbn: 978-4621303252, title: Effective Java 第3版, price: 4400, publishDate: 2018-10-30
isbn: 978-4798151120, title: 独習Java, price: 3278, publishDate: 2019-05-15
isbn: 978-4295008477, title: 新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト], price: 2860, publishDate: 2020-03-13
isbn: 978-4774189093, title: Java本格入門 "モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", price: 3278, publishDate: 2017-04-18
isbn: 978-4774166858, title: 改訂2版 パーフェクトJava, price: 3520, publishDate: 2014-11-01
自分で定義したクラスで読み込む(ヘッダーなし)

ヘッダーなしの場合は、こちら。

    @Test
    public void readCsvWithoutHeaderAsClass() throws IOException {
        CsvMapper mapper = new CsvMapper();
        mapper.registerModules(new JavaTimeModule());  // for LocalDate / LocalDateTime
        CsvSchema schema =
                mapper
                        .schemaFor(Book.class)
                        .withoutHeader()
                        .withSkipFirstDataRow(true)  // 1レコード目を飛ばす
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"')  // default
                        .withEscapeChar('\\');

        try (BufferedReader reader = Files.newBufferedReader(Paths.get("src/test/resources/books.csv"), StandardCharsets.UTF_8)) {
            MappingIterator<Book> iterator =
                    mapper
                            .readerFor(Book.class)
                            .with(schema)
                            .readValues(reader);

            while (iterator.hasNext()) {
                Book book = iterator.next();

                System.out.printf("isbn: %s, title: %s, price: %d, publishDate: %s%n", book.getIsbn(), book.getTitle(), book.getPrice(), book.getPublishDate());
            }
        }
    }

あらためて説明しなおす内容はないので、割愛。

結果。

isbn: 978-4621303252, title: Effective Java 第3版, price: 4400, publishDate: 2018-10-30
isbn: 978-4798151120, title: 独習Java, price: 3278, publishDate: 2019-05-15
isbn: 978-4295008477, title: 新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト], price: 2860, publishDate: 2020-03-13
isbn: 978-4774189093, title: Java本格入門 "モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", price: 3278, publishDate: 2017-04-18
isbn: 978-4774166858, title: 改訂2版 パーフェクトJava, price: 3520, publishDate: 2014-11-01

CSVファイルを書き込む

次は、CSVファイルの書き込みを行ってみましょう。

テストコードの雛形はこちら。

src/test/java/org/littlewings/jackson/csv/JacksonCsvWriteTest.java

package org.littlewings.jackson.csv;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.util.List;

import com.fasterxml.jackson.databind.SequenceWriter;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.dataformat.csv.CsvSchema;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;

public class JacksonCsvWriteTest {
    String[][] booksArray = {
            {"978-4621303252", "Effective Java 第3版", "4400", "2018-10-30"},
            {"978-4798151120", "独習Java", "3278", "2019-05-15"},
            {"978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", "2860", "2020-03-13"},
            {"978-4774189093", "Java本格入門 \"モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで\"", "3278", "2017-04-18"},
            {"978-4774166858", "改訂2版 パーフェクトJava", "3520", "2014-11-01"}
    };

    List<Book> books =
            List.of(
                    Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30)),
                    Book.create("978-4798151120", "独習Java", 3278, LocalDate.of(2019, 5, 15)),
                    Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13)),
                    Book.create("978-4774189093", "Java本格入門 \"モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで\"", 3278, LocalDate.of(2017, 4, 18)),
                    Book.create("978-4774166858", "改訂2版 パーフェクトJava", 3520, LocalDate.of(2014, 11, 1))
            );

    // ここに、テストを書く
}

CSVファイルに書き込む要素は、事前にフィールドに定義しておくことにしました。

では、各種コードを埋めていきます。

Stringの配列を書き込む(ヘッダーあり)

まずは、Stringの配列として書き込むパターンから。ヘッダーありです。

ソースコードは、こんな感じになりました。

    @Test
    public void writeCsvWithHeaderAsStringArray() throws IOException {
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .builder()
                        .addColumns(List.of("isbn", "title", "price", "publishDate"), CsvSchema.ColumnType.STRING)  // カラムを定義
                        .setUseHeader(true)  // print header
                        .setColumnSeparator(',')  // default
                        .setQuoteChar('"')  // default
                        .build();

        try (BufferedWriter writer = Files.newBufferedWriter(Paths.get("target/books_from_array.csv"), StandardCharsets.UTF_8)) {
            SequenceWriter sequenceWriter =
                    mapper
                            .writerFor(String[].class)
                            .with(schema)
                            .writeValues(writer);

            for (String[] values : booksArray) {
                sequenceWriter.write(values);
            }
        }
    }

CsvSchemaの作り方は読み込み時とそう変わりませんが、ヘッダーを作る分だけカラム定義が必要になります。

        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .builder()
                        .addColumns(List.of("isbn", "title", "price", "publishDate"), CsvSchema.ColumnType.STRING)  // カラムを定義
                        .setUseHeader(true)  // print header
                        .setColumnSeparator(',')  // default
                        .setQuoteChar('"')  // default
                        .build();

また、CsvParser.Feature.WRAP_AS_ARRAYは不要です。

書き込みには、SequenceWriterを使い、1レコードずつ書き込んでいきます。

        try (BufferedWriter writer = Files.newBufferedWriter(Paths.get("target/books_from_array.csv"), StandardCharsets.UTF_8)) {
            SequenceWriter sequenceWriter =
                    mapper
                            .writerFor(String[].class)
                            .with(schema)
                            .writeValues(writer);

            for (String[] values : booksArray) {
                sequenceWriter.write(values);
            }
        }

結果はこちら。

target/books_from_array.csv

isbn,title,price,publishDate
978-4621303252,"Effective Java 第3版",4400,2018-10-30
978-4798151120,独習Java,3278,2019-05-15
978-4295008477,"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",2860,2020-03-13
978-4774189093,"Java本格入門 ""モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで""",3278,2017-04-18
978-4774166858,"改訂2版 パーフェクトJava",3520,2014-11-01

ヘッダーも出力されています。

囲み文字のエスケープは、囲み文字を2つ重ねることで行います。読み込み時と違い、こちらは設定ができません。

Stringの配列を書き込む(ヘッダーなし)

ヘッダーなしの場合。

    @Test
    public void writeCsvWithoutHeaderAsStringArray() throws IOException {
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema =
                CsvSchema
                        .emptySchema()
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"');  // default

        try (BufferedWriter writer = Files.newBufferedWriter(Paths.get("target/books_from_array_headerless.csv"), StandardCharsets.UTF_8)) {
            SequenceWriter sequenceWriter =
                    mapper
                            .writerFor(String[].class)
                            .with(schema)
                            .writeValues(writer);

            for (String[] values : booksArray) {
                sequenceWriter.write(values);
            }
        }
    }

ヘッダーがないので、すごくシンプルになります。

結果は、こちら。

target/books_from_array_headerless.csv

978-4621303252,"Effective Java 第3版",4400,2018-10-30
978-4798151120,独習Java,3278,2019-05-15
978-4295008477,"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",2860,2020-03-13
978-4774189093,"Java本格入門 ""モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで""",3278,2017-04-18
978-4774166858,"改訂2版 パーフェクトJava",3520,2014-11-01
自分で定義したクラスで書き込む(ヘッダーあり)

最後は、自分で定義したクラスで書き込む場合です。まずは、ヘッダーありの場合から。

    @Test
    public void writeCsvWithHeaderAsClass() throws IOException {
        CsvMapper mapper = new CsvMapper();
        mapper.registerModules(new JavaTimeModule());  // for LocalDate / LocalDateTime
        CsvSchema schema =
                mapper
                        .schemaFor(Book.class)
                        .withHeader() // print header
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"');  // default

        try (BufferedWriter writer = Files.newBufferedWriter(Paths.get("target/books.csv"), StandardCharsets.UTF_8)) {
            SequenceWriter sequenceWriter =
                    mapper
                            .writerFor(Book.class)
                            .with(schema)
                            .writeValues(writer);

            for (Book book : books) {
                sequenceWriter.write(book);
            }
        }
    }

といっても、ここまで来ると本当に目新しい要素はないですね…。

読み込み時と同じく、CsvMapper#schemaForで対象のClassクラスを指定します。

        CsvSchema schema =
                mapper
                        .schemaFor(Book.class)
                        .withHeader() // print header
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"');  // default

結果。ヘッダーありです。

target/books.csv

isbn,title,price,publishDate
978-4621303252,"Effective Java 第3版",4400,2018-10-30
978-4798151120,独習Java,3278,2019-05-15
978-4295008477,"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",2860,2020-03-13
978-4774189093,"Java本格入門 ""モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで""",3278,2017-04-18
978-4774166858,"改訂2版 パーフェクトJava",3520,2014-11-01
自分で定義したクラスで書き込む(ヘッダーなし)

ヘッダーなしの場合。さらっと。

    @Test
    public void writeCsvWithoutHeaderAsClass() throws IOException {
        CsvMapper mapper = new CsvMapper();
        mapper.registerModules(new JavaTimeModule());  // for LocalDate / LocalDateTime
        CsvSchema schema =
                mapper
                        .schemaFor(Book.class)
                        .withColumnSeparator(',')  // default
                        .withQuoteChar('"');  // default

        try (BufferedWriter writer = Files.newBufferedWriter(Paths.get("target/books_headerless.csv"), StandardCharsets.UTF_8)) {
            SequenceWriter sequenceWriter =
                    mapper
                            .writerFor(Book.class)
                            .with(schema)
                            .writeValues(writer);

            for (Book book : books) {
                sequenceWriter.write(book);
            }
        }
    }

結果。

target/books_headerless.csv

978-4621303252,"Effective Java 第3版",4400,2018-10-30
978-4798151120,独習Java,3278,2019-05-15
978-4295008477,"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",2860,2020-03-13
978-4774189093,"Java本格入門 ""モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで""",3278,2017-04-18
978-4774166858,"改訂2版 パーフェクトJava",3520,2014-11-01

まとめ

Jackson Text Dataformats Moduleを使って、CSVファイルを読み書きしてみました。

Jacksonがベースになっているだけあって、他のCSV関係のライブラリとちょっと変わっている感じがしますが、高速なようですし
Jackson自体もよく使うと思うので、覚えておいてもよいでしょう。