業務システムを作っていると、必ずと言っていいほど「一覧をCSVでダウンロードしたい」「アップロードされたCSVを取り込みたい」という要件が出てきますよね。単純そうに見えて、いざ実装するとExcelで開いたら文字化けする、数十万行でメモリが溢れる、不正な行の扱いに困る、といった落とし穴が待っています。

この記事ではOpenCSVを使って、これら3つのハマりどころを回避しながらダウンロード/アップロードのエンドポイントを実装していきます。Jacksonのcsvモジュールでも読み書きはできますが、アノテーションによるBeanマッピングと細かい挙動の制御がしやすいOpenCSVを前提にします。

この記事で作るもの

ゴールは次の2つのエンドポイントです。

  • 一覧データをCSVとしてダウンロードさせるGET API
  • アップロードされたCSVをパースして取り込むPOST API

そのうえで、実務で必ず問われる「Excelで文字化けしない」「大量行でも落ちない」「不正行を検知して返す」の3点を押さえます。まずは依存の追加からいきましょう。

OpenCSVの依存を追加する

CSVのパースはOpenCSV、BOMの除去には commons-io を使うので、両方追加しておきます。Mavenならこうです。

<dependency>
    <groupId>com.opencsv</groupId>
    <artifactId>opencsv</artifactId>
    <version>5.9</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.16.1</version>
</dependency>

Gradleなら次の2行です。

implementation 'com.opencsv:opencsv:5.9'
implementation 'commons-io:commons-io:2.16.1'

OpenCSVには低レベルの CSVWriter / CSVReader と、Bean変換を担う StatefulBeanToCsv / CsvToBean があります。今回はアノテーションでマッピングしたいので、後者を中心に使っていきます。囲み文字や区切り文字を細かく制御したい場面だけ、低レベルAPIに降りていくイメージです。

CSV用のBeanを定義する

@CsvBindByName はCSVのヘッダ名とフィールドを名前で対応づけ、@CsvBindByPosition は列の位置で対応づけます。今回は取込時の名前マッピングと出力時の列順の両方を扱いたいので、両方を付けておきます。

public class UserCsv {

    @CsvBindByName(column = "ユーザーID")
    @CsvBindByPosition(position = 0)
    private Long id;

    @CsvBindByName(column = "氏名")
    @CsvBindByPosition(position = 1)
    private String name;

    @CsvBindByName(column = "メールアドレス")
    @CsvBindByPosition(position = 2)
    private String email;

    @CsvBindByName(column = "登録日")
    @CsvBindByPosition(position = 3)
    @CsvDate("yyyy/MM/dd")
    private LocalDate registeredAt;

    // getter / setter は省略
}

ここで一番ハマりやすい落とし穴を先に共有します。StatefulBeanToCsvBuilder は書き出し戦略を明示しないと、Bean上に @CsvBindByPosition が1つでもあると ColumnPositionMappingStrategy(位置ベース)を自動で選びます。そしてこの戦略は既定でヘッダ行を出力しません。つまり両方のアノテーションを付けたまま何も指定せずに書き出すと、「ユーザーID/氏名/メールアドレス/登録日」というヘッダは一切出ず、データ行だけのCSVになってしまうのです。

読み込み側も同じで、位置ベース戦略が選ばれるとヘッダ行がスキップされずに1行目がデータとして解釈されてしまいます。ですので、この記事ではダウンロードとアップロードのどちらでも戦略を明示的に渡すことで、この自動選択の罠を避けます。

なお @CsvDate はOpenCSVのバージョンによっては LocalDate にコンバータ設定が要る場合があります。コピペ後に日付だけマッピングが崩れるときは、フォーマット文字列と型の対応を最初に疑うと早いです。

CSVをダウンロードさせる

ヘッダ付き・列順固定でBeanを書き出すために、ColumnPositionMappingStrategy を継承して generateHeader だけ上書きした戦略を用意します。データは @CsvBindByPosition の位置で並ぶので、同じ順序のヘッダ文字列を返せば両者がきれいに揃います。

public class UserCsvWriteStrategy extends ColumnPositionMappingStrategy<UserCsv> {

    public UserCsvWriteStrategy() {
        setType(UserCsv.class);
    }

    @Override
    public String[] generateHeader(UserCsv bean) throws CsvRequiredFieldEmptyException {
        super.generateHeader(bean); // 位置マッピングを初期化する
        return new String[]{"ユーザーID", "氏名", "メールアドレス", "登録日"};
    }
}

ダウンロード本体はこの戦略を withMappingStrategy で渡すだけです。Excelでの文字化けを避けるため、ここではShift_JIS(Windows-31J)で書き出しています。

