一个让人头大的需求
业务方:“把公司全部部门、员工、汇报关系导出到 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 POI | EasyExcel |
|---|
| 内存占用 | 一次性加载全部 | 流式写,几 MB 搞定 |
| 编码体验 | 大量 setCellValue | 注解 + Java Bean |
| 大数据量 | 容易 OOM | 70 万行无压力 |
| 学习曲线 | 陡 | 平 |
| 复杂样式 | 灵活 | 灵活 |
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 写时按行流式输出,不能在写入时直接判断"和上一行合并"。最稳妥的做法是:
- 先按扁平行写完
- 再用 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 秒会超时——大数据量必须异步:
flowchart LR
User --> API[POST /export/job]
API --> Job[创建任务记录]
API --> Queue[发到 MQ]
Worker[Worker] --> Queue
Worker --> S3[导出到 S3/OSS]
Worker --> Notify[推送通知/邮件]
User --> Status[GET /export/status]
Status --> S3
九、踩坑提醒
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 小时就能搞定的标准任务。