はじめに
DBから取得した内容をファイルで出力したいことがありました。
エクセル形式で出力する場合、どのようなことを考慮する必要があるかをまとめます。
参考記事
全体構成
-
Controllerでリクエストを受ける - リクエストから検索条件と出力形式(ファイル種別・文字コード)を取り出す
- ファイル出力処理のメソッドを呼び出す
- DAO の SQL を実行し結果を dto へ変換、出力形式に応じて Apache POI などでファイルを生成する
- 生成したファイルを controller へ返し、クライアントへのダウンロード処理に渡す
この構成が成立するのは、controller が生成ファイル(またはストリーム)を自由にクライアントへ返せる場合である。
フレームワークが特定の戻り型を強制する作りでないこと
エクスポート実行経路で実際に呼ばれるメソッドにだけ自作の private 処理を差し込み、戻り値として独自 Response 型を返す 。それ以外のオーバーライドメソッドを安易に return null で潰すと、フレームワークがその戻り値を参照していた場合に NullPointerException で落ちる。
判断は次の通り。
- 本体処理を差し込むメソッド … private を呼び、生成物を独自 Response 型として返す
- 本体処理を差し込まないメソッド … 戻り値が参照されるか確認する。参照されるなら空オブジェクト等を返す。参照されないなら
return nullでよい
エンコード方式の扱い
出力形式として「ファイル種別」と「エンコード方式」が渡される。ただし エンコード方式が意味を持つのは CSV や TSV などのテキスト出力のときだけ である。xlsx は内部が ZIP と XML(UTF-8 固定)で構成され、POI が内部で encoding を処理するため、エンコード方式のパラメータは xlsx 出力では使われない。
したがってファイル種別で分岐し、エンコード方式は CSV 分岐でのみ使う、という整理が正しい。xlsx 固定ならエンコード方式は無視してよい。
Controller
controller は検索条件を取り出し、ロジッククラスのオーバーライドメソッドを呼び、戻ってきた Response を返すだけにする。
public class DownloadController {
public ExportResponse output(ExportRequest request) {
// 画面から渡された検索条件を取り出す(新規/更新モード含む想定)
SearchCondition condition = request.getSearchCondition();
ExcelExportService service = new ExcelExportService();
// AbstractExporter のオーバーライドメソッドを呼ぶ(戻り値は独自 Response 型)
return service.export(condition);
}
}
ExportResponse はフレームワーク独自の Response 型に読み替えること。
ExcelExportService
AbstractExporter を継承し、実行経路上のメソッドをオーバーライドする。その中で private メソッドを順に呼び、最後に独自 Response 型を返す。
public class ExcelExportService extends AbstractExporter {
/** 最大出力行(データ行) */
private static final int MAX_OUTPUT_ROWS = 100;
// AbstractExporter のメソッドをオーバーライド(メソッド名・シグネチャは実際の定義に合わせる)
@Override
public ExportResponse export(SearchCondition condition) {
List<RecordModel> models = executeSearch(condition); // 1. DAO 実行
List<RecordDto> dtoList = toDtoList(models); // 2. dto 変換
// 最大100行の制約チェック
if (dtoList.size() > MAX_OUTPUT_ROWS) {
throw new IllegalStateException(
"出力行が上限を超えています: " + dtoList.size() + " > " + MAX_OUTPUT_ROWS);
}
File file = writeXlsx(dtoList); // 3. xlsx 出力
return buildResponse(file); // 4. 独自 Response 型に詰めて返す
}
// 実行経路に無いオーバーライドメソッドは戻り値参照に注意して潰す
// 参照されないことを Call Hierarchy で確認してから return null とする
@Override
public SomeType unusedMethod() {
return null;
}
}
1. 検索(検索条件・モードで SQL を出し分け)
既存 DAO をそのまま使い、モードによって呼び出す SQL メソッドを分岐させる。新規/更新モードは検索条件に含める想定とする。
private List<RecordModel> executeSearch(SearchCondition condition) {
RecordDao dao = getDao(); // 既存 DAO の取得
if (condition.isNewMode()) {
return dao.findForNew(condition);
} else {
return dao.findForUpdate(condition);
}
}
2. model から dto への変換
DAO は model のリストを返すため、画面出力したい dto へ詰め替える。手動マッピングが最も確実で速い。dto には 出力する項目だけ 定義すればよい。
private List<RecordDto> toDtoList(List<RecordModel> models) {
List<RecordDto> dtoList = new ArrayList<RecordDto>();
for (RecordModel m : models) {
RecordDto dto = new RecordDto();
dto.setShohinCode(m.getShohinCode());
dto.setShohinName(m.getShohinName());
// 出力する項目分だけ繰り返す
dtoList.add(dto);
}
return dtoList;
}
3. 列定義(ヘッダー・表示形式・対象区分を一元管理)
ヘッダーが10列以上になると、ヘッダー名の配列と値の配列を別々に持つ方式では、列の追加・削除・並び替えのたびに複数箇所を直す必要があり、ずれによる不具合が起きやすい。
実務では 1列分の情報(ヘッダー名・dto からの値の取り出し・表示形式・対象/対象外区分)を1箇所にまとめた列定義 を用意し、それを並べたリストで管理するのが扱いやすい。列を追加するときは定義を1ブロック足すだけで済み、ヘッダー出力・値出力・スタイル適用がすべて同じ定義を参照する。
旧 Java(ラムダ非対応)を想定し、値の取り出しは匿名クラスで列ごとに持たせる。
/** 表示形式の種別 */
private enum CellFormat { STANDARD, TEXT }
/** 1列分の定義 */
private static abstract class ColumnDef {
final String header; // ヘッダー名
final CellFormat format; // 表示形式
final boolean excluded; // 対象外(グレー)スタイルか
ColumnDef(String header, CellFormat format, boolean excluded) {
this.header = header;
this.format = format;
this.excluded = excluded;
}
/** dto から、この列に出力する値を取り出す */
abstract Object getValue(RecordDto dto);
}
/** 列定義一覧(10列以上を想定。1列=1ブロックで追加・削除が容易) */
private static final List<ColumnDef> COLUMNS = buildColumns();
private static List<ColumnDef> buildColumns() {
List<ColumnDef> list = new ArrayList<ColumnDef>();
// 商品コードは先頭ゼロ保持のため文字列形式
list.add(new ColumnDef("商品コード", CellFormat.TEXT, false) {
Object getValue(RecordDto dto) { return dto.getShohinCode(); }
});
list.add(new ColumnDef("商品名", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getShohinName(); }
});
list.add(new ColumnDef("数量", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getSuryo(); }
});
list.add(new ColumnDef("単価", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getTanka(); }
});
list.add(new ColumnDef("金額", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getKingaku(); }
});
list.add(new ColumnDef("取引先コード", CellFormat.TEXT, false) {
Object getValue(RecordDto dto) { return dto.getTorihikiCode(); }
});
list.add(new ColumnDef("取引先名", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getTorihikiName(); }
});
list.add(new ColumnDef("担当者", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getTantosha(); }
});
list.add(new ColumnDef("登録日", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getTorokubi(); }
});
list.add(new ColumnDef("更新日", CellFormat.STANDARD, false) {
Object getValue(RecordDto dto) { return dto.getKoshinbi(); }
});
// 備考・内部管理番号は対象外(グレー表示)
list.add(new ColumnDef("備考", CellFormat.STANDARD, true) {
Object getValue(RecordDto dto) { return dto.getBiko(); }
});
list.add(new ColumnDef("内部管理番号", CellFormat.TEXT, true) {
Object getValue(RecordDto dto) { return dto.getKanriNo(); }
});
return list;
}
4. ヘッダー出力
列定義のリストを順に回してヘッダー行を作る。10列でも100列でも、ヘッダー出力のコードは変えなくてよい。実務ではヘッダーを目立たせる太字スタイルと、スクロール時もヘッダーが残る固定(freeze)を入れておくと使いやすい。
private void writeHeader(Workbook workbook, Sheet sheet) {
CellStyle headerStyle = createHeaderStyle(workbook);
Row headerRow = sheet.createRow(0);
for (int j = 0; j < COLUMNS.size(); j++) {
Cell cell = headerRow.createCell(j);
cell.setCellValue(COLUMNS.get(j).header);
cell.setCellStyle(headerStyle);
}
// ヘッダー行を固定(1行目を常時表示)
sheet.createFreezePane(0, 1);
}
private CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBoldweight(Font.BOLDWEIGHT_BOLD);
style.setFont(font);
style.setAlignment(CellStyle.ALIGN_CENTER);
return style;
}
5. スタイルの事前生成(表示形式・対象区分を含む)
スタイルは 事前に生成して使い回す 。POI はスタイル数に上限があるため、行・セルごとに createCellStyle を呼ぶのは避ける。
ここでは表示形式(標準・文字列)と対象区分(対象・対象外)の組み合わせごとに1つずつスタイルを作り、Map にキャッシュする。文字列形式は組み込みフォーマット @ を指定する。文字列形式にすると、先頭ゼロやコード値が数値として丸められず、入力した文字列のまま表示される。
private Map<String, CellStyle> buildStyleCache(Workbook workbook) {
Map<String, CellStyle> cache = new HashMap<String, CellStyle>();
short textFormat = (short) BuiltinFormats.getBuiltinFormat("@");
for (CellFormat fmt : CellFormat.values()) {
for (int e = 0; e <= 1; e++) {
boolean excluded = (e == 1);
CellStyle style = workbook.createCellStyle();
// 寄せ(対象外は右寄せ、対象は左寄せ)
style.setAlignment(excluded ? CellStyle.ALIGN_RIGHT : CellStyle.ALIGN_LEFT);
// 対象外は背景グレー
if (excluded) {
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(CellStyle.SOLID_FOREGROUND);
}
// 表示形式(文字列のときだけ @ を設定)
if (fmt == CellFormat.TEXT) {
style.setDataFormat(textFormat);
}
cache.put(styleKey(fmt, excluded), style);
}
}
return cache;
}
private String styleKey(CellFormat format, boolean excluded) {
return format.name() + "-" + excluded;
}
6. データ行の書き込み
列定義とスタイルキャッシュを使い、各セルに値と「表示形式・対象区分に応じたスタイル」を当てる。出力は最大100行に収まる。
private File writeXlsx(List<RecordDto> dtoList) {
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("明細");
writeHeader(workbook, sheet);
// スタイルを事前生成して使い回す
Map<String, CellStyle> styleCache = buildStyleCache(workbook);
int rowIndex = 1; // ヘッダーの次の行から
for (RecordDto dto : dtoList) {
Row row = sheet.createRow(rowIndex++);
for (int j = 0; j < COLUMNS.size(); j++) {
ColumnDef col = COLUMNS.get(j);
Cell cell = row.createCell(j);
// 値を表示形式に合わせて設定
setCellValue(cell, col.getValue(dto), col.format);
// 表示形式・対象区分に対応する使い回しスタイルを当てる
CellStyle style = styleCache.get(styleKey(col.format, col.excluded));
cell.setCellStyle(style);
}
}
File tempFile;
FileOutputStream fos = null;
try {
tempFile = File.createTempFile("record_export_", ".xlsx");
fos = new FileOutputStream(tempFile);
workbook.write(fos);
} catch (IOException e) {
throw new RuntimeException("xlsx の書き込みに失敗しました", e);
} finally {
try { if (fos != null) fos.close(); } catch (IOException ignore) {}
try { workbook.close(); } catch (IOException ignore) {} // dispose は不要
}
return tempFile;
}
// 値を表示形式に合わせてセルに設定する
private void setCellValue(Cell cell, Object value, CellFormat format) {
if (value == null) {
cell.setCellValue("");
return;
}
if (format == CellFormat.TEXT) {
// 文字列形式(先頭ゼロやコード値をそのまま保持)
cell.setCellValue(value.toString());
} else if (value instanceof Number) {
cell.setCellValue(((Number) value).doubleValue());
} else {
cell.setCellValue(value.toString());
}
}
別手段3〜8
3. 列定義(dto フィールド名・ヘッダー名・表示書式)
ヘッダーが10列以上になると、ヘッダー名の配列と値の配列を別々に持つ方式では、列の追加・削除・並び替えのたびに複数箇所を直す必要があり、ずれによる不具合が起きやすい。
そこで 1列分の情報(dto のフィールド名・ヘッダー名・表示書式)を1つの定義にまとめ 、それを並べたリストで管理する。これは intra-mart 独自ライブラリの Tuple(フィールド名・呼称・型)と同じ並びである。
表示書式は、Excel の「セルの書式設定」の「表示形式」に対応する。文字列・数値・日付などを列ごとに指定できるよう、CellFormat を enum で定義する。
/** 表示書式の種別(Excel の「表示形式」に対応) */
private enum CellFormat { TEXT, INTEGER, DECIMAL, DATE }
/** 1列分の定義(dto フィールド名・ヘッダー名・表示書式) */
private static class ColumnDef {
final String fieldName; // dto のフィールド名
final String header; // ヘッダー名
final CellFormat format; // 表示書式
ColumnDef(String fieldName, String header, CellFormat format) {
this.fieldName = fieldName;
this.header = header;
this.format = format;
}
}
列定義一覧は、1列1行で並べる。Tuple と同じく「フィールド名・ヘッダー名・表示書式」の順で書く。列を追加するときはこのリストに1行足し、後述の getValue にも対応する分岐を1行足す。
/** 列定義一覧(10列以上を想定) */
private static final List<ColumnDef> COLUMNS = buildColumns();
private static List<ColumnDef> buildColumns() {
List<ColumnDef> list = new ArrayList<ColumnDef>();
// フィールド名, ヘッダー名, 表示書式
list.add(new ColumnDef("shohinCode", "商品コード", CellFormat.TEXT));
list.add(new ColumnDef("shohinName", "商品名", CellFormat.TEXT));
list.add(new ColumnDef("suryo", "数量", CellFormat.INTEGER));
list.add(new ColumnDef("tanka", "単価", CellFormat.INTEGER));
list.add(new ColumnDef("kingaku", "金額", CellFormat.INTEGER));
list.add(new ColumnDef("zeiritsu", "消費税率", CellFormat.DECIMAL));
list.add(new ColumnDef("torihikiCode", "取引先コード", CellFormat.TEXT));
list.add(new ColumnDef("torihikiName", "取引先名", CellFormat.TEXT));
list.add(new ColumnDef("tantosha", "担当者", CellFormat.TEXT));
list.add(new ColumnDef("torokubi", "登録日", CellFormat.DATE));
list.add(new ColumnDef("koshinbi", "更新日", CellFormat.DATE));
list.add(new ColumnDef("biko", "備考", CellFormat.TEXT));
list.add(new ColumnDef("kanriNo", "内部管理番号", CellFormat.TEXT));
return list;
}
4. フィールド名から値を取り出す
フィールド名(文字列)に対応する dto の値を取り出す。メソッド参照を使わず、if 分岐で明示的に書く。列定義に1行足したら、ここにも1行足す。
private Object getValue(RecordDto dto, String fieldName) {
if ("shohinCode".equals(fieldName)) return dto.getShohinCode();
if ("shohinName".equals(fieldName)) return dto.getShohinName();
if ("suryo".equals(fieldName)) return dto.getSuryo();
if ("tanka".equals(fieldName)) return dto.getTanka();
if ("kingaku".equals(fieldName)) return dto.getKingaku();
if ("zeiritsu".equals(fieldName)) return dto.getZeiritsu();
if ("torihikiCode".equals(fieldName)) return dto.getTorihikiCode();
if ("torihikiName".equals(fieldName)) return dto.getTorihikiName();
if ("tantosha".equals(fieldName)) return dto.getTantosha();
if ("torokubi".equals(fieldName)) return dto.getTorokubi();
if ("koshinbi".equals(fieldName)) return dto.getKoshinbi();
if ("biko".equals(fieldName)) return dto.getBiko();
if ("kanriNo".equals(fieldName)) return dto.getKanriNo();
throw new IllegalArgumentException("未定義のフィールド名: " + fieldName);
}
5. 対象外の列を別リストで管理
対象外(背景グレー)にする列は、列定義とは別のリストで管理する。ここではヘッダー名で指定する。
/** 対象外(グレー)にする列のヘッダー名 */
private static final List<String> EXCLUDED_HEADERS = buildExcludedHeaders();
private static List<String> buildExcludedHeaders() {
List<String> list = new ArrayList<String>();
list.add("備考");
list.add("内部管理番号");
return list;
}
private boolean isExcluded(String header) {
return EXCLUDED_HEADERS.contains(header);
}
6. スタイルの事前生成
スタイルは 事前に生成して使い回す 。POI はスタイル数に上限があるため、行・セルごとに createCellStyle を呼ぶのは避ける。
ここでは2種類を用意する。
- 表示書式ごとのスタイル(文字列・整数・小数・日付)。対象列に使う
- 対象外用のスタイル(背景グレー・文字列形式)。対象外列に使う。事前に1つだけ作っておく
// 表示書式ごとのスタイル(対象列に使う)
private Map<CellFormat, CellStyle> buildFormatStyles(Workbook workbook) {
Map<CellFormat, CellStyle> cache = new HashMap<CellFormat, CellStyle>();
for (CellFormat fmt : CellFormat.values()) {
CellStyle style = workbook.createCellStyle();
style.setAlignment(CellStyle.ALIGN_LEFT);
style.setDataFormat(resolveDataFormat(workbook, fmt));
cache.put(fmt, style);
}
return cache;
}
// 対象外用のスタイル(背景グレー・文字列形式)を1つだけ作る
private CellStyle createExcludedStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setAlignment(CellStyle.ALIGN_LEFT);
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(CellStyle.SOLID_FOREGROUND);
style.setDataFormat((short) BuiltinFormats.getBuiltinFormat("@")); // 文字列
return style;
}
// 表示書式の種別を Excel の書式文字列に変換する
private short resolveDataFormat(Workbook workbook, CellFormat format) {
DataFormat df = workbook.createDataFormat();
if (format == CellFormat.TEXT) return df.getFormat("@");
if (format == CellFormat.INTEGER) return df.getFormat("#,##0");
if (format == CellFormat.DECIMAL) return df.getFormat("#,##0.00");
if (format == CellFormat.DATE) return df.getFormat("yyyy/mm/dd");
return df.getFormat("General");
}
7. ヘッダー出力
列定義のリストを順に回してヘッダー行を作る。10列でも100列でも、ヘッダー出力のコードは変えなくてよい。実務ではヘッダーを目立たせる太字スタイルと、スクロール時もヘッダーが残る固定(freeze)を入れておくと使いやすい。
private void writeHeader(Workbook workbook, Sheet sheet) {
CellStyle headerStyle = createHeaderStyle(workbook);
Row headerRow = sheet.createRow(0);
for (int j = 0; j < COLUMNS.size(); j++) {
Cell cell = headerRow.createCell(j);
cell.setCellValue(COLUMNS.get(j).header);
cell.setCellStyle(headerStyle);
}
// ヘッダー行を固定(1行目を常時表示)
sheet.createFreezePane(0, 1);
}
private CellStyle createHeaderStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBoldweight(Font.BOLDWEIGHT_BOLD);
style.setFont(font);
style.setAlignment(CellStyle.ALIGN_CENTER);
return style;
}
8. データ行の書き込み
列定義を順に処理し、対象外の列なら 事前に作っておいたグレー+文字列のスタイル に差し替え、値も文字列として出力する。対象の列は表示書式に対応したスタイルを当てる。出力は最大100行に収まる。
private File writeXlsx(List<RecordDto> dtoList) {
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("明細");
writeHeader(workbook, sheet);
// スタイルを事前生成して使い回す
Map<CellFormat, CellStyle> formatStyles = buildFormatStyles(workbook);
CellStyle excludedStyle = createExcludedStyle(workbook);
int rowIndex = 1; // ヘッダーの次の行から
for (RecordDto dto : dtoList) {
Row row = sheet.createRow(rowIndex++);
for (int j = 0; j < COLUMNS.size(); j++) {
ColumnDef col = COLUMNS.get(j);
Cell cell = row.createCell(j);
Object value = getValue(dto, col.fieldName);
if (isExcluded(col.header)) {
// 対象外 … グレー+文字列スタイル、値も文字列で出力
setCellValue(cell, value, CellFormat.TEXT);
cell.setCellStyle(excludedStyle);
} else {
// 対象 … 表示書式に対応したスタイル
setCellValue(cell, value, col.format);
cell.setCellStyle(formatStyles.get(col.format));
}
}
}
File tempFile;
FileOutputStream fos = null;
try {
tempFile = File.createTempFile("record_export_", ".xlsx");
fos = new FileOutputStream(tempFile);
workbook.write(fos);
} catch (IOException e) {
throw new RuntimeException("xlsx の書き込みに失敗しました", e);
} finally {
try { if (fos != null) fos.close(); } catch (IOException ignore) {}
try { workbook.close(); } catch (IOException ignore) {} // dispose は不要
}
return tempFile;
}
// 値を表示書式に合わせてセルに設定する
private void setCellValue(Cell cell, Object value, CellFormat format) {
if (value == null) {
cell.setCellValue("");
return;
}
if (format == CellFormat.TEXT) {
// 文字列形式(先頭ゼロやコード値をそのまま保持)
cell.setCellValue(value.toString());
} else if (format == CellFormat.INTEGER || format == CellFormat.DECIMAL) {
if (value instanceof Number) {
cell.setCellValue(((Number) value).doubleValue());
} else {
cell.setCellValue(value.toString());
}
} else if (format == CellFormat.DATE) {
if (value instanceof java.util.Date) {
cell.setCellValue((java.util.Date) value); // 表示はスタイルの日付書式に従う
} else {
cell.setCellValue(value.toString());
}
} else {
cell.setCellValue(value.toString());
}
}
コード例
package com.example.export;
import java.text.SimpleDateFormat;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
/**
* テンプレート(xlsm)を読み込み、DB から取得したデータを転記して出力する。
*
* 処理の流れ
* 1. controller から呼ばれる
* 2. フィールドの DAO で DB から特定クラス型(RecordModel)のリストを取得
* 3. ファイル出力に必要な項目だけの dto(RecordDto)へコピー
* 4. クラスパス上のテンプレートを開き、ヘッダー名で列を突き合わせて値を転記
* 5. 一時ファイルへ書き出して返す
*
* テンプレートの前提
* - 1 行目にヘッダーが記載済み
* - データ行(2 行目以降、最大 100 行分)は全セルが「文字列」の表示書式
* - 対象外の列は背景色グレーが設定済み
* - そのため、スタイル・表示書式・背景色はコードで一切設定しない
*
*/
public class ExcelExportService extends AbstractExporter {
/** テンプレートのクラスパス位置(resources 配下、SQL ファイルと同じ階層に配置する) */
private static final String TEMPLATE_PATH = "com/example/export/template.xlsm";
/** ヘッダーが存在する行(0 始まり) */
private static final int HEADER_ROW_INDEX = 0;
/** データ開始行(0 始まり。ヘッダーの次の行) */
private static final int DATA_START_ROW_INDEX = 1;
/** 最大出力行(データ行) */
private static final int MAX_OUTPUT_ROWS = 100;
/**
* DAO
*/
private RecordDao recordDao;
// ------------------------------------------------------------------
// controller から呼ばれる入口
// ------------------------------------------------------------------
/**
* 検索条件で取得したデータをテンプレートに転記して出力する。
*
* @param criteria 画面から渡された検索条件
* @param fileDto ファイル出力に関する情報(出力ファイル名など)
* @return 生成した Excel ファイル
*/
public File exportFile(SearchCriteria criteria, FileDto fileDto) {
// 1. DAO で DB から取得(特定クラス型のリスト)
List<RecordModel> models = recordDao.findByCriteria(criteria);
// 2. ファイル出力に必要な項目だけの dto へコピー
List<RecordDto> dtoList = toDtoList(models);
// 3. 件数チェック(テンプレートのデータ行は最大 100 行のため)
if (dtoList.size() > MAX_OUTPUT_ROWS) {
throw new IllegalStateException(
"出力行が上限を超えています: " + dtoList.size() + " > " + MAX_OUTPUT_ROWS);
}
// 4. テンプレートに転記して出力
return writeToTemplate(dtoList, fileDto);
}
// ------------------------------------------------------------------
// model -> dto コピー
// ------------------------------------------------------------------
/**
* DB から取得した model を、ファイル出力用の dto へ詰め替える。
* dto には出力する項目だけ定義すればよい。
*/
private List<RecordDto> toDtoList(List<RecordModel> models) {
List<RecordDto> dtoList = new ArrayList<RecordDto>();
for (RecordModel m : models) {
RecordDto dto = new RecordDto();
dto.setShohinCode(m.getShohinCode());
dto.setShohinName(m.getShohinName());
dto.setSuryo(m.getSuryo());
dto.setTanka(m.getTanka());
dto.setKingaku(m.getKingaku());
dto.setTorihikiCode(m.getTorihikiCode());
dto.setTorihikiName(m.getTorihikiName());
dto.setTantosha(m.getTantosha());
dto.setTorokubi(m.getTorokubi());
dto.setKoshinbi(m.getKoshinbi());
dto.setBiko(m.getBiko());
dto.setKanriNo(m.getKanriNo());
dtoList.add(dto);
}
return dtoList;
}
// ------------------------------------------------------------------
// 列定義(dto フィールド名・ヘッダー名・値の種別)
// ------------------------------------------------------------------
/**
* 値の種別。テンプレートが全セル文字列書式のため、見た目の制御には使わない。
* 値を文字列へ変換する方法(日付の整形など)の判断にだけ使う。
*/
private enum ValueType { TEXT, NUMBER, DATE }
/** 1 列分の定義(dto フィールド名・ヘッダー名・値の種別) */
private static class ColumnDef {
final String fieldName; // dto のフィールド名
final String header; // ヘッダー名(テンプレートのヘッダーと突き合わせる)
final ValueType type; // 値の種別
ColumnDef(String fieldName, String header, ValueType type) {
this.fieldName = fieldName;
this.header = header;
this.type = type;
}
}
/** 列定義一覧(列位置は持たない。位置はテンプレートのヘッダーから決まる) */
private static final List<ColumnDef> COLUMNS = buildColumns();
private static List<ColumnDef> buildColumns() {
List<ColumnDef> list = new ArrayList<ColumnDef>();
// フィールド名, ヘッダー名, 値の種別
list.add(new ColumnDef("shohinCode", "商品コード", ValueType.TEXT));
list.add(new ColumnDef("shohinName", "商品名", ValueType.TEXT));
list.add(new ColumnDef("suryo", "数量", ValueType.NUMBER));
list.add(new ColumnDef("tanka", "単価", ValueType.NUMBER));
list.add(new ColumnDef("kingaku", "金額", ValueType.NUMBER));
list.add(new ColumnDef("torihikiCode", "取引先コード", ValueType.TEXT));
list.add(new ColumnDef("torihikiName", "取引先名", ValueType.TEXT));
list.add(new ColumnDef("tantosha", "担当者", ValueType.TEXT));
list.add(new ColumnDef("torokubi", "登録日", ValueType.DATE));
list.add(new ColumnDef("koshinbi", "更新日", ValueType.DATE));
list.add(new ColumnDef("biko", "備考", ValueType.TEXT));
list.add(new ColumnDef("kanriNo", "内部管理番号", ValueType.TEXT));
return list;
}
/**
* フィールド名に対応する dto の値を取り出す。
* メソッド参照を使わず if 分岐で明示する。列を追加したらここにも 1 行足す。
*/
private Object getValue(RecordDto dto, String fieldName) {
if ("shohinCode".equals(fieldName)) return dto.getShohinCode();
if ("shohinName".equals(fieldName)) return dto.getShohinName();
if ("suryo".equals(fieldName)) return dto.getSuryo();
if ("tanka".equals(fieldName)) return dto.getTanka();
if ("kingaku".equals(fieldName)) return dto.getKingaku();
if ("torihikiCode".equals(fieldName)) return dto.getTorihikiCode();
if ("torihikiName".equals(fieldName)) return dto.getTorihikiName();
if ("tantosha".equals(fieldName)) return dto.getTantosha();
if ("torokubi".equals(fieldName)) return dto.getTorokubi();
if ("koshinbi".equals(fieldName)) return dto.getKoshinbi();
if ("biko".equals(fieldName)) return dto.getBiko();
if ("kanriNo".equals(fieldName)) return dto.getKanriNo();
throw new IllegalArgumentException("未定義のフィールド名: " + fieldName);
}
// ------------------------------------------------------------------
// テンプレート取得・ヘッダー突き合わせ
// ------------------------------------------------------------------
/** クラスパス上のテンプレートを InputStream で取得する */
private InputStream openTemplate() {
InputStream is = getClass().getClassLoader().getResourceAsStream(TEMPLATE_PATH);
if (is == null) {
throw new IllegalStateException("テンプレートが見つかりません: " + TEMPLATE_PATH);
}
return is;
}
/** テンプレートのヘッダー行から「ヘッダー名 -> 列インデックス」を作る */
private Map<String, Integer> buildHeaderIndex(Row headerRow) {
Map<String, Integer> map = new HashMap<String, Integer>();
for (int j = 0; j < headerRow.getLastCellNum(); j++) {
Cell cell = headerRow.getCell(j);
if (cell != null && cell.getCellType() == Cell.CELL_TYPE_STRING) {
String header = cell.getStringCellValue().trim(); // 前後空白を除去
map.put(header, Integer.valueOf(j));
}
}
return map;
}
/**
* 列定義のヘッダーがすべてテンプレートに存在するか確認する。
* テンプレートとコードのヘッダー定義がずれたときに、原因が分かる形で早期に落とす。
*/
private void validateColumns(Map<String, Integer> headerIndex) {
List<String> missing = new ArrayList<String>();
for (ColumnDef col : COLUMNS) {
if (!headerIndex.containsKey(col.header)) {
missing.add(col.header);
}
}
if (!missing.isEmpty()) {
throw new IllegalStateException(
"テンプレートに存在しないヘッダーがあります: " + missing);
}
}
// ------------------------------------------------------------------
// テンプレートへの転記・出力
// ------------------------------------------------------------------
/**
* テンプレートを開き、ヘッダー名で列を突き合わせて値を転記し、一時ファイルへ出力する。
* スタイル・表示書式・背景色はテンプレート側に設定済みのため、コードでは触らない。
*/
private File writeToTemplate(List<RecordDto> dtoList, FileDto fileDto) {
InputStream is = openTemplate();
XSSFWorkbook workbook = null;
FileOutputStream fos = null;
File outputFile;
try {
// テンプレート(xlsm)を開く。ヘッダー・書式・背景色、マクロはそのまま保持される
workbook = new XSSFWorkbook(is);
XSSFSheet sheet = workbook.getSheetAt(0);
// テンプレートのヘッダーから対応表を作り、列定義との整合を検証する
Row headerRow = sheet.getRow(HEADER_ROW_INDEX);
Map<String, Integer> headerIndex = buildHeaderIndex(headerRow);
validateColumns(headerIndex);
// データ行へ転記(テンプレートに書式付き空行が用意されている前提)
int rowIndex = DATA_START_ROW_INDEX;
for (RecordDto dto : dtoList) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
// 書式付き空行が足りない場合の保険(書式は付かない)
row = sheet.createRow(rowIndex);
}
rowIndex++;
for (ColumnDef col : COLUMNS) {
int colIndex = headerIndex.get(col.header).intValue(); // ヘッダー名で列を引く
Cell cell = row.getCell(colIndex);
if (cell == null) {
cell = row.createCell(colIndex);
}
// 値だけ設定。setCellStyle は呼ばない(テンプレートの書式をそのまま使う)
setCellValue(cell, getValue(dto, col.fieldName), col.type);
}
}
// 一時ファイルへ書き出す。テンプレートが xlsm のため出力も xlsm
outputFile = File.createTempFile("record_export_", ".xlsm");
fos = new FileOutputStream(outputFile);
workbook.write(fos);
} catch (IOException e) {
throw new RuntimeException("テンプレート出力に失敗しました", e);
} finally {
try { if (fos != null) fos.close(); } catch (IOException ignore) {}
// workbook.close() は v3.9 に存在しないため呼ばない(GC に任せる)
try { if (is != null) is.close(); } catch (IOException ignore) {}
}
return outputFile;
}
/**
* 値をセルに設定する。テンプレートが文字列書式のため、すべて文字列として入れる。
* 日付は表示用に整形した文字列にする。
*/
private void setCellValue(Cell cell, Object value, ValueType type) {
if (value == null) {
cell.setCellValue("");
return;
}
if (type == ValueType.DATE && value instanceof java.util.Date) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
cell.setCellValue(sdf.format((java.util.Date) value));
} else {
cell.setCellValue(value.toString());
}
}
}
// マクロ
Sub StampCell()
ThisWorkbook.Sheets(1).Range("Z1").Value = "macro ran " & Now
End Sub
次の段階(テンプレートファイルの読み込み)
テンプレートを使う場合、new XSSFWorkbook(new FileInputStream(templateFile)) でテンプレートを開き、ヘッダー行を再生成せず既存のシートにデータ行だけを追記する。マクロ込みの .xlsm テンプレートを土台にする場合は、開いて書き戻すだけで VBA は保持される。
注意点
-
実行経路上のメソッドに private 処理を差し込み、独自 Response 型を返す。経路外のメソッドを
return nullで潰す場合は、戻り値が参照されないことを Call Hierarchy で確認する。参照されるなら空オブジェクトを返す -
最大100行のため XSSFWorkbook で十分 … メモリを抑えるための
SXSSFWorkbookは不要。SXSSFWorkbook固有のdispose()も登場しない。XSSFWorkbookはclose()のみでよい -
スタイルの使い回し … 行・セルごとに
createCellStyleを呼ぶとスタイル数上限に達するリスクがある。対象用・対象外用を1回ずつ生成して使い回す