apache POI 万字总结:满足你对报表一切幻想

作者:大鱼七成饱日期:2025/10/7

背景

国庆期间接了个兼职,处理机构的几张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)效果:sheet.removeRow(row1)

deleteRow(sheet, 1)效果: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列求和。

ABC
1020=A1+B1
52=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对应问题
    • 导出 .xlsxresponse.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 万字总结:满足你对报表一切幻想》 是转载文章,点击查看原文


相关推荐


【转载】前验光师如何通过聪明模仿而非蛮干构建月收入3.5万美元的SaaS应用
是魔丸啊2025/10/6

转载 大多数人都认为你需要在科技领域拥有突破性的想法才能成功。 Samuel Rondot的想法与众不同。他的整个行动手册建立在一个简单的规则上:不要重新发明轮子——只要让它变得1%更好。 这种心态帮助他辞去了验光师的工作,从零开始自学编程,并推出了三个现在每月收入3.5万美元的SaaS应用。 以下是他如何做到的。 从验光师到自学程序员 Samuel从未计划成为一名程序员。几年前,他在眼镜行业工作,完全没有编程经验。 什么改变了?他想重建一个自己正在使用的Instagram工具——这一次,完全靠


免费领源码-Spring boot的物流管理系统 |可做计算机毕设Java、Python、PHP、小程序APP、C#、爬虫大数据、单片机、文案
vx_dmxq2112025/10/5

目   录 摘  要 Abstract 1  前言 1.1 设计目的 1.2 设计思路 1.3 国内外研究现状 2  相关技术 2.1  Java语言 2.2 MySQL数据库 2.3 Spring Boot框架 2.4 B/S模式 3  系统分析 3.1  可行性分析 3.2  系统需求分析 3.2.1  功能性分析 3.2.2  非功能性需求分析 3.3  系统用例分析 3.3.1  注


第一章 机器学习基础理论:机器学习概述(一)
FPGA+护理+人工智能2025/10/3

第一章 机器学习基础理论:机器学习概述 文章目录 第一章 机器学习基础理论:机器学习概述具体的专栏内容请参考: 人工智能专栏一、目标二、重点与难点三、内容1. 机器学习概述2. 机器学习在精神病护理领域的应用 前面python的基础内容算是完成了,接下来将要进入机器学习部分了。 具体的专栏内容请参考: 人工智能专栏 一、目标 通过本章学习,能够: 理解机器学习的基本概念和发展历程 了解机器学习在精神病护理领域的应用现状与前


在 Trae 国际版中添加 Chrome Dev MCP Server(Windows 实战指南)
wuhanwhite2025/10/2

前言 最近在折腾 Trae 国际版这类 AI 编程工具的时候,我发现它虽然支持接入 MCP(Model Context Protocol),但是只接受通过 npx 或 uvx 启动的 stdio 模式。 与此同时,Chrome 端的 mcp-chrome 扩展确实很好用,可以让 AI 直接获取浏览器标签页、截图、修改网页样式,甚至做一些自动化操作。但它默认暴露的是 Streamable HTTP 接口,并不符合 Trae 的要求。 这篇文章记录一下我在 Windows 环境下的实践


设计模式——单例模式
舒克起飞了2025/10/2

单例模式是一种创建型设计模式,它可以确保一个类在整个程序运行过程中只有一个实例,并提供一个全局访问点以获取该实例。         单例模式的核心思想就是:控制对象的实例化,防止创建多个实例,从而节省资源并保证行为一致性。 关键点: 单例类:包含单例实例的类,通常将构造函数声明为私有;静态成员变量:用于存储单例实例的静态成员变量;获取实例方法:静态方法,用于获取单例实例;私有构造函数:防止外部直接实例化单例类;线程安全处理:确保在多线程环境下单例实例的创建是安全的。构造函数和析构函数是私有


Python 的内置函数 breakpoint
IMPYLH2025/10/2

Python 内建函数列表 > Python 的内置函数 breakpoint def breakpoint(): ''' 调用位置进入调试器 ''' Python 的内置函数 breakpoint() 是一个用于调试的便捷工具,它会在调用时自动触发调试器,让开发者能够暂停程序执行并检查当前状态。这个函数在 Python 3.7 及更高版本中引入,旨在简化调试过程,特别是在复杂程序中设置断点的场景。 基本用法: def calculate_sum(a, b):


Navicat导入Excel至瀚高数据库
瀚高PG实验室10/1/2025

解决方案:将Excel中没有数据行,执行删除操作。步骤五:将Excel分成若干部分导入,核实为数据库地址信息列字段长度设置小于实际数据长度。步骤三:导入显示成功,且显示行数与Excel行数一致。步骤四:导出数据与Excel进行比对,核实缺失数据。将缺失数据单独导入没有问题。解决客户使用Navicat导入Excel至瀚高数据库数据条数不一致问题。二、导入数据少于Excel数据行及Navicat导入结果显示行。步骤一:导入Excel,配置数据与Excel对应关系。一、导入数据多于Excel数据行。


在JavaScript / Node.js中,SQLite异步查询函数实现
Never_Satisfied9/30/2025

本文展示了一个Node.js中使用SQLite3的异步数据库查询实现。该方案通过Promise封装实现了参数化查询和异常处理,确保数据库连接始终安全关闭。核心功能包括:1)异步建立数据库连接;2)参数化SQL查询;3)使用try-finally保证连接关闭;4)完善的错误处理机制。实现特点包括Promise链式调用、async/await语法、连接关闭错误记录等。使用时需注意SQL参数匹配和错误捕获,该方案适用于需要安全访问SQLite数据库的Node.js应用场景。


php artisan db:seed执行的时候遇到报错
快支棱起来2025/10/9

INFO Seeding database. Illuminate\Database\QueryException SQLSTATE[42S22]: Column not found: 1054 Unknown column 'email_verified_at' in 'field list' (Connection: mysql, SQL: insert into users (name, email, email_verified_at, password, remember_token,


C++ const 用法全面总结与深度解析
oioihoii2025/10/10

1. const 基础概念 const 关键字用于定义不可修改的常量,是C++中确保数据只读性和程序安全性的核心机制。它可以应用于变量、指针、函数参数、返回值、成员函数等多种场景,深刻影响代码的正确性和性能。 1.1 本质与编译期处理 const变量在编译时会被编译器严格检查,任何修改尝试都会导致编译错误。与C语言不同,C++中的const变量(尤其是全局const)通常不会分配内存,而是直接嵌入到指令中(类似#define),但在以下情况会分配内存: 取const变量地址时 const变量为

首页编辑器站点地图

Copyright © 2025 聚合阅读

License: CC BY-SA 4.0