@GetMapping("/users/csv")
public void download(HttpServletResponse response) throws Exception {
    response.setContentType("text/csv;charset=Windows-31J");
    String filename = URLEncoder.encode("ユーザー一覧.csv", StandardCharsets.UTF_8)
            .replace("+", "%20");
    response.setHeader("Content-Disposition",
            "attachment; filename*=UTF-8''" + filename);

    Writer w = new OutputStreamWriter(
            response.getOutputStream(), Charset.forName("Windows-31J"));

    List<UserCsv> users = userService.findAllForCsv();
    var writer = new StatefulBeanToCsvBuilder<UserCsv>(w)
            .withMappingStrategy(new UserCsvWriteStrategy())
            .build();
    writer.write(users);
    w.flush();
}

日本語ファイル名は filename= にそのまま書くとブラウザによって化けるので、RFC 5987の filename*=UTF-8'' 形式でエンコードします。URLEncoder は空白を + に変換してしまい厳密にはパーセントエンコードと異なるので、replace("+", "%20") で補正しておくと空白入りのファイル名でも崩れません。Content-Typecharset を明示しておくと、Excel以外のクライアントで開いたときの化けも防げます。throws Exception は説明を短くするための書き方で、業務コードでは投げる例外を具体的に絞る方が望ましいです。

文字化けを防ぐ文字コードとBOM

ExcelでダブルクリックしたときにUTF-8のCSVが文字化けするのは、ExcelがBOMなしUTF-8をシステムの既定コードページ(日本語環境ではShift_JIS)として解釈しようとするからですね。

対策は2択です。ひとつは上のダウンロード例のように、Excelが素直に読めるShift_JIS(実体はWindows-31J / MS932)で出力する方法です。厳密にはMS932とWindows-31Jは一部の機種依存文字の扱いに差が出ることがあり、JVMや環境によって微妙に変換結果が変わる場合があるので、丸数字などを含むデータでは実データでの確認をおすすめします。

もうひとつはUTF-8のまま、先頭にBOM(EF BB BF)を付けてExcelにUTF-8だと認識させる方法です。

response.setContentType("text/csv;charset=UTF-8");
OutputStream out = response.getOutputStream();
out.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);

丸数字やローマ数字などの機種依存文字を含む可能性があるなら、Shift_JISでは変換できず化けるので、BOM付きUTF-8を選びましょう。相手が古い社内システムでUTF-8を読めない場合はShift_JIS、というのが判断基準です。BOMのバイト列そのものは Unicode の仕様で定義されているので、迷ったら一次情報を当たると確実です。

大量データはStreamingResponseBodyで流す

数十万行を List に全部載せてから書き出すと、レスポンスを組み立てる前にヒープが溢れます。StreamingResponseBody を使えば、DBから少しずつ取りながらOutputStreamへ逐次書き込めます。

@GetMapping("/users/csv/large")
public ResponseEntity<StreamingResponseBody> downloadLarge() {
    StreamingResponseBody body = out -> {
        out.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
        Writer w = new OutputStreamWriter(out, StandardCharsets.UTF_8);
        var beanToCsv = new StatefulBeanToCsvBuilder<UserCsv>(w)
                .withMappingStrategy(new UserCsvWriteStrategy())
                .build();

        int page = 0;
        List<UserCsv> chunk;
        try {
            do {
                chunk = userService.findForCsv(PageRequest.of(page++, 1000));
                beanToCsv.write(chunk);
                w.flush();
            } while (!chunk.isEmpty());
        } catch (CsvDataTypeMismatchException | CsvRequiredFieldEmptyException e) {
            // writeTo は IOException しか投げられないのでラップする
            throw new IOException("CSVの書き出しに失敗しました", e);
        }
    };

    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=users.csv")
            .body(body);
}

ポイントは3つです。ページングで取得してチャンクごとに flush() すること、write() が投げる検査例外(CsvDataTypeMismatchException など)は writeToIOException しか許容しないため IOException にラップすること、そして StatefulBeanToCsv は最初の write() 呼び出しでのみヘッダ行を出力し、2回目以降はデータ行だけ追記するのでページごとに呼んでもヘッダは重複しないことです。

ひとつ注意したいのが、ヘッダ(ステータス200)を送った後に途中で例外が出ると、200のまま壊れたCSVが返ってしまう点です。受け取り側で完全性を検証できるよう、末尾に想定行数を書いた行を付ける、あるいはトレーラで件数を返すといった仕掛けを入れておくと、途中で切れたファイルを取り込んでしまう事故を防げます。本格的な大量取込・出力が必要なら Spring Batchで大量データを安全に処理する方法 と組み合わせるのが安全です。

アップロードされたCSVを取り込む

次はアップロードです。MultipartFile のInputStreamを CsvToBean に渡してパースします。取込は名前でマッピングしたいので、HeaderColumnNameMappingStrategy を明示的に指定します。これで位置ベース戦略の自動選択を避け、ヘッダ行が正しくスキップされて列の順序が多少違っても取り込めます。アップロード側でもBOMの除去と文字コード指定を忘れずに。

