背景
国庆期间接了个兼职,处理机构的几张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版本
xml
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
2、基本使用
在POI中,Workbook代表整个Excel文件,sheet是工作表,可以有多个。
Row和Cell是单独的对象,索引都是从0开始。Cell单元格,可存字符串、数字、布尔、日期等。
下面代码中包含了增删改查基本操作。
a、新建excel的操作
注意:sheet.removeRow(row1);
不会像你直观理解的那样"把行从表格中完全移除并上移下面的行"。它会清空该 Row 对象中的所有单元格,其他行号不变。所以下面调用了封装的方法移除。
java
@Test
void testCreateWorkbookAndSheet() throws IOException {
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("员工信息");
// 创建表头
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("编号");
header.createCell(1).setCellValue("姓名");
header.createCell(2).setCellValue("薪资");
// 添加数据
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue(1001);
row1.createCell(1).setCellValue("张三");
row1.createCell(2).setCellValue(12000);
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue(1001);
row2.createCell(1).setCellValue("张三");
row2.createCell(2).setCellValue(12000);
// 修改单元格
row1.getCell(2).setCellValue(13000);
// 删除一行,清空操作
sheet.removeRow(row1);
//如果上移需要使用封装的方法
//deleteRow(sheet, 1);
try (FileOutputStream fos = new FileOutputStream("basic.xlsx")) {
workbook.write(fos);
}
workbook.close();
}
/**
* 删除指定行,并将下面的行上移 https://stackoverflow.com/questions/21946958/how-to-remove-a-row-using-apache-poi
*
* @param sheet 目标Sheet
* @param rowIndex 要删除的行号(0-based)
*/
public void deleteRow(Sheet sheet, int rowIndex) {
int lastRowNum = sheet.getLastRowNum();
if (rowIndex >= 0 && rowIndex < lastRowNum) {
sheet.shiftRows(rowIndex + 1, lastRowNum, -1);
}
if (rowIndex == lastRowNum) {
Row removingRow = sheet.getRow(rowIndex);
if (removingRow != null) {
sheet.removeRow(removingRow);
}
}
}
sheet.removeRow(row1)效果:
deleteRow(sheet, 1)效果:
b、读取已有文件
注意:WorkbookFactory.create兼容新老板版本,推荐使用
java
@Test
void testReadSheet() throws Exception {
InputStream is = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream("daoru.xls");
//WorkbookFactory兼容 xls和xlsx
try (Workbook workbook = WorkbookFactory.create(is)) {
Sheet sheetAt = workbook.getSheetAt(0);
assertNotNull(sheetAt);
Row row = sheetAt.getRow(0);
Cell cell = row.getCell(0);
System.out.println("第一个单元格内容: " + getCellValue(cell));
Sheet sheet2 = workbook.getSheet("基本支出决算明细表");
Row row2 = sheet2.getRow(11);
Cell cell2 = row2.getCell(7);
System.out.println("第一个单元格内容: " + getCellValue(cell2));
// 遍历每个 Sheet
for (int i = 0; i < workbook.getNumberOfSheets() && i < 2; i++) {
Sheet sheet = workbook.getSheetAt(i);
System.out.println("She et[" + i + "] 名称: " + sheet.getSheetName());
// 遍历行
for (Row r : sheet) {
// 遍历单元格
for (Cell c : r) {
String value = getCellValue(c); // 使用工具方法获取显示值
System.out.print(value + "\t");
}
System.out.println();
}
System.out.println("=================================");
}
}
}
c、sheet的基本操作
可以根据index,名字等获取sheet。也支持修改和排序
java
@Test
void testUpdateSheet() throws Exception {
try (Workbook workbook = createWorkbook(XLS)) {
// 创建新 Sheet
Sheet newSheet = workbook.createSheet("新建Sheet");
// 修改已有 Sheet 名称
workbook.setSheetName(0, "用户信息");
// 4️ 调整 Sheet 顺序
workbook.setSheetOrder("用户信息", 1); // 移动到第2个位置
workbook.setSheetOrder("新建Sheet", 0); // 移动到第1个位置
String outputPath = "target/output" + GOV_XLS.substring(XLS.lastIndexOf('.'));
// 5️⃣ 导出为新文件
File outputFile = new File(outputPath);
try (FileOutputStream os = new FileOutputStream(outputFile)) {
workbook.write(os);
}
}
}
d、cell数据的简单转换和double精度问题
-
cell目前有四种数据,下面有示例,公式在后续章节。
-
注意金额的处理 :如果涉及到计算,可以把double转到BigDecimal处理,
double是二进制浮点数,无法精确表示小数
,例如0.1 + 0.2 ≠ 0.3。如果导出的数据必须是数字类型,可以使用Bigdecimal转下(不超过1516位)。数字超过 1516 位有效数字时(无论是整数还是小数),double 就不能精确表示它,只能取"最接近"的二进制数,产生舍入误差。超过15到16位的尽量用字符串。
java
private static String getCellValue(Cell cell) {
if (cell == null) return "";
switch (cell.getCellType()) {
case STRING:
return cell.getStringCellValue();
case NUMERIC:
return String.valueOf(cell.getNumericCellValue());
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case FORMULA: //单元格里存放的是公式,而不是直接的值。
return cell.getCellFormula();
case BLANK:
return "";
default:
return "UNKNOWN";
}
}
3、样式设置(字体、边框、对齐、条件格式)
以下示例展示了字体加粗等样式。
注意:
- style对象可以重复使用,同一个样式尽量只创建一次。
- POI 条件格式公式从单元格左上角开始 ,即 A1 为相对位置,公式可以使用
$
绝对引用。 - 条件格式适合数据量中小的单元格,数万行大表格时条件格式多可能会影响 Excel 打开速度。
java
@Test
void testCreateStyledExcelWithStyleCache() throws Exception {
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("样式示例");
// ========== 样式缓存 ==========
Map<String, CellStyle> styleCache = new HashMap<>();
int rows = 5;
int cols = 5;
for (int i = 0; i < rows; i++) {
Row row = sheet.createRow(i);
for (int j = 0; j < cols; j++) {
Cell cell = row.createCell(j);
cell.setCellValue("R" + (i + 1) + "C" + (j + 1));
// 样式 key:行背景 + 列字体颜色 + 是否加粗
String key = (i % 2) + "-" + (j % 2) + "-" + (i % 2 == 0);
// 复用样式
int finalI = i;
int finalJ = j;
CellStyle style = styleCache.computeIfAbsent(key, k -> createCellStyle(workbook, finalI, finalJ));
cell.setCellStyle(style);
}
}
// 自动调整列宽
for (int j = 0; j < cols; j++) {
sheet.autoSizeColumn(j);
}
// 条件格式示例
SheetConditionalFormatting sheetCF = sheet.getSheetConditionalFormatting();
// 条件1:值 > 80 → 绿色
ConditionalFormattingRule rule1 = sheetCF.createConditionalFormattingRule(ComparisonOperator.GT, "80");
rule1.createPatternFormatting().setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex());
rule1.getPatternFormatting().setFillPattern(FillPatternType.SOLID_FOREGROUND.getCode());
// 条件2:值 < 50 → 红色
ConditionalFormattingRule rule2 = sheetCF.createConditionalFormattingRule(ComparisonOperator.LT, "50");
rule2.createPatternFormatting().setFillForegroundColor(IndexedColors.ROSE.getIndex());
rule2.getPatternFormatting().setFillPattern(FillPatternType.SOLID_FOREGROUND.getCode());
// 应用到区域 B2:B100
sheetCF.addConditionalFormatting(
new CellRangeAddress[]{CellRangeAddress.valueOf("B2:B100")},
rule1, rule2
);
// 导出文件
try (FileOutputStream fos = new FileOutputStream("target/poi-style-demo-cache.xlsx")) {
workbook.write(fos);
}
}
}
/**
* 创建单元格样式(字体、边框、对齐、背景)
*/
private CellStyle createCellStyle(Workbook workbook, int rowIndex, int colIndex) {
CellStyle style = workbook.createCellStyle();
// 字体
Font font = workbook.createFont();
font.setFontName("微软雅黑");
font.setFontHeightInPoints((short) 12);
font.setBold(rowIndex % 2 == 0); // 偶数行加粗
font.setColor(colIndex % 2 == 0 ? IndexedColors.RED.getIndex() : IndexedColors.BLUE.getIndex());
style.setFont(font);
// 边框
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
// 对齐
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
// 背景
style.setFillForegroundColor(rowIndex % 2 == 0 ? IndexedColors.LIGHT_YELLOW.getIndex() : IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
return style;
}
下面是导出的效果图:

4、多级表头与合并单元格
a、多级表头动态读取
EasyExcel读取多表头存在问题,必须写死index才有数据,对于需要动态映射的,则没法处理。使用POI可以模仿EasyExcel注解,写个数据解析类。
注解和实体类如下:
java
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelProperty {
//多级表头,用数组
String[] value();
}
@Data
public class ExcelBudgetData implements Serializable {
@ExcelProperty(value = "预算项目")
private String budgetProject;
// "完成数"下面的"支付数"
@ExcelProperty(value = {"完成数", "支付数"})
private String completedPaymentAmount;
@ExcelProperty(value = "项目类别")
private String projectCategory;
}
表头读取工具如下,注意跨列的解析方式:
java
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import java.util.ArrayList;
import java.util.List;
public class ExcelHeaderUtil {
/**
* 读取多级表头,支持跨列合并
*
* @param sheet Excel sheet
* @param headRowStart 表头起始行 (0-based)
* @param headRowEnd 表头结束行 (0-based)
* @return 拼接后的多级表头列表(用 - 连接)
*/
public static List<String> readMultiLevelHeader(Sheet sheet, int headRowStart, int headRowEnd) {
List<List<String>> headerRows = new ArrayList<>();
int maxCol = 0;
// 读取每一行表头内容
for (int r = headRowStart; r <= headRowEnd; r++) {
Row row = sheet.getRow(r);
List<String> rowData = new ArrayList<>();
if (row != null) {
int lastCol = row.getLastCellNum();
maxCol = Math.max(maxCol, lastCol);
for (int c = 0; c < lastCol; c++) {
String value = getMergedCellValue(sheet, r, c);
rowData.add(value == null ? "" : value.trim());
}
}
headerRows.add(rowData);
}
// 拼接多级表头
List<String> finalHeaders = new ArrayList<>();
for (int c = 0; c < maxCol; c++) {
StringBuilder sb = new StringBuilder();
for (List<String> headerRow : headerRows) {
String val = c < headerRow.size() ? headerRow.get(c) : "";
if (!val.isEmpty()) {
if (sb.length() > 0) {
sb.append("-");
}
sb.append(val);
}
}
finalHeaders.add(sb.toString());
}
return finalHeaders;
}
private static String getMergedCellValue(Sheet sheet, int rowIndex, int colIndex) {
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
CellRangeAddress range = sheet.getMergedRegion(i);
if (range.isInRange(rowIndex, colIndex)) {
if (range.getFirstRow() != rowIndex) {
return "";
}
Row firstRow = sheet.getRow(range.getFirstRow());
Cell firstCell = firstRow.getCell(range.getFirstColumn());
return getCellStringValue(firstCell);
}
}
Row row = sheet.getRow(rowIndex);
if (row == null) {
return null;
}
Cell cell = row.getCell(colIndex);
return getCellStringValue(cell);
}
//公式等没处理,可以自行添加
private static String getCellStringValue(Cell cell) {
if (cell == null) {
return null;
}
switch (cell.getCellType()) {
case STRING:
return cell.getStringCellValue();
case NUMERIC:
return String.valueOf(cell.getNumericCellValue());
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
default:
return null;
}
}
}
使用方式如下:
java
private List<ExcelBudgetData> parseExcel(Sheet sheet) throws IllegalAccessException {
// 1️⃣ 读取多级表头
final int headRowStart = 3;
final int headRowEnd = 4;
List<String> headers = ExcelHeaderUtil.readMultiLevelHeader(sheet, headRowStart, headRowEnd);
// 2️⃣ 映射列到实体字段
Map<Integer, Field> colFieldMap = new HashMap<>();
Field[] fields = ExcelBudgetData.class.getDeclaredFields();
for (int i = 0; i < headers.size(); i++) {
String header = headers.get(i);
for (Field field : fields) {
ExcelProperty prop = field.getAnnotation(ExcelProperty.class);
if (prop != null) {
String joined = String.join("-", prop.value()).trim();
if (joined.equals(header)) {
field.setAccessible(true);
colFieldMap.put(i, field);
break;
}
}
}
}
List<ExcelBudgetData> result = new ArrayList<>();
for (int r = headRowEnd + 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
ExcelBudgetData obj = new ExcelBudgetData();
for (Map.Entry<Integer, Field> entry : colFieldMap.entrySet()) {
int c = entry.getKey();
Field field = entry.getValue();
Cell cell = row.getCell(c);
Object value = getCellValue(cell, field.getType());
field.set(obj, value);
}
result.add(obj);
}
return result;
}
b、写入时跨列
下面是一个跨行和跨列的表头例子

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

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

