業務システムをやっていると「一覧をExcelでダウンロードしたい」「アップロードされたExcelを取り込みたい」という要件、必ずと言っていいほど出てきますよね。Javaでこれをやるなら定番は Apache POI です。

この記事では、Apache POIでxlsxを生成してSpring BootのREST APIからダウンロードさせるところから、大量行でメモリが溢れないようにする SXSSFWorkbook、そしてアップロードされたxlsxをパースしてDTOに詰め替えるところまで、コード中心に一気に見ていきます。

ポイントを先に言っておくと、出力は 通常は XSSFWorkbook、大量データなら SXSSFWorkbook の使い分けが軸になります。ここを押さえておくと全体の見通しがよくなります。

Apache POIの依存追加とセットアップ

まずは依存追加です。xlsx(新形式)を扱うので poipoi-ooxml の両方を入れます。

<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi</artifactId>
  <version>5.3.0</version>
</dependency>
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
  <version>5.3.0</version>
</dependency>

Gradleなら次の通りです。

implementation 'org.apache.poi:poi-ooxml:5.3.0'

poi-ooxml を入れると poi 本体も推移的に入ります。なお .xls(旧形式)は HSSFWorkbook.xlsx(新形式)は XSSFWorkbook ですが、いまから作るものは基本的にxlsxでよいので、本記事はxlsx前提で進めます。

Apache POIのデータ構造は Workbook(ブック)→ Sheet(シート)→ Row(行)→ Cell(セル) という素直な階層です。この入れ子をコードで組み立てていくイメージを持っておけば迷いません。

XSSFWorkbookでExcelを生成する基本

ヘッダー付きの一覧を作ってみます。ヘッダーだけ太字にして、列幅も自動調整します。

public Workbook createUserSheet(List<User> users) {
    Workbook workbook = new XSSFWorkbook();
    Sheet sheet = workbook.createSheet("ユーザー一覧");

    // ヘッダー用スタイル
    CellStyle headerStyle = workbook.createCellStyle();
    Font font = workbook.createFont();
    font.setBold(true);
    headerStyle.setFont(font);

    Row header = sheet.createRow(0);
    String[] titles = {"ID", "名前", "登録日"};
    for (int i = 0; i < titles.length; i++) {
        Cell cell = header.createCell(i);
        cell.setCellValue(titles[i]);
        cell.setCellStyle(headerStyle);
    }

    // 日付セル用スタイル
    CellStyle dateStyle = workbook.createCellStyle();
    CreationHelper helper = workbook.getCreationHelper();
    dateStyle.setDataFormat(helper.createDataFormat().getFormat("yyyy/mm/dd"));

    int rowNum = 1;
    for (User u : users) {
        Row row = sheet.createRow(rowNum++);
        row.createCell(0).setCellValue(u.getId());        // 数値
        row.createCell(1).setCellValue(u.getName());      // 文字列
        Cell dateCell = row.createCell(2);
        dateCell.setCellValue(u.getCreatedAt());          // LocalDateTime
        dateCell.setCellStyle(dateStyle);
    }

    for (int i = 0; i < titles.length; i++) {
        sheet.autoSizeColumn(i);
    }
    return workbook;
}

setCellValue は引数の型で挙動が変わります。String ならそのまま文字列、doubleint なら数値、LocalDateTime / Date なら日付シリアル値として書き込まれます。日付は 値だけでなく表示書式(DataFormat)も指定 しないとただの数値に見えてしまうので注意しましょう。

autoSizeColumn は便利ですが、全行を走査するので大量データでは重くなります。後述のSXSSFでは原則使えないので、その場合は setColumnWidth で固定するのが現実的です。

REST APIでExcelをダウンロードさせる

作ったWorkbookをHTTPレスポンスとして返します。ByteArrayOutputStream に書き出して ResponseEntity<byte[]> で返すのが扱いやすいです。

