Java の CSV/TSV/固定長ファイル Parser ライブラリ uniVocity-parsers の使い方

（2019/12/20修正）公式サイトの Documentation ページのURLを修正しました。
（2017/02/19修正）CsvParserSettings#setRowProcessor が非推奨になっていたので CsvParserSettings#setProcessor へ変更しました。`settings.setRowProcessor(rowProcessor);` → `settings.setProcessor(rowProcessor);` へ変更しています。

Java で CSV ファイルを処理するために最初 OpenCSV の使い方を Web で調べていたのですが、stackoverflow を見ていたら uniVocity-parsers というライブラリが OpenCSV より速いと書かれている記事を見かけたので使い方等を調べてみました。

# 概要、使ってみた感想
* CSV、TSV、固定長ファイルを処理できる Parser ライブラリ。
* 公式サイトやJavaDoc等のドキュメントがまとまっており使用サンプルも豊富で分かりやすいです。
* CSV ファイルのレコードを JavaBean に変換する際にアノテーションで変換ルールを指定できます。
* 他の CSV ライブラリより速いらしいです ( [uniVocity/csv-parsers-comparison](https://github.com/uniVocity/csv-parsers-comparison) に比較結果が書かれています、ただし自分はそこまで試していないので分かりません )
* Java8 の日付時刻API には対応していません ( 標準で用意されているのは Date, Calendar に対応するメソッドでした )。ただし、独自変換のクラスを作成すれば対応できます。
* 個人的には使いやすいライブラリだと思います。

# ライブラリのサイト
GitHub
https://github.com/uniVocity/univocity-parsers
公式サイトの About ページ
http://www.univocity.com/pages/about-parsers
公式サイトの Documentation ページ ( Tutorials、GitHub、Javadocs へのリンクがあります )
https://www.univocity.com/pages/univocity_parsers_documentation

# ライセンス
Apache license 2.0 です。[公式サイトの About ページ](http://www.univocity.com/pages/about-parsers) に記載があります。

# インストール方法
```pom.xml
<dependency>
    <groupId>com.univocity</groupId>
    <artifactId>univocity-parsers</artifactId>
    <version>1.5.6</version>
    <type>jar</type>
</dependency>
```
```build.gradle
dependencies {
    .....
    compile("com.univocity:univocity-parsers:1.5.6")
    .....
}
```

# サンプル ( CSV のみ )

* JUnit4 のテストメソッドで書きます。自分は Spring Boot のプロジェクトを作成して試しています。
* String の配列の取得結果を出力するのに Guava の Joiner を使用しています。
* JavaBean の Getter/Setter の定義やデータ出力のために Lombok の @Data, @ToString を使用しています。

## CSV ファイルを読み込む
CSV ファイルのサンプルとして以下のものを使用します。

```テストデータ.csv
商品コード,商品名,価格,種別,在庫,説明,登録日
SAMPLE-001,サンプル１,1234567890,通常商品,有,"これは""サンプル""です",2015/09/27 12:03:45
SAMPLE-002,サンプル２,5827,メーカー直送商品,無,"説明に改行を入れてみます。
２行目です",未登録
SAMPLE-003,サンプル３,-,テスト商品,無,"価格も登録日も未定です",-

# ↑上は空行、←ここはコメント行です
sample-004, サンプル４ ,8987  ,通常商品,有,"商品名、価格の前後に空白あり",2015/09/25 08:31:00
```

サンプルを書いている時の CSV ファイルは以下の仕様です。

* 文字コードは Windows-31J
* 改行コードは CR+LF
* 項目は必要なもののみダブルクォーテーションで囲む ( 「説明」だけ囲っています )
* データ内の改行あり
* 価格、登録日は未定の場合 "-" にする
* 空行、コメント行あり

### List に一気に読み込む
```java
    @Test
    public void uniVocityParsersTest_001() throws Exception {
        CsvParserSettings settings = new CsvParserSettings();
        settings.getFormat().setLineSeparator("\r\n");  // 改行コードは CR+LF
        settings.setHeaderExtractionEnabled(true);      // 1行目はヘッダ行としてスキップする

        CsvParser parser = new CsvParser(settings);

        try (
                BufferedReader br
                        = Files.newBufferedReader(Paths.get("C:/tmp/テストデータ.csv")
                        , Charset.forName("Windows-31J"));
        ) {
            List<String[]> allRows = parser.parseAll(br);
            allRows.stream().forEach(row -> System.out.println(Joiner.on(", ").join(row)));
        }
    }
```
```出力結果.
SAMPLE-001, サンプル１, 1234567890, 通常商品, 有, これは"サンプル"です, 2015/09/27 12:03:45
SAMPLE-002, サンプル２, 5827, メーカー直送商品, 無, 説明に改行を入れてみます。
２行目です, 未登録
SAMPLE-003, サンプル３, -, テスト商品, 無, 価格も登録日も未定です, -
sample-004, サンプル４, 8987, 通常商品, 有, 商品名、価格の前後に空白あり, 2015/09/25 08:31:00
```

* デフォルトの設定で以下の仕様になっています。
  * 空行はスキップされます。
  * # で始まる行はコメント行としてスキップされます。
  * データの前後の空白は trim されます。
  * データ内の改行はデータの一部とみなされます。

### １行ずつ読み込む
```java
    @Test
    public void uniVocityParsersTest_002() throws Exception {
        CsvParserSettings settings = new CsvParserSettings();
        settings.getFormat().setLineSeparator("\r\n");  // 改行コードは CR+LF
        settings.setHeaderExtractionEnabled(true);      // 1行目はヘッダ行としてスキップする

        CsvParser parser = new CsvParser(settings);

        try (
                BufferedReader br
                        = Files.newBufferedReader(Paths.get("C:/tmp/テストデータ.csv")
                        , Charset.forName("Windows-31J"));
        ) {
            parser.beginParsing(br);
            String[] row;
            while ((row = parser.parseNext()) != null) {
                System.out.println(Joiner.on(", ").join(row));
            }
            // ファイルの最後まで読みこめば自動的にクローズされるが、読み込まない場合には以下のメソッドを呼び出す
            // parser.stopParsing();
        }
    }
```
```出力結果.
SAMPLE-001, サンプル１, 1234567890, 通常商品, 有, これは"サンプル"です, 2015/09/27 12:03:45
SAMPLE-002, サンプル２, 5827, メーカー直送商品, 無, 説明に改行を入れてみます。
２行目です, 未登録
SAMPLE-003, サンプル３, -, テスト商品, 無, 価格も登録日も未定です, -
sample-004, サンプル４, 8987, 通常商品, 有, 商品名、価格の前後に空白あり, 2015/09/25 08:31:00
```

### 読み込む時に商品コードを大文字に変換する＆ヘッダー行を取得する
```java
    @Test
    public void uniVocityParsersTest_003() throws Exception {
        CsvParserSettings settings = new CsvParserSettings();
        settings.getFormat().setLineSeparator("\r\n");  // 改行コードは CR+LF
        settings.setHeaderExtractionEnabled(true);      // 1行目はヘッダ行としてスキップする

        // 商品コードは必ず英大文字に変換する
        ObjectRowListProcessor rowProcessor = new ObjectRowListProcessor();
        rowProcessor.convertFields(Conversions.toUpperCase()).set("商品コード");
        settings.setProcessor(rowProcessor);

        CsvParser parser = new CsvParser(settings);

        try (
                BufferedReader br
                        = Files.newBufferedReader(Paths.get("C:/tmp/テストデータ.csv")
                        , Charset.forName("Windows-31J"));
        ) {
            parser.parse(br);
            String[] headers = rowProcessor.getHeaders();       // ヘッダー行を取得する
            List<Object[]> allRows = rowProcessor.getRows();    // データ行を取得する
            System.out.println(Joiner.on(", ").join(headers));
            allRows.stream().forEach(row -> System.out.println(Joiner.on(", ").join(row)));
        }
    }
```
```出力結果.
商品コード, 商品名, 価格, 種別, 在庫, 説明, 登録日
SAMPLE-001, サンプル１, 1234567890, 通常商品, 有, これは"サンプル"です, 2015/09/27 12:03:45
SAMPLE-002, サンプル２, 5827, メーカー直送商品, 無, 説明に改行を入れてみます。
２行目です, 未登録
SAMPLE-003, サンプル３, -, テスト商品, 無, 価格も登録日も未定です, -
SAMPLE-004, サンプル４, 8987, 通常商品, 有, 商品名、価格の前後に空白あり, 2015/09/25 08:31:00
```

* ObjectRowListProcessor を定義することで以下のことが出来るようになります。
  * ヘッダー行を String[] 型の変数に取得できます。
  * 変換ルールを定義することが出来ます。変換ルールは List<Object[]> にデータを取得する時よりも JavaBean に変換する時にアノテーションで指定する方がメインの使われ方になると思います。

### JavaBean に変換する
```java
import com.univocity.parsers.annotations.*;
import lombok.Data;
import lombok.ToString;
import java.math.BigDecimal;

@Data
@ToString
public class Item {

    // @Trim を付けると前後の半角スペースが除去される
    @Trim
    // 英大文字に変換する
    @UpperCase
    // CSVファイルのカラムと関連付けるフィールドには @Parsed を付ける
    @Parsed(field = "商品コード")
    private String itemCode;

    @Trim
    @Parsed(field = "商品名")
    private String itemName;
    
    // "-" は null とみなす
    @NullString(nulls = { "-" })
    // null の場合には 0 にする ( 空の場合も 0 になる )
    @Parsed(field = "価格", defaultNullRead = "0")
    private BigDecimal price;

    @Trim
    @Parsed(field = "種別")
    private String itemType;

    @BooleanString(falseStrings = { "無" }, trueStrings = { "有" })
    @Parsed(field = "在庫")
    private Boolean stock;

    @Trim
    // index ( 0～ )でも指定可能
    @Parsed(index = 5)
    private String description;

    @NullString(nulls = { "未登録", "-" })
    @Parsed(field = "登録日")
    private String dateOfRegistration;

}
```
```java
    @Test
    public void uniVocityParsersTest_004() throws Exception {
        CsvParserSettings settings = new CsvParserSettings();
        settings.getFormat().setLineSeparator("\r\n");  // 改行コードは CR+LF
        settings.setHeaderExtractionEnabled(true);      // 1行目はヘッダ行としてスキップする

        BeanListProcessor<Item> rowProcessor = new BeanListProcessor<>(Item.class);
        settings.setProcessor(rowProcessor);

        CsvParser parser = new CsvParser(settings);

        try (
                BufferedReader br
                        = Files.newBufferedReader(Paths.get("C:/tmp/テストデータ.csv")
                        , Charset.forName("Windows-31J"));
        ) {
            parser.parse(br);
            List<Item> allRows = rowProcessor.getBeans();       // データ行を取得する
            allRows.stream().forEach(System.out::println);
        }
    }
```
```出力結果.
Item(itemCode=SAMPLE-001, itemName=サンプル１, price=1234567890, itemType=通常商品, stock=true, description=これは"サンプル"です, dateOfRegistration=2015/09/27 12:03:45)
Item(itemCode=SAMPLE-002, itemName=サンプル２, price=5827, itemType=メーカー直送商品, stock=false, description=説明に改行を入れてみます。
２行目です, dateOfRegistration=null)
Item(itemCode=SAMPLE-003, itemName=サンプル３, price=0, itemType=テスト商品, stock=false, description=価格も登録日も未定です, dateOfRegistration=null)
Item(itemCode=SAMPLE-004, itemName=サンプル４, price=8987, itemType=通常商品, stock=true, description=商品名、価格の前後に空白あり, dateOfRegistration=2015/09/25 08:31:00)
```

アノテーションの設定が正しくないと現在の CsvParserSettings の設定が出力されます。上の実装だと以下のように出力されました。

```java
Parser Configuration: CsvParserSettings:
    Column reordering enabled=true
    Empty value=null
    Header extraction enabled=true
    Headers=null
    Ignore leading whitespaces=true
    Ignore trailing whitespaces=true
    Input buffer size=1048576
    Input reading on separate thread=true
    Line separator detection enabled=false
    Maximum number of characters per column=4096
    Maximum number of columns=512
    Null value=null
    Number of records to read=all
    Parse unescaped quotes=true
    Row processor=com.univocity.parsers.common.processor.BeanListProcessor
    Selected fields=none
    Skip empty lines=trueFormat configuration:
    CsvFormat:
        Comment character=#
        Field delimiter=,
        Line separator (normalized)=\n
        Line separator sequence=\r\n
        Quote character="
        Quote escape character=quote escape
        Quote escape escape character=\0
```

### JavaBean に変換する ( 種別は文字列からコードへ変換し、登録日は String → LocalDateTime 型の変数へセットする )

独自変換をする場合には Conversion インターフェースを実装したクラスを作成して、JavaBean に @Convert アノテーションで作成したクラスを使用するよう設定します。

最初に種別を文字列からコードへ変換する ItemTypeStringToCodeConversion クラスを作成します。 

```java
import com.univocity.parsers.conversions.Conversion;
import lombok.Getter;

public class ItemTypeStringToCodeConversion implements Conversion<String, String> {
    
    public ItemTypeStringToCodeConversion(String... args) {
    }

    @Override
    public String execute(String input) {
        return ItemTypeValues.getValue(input);
    }

    @Override
    public String revert(String input) {
        return ItemTypeValues.getText(input);
    }

    @Getter
    public enum ItemTypeValues {
        NORMAL_ITEM("0001", "通常商品")
        , SENDFROMMAKER_ITEM("0002", "メーカー直送商品")
        , TEST_ITEM("9999", "テスト商品");

        private final String value;
        private final String text;

        ItemTypeValues(String value, String text) {
            this.value = value;
            this.text = text;
        }

        public static String getValue(String text) {
            String result = "";
            for (ItemTypeValues val : ItemTypeValues.values()) {
                if (val.getText().equals(text)) {
                    result = val.getValue();
                }
            }

            return result;
        }

        public static String getText(String value) {
            String result = "";
            for (ItemTypeValues val : ItemTypeValues.values()) {
                if (val.getValue().equals(value)) {
                    result = val.getText();
                }
            }

            return result;
        }
    }
}
```

次に登録日を String → LocalDateTime へ変換する StringToLocalDateTimeConversion クラスを作成します。

```java
import com.univocity.parsers.conversions.Conversion;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class StringToLocalDateTimeConversion implements Conversion<String, LocalDateTime> {

    public StringToLocalDateTimeConversion(String... args) {
    }
    
    @Override
    public LocalDateTime execute(String input) {
        LocalDateTime result = null;
        if (input != null) {
            result = LocalDateTime.parse(input, DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"));
        }
        return result;
    }

    @Override
    public String revert(LocalDateTime input) {
        String result = null;
        if (input != null) {
            result = input.format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"));
        }
        return result;
    }

}
```

種別と登録日のフィールドに @Convert で作成したクラスを指定します。

```java
import com.univocity.parsers.annotations.*;
import lombok.Data;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@ToString
public class Item {

    // @Trim を付けると前後の半角スペースが除去される
    @Trim
    // 英大文字に変換する
    @UpperCase
    // CSVファイルのカラムと関連付けるフィールドには @Parsed を付ける
    @Parsed(field = "商品コード")
    private String itemCode;

    @Trim
    @Parsed(field = "商品名")
    private String itemName;
    
    // "-" は null とみなす
    @NullString(nulls = { "-" })
    // null の場合には 0 にする ( 空の場合も 0 になる )
    @Parsed(field = "価格", defaultNullRead = "0")
    private BigDecimal price;

    @Trim
    @Convert(conversionClass = ItemTypeStringToCodeConversion.class)
    @Parsed(field = "種別")
    private String itemType;

    @BooleanString(falseStrings = { "無" }, trueStrings = { "有" })
    @Parsed(field = "在庫")
    private Boolean stock;

    @Trim
    // index ( 0～ )でも指定可能
    @Parsed(index = 5)
    private String description;

    @NullString(nulls = { "未登録", "-" })
    @Convert(conversionClass = StringToLocalDateTimeConversion.class)
    @Parsed(field = "登録日")
    private LocalDateTime dateOfRegistration;

}
```

最後に uniVocityParsersTest_004 メソッドを実行します。

```出力結果.
Item(itemCode=SAMPLE-001, itemName=サンプル１, price=1234567890, itemType=0001, stock=true, description=これは"サンプル"です, dateOfRegistration=2015-09-27T12:03:45)
Item(itemCode=SAMPLE-002, itemName=サンプル２, price=5827, itemType=, stock=false, description=説明に改行を入れてみます。
２行目です, dateOfRegistration=null)
Item(itemCode=SAMPLE-003, itemName=サンプル３, price=0, itemType=9999, stock=false, description=価格も登録日も未定です, dateOfRegistration=null)
Item(itemCode=SAMPLE-004, itemName=サンプル４, price=8987, itemType=0001, stock=true, description=商品名、価格の前後に空白あり, dateOfRegistration=2015-09-25T08:31)
```

## CSVファイルを書き出す

### writeRow メソッドで書き出す
```java
    @Test
    public void uniVocityParsersTest_005() throws Exception {
        try (
                BufferedWriter bw
                        = Files.newBufferedWriter(Paths.get("C:/tmp/テストデータ２.csv")
                        , Charset.forName("Windows-31J")
                        , StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
                ) {
            CsvWriter writer = new CsvWriter(bw, new CsvWriterSettings());
            writer.writeHeaders("商品コード", "商品名", "説明");
            writer.writeRow("SAMPLE-101", "サンプル１０１", "これはテストです");
            writer.writeRow("SAMPLE-102", "サンプル１０２", "途中に\"コメント\",\"コメント２\"を入れてみます");
            writer.close();
        }
    }
```
```出力結果.
商品コード,商品名,説明
SAMPLE-101,サンプル１０１,これはテストです
SAMPLE-102,サンプル１０２,"途中に""コメント"",""コメント２""を入れてみます"
```

### JavaBean のデータを出力する

JavaBean は上で作成した Item クラスを利用します。Conversion のルールは Write 時にも適用されます。

```java
    @Test
    public void uniVocityParsersTest_006() throws Exception {
        List<Item> itemList = new ArrayList<>();
        // １件目
        Item item = new Item();
        item.setItemCode("SAMPLE-201");
        item.setItemName("サンプル２０１号");
        item.setPrice(new BigDecimal("1234567890"));
        item.setItemType("0001");
        item.setStock(false);
        item.setDescription("\"１行目\",\"コメントあり\"の商品です");
        item.setDateOfRegistration(LocalDateTime.of(2015, 9, 27, 10, 15, 0));
        itemList.add(item);
        // ２件目
        item = new Item();
        item.setItemCode("SAMPLE-305");
        item.setItemName("サンプル３０５号");
        item.setPrice(new BigDecimal("56000"));
        item.setItemType("9999");
        item.setStock(true);
        item.setDescription("２行目はテスト商品です");
        item.setDateOfRegistration(LocalDateTime.of(2015, 9, 28, 0, 0, 0));
        itemList.add(item);

        try (
                BufferedWriter bw
                        = Files.newBufferedWriter(Paths.get("C:/tmp/テストデータ２.csv")
                        , Charset.forName("Windows-31J")
                        , StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
        ) {
            CsvWriterSettings settings = new CsvWriterSettings();
            settings.setHeaders("商品コード", "商品名", "価格", "種別", "在庫", "説明", "登録日");

            BeanWriterProcessor<Item> writerProcessor = new BeanWriterProcessor<>(Item.class);
            settings.setRowWriterProcessor(writerProcessor);
            
            CsvWriter writer = new CsvWriter(bw, settings);
            writer.writeHeaders();
            writer.processRecordsAndClose(itemList);
        }
    }
```
```出力結果.
商品コード,商品名,価格,種別,在庫,説明,登録日
SAMPLE-201,サンプル２０１号,1234567890,通常商品,無,"""１行目"",""コメントあり""の商品です",2015/09/27 10:15:00
SAMPLE-305,サンプル３０５号,56000,テスト商品,有,２行目はテスト商品です,2015/09/28 00:00:00
```