Apache Commons CSVとSpring BootでCSV読み書きAPIを作る

CSVファイルのダウンロードとアップロードを行うAPIは、業務アプリケーションで定期的に必要になります。

Spring Bootで実装すること自体は難しくありませんが、CSVの出力形式、ヘッダーの扱い、文字コード、エラーの返し方など、実務では細かい論点が多いです。

この記事では、Apache Commons CSVを使って、Spring BootでCSVの読み書きを行うAPIをどう作るかを整理します。

この記事で分かること

  • Apache Commons CSV を Spring Boot で使う基本形
  • CSVダウンロードAPIの実装例
  • CSVアップロードAPIの実装例
  • 実務で先に決めておきたいCSV仕様

どんなAPIを作るか

今回は、カテゴリ一覧をCSVでダウンロードし、編集したCSVをアップロードして検証するAPIを題材にします。

まずは次の2本があれば十分です。

  • GET /categories/export
    • カテゴリ一覧をCSVで返す
  • POST /categories/import
    • CSVファイルを受け取り、内容を検証して結果を返す

更新処理まで全部入れることもできますが、記事としてはまず「CSVを安全に出し入れする部分」に絞った方が整理しやすいです。

flowchart LR
    Client[管理画面や利用者] --> Export[GET /categories/export]
    Export --> CsvOut[CSVダウンロード]
    Client --> Import[POST /categories/import]
    Import --> Parse[Apache Commons CSVで解析]
    Parse --> Validate[項目チェック]
    Validate --> Result[検証結果レスポンス]

Apache Commons CSV を使う理由

CSVは単純そうに見えて、カンマ区切り、ダブルクォート、改行、ヘッダー付き読み取りのような最低限の面倒があります。

文字列を split(",") するだけでは、クォート付き項目やカンマを含む値を正しく扱えません。

Apache Commons CSVを使うと、次のような基本機能を無理なく扱えます。

  • CSVFormat によるフォーマット定義
  • ヘッダー名ベースでの値取得
  • CSVPrinter によるCSV出力
  • CSVParser によるCSV入力

「すごく高機能なライブラリ」というより、CSV処理で自前実装を避けるための堅実な選択肢、という位置付けです。

依存関係

Gradleなら、依存関係は次のようになります。

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.apache.commons:commons-csv:1.14.1")
}

バージョンは執筆時点のものです。利用時は最新版を確認してください。

spring-boot-starter-web の詳細な依存バージョンは、通常はSpring Boot側の依存管理に従います。個別ライブラリとして明示的に意識しやすいのは、この記事では commons-csv の方です。

CSVダウンロードAPI

まずは、カテゴリ一覧をCSVで返すAPIです。

レスポンスヘッダーで Content-TypeContent-Disposition を付け、本文にCSV文字列を書きます。

今回の実装全体は、GitHub の massakai/java-snippets リポジトリにある apache-commons-csv-spring-boot-api にサンプルとして置いています。

package com.github.massakai.snippets.commonscsv;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@RestController
@RequestMapping("/categories")
public class CategoryController {

    private final CategoryCsvService categoryCsvService;

    public CategoryController(CategoryCsvService categoryCsvService) {
        this.categoryCsvService = categoryCsvService;
    }

    @GetMapping(value = "/export", produces = "text/csv")
    public ResponseEntity<String> exportCategories() throws IOException {
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment()
                                .filename("categories.csv")
                                .build()
                                .toString())
                .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
                .body(categoryCsvService.exportCategories());
    }

    @PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public CategoryImportResponse importCategories(@RequestParam("file") MultipartFile file)
            throws IOException {
        return categoryCsvService.importCategories(file.getInputStream());
    }
}

CSV出力の本体はサービスに寄せています。

package com.github.massakai.snippets.commonscsv;

import java.io.IOException;
import java.io.StringWriter;
import java.util.List;

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.springframework.stereotype.Service;

@Service
public class CategoryCsvService {

