Featured image of post 用 EasyExcel 导出复杂组织机构树到 Excel

用 EasyExcel 导出复杂组织机构树到 Excel

层级 + 合并单元格 + 嵌套 + 大数据量——把组织机构树导成 Excel 是个综合考题,本文给出 EasyExcel 的工程化解法

一个让人头大的需求

业务方:“把公司全部部门、员工、汇报关系导出到 Excel”。打开他们用的 Word 模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
+--------------+--------+--------+----------+--------+
| 一级部门     | 二级   | 三级   | 员工姓名 | 工号   |
+--------------+--------+--------+----------+--------+
|              |        | 一组   | 张三     | 001    |
|              |        |        | 李四     | 002    |
|              | 研发部 +--------+----------+--------+
|              |        | 二组   | 王五     | 003    |
| 技术中心     +--------+--------+----------+--------+
|              |        |        | 赵六     | 004    |
|              | 测试部 |        | 钱七     | 005    |
+--------------+--------+--------+----------+--------+
|              |        |        | ...      | ...    |
+--------------+--------+--------+----------+--------+

需求关键词:

  • 层级关系:部门可能 1-5 层
  • 单元格合并:同部门跨多行合并
  • 数据量大:上千员工
  • 样式要求:表头加粗、边框、分组色
  • 导出快:用户等 30 秒会投诉

POI 拿来撸是噩梦——内存爆炸 + 写起来痛苦。EasyExcel 是这件事的事实标准答案——它把内存占用压到极致,API 也清爽很多。

本文用一个完整实例讲清楚 EasyExcel 导出树形结构 + 合并单元格的姿势。


一、EasyExcel 的核心优势

Apache POIEasyExcel
内存占用一次性加载全部流式写,几 MB 搞定
编码体验大量 setCellValue注解 + Java Bean
大数据量容易 OOM70 万行无压力
学习曲线
复杂样式灵活灵活

EasyExcel 由阿里开源,本质是 POI 上加一层流式封装——背后还是 POI,但内存模型彻底改了。


二、最小可用 Demo

引入:

1
2
3
4
5
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.2</version>
</dependency>

定义实体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Data
public class EmployeeRow {
    @ExcelProperty("一级部门")
    private String dept1;

    @ExcelProperty("二级部门")
    private String dept2;

    @ExcelProperty("三级部门")
    private String dept3;

    @ExcelProperty("员工姓名")
    private String name;

    @ExcelProperty("工号")
    private String empNo;
}

写出:

1
2
3
4
5
List<EmployeeRow> rows = buildRows(orgTree);

EasyExcel.write("org.xlsx", EmployeeRow.class)
        .sheet("组织架构")
        .doWrite(rows);

四行代码,搞定基础导出。


三、把组织树展平成行

业务里组织通常是个树——要导出必须展开成扁平的行集合。

树结构

1
2
3
4
5
6
7
@Data
public class OrgNode {
    private Long id;
    private String name;
    private List<OrgNode> children;
    private List<Employee> employees;
}

DFS 展平

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public List<EmployeeRow> flatten(List<OrgNode> roots) {
    List<EmployeeRow> rows = new ArrayList<>();
    for (OrgNode root : roots) {
        traverse(root, new ArrayDeque<>(), rows);
    }
    return rows;
}

private void traverse(OrgNode node, Deque<String> path, List<EmployeeRow> rows) {
    path.push(node.getName());

    if (node.getChildren() != null && !node.getChildren().isEmpty()) {
        for (OrgNode child : node.getChildren()) {
            traverse(child, path, rows);
        }
    }
    if (node.getEmployees() != null) {
        for (Employee e : node.getEmployees()) {
            EmployeeRow row = new EmployeeRow();
            // 把 path 从根到叶填到 dept1/2/3
            String[] depts = path.descendingIterator().hasNext()
                    ? toArray(path.descendingIterator()) : new String[0];
            row.setDept1(depts.length > 0 ? depts[0] : "");
            row.setDept2(depts.length > 1 ? depts[1] : "");
            row.setDept3(depts.length > 2 ? depts[2] : "");
            row.setName(e.getName());
            row.setEmpNo(e.getEmpNo());
            rows.add(row);
        }
    }
    path.pop();
}

