【Java实战】复杂5层嵌套结构Excel导出方案对比:为何我最终选择了EasyPoi?(含一对多自动合并、金额格式化)

49 阅读7分钟

 


前言

在企业级 B 端系统中,复杂报表导出是一个非常高频的需求。最近在做一个财务报销系统时,遇到了一个极具挑战性的导出需求。

业务背景
我们需要导出一个“扁平化”但包含“层级关系”的报销详情表。数据结构深度达到了 5 层:
客户 -> 最终客户 -> 项目 -> 人员 -> 费用明细

导出要求

  1. 一对多展示:一个客户对应多个最终客户,一个最终客户对应多个项目,以此类推。
  2. 单元格自动合并:为了视觉清晰,左侧相同的父级信息(如客户名称)必须纵向合并。
  3. 数据统计:每一层级都有对应的小计金额(如:个人总额、项目总额)。
  4. 格式要求:金额必须保留两位小数,并使用千分位分隔。

本文将记录从方案选型(Apache POI vs EasyExcel vs EasyPoi)到最终落地的完整过程,重点分享如何使用 EasyPoi 以最少的代码量解决复杂的“一对多”合并问题,并记录开发过程中遇到的 ClassCastExceptionNutz 框架集成等坑点。


一、 方案选型与对比

在 Java 生态中,操作 Excel 主流有四种方式。面对这种树形结构的导出,我做了如下分析:

1. Apache POI (原生)

这是所有工具的基石,功能最强大,但对于此需求来说极其痛苦

  • 痛点:POI 是基于单元格(Cell)操作的。要实现“纵向合并”,我必须自己写算法去计算每一列的 MergeRegion(合并区域)的起始行和结束行。对于动态的 5 层嵌套数据,这个坐标计算逻辑会非常复杂,代码量预计数百行,且极易出 Bug。
  • 结论不推荐,开发效率太低。

2. Alibaba EasyExcel (性能王者)

阿里开源的工具,主打高性能和低内存,适合百万级数据导出。

  • 痛点:EasyExcel 的设计哲学是“扁平化”和“流式写入”。虽然它支持自定义 CellWriteHandler 来处理合并,但需要自己编写策略去判断上下两行内容是否相同从而决定是否合并。而且 EasyExcel 不支持直接传入树形结构,必须先把树“拍平”成 List,这增加了很多额外的工作量。
  • 结论不适合。虽然性能好,但在处理复杂的层级合并展示上,配置过于繁琐。

3. Hutool POI (工具箱)

Hutool 对 POI 做了很好的封装,API 简洁。

  • 痛点:虽然比原生 POI 好用,但本质上它没有内置“对象树到 Excel 一对多”的映射机制。要实现合并,依然需要写循环逻辑去调用 writer.merge()
  • 结论中规中矩,不够自动化。

4. EasyPoi (最终选择)

EasyPoi 的口号是“让 Office 操作变得容易”。

  • 优势
  • 内置一对多支持:提供 @ExcelCollection 注解,直接支持集合嵌套。
  • 自动合并:只需在注解上加 needMerge = true,框架自动处理纵向合并,无需手写一行合并逻辑。
  • 开发快:定义好实体类结构,一行代码即可导出。
  • 结论完美契合。对于数据量在几万条以内、结构复杂的报表,EasyPoi 是开发效率最高的选择。

二、 核心代码实现

1. 引入依赖

pom.xml 中引入 EasyPoi 的 Starter 包:

<dependency>
    <groupId>cn.afterturn</groupId>
    <artifactId>easypoi-spring-boot-starter</artifactId>
    <version>4.4.0</version>
</dependency>

2. 定义层级实体类 (VO)

这是实现零逻辑导出的关键。我们需要定义 5 个层级的类结构,并使用 EasyPoi 的注解进行配置。

关键注解说明

  • @Excel(needMerge = true): 开启纵向自动合并。
  • @Excel(numFormat = "#,##0.00"): 格式化金额,保留两位小数且带千分位。
  • @ExcelCollection(name = ""): 标记集合字段,name="" 表示不生成额外的子表头,直接铺平展示。
import cn.afterturn.easypoi.excel.annotation.Excel;
import cn.afterturn.easypoi.excel.annotation.ExcelCollection;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;

@Data
public class FinancialExcelVO implements Serializable {
    
    // ======== 第一层:客户 ========
    @Excel(name = "客户名称", width = 20, needMerge = true)
    private String customerName;

    @Excel(name = "客户总额", width = 15, needMerge = true, numFormat = "#,##0.00")
    private BigDecimal totalAmount;

    @ExcelCollection(name = "") // 集合,指向下一层
    private List<FinalCustomerVO> finalCustomers;


    // ======== 第二层:最终客户 ========
    @Data
    public static class FinalCustomerVO implements Serializable {
        @Excel(name = "最终客户", width = 20, needMerge = true)
        private String finalCustomerName;

        @Excel(name = "最终客户总额", width = 15, needMerge = true, numFormat = "#,##0.00")
        private BigDecimal totalAmount;

        @ExcelCollection(name = "")
        private List<ProjectVO> projects;
    }