    private static final List<Category> SAMPLE_CATEGORIES = List.of(
            new Category(1, "Books", "Books and magazines"),
            new Category(2, "Games", "Video games and board games"),
            new Category(3, "Kitchen", "Kitchen tools")
    );

    private static final CSVFormat EXPORT_FORMAT = CSVFormat.DEFAULT.builder()
            .setHeader("id", "name", "description")
            .setRecordSeparator("\n")
            .get();

    public String exportCategories() throws IOException {
        StringWriter writer = new StringWriter();

        try (CSVPrinter printer = EXPORT_FORMAT.print(writer)) {
            for (Category category : SAMPLE_CATEGORIES) {
                printer.printRecord(category.id(), category.name(), category.description());
            }
        }

        return writer.toString();
    }
}

出力されるCSVのイメージは次の通りです。

id,name,description
1,Books,Books and magazines
2,Games,Video games and board games
3,Kitchen,Kitchen tools

CSVアップロードAPI

次に、アップロードされたCSVを読み取るAPIです。

ここでは更新処理までは入れず、まずは「読み取って検証し、結果を返す」形にしています。サンプルでは、検証に通った行もレスポンスに含めています。

package com.github.massakai.snippets.commonscsv;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.springframework.stereotype.Service;

@Service
public class CategoryCsvService {

    private static final CSVFormat IMPORT_FORMAT = CSVFormat.DEFAULT.builder()
            .setHeader()
            .setSkipHeaderRecord(true)
            .setIgnoreEmptyLines(true)
            .setTrim(true)
            .get();

    public CategoryImportResponse importCategories(InputStream inputStream) throws IOException {
        List<Category> categories = new ArrayList<>();
        List<CsvImportError> errors = new ArrayList<>();
        int totalRows = 0;

        try (BufferedReader reader =
                     new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
             CSVParser parser = IMPORT_FORMAT.parse(reader)) {

            validateHeaders(parser.getHeaderMap(), errors);

            if (!errors.isEmpty()) {
                return new CategoryImportResponse(0, 0, 0, List.of(), errors);
            }
            for (CSVRecord record : parser) {
                totalRows++;
                List<CsvImportError> rowErrors = validateRecord(record);

                if (rowErrors.isEmpty()) {
                    categories.add(new Category(
                            Integer.parseInt(record.get("id")),
                            record.get("name"),
                            record.get("description")
                    ));
                } else {
                    errors.addAll(rowErrors);
                }
            }
        }

        return new CategoryImportResponse(
                totalRows,
                categories.size(),
                totalRows - categories.size(),
                List.copyOf(categories),
                List.copyOf(errors)
        );
    }

    private void validateHeaders(Map<String, Integer> headerMap, List<CsvImportError> errors) {
        for (String header : List.of("id", "name", "description")) {
            if (!headerMap.containsKey(header)) {
                errors.add(new CsvImportError(1, header, "missing required header"));
            }
        }
    }

    private List<CsvImportError> validateRecord(CSVRecord record) {
        List<CsvImportError> errors = new ArrayList<>();
        int rowNumber = Math.toIntExact(record.getRecordNumber() + 1);
        String id = record.get("id");
        String name = record.get("name");

        if (id.isBlank()) {
            errors.add(new CsvImportError(rowNumber, "id", "must not be blank"));
        } else {
            try {
                int parsedId = Integer.parseInt(id);
                if (parsedId <= 0) {
                    errors.add(new CsvImportError(rowNumber, "id", "must be a positive integer"));
                }
            } catch (NumberFormatException ex) {
                errors.add(new CsvImportError(rowNumber, "id", "must be a positive integer"));
            }
        }

        if (name.isBlank()) {
            errors.add(new CsvImportError(rowNumber, "name", "must not be blank"));
        }

        return errors;
    }
}

結果レスポンスのDTOはシンプルです。

package com.github.massakai.snippets.commonscsv;

import java.util.List;

public record CategoryImportResponse(
        int totalRows,
        int validRows,
        int invalidRows,
        List<Category> categories,
        List<CsvImportError> errors
) {
}