@GetMapping("/users/excel")
public ResponseEntity<byte[]> download() throws IOException {
    byte[] bytes;
    try (Workbook workbook = createUserSheet(userService.findAll());
         ByteArrayOutputStream out = new ByteArrayOutputStream()) {
        workbook.write(out);
        bytes = out.toByteArray();
    }

    String filename = URLEncoder.encode("ユーザー一覧.xlsx", StandardCharsets.UTF_8)
            .replace("+", "%20");

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.parseMediaType(
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
    headers.set(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename*=UTF-8''" + filename);

    return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
}

ポイントは3つです。Content-Typeはxlsx専用の長いMIMEタイプを指定します。日本語ファイル名は文字化けしやすいので filename*=UTF-8'' 形式でURLエンコードした名前を渡します。そして try-with-resourcesWorkbookを必ずclose します。閉じ忘れはメモリリークや一時ファイルの残留につながります。

ファイルダウンロードの一般的な作法そのものは Spring BootでのファイルアップロードとダウンロードをMultipartFileで実装する でも触れているので、合わせて読むと理解が深まります。

大量データ対策:SXSSFWorkbookでメモリ枯渇を防ぐ

XSSFWorkbook は全行をメモリ上に保持します。数百行なら問題ありませんが、数万〜数十万行になると OutOfMemoryError のリスクが一気に高まります。

そこで使うのが SXSSFWorkbook です。指定した行数ぶんだけメモリに保持し、ウィンドウから外れた古い行は一時ファイルへ書き出していく ストリーミング方式 で動きます。

public void writeLargeExcel(OutputStream os, List<Order> orders) throws IOException {
    // 直近100行だけメモリに保持
    try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
        Sheet sheet = workbook.createSheet("注文");
        int rowNum = 0;
        for (Order o : orders) {
            Row row = sheet.createRow(rowNum++);
            row.createCell(0).setCellValue(o.getId());
            row.createCell(1).setCellValue(o.getAmount());
        }
        workbook.write(os);
        workbook.dispose(); // 一時ファイルを削除
    }
}

コンストラクタの引数 rowAccessWindowSize がメモリに残す行数です。ウィンドウより前の行はフラッシュ済みなので、後から getRow で書き換えできない点だけ気をつけてください。

もう一つ大事なのが dispose() です。SXSSFはディスク上に一時ファイルを作るので、これを呼ばないと一時ファイルが残り続けます。close() とあわせて確実に呼びましょう。

使い分けはシンプルです。小規模で書式を凝るなら XSSFWorkbook、行数が多くてとにかく軽く出したいなら SXSSFWorkbook と考えておけば大きく外しません。出力に時間がかかるなら Spring Bootで非同期処理を実装する を参考に別スレッドへ逃がすのも手です。

アップロードされたExcelを読み込んでDTOへマッピング

今度は逆方向、アップロードされたxlsxを読み込みます。MultipartFile から InputStream を取り、WorkbookFactory.create() に渡せば形式(xls/xlsx)を自動判別してくれます。

public List<User> parse(MultipartFile file) throws IOException {
    List<User> result = new ArrayList<>();
    DataFormatter formatter = new DataFormatter();

    try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) {
        Sheet sheet = workbook.getSheetAt(0);
        for (Row row : sheet) {
            if (row.getRowNum() == 0) continue;       // ヘッダー行をスキップ
            if (isEmptyRow(row)) continue;            // 空行をスキップ

            User user = new User();
            user.setId((long) row.getCell(0).getNumericCellValue());
            user.setName(formatter.formatCellValue(row.getCell(1)));
            result.add(user);
        }
    }
    return result;
}

文字列として素直に取りたいなら DataFormatter が便利です。これは Excelの表示上の値そのまま を文字列で返してくれるので、書式付き数値や日付をそのまま取り込みたいケースで重宝します。1行ぶんのセルを読んでDTOへ詰め、List にまとめて返す、という流れですね。

CRUDの土台づくりは Spring BootでREST APIのCRUDを実装する も参考にしてください。

セルの型判定とよくある例外への対処

Excelパースでハマりやすいのが型です。数値セルに対して getStringCellValue() を呼ぶと IllegalStateException: Cannot get a STRING value from a NUMERIC cell が飛びます。セルの型は getCellType() で判定しましょう。

String readAsString(Cell cell) {
    if (cell == null) return "";
    return switch (cell.getCellType()) {
        case STRING -> cell.getStringCellValue();
        case NUMERIC -> DateUtil.isCellDateFormatted(cell)
                ? cell.getLocalDateTimeCellValue().toString()
                : String.valueOf(cell.getNumericCellValue());
        case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
        case BLANK -> "";
        default -> "";
    };
}

日付はExcel内部では数値なので、CellType.NUMERIC のうち DateUtil.isCellDateFormatted() が真のものが日付、というのがコツです。

もう一つの落とし穴が欠損セルです。値が空のセルを getCell すると null が返ることがあり、そのまま触ると NullPointerException になります。getCellMissingCellPolicy を渡すと挙動を制御できます。

// 欠損セルを null ではなく空セルとして返す
Cell cell = row.getCell(2, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);

取り込みでは想定外フォーマットがつきものなので、行番号を添えて「3行目の金額が数値ではありません」のようなメッセージで返すと、利用者が修正しやすくなります。例外を握りつぶさず、どの行のどの列で失敗したかを伝えるのが実務では効きます。

まとめ

Apache POIでのExcel入出力を一通り見てきました。要点を整理しておきます。

  • 出力は XSSFWorkbook が基本、大量行は SXSSFWorkbook でメモリ対策。後者は dispose() を忘れずに
  • ダウンロードはContent-Typeにxlsx用MIMEタイプ、日本語ファイル名は filename*=UTF-8'' でエンコード
  • 読み込みは WorkbookFactory で開き、getCellType() の型判定と DataFormatter が肝。欠損セルは MissingCellPolicy で安全に

Excelは型判定と例外処理さえ丁寧にやれば怖くありません。同じ帳票でもPDFが必要なら Spring BootでPDFを生成する(OpenPDF / iText) もあるので、要件にあわせて使い分けてみてください。