b、引用其他sheet作为下拉选项
以下示例将选项放到了hidden表,Sheet1表用于下拉选择。
java
@Test
void testCascadeDropdownMultiRow() throws Exception {
XSSFWorkbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("Sheet1");
// 1. 创建隐藏Sheet存放下拉数据
Sheet hidden = workbook.createSheet("hidden");
//(为了看效果,暂时打开)
workbook.setSheetHidden(workbook.getSheetIndex(hidden), false);
// 省
String[] provinces = {"广东", "江苏"};
for (int i = 0; i < provinces.length; i++) {
hidden.createRow(i).createCell(0).setCellValue(provinces[i]);
}
// 市
String[] guangdongCities = {"广州", "深圳"};
String[] jiangsuCities = {"南京", "苏州"};
for (int i = 0; i < guangdongCities.length; i++) {
hidden.getRow(i).createCell(1).setCellValue(guangdongCities[i]);
}
for (int i = 0; i < jiangsuCities.length; i++) {
hidden.getRow(i).createCell(2).setCellValue(jiangsuCities[i]);
}
// 2. 定义命名区域
Name nameProvince = workbook.createName();
nameProvince.setNameName("province");
nameProvince.setRefersToFormula("hidden!$A$1:$A$2");
Name nameGuangdong = workbook.createName();
nameGuangdong.setNameName("广东");
nameGuangdong.setRefersToFormula("hidden!$B$1:$B$2");
Name nameJiangsu = workbook.createName();
nameJiangsu.setNameName("江苏");
nameJiangsu.setRefersToFormula("hidden!$C$1:$C$2");
// 3. 设置省下拉(多行)
DataValidationHelper helper = new XSSFDataValidationHelper((XSSFSheet) sheet);
DataValidationConstraint provinceConstraint = helper.createFormulaListConstraint("province");
// 假设我们需要 100 行
CellRangeAddressList provinceAddressList = new CellRangeAddressList(0, 99, 0, 0); // A列 0~99行
DataValidation provinceValidation = helper.createValidation(provinceConstraint, provinceAddressList);
provinceValidation.setShowErrorBox(true);
sheet.addValidationData(provinceValidation);
// 4. 设置市下拉(依赖公式 INDIRECT,多行)
for (int row = 0; row < 100; row++) {
String formula = "INDIRECT(A" + (row + 1) + ")"; // A1~A100
DataValidationConstraint cityConstraint = helper.createFormulaListConstraint(formula);
CellRangeAddressList cityAddressList = new CellRangeAddressList(row, row, 1, 1); // B列对应行
DataValidation cityValidation = helper.createValidation(cityConstraint, cityAddressList);
cityValidation.setShowErrorBox(true);
sheet.addValidationData(cityValidation);
}
// 5. 输出文件
try (FileOutputStream fos = new FileOutputStream("cascade_dropdown_multi.xlsx")) {
workbook.write(fos);
}
workbook.close();
}
级联效果如下:

7、创建图表(柱状、折线)
POI 的图表 API 使用 XDDF。XDDF 是基于 XSSF(xlsx)版本的 API,无法用于 .xls。生成的图表在 Excel 打开后会自动渲染,不支持纯文本查看。示例如下:
java
@Test
void testCreateBarChart() throws Exception {
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("销售数据");
// 准备数据
sheet.createRow(0).createCell(0).setCellValue("季度");
sheet.getRow(0).createCell(1).setCellValue("销售额");
String[] quarters = {"Q1", "Q2", "Q3", "Q4"};
int[] sales = {5000, 7000, 9000, 12000};
for (int i = 0; i < quarters.length; i++) {
Row row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(quarters[i]);
row.createCell(1).setCellValue(sales[i]);
}
// 创建图表对象
XSSFDrawing drawing = sheet.createDrawingPatriarch();
XSSFClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0, 3, 1, 10, 15);
XSSFChart chart = drawing.createChart(anchor);
chart.setTitleText("季度销售柱状图");
chart.setTitleOverlay(false);
XDDFChartLegend legend = chart.getOrAddLegend();
legend.setPosition(LegendPosition.BOTTOM);
// 定义坐标轴
XDDFCategoryAxis bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM);
XDDFValueAxis leftAxis = chart.createValueAxis(AxisPosition.LEFT);
// 定义数据范围
XDDFDataSource<String> xs = XDDFDataSourcesFactory.fromStringCellRange(sheet,
new CellRangeAddress(1, 4, 0, 0));
XDDFNumericalDataSource<Double> ys = XDDFDataSourcesFactory.fromNumericCellRange(sheet,
new CellRangeAddress(1, 4, 1, 1));
// 创建柱状图数据集
XDDFChartData data = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis);
XDDFChartData.Series series = data.addSeries(xs, ys);
series.setTitle("销售额", null);
chart.plot(data);
try (FileOutputStream out = new FileOutputStream("target/chart-demo.xlsx")) {
workbook.write(out);
}
workbook.close();
}

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