エラーも文字列1本ではなく、行番号と項目名を持つ構造にしておくと扱いやすいです。

package com.github.massakai.snippets.commonscsv;

public record CsvImportError(
        int rowNumber,
        String field,
        String message
) {
}

実装時に先に決めたいこと

CSV APIは、実装より先に仕様を決めた方が楽です。

特に次は最初に固定しておくとぶれにくいです。

  • 文字コードはUTF-8だけにするか
  • ヘッダー行を必須にするか
  • 空行を許可するか
  • IDや日付の表現をどうするか
  • エラーがあったときに全件失敗にするか
  • DB更新は全件入れ替えか、upsertか

たとえば最初の実装なら、次のくらいに割り切ると進めやすいです。

  • UTF-8のみ対応
  • ヘッダー必須
  • カラム名固定
  • バリデーション結果は構造化JSONで返す
  • 更新処理はまだ入れない

record.getRecordNumber() の行番号に注意する

Apache Commons CSV の record.getRecordNumber() は、ヘッダーをスキップした後のレコード番号として扱う方が分かりやすいです。

そのため、利用者に見せる「CSVの何行目か」と完全に一致させたい場合は、ヘッダー行の有無を踏まえて補正を考える必要があります。

「1行目はヘッダー、2行目が最初のデータ」の前提なら、エラーメッセージの行番号が利用者の見た目と一致しているかを一度確認した方が安全です。

今回のサンプルでは、ヘッダー行のあとに4件のデータを並べ、2件目以降に不正な行を混ぜたテストを入れています。その結果、-1,Invalid,... の行は 3行目、空の id を含む行は 4行目、空の name を含む行は 5行目 として返っていました。

文字コードとExcelの扱い

実務ではUTF-8にしたい一方で、利用者はExcelでCSVを開くことが多いです。

そのため、次のような論点が出ます。

  • UTF-8 without BOM でよいか
  • Excelで文字化けしない形を優先するか
  • 取り込み側でShift_JISを許可するか

最初から全部吸収しようとすると重くなります。まずはUTF-8に限定し、利用者向けの前提を明記する方が実装しやすいです。

特にWindows版Excelでは、CSVをダブルクリックで開いたときにUTF-8 without BOMが期待どおりに解釈されず、文字化けして見えることがあります。

そのため、実務の手順書や運用ルールでは、次のどちらかを先に決めておくと混乱しにくいです。

  • UTF-8固定にして、Excel取り込み時は文字コードを指定して開いてもらう
  • Excel利用を優先して、BOM付きUTF-8やShift_JISを許容する

サンプル実装は単純さを優先してUTF-8固定にしていますが、実務では利用者がどのツールでCSVを開くかまで含めて決めた方が安全です。

例外系も最初から少し考える

最低限でも、次のケースは想定しておくと後で困りにくいです。

  • ファイル未選択
  • 拡張子はCSVでも中身が壊れている
  • 必須ヘッダーが足りない
  • 列順は違うがヘッダー名は一致している
  • データ件数が極端に多い

特にヘッダー不足は、読み取り中に record.get("header_name") で失敗しやすいので、サンプル実装のように先に parser.getHeaderMap() を検証する構成が扱いやすいです。

まとめ

Apache Commons CSV を使うと、Spring BootでのCSV API実装をかなり素直に書けます。

特に、CSVダウンロードでは CSVPrinter、CSVアップロードでは CSVParser を使うだけで、クォートやヘッダー処理を自前で抱えずに済みます。

実務で大事なのは、ライブラリの使い方そのものより、CSV仕様を先に固定することです。

まずは次の方針で小さく始めるのがやりやすいと思います。

  • UTF-8固定
  • ヘッダー必須
  • カラム名固定
  • アップロード時はまず検証結果を構造化して返す
  • 更新処理はその次の段階で入れる

この形で土台を作っておくと、次に全件更新、upsert、エラー明細CSVの返却のような拡張にもつなげやすいです。

参考

コメントする

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)