CSVダウンロードを作成してみた

書き方
```ruby:qiita.rb

package your.package;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.*;

public class CsvReaderUtils {

    public static <T> List<T> readCsv(Reader reader, Class<T> clazz) throws IOException {
        List<T> result = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(reader)) {
            String headerLine = readCsvLine(br);
            if (headerLine == null || headerLine.trim().isEmpty()) {
                throw new CsvValidationException("ヘッダーが存在しません。", 1, "ヘッダー");
            }

            String[] headers = parseCsvRow(headerLine);
            Map<String, Field> headerFieldMap = buildHeaderFieldMap(clazz);

            for (String header : headers) {
                if (!headerFieldMap.containsKey(header.trim())) {
                    throw new CsvValidationException("ヘッダーがDTOと一致しません。", 1, header);
                }
            }

            String line;
            int lineNumber = 2;
            while ((line = readCsvLine(br)) != null) {
                String[] values = parseCsvRow(line);
                T obj = mapToDto(clazz, headerFieldMap, headers, values, lineNumber);
                result.add(obj);
                lineNumber++;
            }
        } catch (CsvValidationException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException("CSV読み込みエラー", e);
        }
        return result;
    }

    private static String readCsvLine(BufferedReader br) throws IOException {
        StringBuilder sb = new StringBuilder();
        String line;
        boolean inQuotes = false;

        while ((line = br.readLine()) != null) {
            if (sb.length() > 0) sb.append("\n");
            sb.append(line);
            int quoteCount = countQuotes(sb.toString());
            inQuotes = (quoteCount % 2) != 0;
            if (!inQuotes) break;
        }
        return sb.length() == 0 ? null : sb.toString();
    }

    private static int countQuotes(String str) {
        return (int) str.chars().filter(c -> c == '"').count();
    }

    private static String[] parseCsvRow(String line) {
        List<String> result = new ArrayList<>();
        boolean inQuotes = false;
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < line.length(); i++) {
            char c = line.charAt(i);
            if (c == '"') {
                if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
                    sb.append('"');
                    i++;
                } else {
                    inQuotes = !inQuotes;
                }
            } else if (c == ',' && !inQuotes) {
                result.add(sb.toString().trim().replaceAll("\\s+$", ""));
                sb.setLength(0);
            } else {
                sb.append(c);
            }
        }
        result.add(sb.toString().trim().replaceAll("\\s+$", ""));
        return result.toArray(new String[0]);
    }

    private static <T> Map<String, Field> buildHeaderFieldMap(Class<T> clazz) {
        Map<String, Field> map = new HashMap<>();
        for (Field field : clazz.getDeclaredFields()) {
            CsvColumn anno = field.getAnnotation(CsvColumn.class);
            String key = (anno != null ? anno.name() : field.getName()).trim();
            field.setAccessible(true);
            map.put(key, field);
        }
        return map;
    }

    private static <T> T mapToDto(Class<T> clazz, Map<String, Field> headerFieldMap,
                                  String[] headers, String[] values, int lineNumber) throws Exception {
        T obj = clazz.getDeclaredConstructor().newInstance();

        for (int i = 0; i < headers.length; i++) {
            String header = headers[i].trim();
            Field field = headerFieldMap.get(header);
            if (field == null) continue;

            String rawValue = values[i].trim().replaceAll("\\s+$", "");

            CsvValidation validation = field.getAnnotation(CsvValidation.class);

            // === クォート必須チェック ===
            boolean hasSurroundingQuotes = rawValue.startsWith("\"") && rawValue.endsWith("\"");
            if (!hasSurroundingQuotes) {
                throw new CsvValidationException("項目がダブルクォーテーションで囲まれていません。", lineNumber, header);
            }

            // === クォートを外して、内部 "" を " に戻す ===
            String normalized = rawValue.substring(1, rawValue.length() - 1).replaceAll("\"\"", "\"");

            // === 必須／長さ／パターンチェック ===
            if (validation != null) {
                if (validation.required() && normalized.isEmpty()) {
                    throw new CsvValidationException("必須項目が未入力です。", lineNumber, header);
                }
                CsvValidationException.checkLength(normalized, validation.minLength(), validation.maxLength(), lineNumber, header);
                CsvValidationException.checkPattern(normalized, validation.pattern(), lineNumber, header);
            }

            // === 型変換 ===
            Class<?> fieldType = field.getType();
            try {
                if (fieldType == int.class || fieldType == Integer.class) {
                    normalized = normalized.replaceFirst("^0+(?!$)", "");
                    field.set(obj, Integer.parseInt(normalized));
                } else if (fieldType == long.class || fieldType == Long.class) {
                    normalized = normalized.replaceFirst("^0+(?!$)", "");
                    field.set(obj, Long.parseLong(normalized));
                } else if (fieldType == double.class || fieldType == Double.class) {
                    normalized = normalized.replaceFirst("^0+(?!$)", "");
                    field.set(obj, Double.parseDouble(normalized));
                } else if (fieldType == BigDecimal.class) {
                    normalized = normalized.replaceFirst("^0+(?!$)", "");
                    field.set(obj, new BigDecimal(normalized));
                } else {
                    field.set(obj, normalized);
                }
            } catch (NumberFormatException e) {
                throw new CsvValidationException("数値変換エラー: " + e.getMessage(), lineNumber, header);
            }
        }

        return obj;
    }
}


// CsvValidationException.java
package your.package;

/**
 * CSVファイル読み込み時のバリデーションエラーを示すカスタム例外。
 * 文字数や文字種のチェックにも対応。
 */
public class CsvValidationException extends RuntimeException {

    /** エラーが発生したCSVファイルの行番号 */
    private final int lineNumber;

    /** エラーが発生したCSVファイルの項目名（ヘッダー名） */
    private final String columnName;

    /**
     * 詳細メッセージ、行番号、項目名を指定して例外を作成する。
     *
     * @param message エラーの詳細な説明
     * @param lineNumber エラーが発生した行番号
     * @param columnName エラーが発生した項目名
     */
    public CsvValidationException(String message, int lineNumber, String columnName) {
        super(message);
        this.lineNumber = lineNumber;
        this.columnName = columnName;
    }

    /**
     * エラーが発生した行番号を取得する。
     *
     * @return 行番号
     */
    public int getLineNumber() {
        return lineNumber;
    }

    /**
     * エラーが発生した項目名を取得する。
     *
     * @return 項目名
     */
    public String getColumnName() {
        return columnName;
    }

    /**
     * 詳細なエラー情報を含むメッセージを取得する。
     *
     * @return エラーメッセージ
     */
    @Override
    public String getMessage() {
        return String.format("CSVバリデーションエラー [行番号: %d, 項目: '%s']: %s", lineNumber, columnName, super.getMessage());
    }

    /**
     * 文字列が指定した長さの範囲内であるかをチェックする。
     *
     * @param value チェックする文字列
     * @param minLength 最小文字数
     * @param maxLength 最大文字数
     * @param lineNumber チェック対象の行番号
     * @param columnName チェック対象の項目名
     */
    public static void checkLength(String value, int minLength, int maxLength, int lineNumber, String columnName) {
        if (value.length() < minLength || value.length() > maxLength) {
            throw new CsvValidationException("文字数が範囲外です（最小: " + minLength + "、最大: " + maxLength + "）", lineNumber, columnName);
        }
    }

    /**
     * 文字列が指定した正規表現パターンに一致するかをチェックする。
     *
     * @param value チェックする文字列
     * @param pattern 正規表現パターン
     * @param lineNumber チェック対象の行番号
     * @param columnName チェック対象の項目名
     */
    public static void checkPattern(String value, String pattern, int lineNumber, String columnName) {
        if (!value.matches(pattern)) {
            throw new CsvValidationException("指定された文字種以外が含まれています。", lineNumber, columnName);
        }
    }
}


package your.package;

import java.lang.annotation.*;

/**
 * CSVファイルのヘッダー名を指定するためのアノテーション。
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface CsvColumn {
    /** CSVファイルのヘッダー名 */
    String name();
}

package your.package;

import java.lang.annotation.*;

/**
 * CSV項目のバリデーションを指定するためのアノテーション。
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface CsvValidation {
    /** 最小文字数 */
    int minLength() default 0;

    /** 最大文字数 */
    int maxLength() default Integer.MAX_VALUE;

    /** 文字種を制限する正規表現 */
    String pattern() default ".*";
}

package your.package;

/**
 * CSVファイルから読み込まれる個人情報のDTOクラス。
 */
public class PersonDto {

    /** 氏名 */
    @CsvColumn(name = "氏名")
    @CsvValidation(minLength = 1, maxLength = 50, pattern = "^[ぁ-んァ-ヶ一-龥]+$")
    private String name;

    /** 年齢 */
    @CsvColumn(name = "年齢")
    private int age;

    /** 在住地 */
    @CsvColumn(name = "在住地")
    @CsvValidation(maxLength = 100)
    private String address;

    // getter と setter
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }

    public String getAddress() { return address; }
    public void setAddress(String address) { this.address = address; }
}

package your.package;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Field;
import java.util.*;

/**
 * CSVファイルを読み込み、指定されたDTOクラスにマッピングするユーティリティクラス。
 */
public class CsvReaderUtils {

    /**
     * CSVファイルを読み込みDTOのリストに変換する。
     *
     * @param reader CSVファイルのリーダー
     * @param clazz DTOクラス
     * @param <T> DTOの型
     * @return DTOのリスト
     * @throws IOException ファイル読み込みエラー
     */
    public static <T> List<T> readCsv(Reader reader, Class<T> clazz) throws IOException {
        List<T> result = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(reader)) {
            String headerLine = readCsvLine(br);
            if (headerLine == null || headerLine.trim().isEmpty()) {
                throw new CsvValidationException("ヘッダーが存在しません。", 1, "ヘッダー");
            }

            String[] headers = parseCsvRow(headerLine);
            Map<String, Field> headerFieldMap = buildHeaderFieldMap(clazz);

            for (String header : headers) {
                if (!headerFieldMap.containsKey(header.trim())) {
                    throw new CsvValidationException("ヘッダーがDTOと一致しません。", 1, header);
                }
            }

            String line;
            int lineNumber = 2;
            while ((line = readCsvLine(br)) != null) {
                String[] values = parseCsvRow(line);
                T obj = mapToDto(clazz, headerFieldMap, headers, values, lineNumber);
                result.add(obj);
                lineNumber++;
            }
        } catch (CsvValidationException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException("CSV読み込みエラー", e);
        }
        return result;
    }

    /** CSVの1行を読み取る。 */
    private static String readCsvLine(BufferedReader br) throws IOException {
        StringBuilder sb = new StringBuilder();
        String line;
        boolean inQuotes = false;

        while ((line = br.readLine()) != null) {
            if (sb.length() > 0) sb.append("\n");
            sb.append(line);
            int quoteCount = countQuotes(sb.toString());
            inQuotes = (quoteCount % 2) != 0;
            if (!inQuotes) break;
        }
        return sb.length() == 0 ? null : sb.toString();
    }

    /** クォートの数を数える。 */
    private static int countQuotes(String str) {
        return (int) str.chars().filter(c -> c == '"').count();
    }

    /** CSVの1行を各項目に分割する。 */
    private static String[] parseCsvRow(String line) {
        List<String> result = new ArrayList<>();
        boolean inQuotes = false;
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < line.length(); i++) {
            char c = line.charAt(i);
            if (c == '"') {
                if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
                    sb.append('"');
                    i++;
                } else {
                    inQuotes = !inQuotes;
                }
            } else if (c == ',' && !inQuotes) {
                result.add(sb.toString().trim().replaceAll("\\s+$", ""));
                sb.setLength(0);
            } else {
                sb.append(c);
            }
        }
        result.add(sb.toString().trim().replaceAll("\\s+$", ""));
        return result.toArray(new String[0]);
    }

    /** ヘッダーとフィールドのマッピングを作成する。 */
    private static <T> Map<String, Field> buildHeaderFieldMap(Class<T> clazz) {
        Map<String, Field> map = new HashMap<>();
        for (Field field : clazz.getDeclaredFields()) {
            CsvColumn anno = field.getAnnotation(CsvColumn.class);
            String key = (anno != null ? anno.name() : field.getName()).trim();
            field.setAccessible(true);
            map.put(key, field);
        }
        return map;
    }

    /** CSVデータをDTOにマッピングし、バリデーションを実施する。 */
    private static <T> T mapToDto(Class<T> clazz, Map<String, Field> headerFieldMap,
                                  String[] headers, String[] values, int lineNumber) throws Exception {
        T obj = clazz.getDeclaredConstructor().newInstance();
        for (int i = 0; i < headers.length; i++) {
            String header = headers[i].trim();
            Field field = headerFieldMap.get(header);
            if (field == null) continue;

            String rawValue = values[i].trim().replaceAll("\\s+$", "");

            CsvValidation validation = field.getAnnotation(CsvValidation.class);
            if (validation != null) {
                CsvValidationException.checkLength(rawValue, validation.minLength(), validation.maxLength(), lineNumber, header);
                CsvValidationException.checkPattern(rawValue, validation.pattern(), lineNumber, header);
            }

            if (field.getType() == int.class || field.getType() == Integer.class) {
                rawValue = rawValue.replaceFirst("^0+(?!$)", "");
                field.set(obj, Integer.parseInt(rawValue));
            } else {
                field.set(obj, rawValue);
            }
        }
        return obj;
    }
}



```