背景
国庆期间接了个兼职,处理机构的几张Excel报表。初次沟通,感觉挺简单,接入Easyexcel(FastExcel),然后拼lamda表达式就跑出来了。不过毕竟工作了这些年,感觉没这么简单。后面找业务方详细聊了一次,将需求落到纸面上。逐行研究了下BRD,有点挠头,跑数加各种样式,兼容新老版本,老方案是不行了。综合对比,最终选了老牌的 Apache POI 实现,下面说下为啥选POI,还有POI怎么用,包含样式、公式、动态表头、安全防范、百万级数据导入导出等功能。
一、技术选型
如果实现该功能,客户端可以(装个app),服务端也可行。考虑到电脑性能和未来大量的扩展升级,首先排除客户端。服务端有各种语言可以解析excel,但是功能参差不齐,下面对比下比较熟悉的几种(像C#不熟悉,直接排除):Apache POI (Java),Apache POI (Java),FastExcel (Java/Kotlin),Python (openpyxl / pandas / xlrd/xlwt),PHP (PhpSpreadsheet / ExcelReader),Rust (calamine / rust_xlsxwriter)(咋还有rust?因为前面写了几篇这块文章,顺手带上)。
| 功能项 | Apache POI(Java) | FastExcel(Java/Kotlin) | Python(openpyxl / pandas / xlrd/xlwt) | PHP(PhpSpreadsheet / ExcelReader) | Rust(calamine / rust_xlsxwriter) |
|---|---|---|---|---|---|
| 支持格式:xls / xlsx | ✅ (HSSF / XSSF / SXSSF) | ⚠️ 仅 xlsx | ✅ (openpyxl: xlsx / xlrd: xls) | ✅ (xls / xlsx / ods) | ⚠️ calamine: 读多种格式;rust_xlsxwriter: 仅写 xlsx |
| 样式设置(字体、边框、对齐、条件格式) | ✅ 全面 | ⚠️ 基础样式有限 | ✅ openpyxl 支持全面 | ✅ 支持全面 | ✅ rust_xlsxwriter 支持全面,calamine 仅读 |
| 多级表头 / 合并单元格 | ✅ 合并支持良好,可构造多级 | ⚠️ 支持合并,需手动构造表头 | ✅ openpyxl 支持合并 / 多层 | ✅ 合并单元格支持 | ✅ merge_range 支持 |
| 公式(读写 / 计算) | ✅ 写 / 读 / 评估部分公式 | ⚠️ 仅支持写公式,不评估 | ⚠️ 写入公式支持,计算有限 | ⚠️ 写公式支持,部分计算 | ⚠️ 写公式支持,不计算 |
| 下拉选项 / 数据验证 | ✅ DataValidation 支持 | ⚠️ 支持不完善或无文档 | ✅ openpyxl 提供 DataValidation | ✅ 支持下拉与校验 | ✅ rust_xlsxwriter 支持验证 |
| 图表生成 | ✅ 支持 XSSF 图表 | ❌ 不支持图表 | ✅ openpyxl 支持 BarChart / LineChart 等 | ✅ includeCharts 可写出图表 | ✅ rust_xlsxwriter 支持多类图表 |
| 防注入 / 宏攻击防护 | ⚠️ 提供加密保护但无宏隔离 | ❌ 无安全特性 | ⚠️ 不解析宏,宏文件不安全 | ⚠️ 仅单元格锁定,无宏隔离 | ⚠️ 不解析宏文件 |
| 加密 / 密码保护 | ✅ Office 标准加密支持 | ❌ 不支持 | ⚠️ 加密读取支持有限 | ⚠️ 工作表保护,非文件加密 | ⚠️ 加密支持非常有限 |
| 大文件读写 / 流式写入 | ✅ SXSSF 支持流式写入 | ✅ 高性能流式写入 | ⚠️ read_only / write_only 模式 | ⚠️ 受 PHP 内存限制 | ✅ rust_xlsxwriter / calamine 流式高效 |
| 性能 / 内存占用 | ⚠️ 需流式模式优化 | ✅ 极佳性能 | ⚠️ 中等,取决于数据量 | ⚠️ 内存占用大 | ✅ 高性能,内存占用低 |
| 修改已有文件 | ✅ 支持读改写 | ⚠️ 不支持修改已有文件 | ⚠️ 可读写但慢 | ✅ 支持读写修改 | ⚠️ rust_xlsxwriter 仅创建新文件 |
| 生态与文档 | ✅ 成熟 / 官方维护 | ⚠️ 较新,文档有限 | ✅ 文档丰富 | ✅ 文档齐全 | ⚠️ Rust 生态新,功能在发展中 |
- 从上表可以看出Apache POI功能最全面,几乎涵盖所有功能,其他各有优劣。
- 从需求方提供的excel示例文件看,有xls和xlsx格式的,内容里面有公式。每个表数据量不大,但是样式要求高,比如宋体、10号等。文件来源也需要一些防护,毕竟是外部给的。未来可能需要支持参数校验等等。不难看出,只有apache POI能胜任。下面整理下POI入门文档,内容参考POI官网。
二、POI入门与进阶
1、添加POI依赖
本文基于springboot 2.7.18版本
1<dependency> 2 <groupId>org.apache.poi</groupId> 3 <artifactId>poi-ooxml</artifactId> 4 <version>5.2.3</version> 5</dependency> 6
2、基本使用
在POI中,Workbook代表整个Excel文件,sheet是工作表,可以有多个。
Row和Cell是单独的对象,索引都是从0开始。Cell单元格,可存字符串、数字、布尔、日期等。
下面代码中包含了增删改查基本操作。
a、新建excel的操作
注意:sheet.removeRow(row1); 不会像你直观理解的那样“把行从表格中完全移除并上移下面的行”。它会清空该 Row 对象中的所有单元格,其他行号不变。所以下面调用了封装的方法移除。
1 @Test 2 void testCreateWorkbookAndSheet() throws IOException { 3 Workbook workbook = new XSSFWorkbook(); 4 Sheet sheet = workbook.createSheet("员工信息"); 5 6 // 创建表头 7 Row header = sheet.createRow(0); 8 header.createCell(0).setCellValue("编号"); 9 header.createCell(1).setCellValue("姓名"); 10 header.createCell(2).setCellValue("薪资"); 11 12 // 添加数据 13 Row row1 = sheet.createRow(1); 14 row1.createCell(0).setCellValue(1001); 15 row1.createCell(1).setCellValue("张三"); 16 row1.createCell(2).setCellValue(12000); 17 18 Row row2 = sheet.createRow(2); 19 row2.createCell(0).setCellValue(1001); 20 row2.createCell(1).setCellValue("张三"); 21 row2.createCell(2).setCellValue(12000); 22 23 // 修改单元格 24 row1.getCell(2).setCellValue(13000); 25 26 // 删除一行,清空操作 27 sheet.removeRow(row1); 28 //如果上移需要使用封装的方法 29 //deleteRow(sheet, 1); 30 try (FileOutputStream fos = new FileOutputStream("basic.xlsx")) { 31 workbook.write(fos); 32 } 33 workbook.close(); 34 } 35 36 /** 37 * 删除指定行,并将下面的行上移 https://stackoverflow.com/questions/21946958/how-to-remove-a-row-using-apache-poi 38 * 39 * @param sheet 目标Sheet 40 * @param rowIndex 要删除的行号(0-based) 41 */ 42 public void deleteRow(Sheet sheet, int rowIndex) { 43 int lastRowNum = sheet.getLastRowNum(); 44 if (rowIndex >= 0 && rowIndex < lastRowNum) { 45 sheet.shiftRows(rowIndex + 1, lastRowNum, -1); 46 } 47 if (rowIndex == lastRowNum) { 48 Row removingRow = sheet.getRow(rowIndex); 49 if (removingRow != null) { 50 sheet.removeRow(removingRow); 51 } 52 } 53 } 54
sheet.removeRow(row1)效果:
deleteRow(sheet, 1)效果:
b、读取已有文件
注意:WorkbookFactory.create兼容新老板版本,推荐使用
1 @Test 2 void testReadSheet() throws Exception { 3 InputStream is = Thread.currentThread() 4 .getContextClassLoader() 5 .getResourceAsStream("daoru.xls"); 6 //WorkbookFactory兼容 xls和xlsx 7 try (Workbook workbook = WorkbookFactory.create(is)) { 8 9 Sheet sheetAt = workbook.getSheetAt(0); 10 assertNotNull(sheetAt); 11 12 Row row = sheetAt.getRow(0); 13 Cell cell = row.getCell(0); 14 System.out.println("第一个单元格内容: " + getCellValue(cell)); 15 16 Sheet sheet2 = workbook.getSheet("基本支出决算明细表"); 17 Row row2 = sheet2.getRow(11); 18 Cell cell2 = row2.getCell(7); 19 System.out.println("第一个单元格内容: " + getCellValue(cell2)); 20 21 // 遍历每个 Sheet 22 for (int i = 0; i < workbook.getNumberOfSheets() && i < 2; i++) { 23 Sheet sheet = workbook.getSheetAt(i); 24 System.out.println("She et[" + i + "] 名称: " + sheet.getSheetName()); 25 26 // 遍历行 27 for (Row r : sheet) { 28 // 遍历单元格 29 for (Cell c : r) { 30 String value = getCellValue(c); // 使用工具方法获取显示值 31 System.out.print(value + "\t"); 32 } 33 System.out.println(); 34 } 35 36 System.out.println("================================="); 37 } 38 39 } 40 } 41
c、sheet的基本操作
可以根据index,名字等获取sheet。也支持修改和排序
1 @Test 2 void testUpdateSheet() throws Exception { 3 try (Workbook workbook = createWorkbook(XLS)) { 4 // 创建新 Sheet 5 Sheet newSheet = workbook.createSheet("新建Sheet"); 6 7 // 修改已有 Sheet 名称 8 workbook.setSheetName(0, "用户信息"); 9 // 4️ 调整 Sheet 顺序 10 workbook.setSheetOrder("用户信息", 1); // 移动到第2个位置 11 workbook.setSheetOrder("新建Sheet", 0); // 移动到第1个位置 12 13 String outputPath = "target/output" + GOV_XLS.substring(XLS.lastIndexOf('.')); 14 // 5️⃣ 导出为新文件 15 File outputFile = new File(outputPath); 16 try (FileOutputStream os = new FileOutputStream(outputFile)) { 17 workbook.write(os); 18 } 19 } 20 } 21 22
d、cell数据的简单转换和double精度问题
- cell目前有四种数据,下面有示例,公式在后续章节。
- 注意金额的处理:如果涉及到计算,可以把double转到BigDecimal处理,
double是二进制浮点数,无法精确表示小数,例如0.1 + 0.2 ≠ 0.3。如果导出的数据必须是数字类型,可以使用Bigdecimal转下(不超过1516位)。数字超过 1516 位有效数字时(无论是整数还是小数),double 就不能精确表示它,只能取“最接近”的二进制数,产生舍入误差。超过15到16位的尽量用字符串。
1 private static String getCellValue(Cell cell) { 2 if (cell == null) return ""; 3 switch (cell.getCellType()) { 4 case STRING: 5 return cell.getStringCellValue(); 6 case NUMERIC: 7 return String.valueOf(cell.getNumericCellValue()); 8 case BOOLEAN: 9 return String.valueOf(cell.getBooleanCellValue()); 10 case FORMULA: //单元格里存放的是公式,而不是直接的值。 11 return cell.getCellFormula(); 12 case BLANK: 13 return ""; 14 default: 15 return "UNKNOWN"; 16 } 17 } 18
3、样式设置(字体、边框、对齐、条件格式)
以下示例展示了字体加粗等样式。
注意:
- style对象可以重复使用,同一个样式尽量只创建一次。
- POI 条件格式公式从单元格左上角开始,即 A1 为相对位置,公式可以使用
$绝对引用。 - 条件格式适合数据量中小的单元格,数万行大表格时条件格式多可能会影响 Excel 打开速度。
1 @Test 2 void testCreateStyledExcelWithStyleCache() throws Exception { 3 try (Workbook workbook = new XSSFWorkbook()) { 4 Sheet sheet = workbook.createSheet("样式示例"); 5 6 // ========== 样式缓存 ========== 7 Map<String, CellStyle> styleCache = new HashMap<>(); 8 9 int rows = 5; 10 int cols = 5; 11 12 for (int i = 0; i < rows; i++) { 13 Row row = sheet.createRow(i); 14 for (int j = 0; j < cols; j++) { 15 Cell cell = row.createCell(j); 16 cell.setCellValue("R" + (i + 1) + "C" + (j + 1)); 17 18 // 样式 key:行背景 + 列字体颜色 + 是否加粗 19 String key = (i % 2) + "-" + (j % 2) + "-" + (i % 2 == 0); 20 21 // 复用样式 22 int finalI = i; 23 int finalJ = j; 24 CellStyle style = styleCache.computeIfAbsent(key, k -> createCellStyle(workbook, finalI, finalJ)); 25 26 cell.setCellStyle(style); 27 } 28 } 29 30 // 自动调整列宽 31 for (int j = 0; j < cols; j++) { 32 sheet.autoSizeColumn(j); 33 } 34 35 // 条件格式示例 36 SheetConditionalFormatting sheetCF = sheet.getSheetConditionalFormatting(); 37 // 条件1:值 > 80 → 绿色 38 ConditionalFormattingRule rule1 = sheetCF.createConditionalFormattingRule(ComparisonOperator.GT, "80"); 39 rule1.createPatternFormatting().setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex()); 40 rule1.getPatternFormatting().setFillPattern(FillPatternType.SOLID_FOREGROUND.getCode()); 41 42 // 条件2:值 < 50 → 红色 43 ConditionalFormattingRule rule2 = sheetCF.createConditionalFormattingRule(ComparisonOperator.LT, "50"); 44 rule2.createPatternFormatting().setFillForegroundColor(IndexedColors.ROSE.getIndex()); 45 rule2.getPatternFormatting().setFillPattern(FillPatternType.SOLID_FOREGROUND.getCode()); 46 47 // 应用到区域 B2:B100 48 sheetCF.addConditionalFormatting( 49 new CellRangeAddress[]{CellRangeAddress.valueOf("B2:B100")}, 50 rule1, rule2 51 ); 52 53 // 导出文件 54 try (FileOutputStream fos = new FileOutputStream("target/poi-style-demo-cache.xlsx")) { 55 workbook.write(fos); 56 } 57 } 58 } 59 60 /** 61 * 创建单元格样式(字体、边框、对齐、背景) 62 */ 63 private CellStyle createCellStyle(Workbook workbook, int rowIndex, int colIndex) { 64 CellStyle style = workbook.createCellStyle(); 65 66 // 字体 67 Font font = workbook.createFont(); 68 font.setFontName("微软雅黑"); 69 font.setFontHeightInPoints((short) 12); 70 font.setBold(rowIndex % 2 == 0); // 偶数行加粗 71 font.setColor(colIndex % 2 == 0 ? IndexedColors.RED.getIndex() : IndexedColors.BLUE.getIndex()); 72 style.setFont(font); 73 74 // 边框 75 style.setBorderTop(BorderStyle.THIN); 76 style.setBorderBottom(BorderStyle.THIN); 77 style.setBorderLeft(BorderStyle.THIN); 78 style.setBorderRight(BorderStyle.THIN); 79 80 // 对齐 81 style.setAlignment(HorizontalAlignment.CENTER); 82 style.setVerticalAlignment(VerticalAlignment.CENTER); 83 84 // 背景 85 style.setFillForegroundColor(rowIndex % 2 == 0 ? IndexedColors.LIGHT_YELLOW.getIndex() : IndexedColors.GREY_25_PERCENT.getIndex()); 86 style.setFillPattern(FillPatternType.SOLID_FOREGROUND); 87 88 return style; 89 } 90
下面是导出的效果图:

4、多级表头与合并单元格
a、多级表头动态读取
EasyExcel读取多表头存在问题,必须写死index才有数据,对于需要动态映射的,则没法处理。使用POI可以模仿EasyExcel注解,写个数据解析类。
注解和实体类如下:
1import java.lang.annotation.Retention; 2import java.lang.annotation.RetentionPolicy; 3 4@Retention(RetentionPolicy.RUNTIME) 5public @interface ExcelProperty { 6 //多级表头,用数组 7 String[] value(); 8} 9 10@Data 11public class ExcelBudgetData implements Serializable { 12 13 @ExcelProperty(value = "预算项目") 14 private String budgetProject; 15 16 // "完成数"下面的“支付数” 17 @ExcelProperty(value = {"完成数", "支付数"}) 18 private String completedPaymentAmount; 19 20 @ExcelProperty(value = "项目类别") 21 private String projectCategory; 22 23} 24
表头读取工具如下,注意跨列的解析方式:
1import org.apache.poi.ss.usermodel.*; 2import org.apache.poi.ss.util.CellRangeAddress; 3 4import java.util.ArrayList; 5import java.util.List; 6 7public class ExcelHeaderUtil { 8 9 /** 10 * 读取多级表头,支持跨列合并 11 * 12 * @param sheet Excel sheet 13 * @param headRowStart 表头起始行 (0-based) 14 * @param headRowEnd 表头结束行 (0-based) 15 * @return 拼接后的多级表头列表(用 - 连接) 16 */ 17 public static List<String> readMultiLevelHeader(Sheet sheet, int headRowStart, int headRowEnd) { 18 List<List<String>> headerRows = new ArrayList<>(); 19 int maxCol = 0; 20 21 // 读取每一行表头内容 22 for (int r = headRowStart; r <= headRowEnd; r++) { 23 Row row = sheet.getRow(r); 24 List<String> rowData = new ArrayList<>(); 25 if (row != null) { 26 int lastCol = row.getLastCellNum(); 27 maxCol = Math.max(maxCol, lastCol); 28 for (int c = 0; c < lastCol; c++) { 29 String value = getMergedCellValue(sheet, r, c); 30 rowData.add(value == null ? "" : value.trim()); 31 } 32 } 33 headerRows.add(rowData); 34 } 35 36 // 拼接多级表头 37 List<String> finalHeaders = new ArrayList<>(); 38 for (int c = 0; c < maxCol; c++) { 39 StringBuilder sb = new StringBuilder(); 40 for (List<String> headerRow : headerRows) { 41 String val = c < headerRow.size() ? headerRow.get(c) : ""; 42 if (!val.isEmpty()) { 43 if (sb.length() > 0) { 44 sb.append("-"); 45 } 46 sb.append(val); 47 } 48 } 49 finalHeaders.add(sb.toString()); 50 } 51 52 return finalHeaders; 53 } 54 55 private static String getMergedCellValue(Sheet sheet, int rowIndex, int colIndex) { 56 for (int i = 0; i < sheet.getNumMergedRegions(); i++) { 57 CellRangeAddress range = sheet.getMergedRegion(i); 58 if (range.isInRange(rowIndex, colIndex)) { 59 if (range.getFirstRow() != rowIndex) { 60 return ""; 61 } 62 Row firstRow = sheet.getRow(range.getFirstRow()); 63 Cell firstCell = firstRow.getCell(range.getFirstColumn()); 64 return getCellStringValue(firstCell); 65 } 66 } 67 Row row = sheet.getRow(rowIndex); 68 if (row == null) { 69 return null; 70 } 71 Cell cell = row.getCell(colIndex); 72 return getCellStringValue(cell); 73 } 74 //公式等没处理,可以自行添加 75 private static String getCellStringValue(Cell cell) { 76 if (cell == null) { 77 return null; 78 } 79 switch (cell.getCellType()) { 80 case STRING: 81 return cell.getStringCellValue(); 82 case NUMERIC: 83 return String.valueOf(cell.getNumericCellValue()); 84 case BOOLEAN: 85 return String.valueOf(cell.getBooleanCellValue()); 86 default: 87 return null; 88 } 89 } 90} 91
使用方式如下:
1private List<ExcelBudgetData> parseExcel(Sheet sheet) throws IllegalAccessException { 2 // 1️⃣ 读取多级表头 3 final int headRowStart = 3; 4 final int headRowEnd = 4; 5 List<String> headers = ExcelHeaderUtil.readMultiLevelHeader(sheet, headRowStart, headRowEnd); 6 // 2️⃣ 映射列到实体字段 7 Map<Integer, Field> colFieldMap = new HashMap<>(); 8 Field[] fields = ExcelBudgetData.class.getDeclaredFields(); 9 for (int i = 0; i < headers.size(); i++) { 10 String header = headers.get(i); 11 for (Field field : fields) { 12 ExcelProperty prop = field.getAnnotation(ExcelProperty.class); 13 if (prop != null) { 14 String joined = String.join("-", prop.value()).trim(); 15 if (joined.equals(header)) { 16 field.setAccessible(true); 17 colFieldMap.put(i, field); 18 break; 19 } 20 } 21 } 22 } 23 24 25 List<ExcelBudgetData> result = new ArrayList<>(); 26 for (int r = headRowEnd + 1; r <= sheet.getLastRowNum(); r++) { 27 Row row = sheet.getRow(r); 28 if (row == null) { 29 continue; 30 } 31 ExcelBudgetData obj = new ExcelBudgetData(); 32 33 for (Map.Entry<Integer, Field> entry : colFieldMap.entrySet()) { 34 int c = entry.getKey(); 35 Field field = entry.getValue(); 36 Cell cell = row.getCell(c); 37 Object value = getCellValue(cell, field.getType()); 38 field.set(obj, value); 39 } 40 41 result.add(obj); 42 } 43 return result; 44 45 } 46
b、写入时跨列
下面是一个跨行和跨列的表头例子

1@Test 2 void testMultiHeader() throws Exception { 3 Workbook workbook = new XSSFWorkbook(); 4 Sheet sheet = workbook.createSheet("多级表头"); 5 6 // 第一行表头 7 Row row1 = sheet.createRow(0); 8 row1.createCell(0).setCellValue("部门"); 9 row1.createCell(1).setCellValue("销售额"); 10 row1.createCell(3).setCellValue("利润"); 11 12 // 第二行子表头 13 Row row2 = sheet.createRow(1); 14 row2.createCell(1).setCellValue("Q1"); 15 row2.createCell(2).setCellValue("Q2"); 16 row2.createCell(3).setCellValue("Q1"); 17 row2.createCell(4).setCellValue("Q2"); 18 19 // 合并表头单元格 20 sheet.addMergedRegion(new CellRangeAddress(0, 1, 0, 0)); // 部门 21 sheet.addMergedRegion(new CellRangeAddress(0, 0, 1, 2)); // 销售额 22 sheet.addMergedRegion(new CellRangeAddress(0, 0, 3, 4)); // 利润 23 24 try (FileOutputStream out = new FileOutputStream("target/multi-header-demo.xlsx")) { 25 workbook.write(out); 26 } 27 workbook.close(); 28 } 29
5、公式处理
示例数据如下,C列为公式,对A和B列求和。
| A | B | C |
|---|---|---|
| 10 | 20 | =A1+B1 |
| 5 | 2 | =A2*B2 |
a、创建公式并读取求和结果
1 @Test 2 void testReadAndEvaluateFormula() throws Exception { 3 4 // Step 1: 创建含公式的 Excel 文件 5 try (Workbook workbook = new XSSFWorkbook()) { 6 Sheet sheet = workbook.createSheet("Formula"); 7 Row row1 = sheet.createRow(0); 8 //A:10 B:20 C::A1+B1 9 row1.createCell(0).setCellValue(10); 10 row1.createCell(1).setCellValue(20); 11 row1.createCell(2).setCellFormula("A1+B1"); 12 13 try (FileOutputStream out = new FileOutputStream("target/demo.xlsx")) { 14 workbook.write(out); 15 } 16 } 17 18 // Step 2: 重新读取并计算公式 19 try (FileInputStream in = new FileInputStream("target/demo.xlsx"); 20 Workbook workbook = new XSSFWorkbook(in)) { 21 22 Sheet sheet = workbook.getSheetAt(0); 23 FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator(); 24 25 Cell formulaCell = sheet.getRow(0).getCell(2); 26 //执行C列公式 27 evaluator.evaluateFormulaCell(formulaCell); 28 29 assertEquals(30.0, formulaCell.getNumericCellValue(), 0.001); 30 } 31 } 32 33
效果如下:

b、自定义公式
注册自定义函数(UDF,User Defined Function),例如计算税率、平均增长率等。如下自定义公式 MYFUNC(x, y) = x² + y
1 /** 2 * 自定义函数 MYFUNC(x, y) = x^2 + y 3 */ 4 static class MyFunc implements FreeRefFunction { 5 @Override 6 public ValueEval evaluate(ValueEval[] args, OperationEvaluationContext ec) { 7 try { 8 // 先取得单个值(处理引用/区域) 9 ValueEval v0 = OperandResolver.getSingleValue(args[0], 10 ec.getRowIndex(), ec.getColumnIndex()); 11 ValueEval v1 = OperandResolver.getSingleValue(args[1], 12 ec.getRowIndex(), ec.getColumnIndex()); 13 14 double x = OperandResolver.coerceValueToDouble(v0); 15 double y = OperandResolver.coerceValueToDouble(v1); 16 17 return new NumberEval(x * x + y); 18 } catch (EvaluationException | RuntimeException ex) { 19 return ErrorEval.VALUE_INVALID; 20 } 21 } 22 } 23 24 @Test 25 void testRegisterUdfAndEvaluate() throws Exception { 26 try (Workbook workbook = new XSSFWorkbook()) { 27 Sheet sheet = workbook.createSheet("UDF"); 28 Row row = sheet.createRow(0); 29 row.createCell(0).setCellValue(3); // A1 30 row.createCell(1).setCellValue(4); // B1 31 Cell formulaCell = row.createCell(2); 32 formulaCell.setCellFormula("MYFUNC(A1,B1)"); // C1 33 34 // --- 正确注册自定义函数的关键步骤 --- 35 String[] names = {"MYFUNC"}; 36 FreeRefFunction[] impls = { new MyFunc() }; 37 UDFFinder udfToolpack = new DefaultUDFFinder(names, impls); 38 39 // 把 UDF 注册到 Workbook(所有 POI Workbook 实现都支持 addToolPack) 40 workbook.addToolPack(udfToolpack); 41 42 // 计算公式并验证结果 43 FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator(); 44 CellValue cv = evaluator.evaluate(formulaCell); // 返回 CellValue 45 assertEquals(13.0, cv.getNumberValue(), 1e-6); // 3^2 + 4 = 13 46 } 47 } 48
6、下拉选项和数据验证
下拉和验证的绝大多数使用场景都是为了生成 Excel 模板,方便用户填写数据,而不是在程序里校验。简单的可以约束用户输入范围,复杂的比如类似地域级联下拉,甚至下拉框引用另一个sheet。如果是动态的模版,手动配置成本巨高,这时候poi可以解决这个问题。
这块的内容比较多,甚至可以单独写一篇,下面举俩例子。
a、性别和年龄限制
1 @Test 2 void testGenerateUserTemplateWithValidations() throws Exception { 3 Workbook wb = new XSSFWorkbook(); 4 Sheet sheet = wb.createSheet("用户信息"); 5 6 // 标题行 7 Row header = sheet.createRow(0); 8 header.createCell(0).setCellValue("姓名"); 9 header.createCell(1).setCellValue("性别"); 10 header.createCell(2).setCellValue("年龄"); 11 12 DataValidationHelper helper = sheet.getDataValidationHelper(); 13 14 // 性别下拉 15 DataValidationConstraint genderConstraint = 16 helper.createExplicitListConstraint(new String[]{"男", "女"}); 17 DataValidation genderValidation = 18 helper.createValidation(genderConstraint, new CellRangeAddressList(1, 100, 1, 1)); 19 20 // ✅ 显式开启错误提示 21 genderValidation.setShowErrorBox(true); 22 23 genderValidation.createErrorBox("输入错误", "只能选择男女"); 24 25 sheet.addValidationData(genderValidation); 26 27 // 年龄验证:0–120 28 DataValidationConstraint ageConstraint = 29 helper.createNumericConstraint( 30 DataValidationConstraint.ValidationType.INTEGER, 31 DataValidationConstraint.OperatorType.BETWEEN, "0", "120"); 32 DataValidation ageValidation = 33 helper.createValidation(ageConstraint, new CellRangeAddressList(1, 100, 2, 2)); 34 35 36 // ✅ 显式开启错误提示 37 ageValidation.setShowErrorBox(true); 38 39 ageValidation.createErrorBox("输入错误", "请输入 0-120 的整数"); 40 41 sheet.addValidationData(ageValidation); 42 43 // 写入文件 44 try (FileOutputStream out = new FileOutputStream("user_template.xlsx")) { 45 wb.write(out); 46 } 47 wb.close(); 48 } 49

b、引用其他sheet作为下拉选项
以下示例将选项放到了hidden表,Sheet1表用于下拉选择。
1 @Test 2 void testCascadeDropdownMultiRow() throws Exception { 3 XSSFWorkbook workbook = new XSSFWorkbook(); 4 Sheet sheet = workbook.createSheet("Sheet1"); 5 6 // 1. 创建隐藏Sheet存放下拉数据 7 Sheet hidden = workbook.createSheet("hidden"); 8 //(为了看效果,暂时打开) 9 workbook.setSheetHidden(workbook.getSheetIndex(hidden), false); 10 11 // 省 12 String[] provinces = {"广东", "江苏"}; 13 for (int i = 0; i < provinces.length; i++) { 14 hidden.createRow(i).createCell(0).setCellValue(provinces[i]); 15 } 16 17 // 市 18 String[] guangdongCities = {"广州", "深圳"}; 19 String[] jiangsuCities = {"南京", "苏州"}; 20 21 for (int i = 0; i < guangdongCities.length; i++) { 22 hidden.getRow(i).createCell(1).setCellValue(guangdongCities[i]); 23 } 24 for (int i = 0; i < jiangsuCities.length; i++) { 25 hidden.getRow(i).createCell(2).setCellValue(jiangsuCities[i]); 26 } 27 28 // 2. 定义命名区域 29 Name nameProvince = workbook.createName(); 30 nameProvince.setNameName("province"); 31 nameProvince.setRefersToFormula("hidden!$A$1:$A$2"); 32 33 Name nameGuangdong = workbook.createName(); 34 nameGuangdong.setNameName("广东"); 35 nameGuangdong.setRefersToFormula("hidden!$B$1:$B$2"); 36 37 Name nameJiangsu = workbook.createName(); 38 nameJiangsu.setNameName("江苏"); 39 nameJiangsu.setRefersToFormula("hidden!$C$1:$C$2"); 40 41 // 3. 设置省下拉(多行) 42 DataValidationHelper helper = new XSSFDataValidationHelper((XSSFSheet) sheet); 43 DataValidationConstraint provinceConstraint = helper.createFormulaListConstraint("province"); 44 45 // 假设我们需要 100 行 46 CellRangeAddressList provinceAddressList = new CellRangeAddressList(0, 99, 0, 0); // A列 0~99行 47 DataValidation provinceValidation = helper.createValidation(provinceConstraint, provinceAddressList); 48 provinceValidation.setShowErrorBox(true); 49 sheet.addValidationData(provinceValidation); 50 51 // 4. 设置市下拉(依赖公式 INDIRECT,多行) 52 for (int row = 0; row < 100; row++) { 53 String formula = "INDIRECT(A" + (row + 1) + ")"; // A1~A100 54 DataValidationConstraint cityConstraint = helper.createFormulaListConstraint(formula); 55 CellRangeAddressList cityAddressList = new CellRangeAddressList(row, row, 1, 1); // B列对应行 56 DataValidation cityValidation = helper.createValidation(cityConstraint, cityAddressList); 57 cityValidation.setShowErrorBox(true); 58 sheet.addValidationData(cityValidation); 59 } 60 61 // 5. 输出文件 62 try (FileOutputStream fos = new FileOutputStream("cascade_dropdown_multi.xlsx")) { 63 workbook.write(fos); 64 } 65 66 workbook.close(); 67 } 68
级联效果如下:

7、创建图表(柱状、折线)
POI 的图表 API 使用 XDDF。XDDF 是基于 XSSF(xlsx)版本的 API,无法用于 .xls。生成的图表在 Excel 打开后会自动渲染,不支持纯文本查看。示例如下:
1 @Test 2 void testCreateBarChart() throws Exception { 3 XSSFWorkbook workbook = new XSSFWorkbook(); 4 XSSFSheet sheet = workbook.createSheet("销售数据"); 5 6 // 准备数据 7 sheet.createRow(0).createCell(0).setCellValue("季度"); 8 sheet.getRow(0).createCell(1).setCellValue("销售额"); 9 String[] quarters = {"Q1", "Q2", "Q3", "Q4"}; 10 int[] sales = {5000, 7000, 9000, 12000}; 11 12 for (int i = 0; i < quarters.length; i++) { 13 Row row = sheet.createRow(i + 1); 14 row.createCell(0).setCellValue(quarters[i]); 15 row.createCell(1).setCellValue(sales[i]); 16 } 17 18 // 创建图表对象 19 XSSFDrawing drawing = sheet.createDrawingPatriarch(); 20 XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 1, 10, 15); 21 XSSFChart chart = drawing.createChart(anchor); 22 23 chart.setTitleText("季度销售柱状图"); 24 chart.setTitleOverlay(false); 25 26 XDDFChartLegend legend = chart.getOrAddLegend(); 27 legend.setPosition(LegendPosition.BOTTOM); 28 29 // 定义坐标轴 30 XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM); 31 XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT); 32 33 // 定义数据范围 34 XDDFDataSource<String> xs = XDDFDataSourcesFactory.fromStringCellRange(sheet, 35 new CellRangeAddress(1, 4, 0, 0)); 36 XDDFNumericalDataSource<Double> ys = XDDFDataSourcesFactory.fromNumericCellRange(sheet, 37 new CellRangeAddress(1, 4, 1, 1)); 38 39 // 创建柱状图数据集 40 XDDFChartData data = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis); 41 XDDFChartData.Series series = data.addSeries(xs, ys); 42 series.setTitle("销售额", null); 43 chart.plot(data); 44 45 try (FileOutputStream out = new FileOutputStream("target/chart-demo.xlsx")) { 46 workbook.write(out); 47 } 48 workbook.close(); 49 } 50

8、安全防护
a、过滤用户输入
所有用户上传或填充数据必须先过滤公式前缀。'='、'+'、'-'、'@'这些开头的都是公式前缀
1import org.junit.jupiter.api.Test; 2 3public class ExcelSecurityTest { 4 5 // 简单示例:检查单元格内容是否以 '='、'+'、'-'、'@' 开头 6 private boolean isSafe(String input) { 7 if (input == null) return true; 8 return !input.matches("^[=+\\-@].*"); 9 } 10 11 @Test 12 void testFormulaInjection() { 13 String userInput1 = "=SUM(A1:A10)"; 14 String userInput2 = "Alice"; 15 16 System.out.println(isSafe(userInput1)); // false 17 System.out.println(isSafe(userInput2)); // true 18 } 19} 20
b、防宏攻击
对于上传的 Excel,可在导入前转换为纯 XSSF 对象。.xlsm 文件可能包含宏,避免直接打开执行。
1 public static void removeMacros(String inputFile, String outputFile) throws Exception { 2 try (FileInputStream in = new FileInputStream(inputFile); 3 XSSFWorkbook workbook = new XSSFWorkbook(in)) { 4 5 // XSSFWorkbook 读取后不包含宏,直接写出即可 6 workbook.write(new java.io.FileOutputStream(outputFile)); 7 } 8 } 9
.xlsm 转成 .xlsx 转换可清理宏。
c、加密与密码保护
1 @Test 2 @DisplayName("创建Excel → 写入内容 → 保护Sheet → 加密 → 保存 → 解密验证") 3 void testFullPoiEncryptionFlow() throws Exception { 4 // === Step 1: 创建工作簿并写入内容 === 5 Workbook workbook = new XSSFWorkbook(); 6 Sheet sheet = workbook.createSheet("Sheet1"); 7 Row row = sheet.createRow(0); 8 Cell cell = row.createCell(0); 9 cell.setCellValue("Hello, POI 5.2!"); 10 11 // === Step 2: 设置工作表保护(防止修改)=== 12 sheet.protectSheet("sheetpass"); 13 14 // === Step 3: 写入到临时文件(未加密)=== 15 File tempFile = File.createTempFile("poi_plain_", ".xlsx"); 16 try (FileOutputStream fos = new FileOutputStream(tempFile)) { 17 workbook.write(fos); 18 } 19 workbook.close(); 20 21 // === Step 4: 使用标准加密模式加密文件 === 22 try (POIFSFileSystem fs = new POIFSFileSystem()) { 23 // ✅ 正确写法:创建 EncryptionInfo 时不传 fs 24 EncryptionInfo info = new EncryptionInfo(EncryptionMode.standard); 25 Encryptor encryptor = info.getEncryptor(); 26 encryptor.confirmPassword(PASSWORD); 27 28 // 将未加密文件内容写入加密输出流 29 try (OPCPackage opc = OPCPackage.open(tempFile, PackageAccess.READ_WRITE); 30 OutputStream os = encryptor.getDataStream(fs)) { 31 opc.save(os); 32 } 33 34 // 保存加密后的文件 35 try (FileOutputStream fos = new FileOutputStream("target/pwd_demo.xlsx")) { 36 fs.writeFilesystem(fos); 37 } 38 } 39 File file = new File("target/pwd_demo.xlsx"); 40 assertTrue(file.exists(), "加密文件应生成"); 41 42 // === Step 5: 使用密码读取并验证内容 === 43 try (POIFSFileSystem fs = new POIFSFileSystem(Files.newInputStream(file.toPath()))) { 44 EncryptionInfo info = new EncryptionInfo(fs); // ✅ 读取时传 fs 45 Decryptor decryptor = Decryptor.getInstance(info); 46 47 if (!decryptor.verifyPassword(PASSWORD)) { 48 fail("密码错误,无法解密"); 49 } 50 51 try (InputStream dataStream = decryptor.getDataStream(fs); 52 Workbook wb2 = WorkbookFactory.create(dataStream)) { 53 54 Sheet sheet2 = wb2.getSheetAt(0); 55 Row row2 = sheet2.getRow(0); 56 Cell cell2 = row2.getCell(0); 57 58 assertEquals("Hello, POI 5.2!", cell2.getStringCellValue()); 59 } 60 } catch (EncryptedDocumentException e) { 61 fail("文件解密失败: " + e.getMessage()); 62 } 63 } 64

9、大文件读取和写入
当 Excel 文件行数达到 10万+ 时,普通的 XSSFWorkbook 方式会迅速消耗内存。介绍两种高效的读写方式:
| 场景 | 推荐方案 | 特点 |
|---|---|---|
| 大文件写出 | SXSSFWorkbook | 支持流式写出,内存占用低 |
| 大文件读取 | StreamingReader(第三方库) | 支持流式读取,不加载全部数据 |
a、大文件写出 —— SXSSFWorkbook
1import org.apache.poi.xssf.streaming.SXSSFWorkbook; 2import org.apache.poi.ss.usermodel.*; 3import org.junit.jupiter.api.Test; 4 5import java.io.FileOutputStream; 6import java.io.IOException; 7 8public class ExcelLargeWriteTest { 9 10 @Test 11 void testLargeExcelWrite() throws IOException { 12 // 保留100行在内存中,其他写入磁盘 13 SXSSFWorkbook workbook = new SXSSFWorkbook(100); 14 Sheet sheet = workbook.createSheet("大数据"); 15 16 // 写入100万行 17 for (int i = 0; i < 1_000_000; i++) { 18 Row row = sheet.createRow(i); 19 row.createCell(0).setCellValue("Row-" + i); 20 row.createCell(1).setCellValue(Math.random() * 1000); 21 } 22 23 try (FileOutputStream out = new FileOutputStream("target/large-write-demo.xlsx")) { 24 workbook.write(out); 25 } 26 27 // 清理临时文件 28 workbook.dispose(); 29 workbook.close(); 30 } 31} 32
SXSSFWorkbook在写入时会将溢出的数据写入临时文件。调用dispose()可删除这些临时文件。写出性能远高于XSSFWorkbook,但不能再读回同一个对象。
b、大文件读取 —— StreamingReader
POI 官方未提供流式读取,因此借助 第三方库:com.monitorjbl:xlsx-streamer 。
添加依赖:
1<dependency> 2 <groupId>com.monitorjbl</groupId> 3 <artifactId>xlsx-streamer</artifactId> 4 <version>2.2.0</version> 5</dependency> 6
1 @Test 2 void testLargeExcelRead() throws IOException { 3 try (FileInputStream in = new FileInputStream("target/large-write-demo.xlsx"); 4 Workbook workbook = StreamingReader.builder() 5 .rowCacheSize(100) // 缓存100行 6 .bufferSize(4096) // 读取缓冲区 7 .open(in)) { 8 9 Sheet sheet = workbook.getSheetAt(0); 10 int count = 0; 11 for (Row row : sheet) { 12 count++; 13 if (count % 100_000 == 0) { 14 System.out.println("已读:" + count + " 行"); 15 } 16 } 17 System.out.println("总行数:" + count); 18 } 19 } 20
每次只缓存有限行数据(默认10),内存占用极低。不支持修改,只能读取。适合 ETL、批量导入、日志分析等任务。如仅读取的话推荐xlsx-streamer,几乎没有反射,注解等,出错面少,调试简单。追求性能业务用户交互等,推荐EasyExcel(FastExcel),高度封装,性能甚至更好一些。
10、其他注意点
- 输出流应及时finally / try-with-resources 中关闭
- 导出文件ContentType对应问题
- 导出
.xlsx:response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); - 导出
.xls(老格式):response.setContentType("application/vnd.ms-excel");
- 导出
- 文件打不开,检查下这几个问题
| 问题 | 原因 | 正确做法 |
| ----------- | ------------------------ | ----------------------- |
| 文件部分写入 | 输出流提前关闭 | 保证 wb.write(out) 结束后再关闭 |
| 多线程写同一文件 | 文件锁冲突 | 每线程独立 Workbook 或使用锁 |
| 输出重复 header | 重复设置 Content-Disposition | 统一封装下载方法 |
三、总结
在上家单位一直用EasyExcel,反过来想想,都是内部员工用,数据规范,不要求样式等,偶尔数据量大点。面对这类外部需求,不能简单的套用,分析需求,综合对比才是合适做法。本文从基础使用,到样式、图标等涵盖了POI处理的核心功能。前几天选型对比完后,确实有点被POI的强大震撼到。有了这张牌,后面即使Excel需求有挑战,也有信心拿下。
如果觉得有用,请点下关注吧(本人公众号_大鱼七成饱_),您的关注是我分享最大的动力。
《apache POI 万字总结:满足你对报表一切幻想》 是转载文章,点击查看原文。