这一步得到了 Excel 的扁平行数据——树形被打平、每个员工对应一行。


四、单元格合并:组织树的核心

光打平还不够——同部门下多个员工,部门列应该合并。这是组织树导 Excel 最核心的需求

思路:写完后再合并

EasyExcel 写时按行流式输出,不能在写入时直接判断"和上一行合并"。最稳妥的做法是:

  1. 先按扁平行写完
  2. 再用 POI 的 addMergedRegion 合并同列连续相同值

EasyExcel 提供了 WriteHandler 让你介入流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class TreeMergeHandler extends AbstractMergeStrategy {

    private final List<EmployeeRow> data;

    public TreeMergeHandler(List<EmployeeRow> data) { this.data = data; }

    @Override
    protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {
        // 处理 dept1, dept2, dept3 三列
        if (relativeRowIndex == null || relativeRowIndex == 0) return;

        int colIdx = cell.getColumnIndex();
        if (colIdx > 2) return;   // 后两列不合并(员工姓名/工号列)

        int absoluteRow = cell.getRowIndex();
        String prevValue = sheet.getRow(absoluteRow - 1).getCell(colIdx).getStringCellValue();
        String currValue = cell.getStringCellValue();

        if (Objects.equals(prevValue, currValue)) {
            // 找到现有 region 并扩展,或新建
            for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
                CellRangeAddress region = sheet.getMergedRegion(i);
                if (region.getFirstColumn() == colIdx
                        && region.getLastRow() == absoluteRow - 1) {
                    sheet.removeMergedRegion(i);
                    sheet.addMergedRegion(new CellRangeAddress(
                            region.getFirstRow(), absoluteRow, colIdx, colIdx));
                    return;
                }
            }
            // 没找到,新建
            sheet.addMergedRegion(new CellRangeAddress(
                    absoluteRow - 1, absoluteRow, colIdx, colIdx));
        }
    }
}

注册 handler:

1
2
3
4
EasyExcel.write("org.xlsx", EmployeeRow.class)
        .registerWriteHandler(new TreeMergeHandler(rows))
        .sheet("组织架构")
        .doWrite(rows);

效果——同部门跨多行的部门列被合并成一个单元格。


五、样式:表头加粗 + 居中 + 边框

通过 HorizontalCellStyleStrategy 配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
WriteCellStyle headerStyle = new WriteCellStyle();
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
WriteFont headerFont = new WriteFont();
headerFont.setFontHeightInPoints((short) 12);
headerFont.setBold(true);
headerStyle.setWriteFont(headerFont);

WriteCellStyle bodyStyle = new WriteCellStyle();
bodyStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
bodyStyle.setVerticalAlignment(VerticalAlignment.CENTER);
bodyStyle.setBorderTop(BorderStyle.THIN);
bodyStyle.setBorderBottom(BorderStyle.THIN);
bodyStyle.setBorderLeft(BorderStyle.THIN);
bodyStyle.setBorderRight(BorderStyle.THIN);

EasyExcel.write("org.xlsx", EmployeeRow.class)
        .registerWriteHandler(new HorizontalCellStyleStrategy(headerStyle, bodyStyle))
        .registerWriteHandler(new TreeMergeHandler(rows))
        .sheet("组织架构")
        .doWrite(rows);

六、列宽自适应

LongestMatchColumnWidthStyleStrategy 让列宽根据内容长度自适应:

1
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())

注意——它的算法是按"行最长字符串"算列宽,对中文支持不够好(一个汉字算一个字符宽,但实际显示更宽)。生产里通常自己写一个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class CJKColumnWidthHandler implements CellWriteHandler {
    @Override
    public void afterCellDispose(CellWriteHandlerContext ctx) {
        Cell cell = ctx.getCell();
        Sheet sheet = cell.getSheet();
        int colIdx = cell.getColumnIndex();
        int len = cell.getStringCellValue() != null
                ? estimateWidth(cell.getStringCellValue()) : 10;
        if (sheet.getColumnWidth(colIdx) < len * 256) {
            sheet.setColumnWidth(colIdx, Math.min(len * 256, 80 * 256));
        }
    }

    private int estimateWidth(String s) {
        int w = 0;
        for (char c : s.toCharArray()) {
            w += (c >= '一' && c <= '龥') ? 2 : 1;
        }
        return w;
    }
}

