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

背景

国庆期间接了个兼职,处理机构的几张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位)。数字超过 15 16 位有效数字时(无论是整数还是小数),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对应问题

    • 导出 .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需求有挑战,也有信心拿下。

如果觉得有用,请点下关注吧(本人公众号大鱼七成饱),您的关注是我分享最大的动力。

相关推荐
数据知道3 小时前
Go基础:Go语言应用的各种部署
开发语言·后端·golang·go语言
数据知道4 小时前
Go基础:用Go语言操作MySQL详解
开发语言·数据库·后端·mysql·golang·go语言
种时光的人5 小时前
无状态HTTP的“记忆”方案:Spring Boot中Cookie&Session全栈实战
服务器·spring boot·后端·http
m0_480502646 小时前
Rust 登堂 之 Cell 和 RefCell(十二)
开发语言·后端·rust
LunarCod6 小时前
Onvif设备端项目框架介绍
后端·嵌入式·c/c++·wsdl·rv1126·onvif
羊锦磊7 小时前
[ Spring 框架 ] 数据访问和事务管理
java·后端·spring
未来coding7 小时前
Spring Boot SSE 流式输出,智能体的实时响应
java·spring boot·后端
whltaoin7 小时前
Spring Boot自定义全局异常处理:从痛点到优雅实现
java·spring boot·后端
7hyya7 小时前
如何将Spring Boot 2接口改造为MCP服务,供大模型调用!
人工智能·spring boot·后端