@PostMapping("/users/csv")
@Transactional
public ResponseEntity<?> upload(@RequestParam MultipartFile file) throws Exception {
    // BOM付きでも透過的に読める BOMInputStream を使う
    InputStream bomIn = BOMInputStream.builder()
            .setInputStream(file.getInputStream())
            .get();
    Reader reader = new InputStreamReader(bomIn, StandardCharsets.UTF_8);

    var strategy = new HeaderColumnNameMappingStrategy<UserCsv>();
    strategy.setType(UserCsv.class);

    CsvToBean<UserCsv> csvToBean = new CsvToBeanBuilder<UserCsv>(reader)
            .withMappingStrategy(strategy)
            .withIgnoreLeadingWhiteSpace(true)
            .build();

    List<UserCsv> rows = csvToBean.parse();
    // ここでバリデーションへ
}

BOMInputStream(commons-io)を挟むと、BOM付き・なしのどちらのファイルが来ても先頭に余計な文字が混ざりません。commons-io 2.12以降では new BOMInputStream(in) コンストラクタが非推奨になり、上のように BOMInputStream.builder() を使う記法が推奨です。@CsvBindByName を付けているので、ヘッダ名が想定と違えばマッピングに失敗し、フォーマット違いを早期に検出できます。

メソッドに付けた @Transactional は、取り込んだ行をDBへ保存する処理までを1つのトランザクションにまとめる意図です。「1件でもエラーなら全体ロールバック」にしたい場合、保存処理をこのメソッド内(同じトランザクション境界)に収めるのがポイントです。

行単位でバリデーションしてエラー行を返す

取り込むだけなら簡単ですが、実務では「何行目のどの項目が不正か」を返せることが求められます。Bean Validationを各行に適用し、エラーを行番号付きで集約しましょう。

private final Validator validator; // jakarta.validation

List<RowError> errors = new ArrayList<>();
List<UserCsv> valid = new ArrayList<>();
int line = 2; // ヘッダを1行目としてデータは2行目から

for (UserCsv row : rows) {
    var violations = validator.validate(row);
    if (violations.isEmpty()) {
        valid.add(row);
    } else {
        for (var v : violations) {
            errors.add(new RowError(line, v.getPropertyPath().toString(), v.getMessage()));
        }
    }
    line++;
}

RowError は行番号・項目名・メッセージを持つだけのDTOです。あとは方針次第で、エラーが1件でもあれば全体を止めて errors を返すか、正常行(valid)だけ取り込んでエラー一覧を併せて返すかを選びます。取込系は「全件成功か全件失敗か」を明確にしたいことが多いので、まずはエラーが1件でもあれば例外を投げて @Transactional にロールバックさせる設計にしておくと事故が減ります。

CSV固有のエッジケースに対応する

最後に現場で崩れやすい箇所です。フィールドにカンマや改行、ダブルクォートが含まれる場合、RFC 4180ではフィールド全体をダブルクォートで囲み、内部のクォートは2重にしてエスケープします。OpenCSVはこの読み書きを標準で処理してくれるので、基本は意識しなくて大丈夫です。

タブ区切り(TSV)や独自の囲み文字を扱いたいときは CSVParserBuilder で作ったパーサを withCSVParser で結び付けます。

CSVParser parser = new CSVParserBuilder()
        .withSeparator('\t')      // タブ区切り
        .withQuoteChar('"')
        .build();

CsvToBean<UserCsv> csvToBean = new CsvToBeanBuilder<UserCsv>(reader)
        .withType(UserCsv.class)
        .withCSVParser(parser)    // 作ったパーサを接続する
        .build();

withSeparator を直接指定する方法もありますが、囲み文字なども合わせてカスタマイズするなら withCSVParser でまとめて渡す方が一貫します。空行や列数が合わない行が混ざると例外になることがあるので、取込前に軽く行数チェックを入れるか、例外を握って RowError に落とすと親切です。

まとめ

OpenCSVを使ったCSVの出力と取込を、実務要件に沿って実装してきました。振り返ると、ダウンロードは書き出し戦略を明示してヘッダと列順を固定するのが肝、文字化けはBOM付きUTF-8かShift_JISかを最初に決める、大量データは StreamingResponseBody でチャンク出力、取込は HeaderColumnNameMappingStrategy でパースして行単位バリデーションでエラー行を返す、という流れでしたね。

特に、@CsvBindByPosition があると位置ベース戦略が自動で選ばれてヘッダが消える挙動は、最初に知っておくと無駄なハマりを避けられます。

Excel形式やPDFで帳票を出したい場合は Apache POIでExcelを出力する方法OpenPDFでPDFを生成する方法 を、汎用的なファイルの送受信は MultipartFileでファイルアップロード/ダウンロードを実装する方法 を参考にしてみてください。