EasyPoiを活用したExcelおよびWordデータのインポート・エクスポート実装ガイド

Office形式データ処理用の汎用ストリームマネージャー

Java環境における表計算およびワードプロセッサファイルの操作を一元化するための基底クラスを実装します。EasyPoiおよびApache POIを内部でラップし、HTTPレスポンスへのマージやエンコーディング設定を標準化することで、コントローラー層の実装負荷を低減します。

package com.example.infrastructure.io;

import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.ExcelImportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.ImportParams;
import cn.afterturn.easypoi.excel.entity.TemplateExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.result.ExcelImportResult;
import cn.afterturn.easypoi.word.WordExportUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;

/**
 * Office系ドキュメントの作成・取得を担うスタティックヘルパー
 */
public final class DocumentIOProcessor {

    private DocumentIOProcessor() {
        // ユーティリティクラスのためインスタンス化不可
    }

    /**
     * マップリストからXLSXを生成してダウンロードヘッダーを設定
     */
    public static void sendXlsxFromMap(List<Map<String, Object>> payload, String targetName, HttpServletResponse httpResp) throws IOException {
        Workbook wb = buildWorkbook(payload);
        pushStream(targetName, httpResp, wb);
    }

    /**
     * POJO型定義に従ってシリアル出力
     */
    public static void sendXlsxFromEntity(List<?> records, Class<?> entityType, String targetName, HttpServletResponse httpResp) throws IOException {
        Workbook wb = buildWorkbook(records, entityType);
        pushStream(targetName, httpResp, wb);
    }

    /**
     * 書式テンプレートに基づく動的帳票発行
     */
    public static void sendXlsxViaTemplate(TemplateExportParams templateConfig, Map<String, Object> context, String targetName, HttpServletResponse httpResp) throws IOException {
        Workbook wb = ExcelExportUtil.exportExcel(templateConfig, context);
        pushStream(targetName, httpResp, wb);
    }

    /**
     * ワードテンプレートからのレンダリングと送信
     */
    public static void sendDocxViaTemplate(Map<String, Object> context, String templatePath, String targetName, HttpServletResponse httpResp) throws Exception {
        XWPFDocument doc = WordExportUtil.exportWord07(templatePath, context);
        writeDocResponse(targetName, httpResp, doc);
    }

    /**
     * ストリーム受信からのPOJOマッピング(基本インポート)
     */
    public static <T> List<T> parseFromUpload(MultipartFile uploadedFile, Class<T> targetType) throws IOException {
        if (uploadedFile == null || uploadedFile.isEmpty()) {
            return List.of();
        }
        return executeImport(uploadedFile.getInputStream(), new ImportParams(), targetType);
    }

    /**
     * ファイルパス指定によるレコード取得(メタ情報付き)
     */
    public static <T> List<T> parseFromFile(String diskPath, int headerLines, int metaLines, Class<T> targetType) throws IOException {
        if (StringUtils.isBlank(diskPath)) {
            return List.of();
        }
        ImportParams cfg = createConfig(headerLines, metaLines);
        return executeImport(new File(diskPath), cfg, targetType);
    }

    /**
     * バリデーション結果を含む詳細インポート
     */
    public static <T> ExcelImportResult<T> parseWithValidation(MultipartFile uploadedFile, Class<T> targetType) throws IOException {
        if (uploadedFile == null || uploadedFile.isEmpty()) {
            return null;
        }
        ImportParams cfg = createStrictConfig();
        return ExcelImportUtil.importExcelMore(uploadedFile.getInputStream(), targetType, cfg);
    }

    // ── Private Helpers ──

    private static Workbook buildWorkbook(List<Map<String, Object>> data) {
        return ExcelExportUtil.exportExcel(data, ExcelType.XSSF);
    }

    private static Workbook buildWorkbook(List<?> records, Class<?> clazz) {
        ExportParams p = new ExportParams(null, null, ExcelType.XSSF);
        p.setCreateHeadRows(true);
        return ExcelExportUtil.exportExcel(p, clazz, records);
    }

    private static ImportParams createConfig(int headers, int metas) {
        ImportParams p = new ImportParams();
        p.setTitleRows(headers);
        p.setHeadRows(metas);
        p.setNeedSave(true);
        return p;
    }

    private static ImportParams createStrictConfig() {
        ImportParams p = new ImportParams();
        p.setTitleRows(1);
        p.setHeadRows(1);
        p.setNeedVerify(true);
        p.setNeedSave(true);
        return p;
    }

    private static <T> List<T> executeImport(Object source, ImportParams config, Class<T> targetType) throws IOException {
        try {
            return ExcelImportUtil.importExcel(source, targetType, config);
        } catch (NoSuchElementException e) {
            throw new IOException("対象ファイルが未検出または空です");
        }
    }

