背景
在许多Web项目中,列表分页功能是常见需求。而为了满足用户需求,导出列表结果数据以便用户离线查看和管理也是必需的。因此,导出列表数据到Excel格式成为一种常见的功能需求。
对于稍大型的公司来说,这种通用性的功能通常有成套的组件封装,结合前端框架(例如Vue)实现一套易用的组件,极大简化了开发工作,不必自己去实现导出的接口和前端的参数封装与接口调用等繁琐的工作。
技术选型
如果没有现成的组件,可以通过一些简单的方式自行实现。常见的用于操作Excel的Java库有Apache POI、EasyExcel等。以下是它们的简要对比:
| API名称 | 简介 | 优势 | 劣势 |
|---|---|---|---|
| Apache POI | Apache软件基金会的开源项目,支持读写多种Office文件格式 | 功能强大,社区活跃,可处理大型文件 | 学习曲线较陡,性能相对较低 |
| EasyExcel | 阿里巴巴开源的Excel读写库,专注于高效处理Excel文件 | 高效处理,简单易用,支持自定义 | 社区相对较小,不支持Office其他文件格式 |
| JExcelAPI | 简单而强大的Java库,用于读写Excel文件 | 简单易用,适合中小型文件 | 功能有限,不支持.xlsx文件,社区和文档资源较少 |
在选择操作Excel的Java API时,应根据具体需求和项目情况选择最合适的工具。如果需要处理大型Excel文件且对性能有较高要求,可以选择EasyExcel;如果对功能要求较高且不担心学习成本,可以选择Apache POI;如果只是简单读写小型Excel文件,可以选择JExcelAPI。
对于我们项目,考虑到高效性和易用性,选择了EasyExcel。官方文档地址:easyexcel.opensource.alibaba.com/docs/curren…
设计思路
结合实际应用场景,导出的数据通常和列表查询的数据一致。因此,可以复用列表分页接口,从接口中获取数据并写入Excel中进行导出下载。
虽然直接在接口层面调用分页列表查询接口能够复用部分查询代码,但仍需要单独提供对应的导出接口,并且需要维护与分页接口一致的查询条件,否则导出的数据和列表查询的数据会不一致。因此,可以共用一个接口,通过某个特定的参数控制是响应数据还是导出。
在Spring项目中,可以考虑实现HandlerInterceptor拦截器,拦截分页接口并判断参数是否需要修改响应。但在拦截器中获取接口数据较为麻烦,而且需要考虑各个拦截器之间的执行顺序。
实现示例
切面拦截
通过AOP切面的方式处理相对合适,获取接口返回值也较为方便。一开始使用了@AfterReturning返回通知,在分页接口返回结果后切入,获取分页数据并使用EasyExcel生成Excel文件,写入response的输出流中。
@Aspect
@Component
public class ExportableAspect {
private final HttpServletRequest request;
private final HttpServletResponse response;
public ExportableAspect(HttpServletRequest request, HttpServletResponse response) {
this.request = request;
this.response = response;
}
@AfterReturning(pointcut = "@annotation(exportable)", returning = "result")
public void afterReturning(Exportable exportable, Object result) throws IOException {
if ("true".equals(request.getParameter("export")) && result instanceof BaseResponseVO<?>) {
BaseResponseVO<?> pageResponse = (BaseResponseVO<?>) result;
Object data = pageResponse.getData();
if (!(data instanceof PageResponse)) {
return;
}
PageResponse<?> page = (PageResponse<?>) data;
List<?> dataList = page.getRecords();
if (dataList == null || dataList.isEmpty()) {
dataList = Lists.newArrayList();
}
String fileName = URLEncoder.encode(exportable.fileName(), "UTF-8").replaceAll("\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
EasyExcel.write(response.getOutputStream())
.head(exportable.tableHeader())
.excelType(ExcelTypeEnum.XLSX)
.sheet(exportable.fileName())
.doWrite(dataList);
}
}
}
遇到的问题
虽然这样能够正常导出Excel文件,但控制台会报错,错误信息如下:
java.lang.IllegalStateException: Cannot call sendError() after the response has been committed
....
...No converter for [class xxx.BaseResponseVO] with preset Content-Type 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8'
响应已提交,返回错误码失败。因为在切面中已经修改了响应的ContentType,而分页接口是有返回值并且有数据需要继续处理,所以转换出错了。
通过调整切入方式可以解决这个问题,将切入方式调整成环绕通知,执行方法获取接口数据。当数据写入输出流之后,将接口方法的返回值设为null值,就不会出现上述错误。
@Around("@annotation(exportable)")
public Object afterReturning(ProceedingJoinPoint joinPoint, Exportable exportable) throws Throwable {
Object result = joinPoint.proceed();
if (!isExportRequest(result)) {
return result;
}
BaseResponseVO<?> data = (BaseResponseVO<?>) result;
PageResponse<?> page = (PageResponse<?>) data.getData();
List<?> dataList = page.getRecords();
if (dataList == null || dataList.isEmpty()) {
dataList = Lists.newArrayList();
}
try {
String fileName = URLEncoder.encode(exportable.fileName(), "UTF-8").replaceAll("\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
EasyExcel.write(response.getOutputStream())
.head(exportable.tableHeader())
.excelType(ExcelTypeEnum.XLSX)
.sheet(exportable.fileName())
.doWrite(dataList);
} catch (UnsupportedEncodingException e) {
log.error("文件名编码异常", e);
} catch (IOException e) {
log.error("响应IO异常", e);
}
return null;
}
自定义注解
最终,使用导出功能变得非常方便,只需在目标分页接口上加上自定义的注解,设置导出文件名和表头即可。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Exportable {
/**
* 导出文件名
* @return 导出文件名
*/
String fileName() default "export";
/**
* 导出表格表头
* @return 表头设置
*/
Class<?> tableHeader();
}
其中表头的设置可以参考EasyExcel的官方文档,使用@ExcelProperty设置表头展示的名字、宽度、排序等。
@Getter
@Setter
@EqualsAndHashCode
public class DemoData {
@ExcelProperty("字符串标题")
private String string;
@ExcelProperty("日期标题")
private Date date;
@ExcelProperty("数字标题")
private Double doubleData;
/**
* 忽略这个字段
*/
@ExcelIgnore
private String ignore;
}
前端建议
前端只需要在列表接口上增加导出数据的功能即可,可以在列表组件的基础上封装一个导出属性,通过控制导出属性是否开启,来展示列表的导出功能。前端具体实现在此不再赘述具体实现。
上述是基于分页接口实现导出本页的功能,如果需要导出全部,最简单的方式可以控制分页参数将全部数据查询到一页导出。
总结
本文介绍了在Spring Boot项目中,如何实现高效的Excel数据导出功能。通过选择合适的技术方案,我们采用了阿里巴巴开源的EasyExcel库,并通过自定义注解和AOP切面的方式实现了后端的导出功能。同时,前端通过封装列表组件,通过属性控制,实现了用户友好的数据导出功能。
这种方案的优点在于高效性和易用性,前后端代码的复用性高,维护成本低。无论是需要处理大数据量还是小数据量的导出需求,该方案都能很好地满足项目需求。如果你也在寻找一种高效便捷的Excel数据导出方案,希望本文能给你带来一些启发和帮助。