基于 fastexcel 扩展批量并发导出与注解式读写能力
https://github.com/sunnyboy3/fastexcel.git 欢迎fork
在 Java 项目中处理 Excel,常见诉求通常有两个:
- 大数据量导出要快,不能因为一次性加载过多数据导致内存压力过大。
- 业务代码要简单,最好能像 EasyExcel 一样,用实体类注解完成 Excel 列和 Java 字段的映射。
fastexcel 本身是一个轻量、高性能的 Excel 读写库,底层面向流式写入和读取,适合在服务端导出大文件。本文介绍的是在原有 fastexcel 框架上扩展出的两个模块,以及围绕批量导出补充的样式能力:
fastexcel-batch:支持分页、并发预取、多 Sheet 批量写入,提升大数据导出性能。fastexcel-annotation:模仿 EasyExcel 的注解式用法,支持通过实体类属性注解完成 Excel 读取和写入。- 写入样式支持:通过
CellStyle、SheetStyle、MergedRegion声明表头样式、列样式、列宽、冻结窗格和合并单元格。
这两个模块组合起来,可以让业务代码既保留 fastexcel 的性能优势,又获得更接近业务模型的开发体验。
一、为什么要扩展
原始 fastexcel 更偏底层 API,写入时通常需要直接操作 Workbook、Worksheet,例如指定某一行某一列写入什么值。这种方式足够灵活,但在业务系统里会带来一些重复代码:
- 每个导出场景都要手动写表头。
- 每个字段都要手动指定列号。
- 大数据量导出时,需要自己处理分页、Sheet 拆分、线程池、异常和资源关闭。
- 读取 Excel 时,需要自己从
Row、Cell中取值再塞回实体类。
因此扩展的方向很明确:
- 写入层:把分页、多线程、Sheet 拆分封装起来。
- 映射层:把 Excel 列和 Java 字段的关系声明到实体类上。
- 样式层:把表头、列宽、数字格式、冻结窗格、合并区域等写入样式声明成可复用模板。
二、模块一:fastexcel-batch 分页并发写入
fastexcel-batch 的核心类是:
java
org.dhatim.fastexcel.batch.BatchExcelWriter
它的设计目标是把一个大数据导出任务拆成多个 Sheet,每个 Sheet 对应一次分页数据请求。在写当前 Sheet 的同时,后台线程可以提前获取后续 Sheet 的数据,从而减少写入线程等待数据源的时间。
2.1 核心能力
fastexcel-batch 主要提供以下能力:
- 分页读取数据:通过
SheetDataProvider按offset、limit获取每个 Sheet 的数据。 - 自动拆分 Sheet:通过
sheetSize控制每个 Sheet 的最大行数。 - 并发预取:通过
threadPoolSize控制后台获取数据的线程数。 - 预取队列背压:通过
queueCapacity控制已经获取但尚未写入的 Sheet 数量。 - 支持样式和合并单元格:通过
SheetStyle、CellStyle、MergedRegion描述布局。 - 支持注解实体写入:通过
RowWriters.annotation(...)和fastexcel-annotation模块联动。 - 支持导出结果统计:通过
ExportResult返回写入总行数和每个 Sheet 的结果。
2.2 导出配置 ExportOptions
批量导出使用 ExportOptions 描述导出参数:
java
ExportOptions options = ExportOptions.builder(total)
.sheetSize(100000)
.threadPoolSize(10)
.sheetNamePrefix("销售")
.build();
常用参数说明:
| 参数 | 作用 |
|---|---|
totalRows |
已知总行数,用于计算 Sheet 数量 |
sheetSize |
每个 Sheet 写入多少行数据 |
threadPoolSize |
并发获取分页数据的线程数 |
queueCapacity |
预取队列容量,用于控制背压 |
sheetNamePrefix |
Sheet 名称前缀,例如生成 销售_1、销售_2 |
fetchTimeoutMs |
获取分页数据的超时时间 |
flushInterval |
每隔多少行 flush 一次,降低大文件内存压力 |
如果总行数未知,也可以使用:
java
ExportOptions options = ExportOptions.unbounded()
.sheetSize(100000)
.threadPoolSize(4)
.build();
这种模式会持续分页获取数据,直到某次返回的数据量小于 sheetSize 或为空。
2.3 实体类定义
示例实体类如下:
java
public static class Sale {
@ExcelProperty(value = "区域", index = 0)
private String region;
@ExcelProperty(value = "产品", index = 1)
private String product;
@ExcelProperty(value = "数量", index = 2)
private Integer quantity;
@ExcelProperty(value = "金额", index = 3)
private BigDecimal amount;
public Sale() {
}
public Sale(String region, String product, Integer quantity, BigDecimal amount) {
this.region = region;
this.product = product;
this.quantity = quantity;
this.amount = amount;
}
}
这里的 @ExcelProperty 同时指定了表头名称和列索引:
value:写出时作为表头名,读取时也可以按表头匹配。index:指定字段所在列,从 0 开始。
2.4 分页数据提供者
在测试方法中,使用 mockPage 模拟分页数据:
java
private static List<Sale> mockPage(long offset, int limit, int total) {
List<Sale> list = new ArrayList<>(limit);
long end = Math.min(offset + limit, total);
for (long i = offset; i < end; i++) {
list.add(new Sale(
"华东",
"产品-" + (i % 20),
(int) (i % 100),
new BigDecimal(String.format("%.2f", 100 + i * 3.5))));
}
return list;
}
实际业务中,这里通常会替换成数据库分页查询、ES 查询、接口分页调用等。
2.5 写出 Excel
完整写出逻辑可以参考测试方法:
java
org.dhatim.fastexcel.batch.StyledBatchExportExampleTest#testFile
核心代码如下:
java
int total = 400000;
ExportOptions options = ExportOptions.builder(total)
.sheetSize(100000)
.threadPoolSize(10)
.sheetNamePrefix("销售")
.build();
BatchExcelWriter<Sale, Void> writer = new BatchExcelWriter<>(
options,
(params, req) -> mockPage(req.getOffset(), req.getLimit(), total),
RowWriters.annotation(Sale.class, sheetStyle));
ExportResult result = writer.export(
WorkbookSink.toPath(Paths.get("/Users/wp/Documents/softwears/batch-writter.xlsx")),
null);
这段代码完成了几件事:
- 设置总数据量为 40W。
- 每个 Sheet 写入 10W 行。
- 使用 10 个线程并发预取分页数据。
- 使用注解实体
Sale自动写表头和数据列。 - 输出到本地 Excel 文件。
经测试,40W 数据写入耗时约 2.619s。测试入口为:
java
org.dhatim.fastexcel.batch.StyledBatchExportExampleTest#testFile
如果你的计时器输出单位是毫秒或纳秒,建议在博客发布前统一换算单位。对于 40W 行 Excel 写入,通常用秒作为展示单位更符合读者预期。
2.6 写入样式支持
除了分页并发写入,fastexcel-batch 还扩展了写入样式能力。原始 fastexcel 虽然已经提供了底层样式 API,但在批量导出场景下,如果每个导出方法都手写样式设置,代码会很分散,也不容易复用。
因此这里引入了三类声明式对象:
| 类 | 作用 |
|---|---|
CellStyle |
描述单元格样式,例如字体、颜色、填充色、对齐、边框、数字格式 |
SheetStyle |
描述一个 Sheet 的整体布局,例如表头样式、列样式、列宽、冻结窗格、合并区域 |
MergedRegion |
描述合并单元格区域,支持横向合并、纵向合并和任意矩形区域 |
样式定义示例:
java
CellStyle headerStyle = CellStyle.builder()
.bold()
.fontColor("FFFFFF")
.fillColor("4472C4")
.horizontalAlignment("center")
.verticalAlignment("center")
.border("thin")
.build();
CellStyle moneyStyle = CellStyle.builder()
.fillColor("FFFFCC")
.numberFormat("#,##0.00")
.horizontalAlignment("right")
.build();
SheetStyle sheetStyle = SheetStyle.builder()
.headerStyle(headerStyle, 4)
.columnStyle(3, moneyStyle)
.columnWidth(0, 12)
.columnWidth(1, 18)
.columnWidth(2, 10)
.columnWidth(3, 16)
.freezeHeader()
.dynamicMerges(dataRows -> dataRows >= 2
? Collections.singletonList(MergedRegion.column(0, 1, dataRows))
: Collections.emptyList())
.build();
这里有几个关键点:
headerStyle(headerStyle, 4):把表头样式应用到第 0 行的前 4 列。columnStyle(3, moneyStyle):把金额列设置为统一数字格式和右对齐。columnWidth(...):统一设置列宽,避免导出后列宽过窄。freezeHeader():冻结首行,用户查看大文件时表头始终可见。dynamicMerges(...):根据实际数据行数动态计算合并区域。
这个设计让数据写入和外观设置解耦:
RowWriter只负责写数据。SheetStyle负责列宽、表头样式、列样式、冻结窗格、合并区域。
这样业务导出可以复用同一套样式模板,也便于维护。
2.6.1 CellStyle 支持哪些样式
CellStyle 使用 Builder 模式声明样式,常用能力包括:
- 字体名称:
fontName("Arial") - 字号:
fontSize(12) - 字体颜色:
fontColor("FFFFFF") - 加粗:
bold() - 斜体:
italic() - 下划线:
underline() - 删除线:
strikethrough() - 背景填充色:
fillColor("4472C4") - 水平对齐:
horizontalAlignment("center") - 垂直对齐:
verticalAlignment("center") - 自动换行:
wrapText() - 文本旋转:
rotation(45) - 缩进:
indent(1) - 数字格式:
numberFormat("#,##0.00") - 边框:
border("thin") - 边框颜色:
borderColor("000000")
例如金额列样式可以这样写:
java
CellStyle moneyStyle = CellStyle.builder()
.fillColor("FFFFCC")
.numberFormat("#,##0.00")
.horizontalAlignment("right")
.build();
这样写出的金额列会带有浅黄色背景、千分位两位小数格式,并右对齐显示。
2.6.2 SheetStyle 负责整张表布局
SheetStyle 不只负责单元格样式,它更像一个 Sheet 模板:
java
SheetStyle sheetStyle = SheetStyle.builder()
.headerStyle(headerStyle, 4)
.columnStyle(3, moneyStyle)
.columnWidth(0, 12)
.columnWidth(1, 18)
.columnWidth(2, 10)
.columnWidth(3, 16)
.freezeHeader()
.build();
它可以把一类导出的"外观规范"固定下来。比如所有销售报表都使用蓝底白字表头、金额列右对齐、首行冻结,就可以把这套 SheetStyle 作为模板复用。
在批量写入时,通过下面的方式把样式和注解写入器组合起来:
java
RowWriters.annotation(Sale.class, sheetStyle)
这样 BatchExcelWriter 写每个 Sheet 时,会自动应用表头样式、列宽、列样式和冻结窗格。
2.6.3 合并单元格:静态合并和动态合并
MergedRegion 用来描述合并区域,坐标都是从 0 开始:
java
// 合并 A1:D1,适合作为标题横幅
MergedRegion title = MergedRegion.row(0, 0, 3);
// 合并 A2:A101,适合分组列纵向合并
MergedRegion group = MergedRegion.column(0, 1, 100);
// 任意矩形区域
MergedRegion box = MergedRegion.of(1, 0, 5, 3);
静态合并适合固定位置,例如标题横幅:
java
SheetStyle style = SheetStyle.builder()
.merge(MergedRegion.row(0, 0, 3))
.build();
动态合并适合行数不固定的批量导出。例如每个 Sheet 的数据行数可能不同,可以根据实际写入行数合并区域列:
java
SheetStyle style = SheetStyle.builder()
.dynamicMerges(dataRows -> dataRows >= 2
? Collections.singletonList(MergedRegion.column(0, 1, dataRows))
: Collections.emptyList())
.build();
这里的 dataRows 是当前 Sheet 实际写入的数据行数,不包含表头。比如一个 Sheet 写了 100000 行数据,就可以把第 0 列从第 1 行合并到第 100000 行。
2.6.4 为什么样式这样设计
样式支持的设计重点有三个:
-
样式声明不可变、可复用
CellStyle和SheetStyle都是构建后不可变对象,可以在多个 Sheet、多个导出任务中复用。 -
数据和外观分离
业务代码只关心数据怎么来,样式模板只关心 Excel 长什么样。两者通过
RowWriters.styled(...)或RowWriters.annotation(..., sheetStyle)组合。 -
兼顾大文件写入性能
表头样式、列宽、冻结窗格会在写入数据前应用;动态合并在数据写完后统一应用。列样式会尽量复用 fastexcel 底层样式缓存,避免每个单元格重复创建样式对象。
对于后台导出而言,样式不只是"好看",它还直接影响可读性。比如冻结表头、金额格式、列宽、合并分组列,都能让几十万行数据导出后更容易查看和交付。
三、模块二:fastexcel-annotation 注解式读写
fastexcel-annotation 的目标是让 fastexcel 支持类似 EasyExcel 的实体类映射方式。
核心能力包括:
@ExcelProperty:声明字段和 Excel 列的关系。@ExcelIgnore:忽略不需要读写的字段。@ExcelConverter:指定字段级自定义转换器。ExcelWriterMapper:把 Java Bean 写入 Excel。ExcelReaderMapper:把 Excel 行读取成 Java Bean。ConverterRegistry:按类型注册全局转换器。
3.1 @ExcelProperty
@ExcelProperty 可以按列索引映射:
java
@ExcelProperty(index = 0)
private String name;
也可以按表头名称映射:
java
@ExcelProperty("Name")
private String name;
两者同时存在时,index 优先级更高。
3.2 注解式写入
使用 ExcelWriterMapper 可以直接把实体列表写入 Excel:
java
ExcelWriterMapper<Sale> mapper = new ExcelWriterMapper<>(Sale.class);
try (Workbook wb = new Workbook(outputStream, "App", "1.0")) {
Worksheet ws = wb.newWorksheet("销售");
mapper.write(ws, sales, 0, true);
}
其中 includeHeader = true 时,会自动根据 @ExcelProperty(value = "...") 写出表头。
在 fastexcel-batch 中,也可以直接这样使用:
java
RowWriters.annotation(Sale.class)
这会把注解式写入能力包装成批量写入框架可识别的 RowWriter。
3.3 注解式读取
读取时使用 ExcelReaderMapper:
java
ExcelReaderMapper<Sale> mapper = new ExcelReaderMapper<>(Sale.class);
try (ReadableWorkbook wb = new ReadableWorkbook(file);
Stream<Row> rows = wb.getFirstSheet().openStream()) {
List<Sale> sales = rows
.skip(1)
.map(mapper::map)
.collect(Collectors.toList());
}
这里 .skip(1) 是为了跳过表头行。如果实体字段完全依赖表头名称匹配,也可以使用 mapper.stream(sheet),让 mapper 自动消费表头行。
3.4 读取多个 Sheet
如果一个 Excel 文件有多个 Sheet,需要通过 ReadableWorkbook#getSheets() 遍历全部 Sheet:
java
ExcelReaderMapper<Sale> mapper = new ExcelReaderMapper<>(Sale.class);
List<Sale> allRows = new ArrayList<>();
try (ReadableWorkbook wb = new ReadableWorkbook(file)) {
List<Sheet> sheets = wb.getSheets().collect(Collectors.toList());
for (Sheet sheet : sheets) {
try (Stream<Row> rows = sheet.openStream()) {
List<Sale> sheetRows = rows
.skip(1)
.map(mapper::map)
.collect(Collectors.toList());
allRows.addAll(sheetRows);
}
}
}
测试方法:
java
org.dhatim.fastexcel.annotation.ExcelReaderMapperTest#readFile
读取 StyledBatchExportExampleTest#testFile 写出的文件时,输出示例:
text
销售_1: 100000
销售_2: 100000
销售_3: 100000
销售_4: 100000
total: 400000
如果文件路径不固定,可以通过 JVM 参数指定:
bash
-Dfastexcel.reader.test.file=/path/to/your.xlsx
3.5 try-with-resources 自动关闭资源
读取 Excel 时推荐使用 try-with-resources:
java
try (ReadableWorkbook wb = new ReadableWorkbook(file)) {
// read sheets
}
ReadableWorkbook 实现了 Closeable,退出 try 块时会自动调用 close(),释放底层 ZIP 包资源。
如果使用 sheet.openStream(),行流也建议单独关闭:
java
try (ReadableWorkbook wb = new ReadableWorkbook(file)) {
for (Sheet sheet : wb.getSheets().collect(Collectors.toList())) {
try (Stream<Row> rows = sheet.openStream()) {
// read rows
}
}
}
这对大文件尤其重要,能避免文件句柄、XML 解析器、ZIP entry 等资源长时间占用。
四、整体设计思路
这次扩展可以概括为两层:
4.1 数据层:批量写入流水线
fastexcel-batch 负责处理导出流程:
text
分页请求 -> 并发预取 -> 顺序写入 Sheet -> 样式应用 -> 输出结果
它关心的是性能、并发、分页、Sheet 拆分和资源管理。
4.2 映射层:注解式实体映射
fastexcel-annotation 负责处理对象和 Excel 列之间的关系:
text
Java Bean <-> @ExcelProperty <-> Excel Row/Cell
它关心的是业务代码简洁性、字段类型转换、表头映射和读写统一。
两个模块组合后,业务代码可以只关心数据从哪里来、导出到哪里去,而不需要反复编写列映射、分页循环、Sheet 拆分等模板逻辑。
五、适用场景
这套扩展比较适合以下场景:
- 后台管理系统导出几十万到百万级数据。
- 数据源支持分页查询,例如 MySQL、PostgreSQL、ES、远程 API。
- 导出结果需要拆分多个 Sheet。
- 导出列和实体字段关系稳定。
- 希望减少手写单元格代码。
- 希望保留 fastexcel 的轻量和高性能优势。
六、注意事项
6.1 Sheet 行数限制
.xlsx 单个 Sheet 最大行数是 1,048,576 行。实际业务中建议设置更小的 sheetSize,例如 5W、10W、20W,便于打开和查看。
6.2 并发数不是越大越好
threadPoolSize 主要影响数据获取阶段。如果数据源是数据库,并发过高可能反而增加数据库压力。建议根据数据源类型调整:
- 数据库分页查询:一般 4 到 8 个线程即可。
- 远程接口:可以适当增大,但要考虑限流。
- CPU 密集型转换:线程数接近 CPU 核数。
6.3 读取大文件时优先使用流式 API
ExcelReaderMapper#map(Sheet) 会先把所有行读到 List 中。对于大文件,更推荐:
java
try (Stream<Row> rows = sheet.openStream()) {
rows.map(mapper::map).forEach(...);
}
或在按表头自动映射时使用:
java
try (Stream<Sale> sales = mapper.stream(sheet)) {
sales.forEach(...);
}
6.4 表头行要特别处理
如果使用列索引读取,表头行不会自动跳过,需要手动 .skip(1)。
如果使用表头名称读取,ExcelReaderMapper 可以自动消费第一行作为表头,但要确保实体字段没有显式 index,或者按你的业务逻辑手动调用 resolveHeaderRow。
七、总结
本次扩展让 fastexcel 在两个方向上变得更适合业务系统:
fastexcel-batch解决大数据量导出中的分页、并发、拆 Sheet 和样式问题。fastexcel-annotation解决实体类和 Excel 列之间的映射问题,让读写代码更接近 EasyExcel 的使用体验。
对于需要高性能导出,又希望代码简洁可维护的项目来说,这种设计可以在性能和易用性之间取得一个不错的平衡。
最终效果是:开发者只需要定义实体类、分页数据源和导出参数,就可以完成几十万行 Excel 的高性能导出,并且可以用同一套注解模型再把 Excel 文件读回业务对象。