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 | 文字 |
Number(Integer/Long/Double/BigDecimal…) | 數值 |
Date | 日期(未設格式時自動套用 yyyy-mm-dd hh:mm:ss) |
Boolean | 布林 |
null | 空白儲存格 |
| 其他 | 轉為字串 |
讀取(Cell#getValue() 依儲存格型別還原)
| 儲存格型別 | 還原為 |
|---|---|
| 文字 | String(空字串與字面 NULL 視為 null) |
| 數值(整數值) | Long |
| 數值(非整數) | Double |
| 數值(日期格式) | Date |
| 布林 | Boolean |
| 公式 | 先求值,再依結果型別還原 |
| 空白 | null |
讀出後可用 WorkbookRecord 的型別安全方法進一步轉換:getAsString、getAsInteger、getAsLong、getAsBigDecimal、getAsDate、getAsLocalDateTime、getAsBoolean 等。
常見場景
場景 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 | @ | 純文字 |
DATE | yyyy-mm-dd | 日期 |
DATETIME | yyyy-mm-dd hh:mm:ss | 日期時間(Date 預設) |
TIME | hh:mm:ss | 時間 |
INTEGER | #,##0 | 整數(千分位) |
FLOAT | #,##0.00 | 小數(千分位) |
MONEY | $#,##0.00 | 貨幣 |
PERCENT | 0.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/b、em/i、u、s/del/strike、sup、sub、style="color: …"。
一般文字不套字型、沿用儲存格既有樣式。marks 詞彙與 Document 模組 同源,
同一份 RichTextEditor 內容匯出 Word 與 Excel 認得的行內格式一致。
:::note 顏色只認 color,不認 background-color
文字色取 style 中屬性名恰為 color 的宣告;background-color(RichTextEditor 的螢光筆)是背景、不是文字色,故不予套用。
Excel 的儲存格底色是樣式層級的(CellStyle#setBackgroundColor),無法逐段設定——螢光標記在儲存格內只保留文字。
值支援 #rrggbb、#rgb、rgb() / 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。