9、大文件读取和写入
当 Excel 文件行数达到 10万+ 时,普通的 XSSFWorkbook
方式会迅速消耗内存。介绍两种高效的读写方式:
场景 | 推荐方案 | 特点 |
---|---|---|
大文件写出 | SXSSFWorkbook |
支持流式写出,内存占用低 |
大文件读取 | StreamingReader (第三方库) |
支持流式读取,不加载全部数据 |
a、大文件写出 ------ SXSSFWorkbook
java
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.ss.usermodel.*;
import org.junit.jupiter.api.Test;
import java.io.FileOutputStream;
import java.io.IOException;
public class ExcelLargeWriteTest {
@Test
void testLargeExcelWrite() throws IOException {
// 保留100行在内存中,其他写入磁盘
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
Sheet sheet = workbook.createSheet("大数据");
// 写入100万行
for (int i = 0; i < 1_000_000; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue("Row-" + i);
row.createCell(1).setCellValue(Math.random() * 1000);
}
try (FileOutputStream out = new FileOutputStream("target/large-write-demo.xlsx")) {
workbook.write(out);
}
// 清理临时文件
workbook.dispose();
workbook.close();
}
}
SXSSFWorkbook在写入时会将溢出的数据写入临时文件。调用dispose()可删除这些临时文件。写出性能远高于XSSFWorkbook,但不能再读回同一个对象。
b、大文件读取 ------ StreamingReader
POI 官方未提供流式读取,因此借助 第三方库:com.monitorjbl:xlsx-streamer
。
添加依赖:
xml
<dependency>
<groupId>com.monitorjbl</groupId>
<artifactId>xlsx-streamer</artifactId>
<version>2.2.0</version>
</dependency>
java
@Test
void testLargeExcelRead() throws IOException {
try (FileInputStream in = new FileInputStream("target/large-write-demo.xlsx");
Workbook workbook = StreamingReader.builder()
.rowCacheSize(100) // 缓存100行
.bufferSize(4096) // 读取缓冲区
.open(in)) {
Sheet sheet = workbook.getSheetAt(0);
int count = 0;
for (Row row : sheet) {
count++;
if (count % 100_000 == 0) {
System.out.println("已读:" + count + " 行");
}
}
System.out.println("总行数:" + count);
}
}
每次只缓存有限行数据(默认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需求有挑战,也有信心拿下。
如果觉得有用,请点下关注吧(本人公众号大鱼七成饱),您的关注是我分享最大的动力。