七、Web 接口下载

Spring Controller 写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@GetMapping("/export/org")
public void export(HttpServletResponse response) throws IOException {
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setCharacterEncoding("utf-8");
    String fileName = URLEncoder.encode("组织架构", "UTF-8").replaceAll("\\+", "%20");
    response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");

    List<EmployeeRow> rows = orgService.flattenForExport();
    EasyExcel.write(response.getOutputStream(), EmployeeRow.class)
            .registerWriteHandler(new TreeMergeHandler(rows))
            .sheet("组织架构")
            .doWrite(rows);
}

文件名编码这块要小心——中文不处理浏览器下载会乱码。filename*=utf-8''xxx 是 RFC 5987 标准


八、大数据量场景

数据量到几万、几十万行时,几个工程要点:

1. 用流式写

1
2
3
4
5
6
7
8
ExcelWriter writer = EasyExcel.write(outputStream, EmployeeRow.class).build();
WriteSheet sheet = EasyExcel.writerSheet("组织架构").build();
int batchSize = 1000;
for (int offset = 0; offset < total; offset += batchSize) {
    List<EmployeeRow> batch = orgService.getRows(offset, batchSize);
    writer.write(batch, sheet);
}
writer.finish();

每次只加载一批,JVM 内存不会爆

2. 多 Sheet

单 Sheet 上限 1,048,576 行——超过要拆 Sheet:

1
2
3
4
for (int sheetNo = 0; sheetNo < sheetCount; sheetNo++) {
    WriteSheet sheet = EasyExcel.writerSheet(sheetNo, "Sheet" + sheetNo).build();
    writer.write(getRows(sheetNo), sheet);
}

3. 合并单元格的内存瓶颈

合并单元格的 addMergedRegion 信息是全量记录在内存里——大量合并会让导出再次膨胀。几万行 + 大量合并是 EasyExcel 也吃不消的场景——这种情况建议改用 CSV 或者拆分多 Sheet。

4. 异步导出

接口同步返回 30 秒会超时——大数据量必须异步:


九、踩坑提醒

1. 模板模式 vs Bean 模式

简单导出用 @ExcelProperty 注解(Bean 模式);表头复杂、含图片、需要复用的导出用 模板模式

1
2
3
4
EasyExcel.write("out.xlsx")
        .withTemplate("template.xlsx")
        .sheet()
        .doWrite(data);

模板里用 {name} 占位符。

2. POI 版本冲突

EasyExcel 自带 POI 4.x,如果项目里有其他依赖引入了 POI 3.x,冲突会让 ClassNotFound。用 mvn dependency:tree 检查并 exclude。

3. 中文表头乱码

@ExcelProperty("中文表头") 一般没问题,但如果中文出现在动态字段(比如表头从配置读),要确保字符串编码 UTF-8。

4. 日期格式

1
2
3
@ExcelProperty("入职日期")
@DateTimeFormat("yyyy-MM-dd")
private LocalDate joinDate;

不加 @DateTimeFormat 会导成 Excel 的"序列号"格式(比如 44927)。

5. 数字精度

金额最好用 BigDecimal,并配合:

1
2
3
@ExcelProperty("金额")
@NumberFormat("#,##0.00")
private BigDecimal amount;

小结

把全文压一句:

导出复杂结构 Excel 的核心是『先扁平、再合并、再样式』——EasyExcel 把内存压到极致,写法干净,是 Java 项目里 Excel 导出的最佳选择。

工程要点:

  • 业务层把树展开成扁平行——逻辑独立、便于测试
  • 单元格合并用 WriteHandler,不要业务里自己控
  • 大数据量必须流式 + 异步
  • 样式用 Strategy,列宽用自定义中文 handler
  • 生产代码记得设 Content-Disposition 处理中文文件名

把这套吃透,下次"导出报表"不再是噩梦——是个 1 小时就能搞定的标准任务。

使用 Hugo 构建
主题 StackJimmy 设计