Jackson CSVを利用してCSVファイルを読み書きする

# はじめに
CSVファイルの読み書きライブラリと言えば、以前は``OrangeSignal CSV``を使用していたのですが、2014年で開発が止まってしまっています。
とは言え、上記ライブラリでも今でも十分だったりはするのですが、Javaのバージョンが上がって利用できなくなってしまうことがなくはないので、出来れば現在進行形でアクティブなライブラリを使用したいと思っていました。
``Open CSV``は今なおアクティブなのですが、Mapのサポートがない、ということで、何か良いライブラリがないか探していたところ、Jackson CSVを見つけました。

# 前提条件
* Excelで出力したCSVファイルが読めること
* 出力したCSVファイルをExcelで読めること
* UTF-8に対応していること

上記条件を満たすためには、以下の必要があります。
* BOM付きファイルを読めること
* 出力ファイルはBOM付きであること
* 区切り文字は","、改行はCRLF
* 項目中に、","、改行（CRLF,LF,CR）、"(ダブルクォーテーション)を含む場合は、項目はダブルクォーテーションでくくる。項目中のダブルクォーテーションは "" 出力によりエスケープ

# 必要ライブラリ

* [Jackson Text Data Format Module](https://github.com/FasterXML/jackson-dataformats-text)
* [Apache Commons IO](https://commons.apache.org/proper/commons-io/)

# 参考サイト
Jackson CSVの使い方に関しては以下のサイトが参考になります。

https://kazuhira-r.hatenablog.com/entry/2021/05/06/001207

こちらを見れば、Pojo ⇔ CSV のパターンとか、Stringの配列のパターンとか、Mapのパターンとか、いろいろなパターンでの読み書きのサンプルがあるので、こちらでは、主に、前提条件を満たすファイルの読み書き、という部分に焦点を絞って説明したいと思います。

# CSVファイルの読み込み
以下の例は、前提条件を満たすヘッダ有のCSVファイルを一気にMap形式で読み込んでいます。
```java
CsvMapper mapper = new CsvMapper();
CsvSchema schema = CsvSchema.builder().build()
    .withHeader()
    .withColumnSeparator(',')
    .withQuoteChar('"')
    .withEscapeChar('\"')
    .withLineSeparator("\r\n");

//BOM付きのInputStreamを生成してUTF-8で読み込む
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(BOMInputStream.builder()
            .setInputStream(new FileInputStream("c:/Temp/sample.csv"))
            .get(), "UTF-8"))) {

    // CSVファイルをMap型のリストとして読み込む
    List csvMapList = mapper
            .readerFor(Map.class)
            .with(schema)
            .readValues(reader).readAll();

    // 読み込んだcsvMapListに対する処理
    // ...
}

```
Bom付きデータを読み込むため、Apache Commons IO のBomInputStreamを利用しています。
このBomInputStreamは、Bom付きの場合はBomを排除して読み込み、Bom付きでない場合はそのまま読み込んでくれます。

Mapで読み込む場合は、１行目はヘッダ行である必要があり、ヘッダなしでschema設定をするとエラーとなります。
ヘッダの項目名がKey値、２行目以降のデータ行のそれぞれの値がvalue値にマッピングされます。

Beanの場合は、``CsvMapper#readerFor``メソッドの引数にBeanのクラスを設定し、そして、Beanのフィールドに@JsonPropertyをそれぞれ付与してフィールド名（ヘッダ名）を定義すれば、いい感じでマッピングしてくれます。

# CSVファイルの書き込み
以下の例は、MapのListのデータを一気にCSV出力しています。
CSVの出力形式は、前提条件に則ってます。
```java
List<Map<String, Object>> outputList = new ArrayList<Map<String, Object>>();

// 順序性を保つためLinkedHashMapを利用している
Map<String, Object> csvLine = new LinkedHashMap<String, Object>();
csvLine.put("ヘッダ１", "1レコード1項目目");
csvLine.put("ヘッダ２", "1レコード2項目目");
// ...
outputList.add(csvLine);
// 以降、各レコードのデータをMapに詰めて、outputListにaddする。
// ...

//ヘッダの情報を1レコード目のMapのキーから取得
CsvSchema.Builder builder = CsvSchema.builder();
for (String col : outputList.get(0).keySet()) {
    builder.addColumn(col);
}

CsvMapper mapper = new CsvMapper();
CsvSchema schema = CsvSchema.builder().build()
    .withHeader()
    .withColumnSeparator(',')
    .withQuoteChar('"')
    .withEscapeChar('\"')
    .withLineSeparator("\r\n");

//CSV出力
try(FileOutputStream stream = new FileOutputStream("c:/Temp/sample.csv")) {
    //BOM出力
    byte[] bom = { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF };
    stream.write(bom);

    // CSVのデータをファイルにUTF-8で出力
    BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(stream, "UTF-8"));
    mapper.writer(schema).writeValues(writer).writeAll(outputList).flush();
}
```
Bom付きデータを出力するため、最初に3バイトBomデータを付与しています。
BomInputStreamはあるのですがBomOutputStreamは存在しないので自前で何とかしてます。

# すべての項目を""で括りたい場合
上記で、Excelで出力されたCSVファイルを読み込み、Excelで読み込めるCSVファイルを出力できます。
おそらく、通常の業務ではこれくらい出来れば十分なケースがほとんどかと思いますが、``OrangeSignal CSV``では、無条件にすべての項目が""でくくって出力されていたので、そちらの仕様にどうしても合わせたい、という場合は、以下のようにすれば出来ます。

## NULLの項目も""でくくって出力
以下のように、NullValueSerializerを匿名クラスで定義して、空文字を書き込むようにします。
``CsvMapper``は``ObjectMapper``のサブクラスなので、``ObjectMapper``と同じように出力内容を制御できます。

```java
CsvMapper mapper = new CsvMapper();

// NULLは空文字として扱う
mapper.getSerializerProvider().setNullValueSerializer(new JsonSerializer<Object>() {
    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
            throws IOException {
        gen.writeString("");
    }
});
```

## 文字列は全て""でくくって出力
``Csvapper#configure``の引数に、``(CsvGenerator.Feature.ALWAYS_QUOTE_STRINGS, true)``を与えるだけです。

```java
//すべての文字列をダブルクォーテーションでくくる
CsvMapper mapper = new CsvMapper();
mapper.configure(CsvGenerator.Feature.ALWAYS_QUOTE_STRINGS, true);
```

## 数値も全て""でくくって出力
上記の設定で、文字列は""でくくって出力されるのですが、数値に関しては""でくくって出力されません。
数値の場合は、一工夫必要です。
以下の例のように、数値を文字列として出力するシリアライザーをCsvMapperに登録する必要があります。

```java
CsvMapper mapper = new CsvMapper();
// ...

// 数値を文字列として扱う
SimpleModule module = new SimpleModule();
module.addSerializer(new StdSerializer<>(Number.class) {
    @Override
    public void serialize(Number value, JsonGenerator gen, SerializerProvider provider)
            throws IOException {
        gen.writeString(value.toString());
    }
});
mapper.registerModule(module);

```

# その他
## 日付の変換
入力した日付文字列を日付型（LocalDateやLocalDateTimeなど）に変換したい場合や、逆に、日付型を特定の日付文字列に変換したい場合は、``CsvMapper#registerModule``メソッドを使って、モジュールに日付を変換するためのシリアライザー、デシリアライザーを追加すれば対応できます。

LocalDateやLocalDateTimeなどのJSR-310型の日付を扱うためには、以下のライブラリがさらに必要です。

[Jackson Datatype: JSR310](https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310)

### CSVを読み込む場合の例

```java:CSVファイルを読み込み日付をLocalDateとして読み込む場合
CsvMapper mapper = new CsvMapper();
// ...

SimpleModule module = new SimpleModule();
// uuuu/MM/dd形式の文字列をLocalDateにするデシリアライザーを追加
module.addDeserializer(LocalDate.class,
        new LocalDateDeserializer(
                DateTimeFormatter.ofPattern("uuuu/MM/dd").withResolverStyle(ResolverStyle.STRICT)));
mapper.registerModule(module);
//...
```
### CSVに書き込む場合の例

```java:LocalDateの値をuuuu/MM/ddとして書き込む場合
CsvMapper mapper = new CsvMapper();
// ...

SimpleModule module = new SimpleModule();
// LocalDateの値をuuuu/MM/dd形式の文字列に出力するシリアライザーを追加
module.addSerializer(new LocalDateSerializer(DateTimeFormatter.ofPattern("uuuu/MM/dd")));

mapper.registerModule(module);
//...
```


# 最後に
CSVMapperクラスはObjectMapperクラスのサブクラスなので、ObjectMapperの機能と同じような扱いで様々な機能が使えます。
ここで取り上げた例も、シリアライザーやデシリアライザーを使用した例は、CSVMapperというよりはObjectMapperの機能を利用したものです。
このように、CSVMapperのschema設定を行った後は、JSONと同じ扱いでObjectMapperと同じように制御が可能です。
ここでは主にMapでの読み書きの例を挙げたので出番はなかったのですが、JavaBean ⇔ CSVファイル の場合は、JsonPropeertyアノテーションでヘッダ名を指定したり、JsonFormatアノテーションで日付の入出力形式を指定したりすることが出来ます。

個人的には、CSV読み書きという機能をシステムとして汎用化するなら、Mapで読み書きするのが楽かなとは思います。
しかし、読み込んだデータをBeanValidatorを利用してチェックしたい場合とかは、CSV入力用のBeanで項目定義した方が都合がよいケースもあるかと思います。
いずれにしろ、それぞれの要件に合わせていろいろなパターンを選べます。