    private static void pushStream(String filename, HttpServletResponse resp, Workbook wb) throws IOException {
        configureCommonHeaders(resp, filename + ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        wb.write(resp.getOutputStream());
    }

    private static void writeDocResponse(String filename, HttpServletResponse resp, XWPFDocument doc) throws IOException {
        configureCommonHeaders(resp, filename + ".docx", "application/msword");
        doc.write(resp.getOutputStream());
    }

    private static void configureCommonHeaders(HttpServletResponse resp, String fname, String contentType) {
        resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
        resp.setContentType(contentType);
        String encoded = URLEncoder.encode(fname, StandardCharsets.UTF_8.name()).replace("+", "%20");
        resp.setHeader("Content-Disposition", "attachment; filename=" + encoded);
    }
}

アップロードデータの構造検証パターン

外部から入力されたシートは信頼できないデータソースであるため、メモリ内の重複チェックと既存データベースの制約条件検証を分離して実行します。検証エラーが発生した行は元のモデルに保持し、最終的にクライアントへ返却可能なリストとして整形します。

// バリデーション対象DTO
record StaffUploadRow(
    String id,
    String displayName,
    String contactNumber,
    String parentId,
    String childId,
    String status,
    String errorMessage
) {
    public StaffUploadRow withError(String msg) {
        return new StaffUploadRow(id, displayName, contactNumber, parentId, childId, "FAIL", (errorMessage == null ? "" : errorMessage + "; ") + msg);
    }
}

// コントローラー内処理フロー例
public ResponseEnvelope<StaffUploadResult> processBulkRegistration(MultipartFile sheet, UploadContext ctx) throws Exception {
    // 1. EasyPoiベースのマッピング
    List<StaffUploadRow> rawRecords = DocumentIOProcessor.parseFromUpload(sheet, StaffUploadRow.class);

    // 2. 内部一貫性チェック(同一ファイル内の重複判定)
    var deduped = validateInternalConsistency(rawRecords);

    // 3. 外部依存チェック(DB状態・組織マスタ連携)
    var validated = verifyExternalConstraints(deduped, ctx);

    // 4. 結果packaging
    return assembleResponse(validated);
}

private List<StaffUploadRow> validateInternalConsistency(List<StaffUploadRow> rows) {
    // 識別子ごとのグループ化
    var byId = groupByKey(rows, r -> r.id());
    var byPhone = groupByKey(rows, r -> r.contactNumber());
    var byCombo = groupByKey(rows, r -> r.parentId() + ":" + r.childId() + ":" + r.displayName());

    return rows.stream().map(row -> {
        if (byId.getOrDefault(row.id(), List.of()).size() > 1) return row.withError("一意ID重複");
        if (StringUtils.isNotBlank(row.contactNumber()) && byPhone.getOrDefault(row.contactNumber(), List.of()).size() > 1) {
            return row.withError("連絡先番号重複");
        }
        if (byCombo.containsKey(row.parentId() + ":" + row.childId() + ":" + row.displayName())) {
            return row.withError("所属権限セット重複");
        }
        return row;
    }).toList();
}

private List<StaffUploadRow> verifyExternalConstraints(List<StaffUploadRow> rows, UploadContext ctx) {
    // モックDB参照マップ(実際はリポジトリクエリ結果を格納)
    var existingIds = ctx.knownIdentifierSet();
    var existingPhones = ctx.knownContactSet();
    var orgHierarchy = ctx.organizationLookupTable();

    return rows.stream().map(row -> {
        if (existingIds.contains(row.id())) return row.withError("システムに既に登録済み");
        
        if (StringUtils.isNotBlank(row.contactNumber()) && existingPhones.contains(row.contactNumber())) {
            return row.withError("電話番号が既存ユーザーと競合");
        }

        // 階層整合性チェック
        String parentKey = row.parentId();
        String childKey = row.childId();
        if (!parentKey.equals(childKey) && !orgHierarchy.hasRelation(parentKey, childKey)) {
            return row.withError("親組織と子組織の紐付け関係が存在しません");
        }
        return row;
    }).toList();
}

// 共通グルーピングヘルパー
private static <K,V> Map<K,List<V>> groupByKey(List<V> items, Function<V,K> keyExtractor) {
    return items.stream().collect(Collectors.groupingBy(keyExtractor));
}

検索結果の一括エクスポート呼び出し

ビジネスロジックレイヤーから取得したドメインオブジェクト群を、直接ダウンロードエンドポイントへ転送するパターンです。ビューモデルと実装クラスの対応付けにより、セキュリティ属性が漏洩しないように制御できます。

public void deliverSummaryReport(QueryCriteria criteria, HttpServletResponse resp) throws IOException {
    PageResult<FinancialPoolVO> pageData = poolService.retrieve(criteria);
    
    // ビューモデルのみを使用し、内部エンティティを曝露しない
    List<FinancialPoolVO> snapshot = pageData.getContent();
    
    DocumentIOProcessor.sendXlsxFromEntity(
        snapshot, 
        FinancialPoolVO.class, 
        "重点監視対象一覧_" + System.currentTimeMillis(), 
        resp
    );
}

タグ: EasyPoi ApachePOI Java17 SpringFramework DataImportExport

5月31日 16:42 投稿