本ブログはアフィリエイト広告を利用しています。

Commons CSVで読み込んだデータをアノテーションを使ってモデルクラスにいい感じにマッピングする

Commons CSVを使用して汎用的にCSVを読み込む方法です。

環境

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-csv -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
    <version>1.10.0</version>
</dependency>

CSV読み込み & マッピング

以下がサンプルコードです。実行が楽なのでテストクラスに作成しています。
CSVRecord.get(int)、CSVRecord.get(string)で読むのが使い方として多いのかなと思います。
読み込むCSVが1種類、以下の場合CsvModelAクラスのみであればこれでもいいでしょう。
しかしCsvModelB、C、Dと複数のCSVを読み込みたい場合に、このコード(主に各モデルクラスに代入するコード)を量産する必要があります。
CommonCSVにはいい感じにモデルにマッピングしてくれる機能はない(はず)のでしょうがないのですが、少々不便に感じました。

import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import org.junit.jupiter.api.Test;

public class CommonsCsvTest {

    @Test
    public void readCsv() {
        String filePath = "C:\\develop\\csv\\test.csv";
        Charset charset = Charset.forName("Shift-JIS");
        try (Reader in = new FileReader(filePath, charset)) {

            CSVFormat customCsvFormat = CSVFormat.Builder.create()
                    .setIgnoreEmptyLines(true)
                    .setIgnoreSurroundingSpaces(true)
                    .setCommentMarker('#')
                    .setSkipHeaderRecord(true)
                    .setHeader("ヘッダ1", "ヘッダ2", "ヘッダ3")
                    .build();

            Iterable<CSVRecord> records = customCsvFormat.parse(in);

            List<CsvModelA> models = new ArrayList<>();
            for (CSVRecord record : records) {
                CsvModelA modelA = new CsvModelA();
                // パターン1
                modelA.setColumn1(record.get("ヘッダ1"));
                modelA.setColumn2(record.get("ヘッダ2"));
                modelA.setColumn3(record.get("ヘッダ3"));

                // パターン2
                modelA.setColumn1(record.get(0));
                modelA.setColumn2(record.get(1));
                modelA.setColumn3(record.get(2));
                models.add(modelA);

                System.out.println(modelA.getColumn1() + ", " + modelA.getColumn2() + ", " + modelA.getColumn3());
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class CsvModelA {
    private String column1;

    private String column2;

    private String column3;
}
import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class CsvModelB {

    private String column1;

    private String column2;

    private String column3;

    private String column4;
}

汎用的にモデルクラスにマッピングする

どうにか汎用的に実装できないかなと思ってアノテーションを使用して実装してみました。

まずはヘッダを定義するためのアノテーションを作成します。
@CsvHeader(name = “xxx”)とアノテーションを付与します。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CsvHeader {
    String name();
}

このアノテーションを使用してモデルクラスを変更します。
アノテーションのnameにヘッダ名を設定します。

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class CsvModelA {
    @CsvHeader(name = "ヘッダ1")
    private String column1;

    @CsvHeader(name = "ヘッダ2")
    private String column2;

    @CsvHeader(name = "ヘッダ3")
    private String column3;
}
import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
public class CsvModelB {

    @CsvHeader(name = "ヘッダ1")
    private String column1;

    @CsvHeader(name = "ヘッダ2")
    private String column2;

    @CsvHeader(name = "ヘッダ3")
    private String column3;

    @CsvHeader(name = "ヘッダ4")
    private String column4;
}

次にCSV読み込みの実装です。
@CsvHeaderで指定された変数と読み込んだCSVのカラムをマッピング、リフレクションでモデルクラスに値を設定しています。
バリデーションも必要であれば@Size等をモデルクラスに付与することでチェックできます。
なお、以下の実装ではString, int, BigDecimalのみ対応しています。
必要であればbooleanやdoubleなども判定に追加してください。(field.set()の箇所)

import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import lombok.Data;
import lombok.experimental.Accessors;

public class CsvUtil<T> {

    public CsvResult readCsv(String filePath, Class<T> cls) throws ReflectiveOperationException, IOException {
        Charset charset = Charset.forName("Shift-JIS");
        Map<CSVRecord, Set<ConstraintViolation<T>>> violations = new LinkedHashMap<>();
        List<T> csvList = new ArrayList<>();
        try (Reader in = new FileReader(filePath, charset)) {

            CSVFormat customCsvFormat = CSVFormat.Builder.create()
                    .setIgnoreEmptyLines(true)
                    .setIgnoreSurroundingSpaces(true)
                    .setCommentMarker('#')
                    .build();

            Iterable<CSVRecord> records = customCsvFormat.parse(in);

            // ヘッダー行を取得
            CSVRecord headers = records.iterator().next();

            // モデルクラスのフィールドを取得
            Field[] fields = cls.getDeclaredFields();

            // ヘッダーとフィールドのマッピングを作成
            Map<String, Field> fieldMap = new HashMap<>();
            for (Field field : fields) {
                field.setAccessible(true);
                CsvHeader header = field.getAnnotation(CsvHeader.class);
                if (header == null) {
                    continue;
                }
                String annotationHeader = header.name();
                fieldMap.put(annotationHeader, field);
            }

            // バリデーション
            ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
            Validator validator = factory.getValidator();

            // CSVファイルの各行をループして、各列のデータをモデルクラスのフィールドにマッピング
            for (CSVRecord record : records) {
                try {
                    Constructor<T> constructor = cls.getDeclaredConstructor();
                    constructor.setAccessible(true);
                    T instance = constructor.newInstance();
                    for (int i = 0; i < headers.size(); i++) {
                        String value = record.get(i);
                        Field field = fieldMap.get(headers.get(i));
                        if (field != null) {
                            Class<?> fieldType = field.getType();
                            // 各カラムの型に変換して値を設定。以下で足りなければ追加。
                            if (fieldType == String.class) {
                                field.set(instance, value);
                            } else if (fieldType == int.class) {
                                field.set(instance, Integer.parseInt(value));
                            } else if (fieldType == BigDecimal.class) {
                                field.set(instance, new BigDecimal(value));
                            }
                        }
                    }

                    Set<ConstraintViolation<T>> vio = validator.validate(instance);
                    if (vio.size() > 0) {
                        violations.put(record, vio);
                    }

                    csvList.add(instance);
                } catch (SecurityException e) {
                    throw e;
                } catch (ReflectiveOperationException e) {
                    throw e;
                }
            }
        } catch (IOException e) {
            throw e;
        }

        CsvResult result = new CsvResult();
        result.setCsvList(csvList)
            .setViolations(violations);

        return result;
    }

    @Data
    @Accessors(chain = true)
    public class CsvResult {
        private List<T> csvList;
        private Map<CSVRecord, Set<ConstraintViolation<T>>> violations;
    }
}

最後に呼び出し元です。
以下の実装ではCsvModelAを使用していますが、CsvModelBに変更しても同様の呼び出し方法で実行できます。

public class CommonsCsvTest2 {

    @Test
    public void readCsv() throws ReflectiveOperationException, IOException {
        String filePath = "C:\\develop\\csv\\test.csv";
        CsvUtil<CsvModelA> util = new CsvUtil<>();
        var result = util.readCsv(filePath, CsvModelA.class);
        for (var r : result.getCsvList()) {
            System.out.println(r.getColumn1() + ", " + r.getColumn2() + ", " + r.getColumn3());
        }
    }
}

以上です。

コメント

タイトルとURLをコピーしました