跳至主要内容

Workbook (Excel) 模組使用指南

Package: io.leandev.appfuse.workbook.* 狀態: 穩定(v1) 格式: Office Open XML(.xlsx)、舊版二進位(.xls


簡介

AppFuse Workbook 提供穩定的 Excel 讀寫 API,封裝底層 Apache POI,讓應用層不直接接觸 POI 型別。

核心特色

特色說明價值
型別感知讀寫setValue(Object) 自動對映、getValue() 還原 Java 型別不必手動判斷 cell type
欄位名稱存取record.getAsBigDecimal("price")沿用框架 Record 的型別安全轉換
物件序列化writer.write(product) 依 Header 自動提取屬性無需手動映射
依賴隔離層底層 POI 不洩漏到公開 API升級 / 替換底層不影響應用層

範圍與限制

項目支援
.xlsx(XSSF)
.xls(HSSF,舊版二進位)✅ 讀取自動判定格式;顏色有先天限制(見下)
Rich Text(單格內混排字型)✅ 以 HTML 子集寫入;區塊標記會壓平(見下)
串流寫出(SXSSF,超大檔)❌ 規劃中;目前為記憶體模式

記憶體模式:整份活頁簿常駐記憶體。匯出數十萬列以上時請留意記憶體用量。


檔案格式(.xlsx / .xls

讀取時格式自動判定,呼叫端無需事先得知:

// .xlsx 或 .xls 都用同一個 open(),不必先判斷副檔名
try (Workbook workbook = Workbook.open(uploadedFile.getInputStream())) {
WorkbookFormat format = workbook.format(); // 需要時可查詢實際格式
}

建立時預設為 .xlsx,需要舊格式才指定:

Workbook.create(); // .xlsx(預設)
Workbook.create(WorkbookFormat.XLS); // .xls(Excel 97–2003)

.xls 的先天限制

.xls 是 Excel 97–2003 的舊版二進位格式,除非要與只吃舊格式的系統交換檔案,否則建議一律採用 .xlsx

限制.xls.xlsx
每張工作表上限65,536 列 × 256 欄1,048,576 列 × 16,384 欄
顏色56 色索引調色盤任意 RGB

顏色是最容易踩到的一項.xls 無法表示任意 RGB,寫入時會自動取調色盤中最接近的顏色——同一個 java.awt.Color 在兩種格式下的呈現可能不同,且此近似不可逆:

// .xlsx:精確保留 #3A7BD5
// .xls :落到調色盤中最接近的藍
style.setBackground(new Color(0x3A, 0x7B, 0xD5));

// 調色盤內的顏色(如純紅)兩種格式皆精確保留
style.setBackground(Color.RED);

需要精確色彩(如品牌色)時,請採用 .xlsx


快速開始

寫入 Excel

import io.leandev.appfuse.workbook.*;

try (Workbook workbook = Workbook.create();
OutputStream os = new FileOutputStream("products.xlsx")) {

WorkbookWriter writer = new WorkbookWriter(workbook.createSheet("Products"));
writer.writeHeaders("id", "name", "price", "stock");

for (Product product : productService.findAll()) {
writer.write(product); // 依 Header 順序自動提取物件屬性
}

workbook.autoSizeColumns();
workbook.write(os);
}

讀取 Excel

try (InputStream is = new FileInputStream("products.xlsx");
Workbook workbook = Workbook.open(is)) {

WorkbookReader reader = new WorkbookReader(workbook.getSheetAt(0));
reader.readHeaders();

for (WorkbookRecord record = reader.read(); record != null; record = reader.read()) {
String name = record.getAsString("name");
BigDecimal price = record.getAsBigDecimal("price");
Integer stock = record.getAsInteger("stock");
// 處理資料...
}
}

自訂欄位名稱(Header)

上例以 Excel 第一列作為欄位名稱。若檔案沒有標題列,或想以自己的名稱取代原始標題,改用 setHeaders(List<String>)。名稱依欄位位置對應(清單第 N 個名稱 → 第 N 欄)。

readHeaders() 會讀掉一列並使游標前進;setHeaders() 只設定名稱、不移動游標。兩種情境如下:

try (InputStream is = new FileInputStream("products.xlsx");
Workbook workbook = Workbook.open(is)) {

WorkbookReader reader = new WorkbookReader(workbook.getSheetAt(0));

// 情境 1:檔案「沒有」標題列 —— 直接指定欄位名稱,read() 從第 1 列開始
reader.setHeaders(List.of("name", "price", "stock"));

// 情境 2:檔案「有」標題列、但想換掉 —— 先讀掉原始標題列(游標前進到第 2 列),再覆寫名稱
// reader.readHeaders();
// reader.setHeaders(List.of("name", "price", "stock"));

for (WorkbookRecord record = reader.read(); record != null; record = reader.read()) {
String name = record.getAsString("name");
BigDecimal price = record.getAsBigDecimal("price");
Integer stock = record.getAsInteger("stock");
// 處理資料...
}
}

情境 2 取用其一即可(範例中以註解呈現):不呼叫 readHeaders()read() 會把第一列也當成資料。

使用 Stream API

try (Workbook workbook = Workbook.open(inputStream)) {
WorkbookReader reader = new WorkbookReader(workbook.getSheetAt(0));
reader.readHeaders();

List<Product> lowStock = reader.stream()
.filter(record -> record.getAsInteger("stock") < 10)
.map(record -> new Product(
record.getAsString("name"),
record.getAsBigDecimal("price"),
record.getAsInteger("stock")))
.toList();
}

read() / stream() 會自動跳過空白列。


支援的資料型別

寫入(Cell#setValue(Object) 依執行期型別對映)

Java 型別寫入結果
String文字
NumberInteger/Long/Double/BigDecimal…)數值
Date日期(未設格式時自動套用 yyyy-mm-dd hh:mm:ss
Boolean布林
null空白儲存格
其他轉為字串

讀取(Cell#getValue() 依儲存格型別還原)

儲存格型別還原為
文字String(空字串與字面 NULL 視為 null
數值(整數值)Long
數值(非整數)Double
數值(日期格式)Date
布林Boolean
公式先求值,再依結果型別還原
空白null

讀出後可用 WorkbookRecord 的型別安全方法進一步轉換:getAsStringgetAsIntegergetAsLonggetAsBigDecimalgetAsDategetAsLocalDateTimegetAsBoolean 等。


常見場景

場景 1: 從資料庫匯出報表

List<Order> orders = orderRepository.findAll();

try (Workbook workbook = Workbook.create();
OutputStream os = response.getOutputStream()) {

WorkbookWriter writer = new WorkbookWriter(workbook.createSheet("Orders"));
writer.writeHeaders("orderNo", "customer", "amount", "createdOn");
orders.forEach(writer::write);

workbook.autoSizeColumns();
workbook.write(os);
}

場景 2: 匯入到資料庫

try (Workbook workbook = Workbook.open(uploadedFile.getInputStream())) {
WorkbookReader reader = new WorkbookReader(workbook.getSheetAt(0));
reader.readHeaders();

List<Product> products = reader.stream()
.map(record -> new Product(
record.getAsString("name"),
record.getAsBigDecimal("price"),
record.getAsInteger("stock")))
.toList();

productRepository.saveAll(products);
}

場景 3: 逐格控制(標題列樣式 + 合併儲存格)

try (Workbook workbook = Workbook.create()) {
Worksheet sheet = workbook.createSheet("Report");

// 標題樣式(共用)
CellStyle titleStyle = workbook.createCellStyle()
.setFontSize((short) 16)
.setBold(true)
.setAlign(Align.CENTER);

// 合併首列三欄作為標題
Row titleRow = sheet.createRow();
Cell title = titleRow.createCell();
title.setValue("2026 Q1 銷售報表");
title.setStyle(titleStyle);
sheet.addMergedRegion(0, 0, 0, 2);

Row header = sheet.createRow();
header.createCell().setValue("產品");
header.createCell().setValue("數量");
header.createCell().setValue("金額");

Row data = sheet.createRow();
data.createCell().setValue("玫瑰花束");
data.createCell().setValue(12);
Cell amount = data.createCell();
amount.setValue(new BigDecimal("15360"));
amount.setDataFormat(DataFormat.MONEY);

workbook.write(outputStream);
}

樣式

逐格設定

Cell cell = row.createCell();
cell.setValue("重要");
cell.setColor(Color.RED); // java.awt.Color
cell.setBackground(Color.YELLOW);
cell.setAlign(Align.CENTER);
cell.setBorderStyle(BorderStyle.THIN);
cell.setDataFormat(DataFormat.MONEY); // 或 setDataFormat("#,##0.00")
cell.setWrapText(true);

共用樣式(建議:大量套用時)

Excel 對單一活頁簿的樣式數量有上限(約 64,000)。大量套用相同外觀時,建立一次共用樣式再套用到多個儲存格,避免逐格產生新樣式:

CellStyle money = workbook.createCellStyle()
.setDataFormat(DataFormat.MONEY.pattern())
.setColor(Color.BLACK)
.setBold(true)
.setAlign(Align.RIGHT);

for (Order order : orders) {
Cell cell = sheet.createRow().createCell();
cell.setValue(order.getAmount());
cell.setStyle(money); // 重用同一個樣式物件
}

內建資料格式(DataFormat

列舉Excel 格式用途
TEXT@純文字
DATEyyyy-mm-dd日期
DATETIMEyyyy-mm-dd hh:mm:ss日期時間(Date 預設)
TIMEhh:mm:ss時間
INTEGER#,##0整數(千分位)
FLOAT#,##0.00小數(千分位)
MONEY$#,##0.00貨幣
PERCENT0.00%百分比

需要自訂格式時改用 cell.setDataFormat("自訂 Excel 格式字串")


Rich Text(單格內混排字型)

把 appfuse-web RichTextEditor 的 HTML 輸出直接寫進儲存格,行內格式逐段保留:

Cell cell = row.createCell();
cell.setRichText("價格 <strong>1280</strong> 元"); // 只有「1280」是粗體
cell.setWrapText(true); // 內容含換行時必加(見下)

支援的行內 marks:strong/bem/ius/del/strikesupsubstyle="color: …"。 一般文字不套字型、沿用儲存格既有樣式。marks 詞彙與 Document 模組 同源, 同一份 RichTextEditor 內容匯出 Word 與 Excel 認得的行內格式一致。

:::note 顏色只認 color,不認 background-color 文字色取 style 中屬性名恰為 color 的宣告;background-colorRichTextEditor螢光筆)是背景、不是文字色,故不予套用。 Excel 的儲存格底色是樣式層級的(CellStyle#setBackgroundColor),無法逐段設定——螢光標記在儲存格內只保留文字。 值支援 #rrggbb#rgbrgb() / rgba()(alpha 忽略);具名顏色(red 等)不支援。 :::

區塊標記會被壓平

Excel 儲存格內沒有段落、清單、圖片的概念,故區塊標記一律壓平為文字:

HTML儲存格內
<p> / <br>換行
<ul> / <ol> / 1. 前綴 + 換行(與 Document 模組 的清單呈現一致)
<h1><h3>粗體
<blockquote>純文字
<img>略過
cell.setRichText("<h2>訂單摘要</h2><p>共 3 項</p><ul><li>玫瑰</li><li>百合</li></ul>");
// → "訂單摘要\n共 3 項\n• 玫瑰\n• 百合"(「訂單摘要」為粗體)

需要真正的段落、清單與圖片版面時,請改用 Document 模組 輸出 Word。

:::caution 含換行時記得開自動換行 Excel 預設不顯示儲存格內的換行——壓平後的多段內容會擠成一行。請一併 cell.setWrapText(true)。 :::

與樣式的先後順序

Rich text 的字型以呼叫當下儲存格樣式的字型為基底(字型名稱、字級、顏色),再疊上 HTML marks。 同時要設樣式時,setStyle()setRichText()

CellStyle big = workbook.createCellStyle().setFontSize((short) 16);

Cell cell = row.createCell();
cell.setStyle(big); // 先設樣式
cell.setRichText("一般 <strong>粗體</strong>"); // 粗體段也會是 16pt

反過來的話,<strong> 會以呼叫當下的(預設)字級為基底,變成小一號的粗體。


公式

// 寫入公式(不含前導 =)
Cell total = row.createCell();
total.setFormula("SUM(B2:B10)");

// 讀取時自動求值
Object value = workbook.getSheetAt(0).getRow(0).getCell(2).getValue();

常見問題

Q: 為什麼不直接用 Apache POI?

A: AppFuse Workbook 提供穩定的 API 契約並隔離底層 POI 型別,與 CSV 模組同策略。底層升級或替換時,應用層代碼不受影響;同時統一了型別讀寫與 Record 存取慣例。

Q: 支援 .xls(舊版二進位)嗎?

A: 支援讀寫。讀取時 Workbook.open() 會自動判定格式,不需先判斷副檔名;建立時以 Workbook.create(WorkbookFormat.XLS) 指定。留意 .xls 的先天限制(列數上限、56 色調色盤),見上方「檔案格式」一節。

Q: 同一段程式碼可以同時處理 .xls.xlsx 嗎?

A: 可以。兩種格式共用同一套 API,差別只在建立時的格式指定;讀取路徑完全相同。唯一需要留意的是顏色——.xls 只能落在 56 色調色盤上(見上)。

Q: 可以在一個儲存格內混排字型嗎(部分粗體、部分變色)?

A: 可以,用 cell.setRichText(html)RichTextEditor 的 HTML 輸出。留意區塊標記會被壓平、且含換行時要 setWrapText(true),見上方「Rich Text」一節。

Q: 要匯出非常大的檔案(數十萬列)會 OOM 嗎?

A: v1 為記憶體模式,整份活頁簿常駐記憶體。超大檔的串流寫出(SXSSF)在規劃中;現階段建議改用 CSV 模組 串流匯出。

Q: 如何處理欄位不存在的情況?

if (record.indexOf("optional_column") >= 0) {
String value = record.getAsString("optional_column");
}

API 參考

詳細的類別設計和方法簽名,請參閱 Javadoc