先坦白一下,我本来也是个“能不用POI就不用POI”的人。直到接手一个查询报表导出功能,被一句“列不固定,查出来是什么字段就导什么字段”折磨得不轻,才认认真真把 SpringMVC 的 AbstractXlsxView 研究了一遍。今天这篇就是想把动态列导出怎么做、为什么用 AbstractXlsxView 更省事、以及我在实际项目里踩过的几个坑一次性说清楚,适合正在写报表导出、又不想引入重型组件的后端同学参考。
我猜你大概率也遇到过类似需求:前端拉了一个查询结果,列头是根据筛选条件或用户勾选动态生成的,后端拿到查询结果之后要原样导成 Excel。表面上看不难,无非是遍历数据写单元格,但真做起来会发现列头提取、顺序控制、单元格类型、样式复用、内存占用这些问题全冒出来了。本文会把整个实现链路拆开讲,从选型、原理到完整代码,再到实测翻车点,尽量让你看完能直接在自己的项目里复现。
1. 为什么导出功能要选 AbstractXlsxView 而不是手撸 POI
1.1 一次动态列导出的需求长什么样
我们先明确一下什么叫动态列。最常见的两种形态:
第一种是用户自定义导出列。页面上给一堆字段让用户勾选,选哪些列导出哪些列,顺序也可以拖拽调整。后端接口拿到的是一组字段标识,查询后按这些字段生成 Excel。
第二种是查询结果本身不固定。比如做了动态 SQL 查询,不同条件下查出来的字段集合不一样;或者报表模块是通用引擎,数据源表结构都可能变化,导出模块永远不知道自己下一个 Sheet 会有哪些列。
我这次接手的就是第二种。当时旧代码是这么写的:拿一个固定实体类,字段全写死,然后调用 POI 一行行复制粘贴。换一个需求就得改一次导出代码。等到了动态列场景,这套写法直接崩了。
动态列导出的本质是:列结构是运行期才知道的元数据,Excel 的实现方式必须围绕“列名集合”来展开,而不是围绕固定的 JavaBean 字段。
1.2 AbstractXlsxView 在 SpringMVC 里的定位
AbstractXlsxView 是 Spring 框架提供给 SpringMVC 的 Excel 下载视图基类,位置在org.springframework.web.servlet.view.document包下。它本身继承 AbstractView,内部用 Apache POI 的 XSSFWorkbook 生成.xlsx文件。
它做的事情说白了有三件:
- 把 Excel 渲染当成一个 MVC 视图来处理,和数据模型 Model 天然打通。
- 内部帮你创建 XSSFWorkbook,并负责把 workbook 写入
response.getOutputStream()。 - 自动处理 Content-Type 为
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。
你只需要继承它,实现buildExcelDocument方法,从 model 里拿数据,往 XSSFWorkbook 里写内容。渲染完成后 Spring 会把文件写给浏览器,不需要你自己管理响应流、关闭流。
这跟传统写法的区别很直接。手撸 POI 时,Controller 里往往长这样:创建 workbook、创建 sheet、循环填数、设置响应头、把 workbook.write 到输出流、finally 里关闭 workbook——每个导出接口都要重复这一坨。用 AbstractXlsxView,Controller 只需要返回一个 ModelAndView,剩下的交给视图层。
1.3 为什么不用直接写 POI 或 EasyExcel
有同事问我,直接用 POI 封装一个工具类不就行了,为什么要多学一个 Spring 的视图类?我的看法是分场景。
如果你只导出一两个固定格式文件,自己写工具类完全够。但只要是 SpringMVC 项目,导出接口往往会遇到几个共同诉求:响应头设置、视图复用、模型数据传递、后续可能增加 PDF/CSV 导出。这些都是 MVC 视图机制擅长的事,没必要在业务层手动操作 HttpServletResponse。
EasyExcel 是阿里开源的优秀组件,在处理超大 Excel、复杂表头方面很有优势。但动态列场景下,EasyExcel 的静态模型优势反而发挥不出来,你得用它的动态头 API,绕一圈最后还是回到“构建列头集合 + 逐格写入”。而它的实现本身做了很多缓存和优化,如果你只是想要一个轻量方案,额外引入组件显得重了。
当然,如果你的数据量到了几十万行级,或者需要复杂的单元格合并、条件格式,那 EasyExcel 或原生 POI 更合适。AbstractXlsxView 的黄金区间是中小数据量(我个人建议单 Sheet 2 万行以内)、列结构不固定、并发量不大的内部系统导出。这个结论是结合我自己项目里的监控数据得出的,后面第 4 章会详细说大数据量的坑。
2. 动态列的核心思路:先提列头,再写数据
2.1 动态列的三种常见数据形态
写动态列导出之前,先得想清楚你的数据长什么样。我归纳了一下,常见三种形态:
第一种是List<Map<String, Object>>。每行是一个 Map,key 就是列标识,value 是单元格值。这种结构最灵活,也是动态列的标准载体。
第二种是List<JavaBean>。字段个数是固定的,不属于真正的动态列。但如果实体类本身有几十个字段,并且需要根据配置导出其中一部分,那也和动态列处理思路类似。
第三种是动态 SQL 查询返回的ResultSet。你可以通过ResultSetMetaData.getColumnLabel()拿到动态列名,再逐行取值。这种情况通常配合 Map 接收,但要注意列名的获取方式。
我推荐统一约定为第一种:所有动态列相关的 Service 层接口,最终都返回List<Map<String, Object>>。好处是导出视图不需要关心数据来自哪张表、哪个 JavaBean,逻辑完全统一。
2.2 列头提取与全量去重:LinkedHashSet 是功臣
当数据是List<Map<String, Object>>时,第一步是提取所有列的 key。但你没法保证每条数据里的 key 都一模一样。比如有一条数据因为某个字段为空,查询层可能直接不给这个 key,也可能给个 null,两种情况都存在。所以必须做“全量收集 + 去重”。
我通常用一个LinkedHashSet<String>来完成这件事:
LinkedHashSet<String> columns = new LinkedHashSet<>(); for (Map<String, Object> row : rows) { if (row != null) { columns.addAll(row.keySet()); } }为什么用 LinkedHashSet?因为普通 HashSet 的 key 顺序是不确定的,而 LinkedHashSet 会保证两个特性:不重复 + 保持首次插入顺序。也就是说,列的顺序会按照第一条数据里出现的先后顺序来确定,恰好能满足“查出来什么字段就按什么字段顺序导出”的直观预期。
这里有一个细节值得注意:如果第一条数据中恰好缺失了某列,而第二条数据才有这个列,那该列会出现在列头的末尾,而不是中间。如果你需要严格按某个业务顺序导出,就不能只依赖 LinkedHashSet 的收集顺序,我下面会说列顺序控制方案。
2.3 列顺序控制与中文表头映射
动态列场景经常还有一个隐含需求:列头显示中文,而 Map 里的 key 往往是英文字段名或数据库列名。最粗鲁的做法是写一堆 if/else 判断 key,然后设置中文表头。但既然是动态列,写死 if/else 就失去了动态的意义。
我的做法是维护一份动态列配置,被称为“列元数据”:
public class ExcelColumnMeta { private String key; // Map 中的字段标识 private String title; // Excel 表头显示名 private int width; // 列宽,单位是 1/256 字符 }当列是运行时动态生成的时候,这份配置也由运行时生成。比如前端传过来的勾选字段,后端根据字段字典补全中文名和宽度。这样在buildExcelDocument里只需要遍历这份配置即可:
List<ExcelColumnMeta> metas = (List<ExcelColumnMeta>) model.get("columnMetas"); for (int i = 0; i < metas.size(); i++) { ExcelColumnMeta meta = metas.get(i); String key = meta.getKey(); // 从每行数据中取 key 对应的值 }如果某些 key 没有出现在行数据里,单元格就留空;如果有些行数据包含未配置的 key,要么忽略,要么追加到末尾。我会倾向于忽略未知字段,这样更可控。
3. 动手实现:AbstractXlsxView 动态列导出的编码拆解
3.1 Maven 依赖和版本选择
首先你需要 Spring Web MVC 和 POI 的依赖。我的建议版本组合是:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.31</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>如果你用的是 Spring Boot,那 spring-webmvc 依赖已经在了,只需要加 POI。版本上注意一点:POI 4.x 和 5.x 的 API 有变化。典型差异是单元格对齐方式:POI 4.x 用的是CellStyle.ALIGN_CENTER,POI 5.x 改成了HorizontalAlignment.CENTER。我下面的代码以 POI 5.x 为准,如果你还在用 4.x,自行替换即可。
另外,AbstractXlsxView 类在 Spring 5.x 中位于org.springframework.web.servlet.view.document.AbstractXlsxView。如果你的项目用的是 Spring 6,框架迁移到 Jakarta 命名空间,但这个类的用法基本没变,只是底层依赖版本不同,不必太担心。
3.2 自定义视图类 DynamicColumnExcelView
接下来是核心:继承 AbstractXlsxView,实现buildExcelDocument。
public class DynamicColumnExcelView extends AbstractXlsxView { @Override protected void buildExcelDocument(Map<String, Object> model, XSSFWorkbook workbook, HttpServletRequest request, HttpServletResponse response) throws Exception { // 1. 从 model 中取数据 List<Map<String, Object>> rows = (List<Map<String, Object>>) model.get("rows"); List<ExcelColumnMeta> columnMetas = (List<ExcelColumnMeta>) model.get("columnMetas"); // 2. 创建 sheet XSSFSheet sheet = workbook.createSheet("动态报表"); // 3. 创建样式,只创建一次 CellStyle headerStyle = workbook.createCellStyle(); headerStyle.setAlignment(HorizontalAlignment.CENTER); headerStyle.setVerticalAlignment(VerticalAlignment.CENTER); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); Font headerFont = workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); CellStyle dataStyle = workbook.createCellStyle(); dataStyle.setVerticalAlignment(VerticalAlignment.CENTER); // 4. 处理列头 if (columnMetas == null || columnMetas.isEmpty()) { // 兜底:如果没有显式列配置,就从数据中收集 key 作为列 LinkedHashSet<String> keys = new LinkedHashSet<>(); for (Map<String, Object> row : rows) { if (row != null) { keys.addAll(row.keySet()); } } columnMetas = new ArrayList<>(); for (String key : keys) { ExcelColumnMeta meta = new ExcelColumnMeta(); meta.setKey(key); meta.setTitle(key); meta.setWidth(20); columnMetas.add(meta); } } // 5. 写表头 Row headerRow = sheet.createRow(0); for (int i = 0; i < columnMetas.size(); i++) { ExcelColumnMeta meta = columnMetas.get(i); Cell cell = headerRow.createCell(i); cell.setCellValue(meta.getTitle()); cell.setCellStyle(headerStyle); sheet.setColumnWidth(i, meta.getWidth() * 256); } // 6. 写数据 if (rows != null) { int rowIndex = 1; for (Map<String, Object> row : rows) { if (row == null) { continue; } Row dataRow = sheet.createRow(rowIndex); for (int i = 0; i < columnMetas.size(); i++) { String key = columnMetas.get(i).getKey(); Object value = row.get(key); Cell cell = dataRow.createCell(i); writeCellValue(cell, value); cell.setCellStyle(dataStyle); } rowIndex++; } } // 7. 冻结表头行 sheet.createFreezePane(0, 1); } private void writeCellValue(Cell cell, Object value) { if (value == null) { cell.setCellValue(""); } else if (value instanceof Number) { cell.setCellValue(((Number) value).doubleValue()); } else if (value instanceof java.util.Date) { cell.setCellValue((java.util.Date) value); } else if (value instanceof Boolean) { cell.setCellValue((Boolean) value); } else { cell.setCellValue(value.toString()); } } }这段代码的核心逻辑可以概括为:先处理列元数据,再写表头,再按列元数据逐行逐列写数据。writeCellValue做了基础的类型判断,保证 Number、Date、Boolean 能保留 Excel 原生类型。
这里有三个我自己反复提醒自己的要点:
第一,CellStyle必须在循环外面创建。POI 里每个 CellStyle 都是要占用内存的独立对象,如果一个循环里创建几万个样式,即使数据量不大也会非常顿。我见过有人 5000 行导出卡了十几秒,原因就是把样式创建写进了循环体。
第二,空值不能直接调用cell.setCellValue(null),那样会写入一个空字符串也可能报 NPE 或留下奇怪的空单元格。统一做一次判断更安全。
第三,当列头来自运行期数据,如果用户勾选了敏感字段(比如内部 ID、数据库主键),Excel 会原样带出。这个问题不在代码层面解决,而在列元数据生成时做白名单过滤,我后面会再提到。
3.3 Controller 怎么返回视图
写完了视图类,Controller 的返回方式有两种,我先说直接 new 的方式,这种最好理解:
@GetMapping("/export") public ModelAndView export(@RequestParam(required = false) String keyword) { List<Map<String, Object>> rows = reportService.queryDynamicData(keyword); List<ExcelColumnMeta> columnMetas = reportService.buildColumnMetas(keyword); Map<String, Object> model = new HashMap<>(); model.put("rows", rows); model.put("columnMetas", columnMetas); return new ModelAndView(new DynamicColumnExcelView(), model); }ModelAndView 的第一个参数直接传视图对象,第二个参数传模型数据,一个典型的 MVC 流程。这个写法不需要配置任何 ViewResolver,因为视图对象已经明确指定了。
但这里有一个容易被忽略的点:文件名和下载头。AbstractXlsxView只负责把 workbook 写到输出流,不会替你设置Content-Disposition,所以文件名需要你自己在 Controller 里设置:
response.setCharacterEncoding("UTF-8"); String fileName = URLEncoder.encode("动态报表.xlsx", "UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);中文文件名一定记得编码,否则浏览器下载时很容易直接变成一串乱码,或者干脆报下载失败。
3.4 通过 BeanNameViewResolver 注册视图的完整姿势
直接 new 视图对象虽然简单,但每次请求都会 new 一个视图类,某些场景下你希望视图对象是单例的、可复用的。这时候用 BeanNameViewResolver 更合适。
步骤如下。
第一步,在配置类里注册一个 BeanNameViewResolver:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Bean public BeanNameViewResolver beanNameViewResolver() { return new BeanNameViewResolver(); } }第二步,把视图类声明成一个 Bean:
@Bean(name = "dynamicExcelView") public View dynamicExcelView() { return new DynamicColumnExcelView(); }第三步,Controller 返回视图名:
@GetMapping("/export") public ModelAndView export(@RequestParam(required = false) String keyword) { List<Map<String, Object>> rows = reportService.queryDynamicData(keyword); List<ExcelColumnMeta> columnMetas = reportService.buildColumnMetas(keyword); Map<String, Object> model = new HashMap<>(); model.put("rows", rows); model.put("columnMetas", columnMetas); return new ModelAndView("dynamicExcelView", model); }BeanNameViewResolver 的原理是:Spring 根据视图名去容器里找一个同名 Bean,把它当作 View 返回。这种方式的好处是视图实例由 Spring 管理,可以注入其他依赖。比如你可以把列宽默认值、Excel 样式配置注入到视图里,让代码更灵活。
如果你用的是 Spring Boot,默认的 ViewResolver 链里通常已经包含 BeanNameViewResolver 的自动配置,但显式声明一个也无妨,不会冲突,反而更直观。
4. 实测中最容易翻车的几个坑
4.1 文件名与 Content-Disposition 的乱码问题
第一次上线这个导出功能,测试反馈说下载文件名是乱的。我排查后发现是编码没有处理好。
如果你只写response.setHeader("Content-Disposition", "attachment; filename=" + fileName),当 fileName 是中文时,不同浏览器表现完全不一样。现代浏览器建议用 RFC 5987 规范,也就是filename*=UTF-8''的形式;旧浏览器则更认filename参数里带 UTF-8 编码后的内容。
稳妥的做法是两者都写,这就是 3.3 里的写法。实际执行时,现代浏览器会优先读filename*,旧浏览器读filename。对于内部系统来说,这个方法已经能覆盖绝大多数情况。
还有一个容易踩的点:URLEncoder.encode会把空格变成+,在 Content-Disposition 里+可能显示异常。你要是实在不放心,可以自己再把+替换成%20:
String fileName = URLEncoder.encode("动态报表.xlsx", "UTF-8").replace("+", "%20");这种细节肉眼很难发现,但实际测试多了就知道确实存在。
4.2 空数据导出时只有一个表头
动态列场景里,最容易被忽略的是查询结果完全为空时导出行为。很多人的导出代码拿到空 List 就直接不写数据行,结果 Excel 里只有列头没有数据,甚至列头都没有,因为列头也是从数据里收集的。
这个问题本质上是:动态列在空数据情况下,列信息从哪来?如果你的列头是通过遍历rows的 key 收集的,那空数据必然得不到列头。所以我的建议是,动态列数据源尽量由 Service 层额外提供一个列元数据接口,哪怕列元数据是从前端传过来的字段列表,也能兜住空数据场景。
如果确实没有列元数据,那至少做一层兜底,比如在没有列头、没有数据的情况下给个空 Sheet,并写一行提示“无查询结果”。
4.3 动态列的日期、数字、百分比格式
动态列最烦的就是格式不可控。同样是 Object,它可能是 String,可能是 Double,可能是 Date,也可能是 BigDecimal。写单元格时可以像 3.2 那样做实例判断,但写出来的单元格格式是 Excel 默认的,日期默认yyyy-mm-dd,长数字可能显示成科学计数法。
对于动态列,我认为有两种选择。
选择一:数据层统一把值转成字符串再返回。优点是不需要考虑单元格类型,内容显示什么就是什么;缺点是失去了 Excel 原生数字特性,比如不能求和、筛选不智能。
选择二:列元数据里加一个字段类型标识,buildExcelDocument里根据类型标识选择不同写入方式。
我在实际项目里采取的是选择二,但做得比较轻量。列元数据里存一个dataType,可选值为string / number / date / amount。写入时按类型分支处理,日期类型再设一个日期格式样式:
if ("date".equals(meta.getDataType())) { CellStyle dateStyle = workbook.createCellStyle(); dateStyle.setDataFormat(workbook.getCreationHelper().createDataFormat().getFormat("yyyy-mm-dd")); cell.setCellStyle(dateStyle); cell.setCellValue((java.util.Date) value); }不过要注意,这里的样式创建也要控制次数。最理想的做法是在写表头前就把每种类型对应的样式都创建好,放进一个 Map,后续直接取用。
4.4 大数据量导出撑爆内存
这部分我必须强调,AbstractXlsxView 内部默认创建的是 XSSFWorkbook。XSSFWorkbook 会把整个文档结构都存在内存里,数据量越大,堆内存占用越夸张。我自己做过一个粗略测试,单 Sheet 5 万行、每行 20 列,XSSFWorkbook 的内存占用轻松超过 500MB,这还是在没有复杂样式的情况下。
所以如果你们系统导出的数据量动不动就是几万行,直接套 AbstractXlsxView 是在给自己埋雷。处理方式有两种,取决于你的需求。
方式一:限制阈值,超过 N 行就提示用户缩小查询范围,或者改为分批导出多个 Sheet。这个方案实现简单,但用户体验一般。
方式二:使用 SXSSFWorkbook。SXSSFWorkbook 是 POI 提供的流式处理实现,只在内存保留滑动窗口内的行数,超出部分写入磁盘临时文件,可以有效降低内存峰值。但 AbstractXlsxView 的renderMergedOutputModel内部写死了用 XSSFWorkbook,想用 SXSSFWorkbook 就得放弃视图的自动渲染逻辑,自己在 Controller 里创建一个简易导出。
这里我给一个参考实现,不依赖 AbstractXlsxView:
@GetMapping("/export-large") public void exportLarge(HttpServletResponse response) throws IOException { try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { SXSSFSheet sheet = workbook.createSheet("大数据导出"); // 写表头、写数据 // 这一段和 AbstractXlsxView 里的逻辑类似,只是换成 SXSSFWorkbook API response.setCharacterEncoding("UTF-8"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=data.xlsx"); workbook.write(response.getOutputStream()); } }所以对大数据的结论是:如果量级超过 2 万行,我建议单独写导出逻辑,用 SXSSFWorkbook 或 EasyExcel,不要硬套 AbstractXlsxView。如果量级在几千行内,AbstractXlsxView 用起来是最舒服的。
4.5 样式大量重复创建导致卡顿
前面说过样式要放循环外,这里我再展开说。POI 的 CellStyle 设计得像一个池子,每次创建新样式,都会在 workbook 的样式表里新增一条记录,最终写入 xlsx 文件。如果你为每个单元格都创建一个新样式,不仅内存涨得快,文件本身也可能因为样式表冗余而变大。
正确做法是:表头用 headerStyle,正文用 dataStyle,日期列再用一个 dateStyle,最多再加几个特殊格式样式。把这些样式在buildExcelDocument开头创建好,后续循环里setCellStyle(style)直接复用。POI 内部会对相同样式的实例做去重吗?实际上如果你每次新建样式,即使配置完全相同,它也不会自动复用之前的样式对象,所以你必须在应用层控制。
我之前优化过一个导出接口,只是把样式创建从循环里拿出来,耗时就从 8 秒降到了 1 秒左右,效果立竿见影。
5. 让动态列导出的代码更健壮
5.1 可复用导出行模板:ExcelColumnMeta 配置
做到了动态列之后,我发现下一个瓶颈是维护性。导出代码的逻辑越来越像:取数据、整理列元数据、调用视图导出。如果每个接口都各自拼一遍 ModelAndView,很快会出现大量的样板代码。
我的办法是抽象一个导出行模板对象,或者叫导出请求对象。它的作用是统一入口:
public class ExportRequest { private String fileName; private String sheetName; private List<ExcelColumnMeta> columns; private List<Map<String, Object>> rows; }Controller 只需要构建一个ExportRequest,然后调用一个通用的导出服务,剩下的列宽、样式、编码处理全在视图层完成。这样做的好处是:新的导出需求来了,后端同学只需要关心数据怎么查、列元数据怎么来,导出这件事本身的代码几乎不再变动。
比如这样一个通用 Controller:
@PostMapping("/export/generic") public ModelAndView exportGeneric(@RequestBody ExportRequest request) { Map<String, Object> model = new HashMap<>(); model.put("rows", request.getRows()); model.put("columnMetas", request.getColumns()); return new ModelAndView("genericExcelView", model); }注意这里有安全前提:如果导出请求来自前端,ExcelColumnMeta里的 key 必须做白名单校验,不能直接让前端拼出任意数据库字段。否则用户传入一个内部敏感字段的 key,后端又刚好去数据库查了这个字段,数据就泄露了。我在项目里是这么处理的:前端传字段标识,后端根据标识到字典表里翻译成真正的列 key,这等于加了一道映射层。
5.2 几个偏门但实用的导出技巧
第一个是冻结表头行。数据量大的时候往下滚动看不到列头,体验很差。sheet.createFreezePane(0, 1)一行代码就能解决,我一般在创建完表头后直接调用。
第二个是列宽自适应。动态列宽度很难提前估算,我一般会在列元数据里配默认宽度,但也会额外设置一版自动宽度策略。不过sheet.autoSizeColumn(i)对大量数据是有性能损耗的,而且对中文支持一般,所以我只在列数少、数据量小的场景使用,其他情况宁可手动配宽度。
第三个是给整个 Sheet 加自动筛选。做完表头之后调用sheet.setAutoFilter(new CellRangeAddress(0, lastRow, 0, lastCol)),导出的 Excel 在表头行就会带筛选箭头,方便使用方在 Excel 里继续过滤。这个方法在报表场景里很加分,代码量也不大。
5.3 动态列名别直接拼进 SQL
虽然标题是讲 Excel 导出,但动态列导出的上游往往是动态查询。这里我吃过一次亏,必须提醒一句:动态列名如果最终要拼进 SQL,一定不能直接拼接前端传进来的字符串。哪怕是内部系统,也要做一层字段映射或白名单过滤。
具体做法是:前端传一个逻辑字段编码,后端从字段字典里找到真正映射的数据库列名。如果找不到,直接拒绝导出。不要在 SQL 里出现类似order by ${userInput}这种写法,那既是 SQL 注入风险,也会在列名不存在时直接导致查询失败。
同样的道理,如果单元格内容来自用户输入,写到 Excel 时要注意公式注入。比如用户输入=cmd|...,导出后在 Excel 打开可能会被当作公式执行。最稳妥的兜底方案是:当字符串以=、+、-、@开头时,在前面加一个单引号,或者统一用writeCellValue里的字符串分支做一次转义。这个处理虽然丑,但对用户输入不友好的场景真的有用。
5.4 后续可以扩展的方向
动态列导出做到这一版,已经能在大多数报表需求里跑得很稳了。再往后想扩展,有几个方向我觉得比较自然:
第一个是多 Sheet 导出。现在的方案只建了一个 Sheet,如果报表分多个分组,可以按分组创建多个 Sheet,每个 Sheet 独立走一遍列元数据。这个过程 Extension 起来其实很轻,只要把创建 Sheet 的逻辑抽成一个私有方法即可。
第二个是复杂表头合并。比如第一行放一个大类,下面每列再放子类,这就需要先合并单元格再写数据。实现方式也不复杂,但要注意合并单元格和普通单元格样式之间的冲突。建议在数据写完后再做合并,避免覆盖已有内容。
第三个是非 Excel 格式导出。既然已经享受了 SpringMVC 视图抽象的好处,PDF、CSV 其实完全可以照同一套思路来做,只是把视图类换掉。CSV 的动态列可能比 Excel 更简单,因为压根不需要处理单元格样式。
我做动态列导出做到后面,最大的体会是:80% 的时间花在列元数据的建模上,真正操作 POI Workbook 的部分其实很固定。先把“列从哪里来、列顺序是什么、值是什么类型”这三件事理清楚,代码怎么搬都很顺;这三件事没定,用什么导出组件都别扭。如果你正准备做动态列导出,我建议别一上来就猛写 Excel 处理代码,先把你数据源返回的列结构设计好,这是最值得投入的一步。后面等你多接几个需求,就会发现当初做的那层列元数据配置,能帮你省掉非常多的重复工作。