    // ======== 第三层:项目 ========
    @Data
    public static class ProjectVO implements Serializable {
        @Excel(name = "项目名称", width = 25, needMerge = true)
        private String projectName;

        @Excel(name = "项目总额", width = 15, needMerge = true, numFormat = "#,##0.00")
        private BigDecimal totalAmount;

        @ExcelCollection(name = "")
        private List<UserVO> users;
    }

    // ======== 第四层:人员 ========
    @Data
    public static class UserVO implements Serializable {
        @Excel(name = "报销人员", width = 15, needMerge = true)
        private String userName;

        @Excel(name = "个人总额", width = 15, needMerge = true, numFormat = "#,##0.00")
        private BigDecimal totalAmount;

        @ExcelCollection(name = "")
        private List<DetailVO> details;
    }

    // ======== 第五层:费用明细 (最底层) ========
    @Data
    public static class DetailVO implements Serializable {
        @Excel(name = "费用类型", width = 20)
        private String typeName;

        @Excel(name = "小计金额", width = 15, numFormat = "#,##0.00")
        private BigDecimal subTotal;

        @Excel(name = "单据数量", width = 10)
        private Integer count;
    }
}

3. Controller 层导出逻辑

这里的难点在于:我的业务 Service 返回的是一个 NutMap (类似 Map 的结构),而 EasyPoi 需要的是强类型的 List<FinancialExcelVO>

我们需要利用 JSON 工具进行一次“中转”。

@At("/exportExcel")
@Ok("void") // 【重要】文件下载必须声明为 void,防止框架二次渲染
public void queryFinancialDetailsForExcel(HttpServletResponse response, @Param("..") NutMap params) {
    try {
        // 1. 获取业务数据 (Service层返回的是包含 list 和 totalAmount 的复杂 Map)
        NutMap ret = iFinanceExpenseService.queryFinancialDetailsForExcel(params);
        
        // 2. 数据转换:NutMap -> JSON -> List<VO>
        // 【关键点】先提取出 list 部分,不要把外层的 totalAmount 带进去,否则 JSON 解析会报错
        Object listData = ret.get("list");
        String jsonStr = Json.toJson(listData);
        // 利用 JSON 将 Map 数组转为对象列表
        List<FinancialExcelVO> exportList = Json.fromJsonAsList(FinancialExcelVO.class, jsonStr);

        // 3. EasyPoi 导出配置
        ExportParams exportParams = new ExportParams("财务报销层级报表", "报销详情");
        exportParams.setType(ExcelType.XSSF); // 推荐使用 XSSF (.xlsx)

        // 4. 生成 Workbook (自动化合并的核心步骤)
        Workbook workbook = ExcelExportUtil.exportExcel(exportParams, FinancialExcelVO.class, exportList);

        // 5. 设置响应头并写入流
        response.setCharacterEncoding("UTF-8");
        // 使用标准的 Excel contentType
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        String fileName = URLEncoder.encode("报销统计_" + DateUtil.today() + ".xlsx", "UTF-8");
        response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
        
        workbook.write(response.getOutputStream());
        workbook.close();

    } catch (Exception e) {
        log.error("导出失败", e);
        // 可以在这里处理异常,比如返回一段 JSON 提示错误,但在流写入后比较困难
    }
}


三、 踩坑记录 (Troubleshooting)

在开发过程中,我遇到了几个比较典型的问题,记录如下:

坑点 1:ClassCastException: ArrayList cannot be cast to Map

现象
在使用 Json.fromJsonAsList 时抛出异常。
原因
我最初直接把 Service 返回的整个 ret 对象(包含了 totalAmountlist)转成了 JSON。
JSON 格式是 { "total": 100, "list": [...] }
fromJsonAsList 期望的输入必须是数组格式 [ ... ]
解决
先执行 ret.get("list") 拿到纯列表数据,再进行 JSON 转换。

坑点 2:导出文件损坏或无法打开

现象
Excel 能下载,但打开提示文件损坏,或者文件内容末尾包含一段 JSON 字符串。
原因
在使用 Nutz 框架(或 SpringMVC)时,方法注解使用了 @Ok("json")。这导致代码中写入了 Excel 流之后,框架又试图把方法的返回值序列化成 JSON 写入流中,破坏了二进制结构。
解决
将方法注解改为 @Ok("void") (Nutz) 或在 Spring 中使用 void 返回值,并完全接管 HttpServletResponse

坑点 3:金额没有小数位

现象
数据库里是 100.00,导出来的 Excel 显示 100,财务要求必须保留两位小数。
原因
Excel 默认的常规格式会自动抹除末尾的零。
解决
@Excel 注解中添加 numFormat = "#,##0.00"


四、 总结

对于“一对多”乃至“多对多”的复杂层级 Excel 导出:

  1. 如果你追求极致性能(百万级数据),且不介意手写复杂的合并策略,选 EasyExcel
  2. 如果你追求开发效率,数据量在几万条以内,且结构嵌套深,EasyPoi 是不二之选。它通过注解 needMerge = true@ExcelCollection 完美解决了纵向合并和层级映射的问题,将几百行代码压缩到了几十行。

希望这篇文章能帮你解决复杂的 Excel 导出难题!


参考文档