上周运营提了一个新需求说报表导出的时候要能自己勾选导出哪些列不同角色看的列还不一样。这已经不是第一次为了导出表头改代码了以前每次字段调整都要去改实体类上的ExcelProperty注解加一个字段、改一个列名都得出一次版本。这次我直接把导出工具类重构了一版核心思路就一句话表头不再写死在注解里而是运行时动态传入。做完之后发现这个改动带来的一连串收益远超预期今天把整个设计和实现过程完整记录一下。这篇内容主要面向做 Java 后端、经常要和 Excel 导出打交道的朋友。如果你还在用硬编码表头的方式写导出每次需求变更都要发版那这篇文章正好能解决你的问题。我会从头到尾讲清楚为什么表头要动态化、工具类怎么设计、核心代码怎么落地以及我在实现过程中踩过的各种坑。1. 为什么表头非要动态不可从一次改到烦的需求说起1.1 运营一句话后端改半天做后台系统的同学应该都有这种经历。运营同学跑过来说这个用户列表的导出能不能把最后登录时间放在手机号前面你看了看代码实体类上的ExcelProperty(value 手机号)和ExcelProperty(value 最后登录时间)顺序写死了得调注解顺序重新发版。好不容易上线了运营又说现在不看手机号了帮我换成注册来源吧。你又得改注解、提测、发版。一次两次还行一个月来五次谁受得了后来运营又提了一个更复杂的需求不同的人打开导出页面看到的列不一样。管理员要看手机号普通运营不能看手机号只让看脱敏后的用户名和注册时间。这种需求靠注解就真的没法实现了因为同一个实体类不可能同时对应多套表头结构。你得写两个实体类各写一套注解加一个场景再加一个类。1.2 注解写死表头的三个核心痛点我把用注解方式做导出的痛点梳理了一下归根结底是这三条。第一表头变更必须改代码发版。这是最直接的痛。表头本质上是一个需求层面的东西业务方改表头跟改页面上的文案一样频繁把这种高频变化的东西硬编码进代码里等于每次变更都要走一遍完整的开发流程。第二一个实体类只能绑一套表头。你想让同一个查询结果导出成两种不同列组合的 Excel用注解就非常别扭。要么写两个实体类要么导出前先做一遍字段映射把实体转成另一个结构非常繁琐。第三复杂表头实现成本高。像用户信息这个大分类下面挂姓名和年龄两个子列这种多层合并表头用注解虽然也能做但写起来很绕需要额外定义一堆内部类。等你想支持运行时用户自己选列注解这套玩法就彻底走不通了。1.3 动态表头解决的其实是响应变化的问题动态表头这个词听起来挺玄乎本质上就是把表头长什么样这个决策时机从编译期推迟到运行时。你不再告诉代码表头永远是这三列而是让调用方在调用导出方法的时候把想要的表头结构传进来。表头想变调用方改一下传参就行导出工具类一行代码都不用改。这个思路和依赖注入其实是一个道理把变化的部分从稳定的部分中剥离出来。稳定的部分是我怎么生成一个 Excel 文件变化的部分是表头长什么样、数据有哪些列。工具类负责处理稳定的部分动态表头负责承载变化的部分。这样一来任何表头层面的调整都只是调用方的参数变化不会再波及导出的底层代码。2. 需求边界一个动态表头导出工具类到底要做到什么程度2.1 先列清楚功能需求清单动手写代码之前我先把需求边界划清楚。如果只说支持动态修改表头很容易做成一个四不像所以我把工具类需要支持的能力列了个清单。表头列表由调用方传入不依赖实体类上的注解想导哪些列、列名叫什么全部由参数决定。支持多层合并表头即一个父列下挂多个子列这是实际业务里最常用的复杂表头形态。数据行和表头列顺序严格对应调用方按表头顺序逐列给数据。支持大数据量分批写入不能因为导出 10 万行数据就把堆内存打满。不绑定具体业务模型任何业务都能复用同一个工具类。有了这份清单工具类的定位就清楚了它不是一个用户导出工具也不是订单导出工具而是一个通用的Excel 导出底座。2.2 技术选型为什么选 EasyExcel 而不是原生 POI这个工具类底层我用的是阿里开源的 EasyExcel。没有从零基于 POI 手写原因很现实EasyExcel 在 POI 之上封装了很多易用性和内存优化尤其是SAX模式逐行读写的设计能显著降低大数据量导出时内存的占用这在后文踩坑部分我会详细对比数据。易用性上EasyExcel 天然支持把表头定义成ListListString传入这正好是动态表头需要的形态。原生 POI 要自己创建 Row 和 Cell 去拼表头代码量会翻一倍。我整理了一个简单对比当时就是基于这个对比锁定的 EasyExcel对比项原生 POIEasyExcel表头动态化便利度需要手动操作 Cell直接传入 List 结构开箱即用5 万行数据的粗略内存占用较高容易 OOM明显更低实测比较稳API 易用性代码较多心智负担重封装度高配置清晰社区活跃度稳定但更新慢国内企业使用广泛资料多2.3 动态表头模式的边界条件这里有一点必须先说清楚动态表头模式下注解自动映射的能力就废了。你用ExcelProperty绑定实体类字段时EasyExcel 会自动把对象属性和列对应起来但一旦表头改成运行时传入的ListListString数据部分就必须配套改成ListListObject——外层 List 代表一行记录内层 List 按表头顺序存放这一行的每个单元格的值。顺序一旦错位数据就会填到错误的列下面这在第 5 部分踩坑里我会详细展开。另外动态表头模式下默认的单元格样式会变得比较原始比如表头没有加粗、内容不做居中。要处理这个问题需要额外的样式 Handler 或者策略类并不会因为用了 EasyExcel 就自动带过来。3. 整体设计先画清楚主流程再写代码3.1 工具类的核心结构整个工具类的设计我分成三个部分一个对外暴露的静态方法入口ExcelExportUtils、一个承载表头结构的元数据模型、以及底层的写入执行流程。没有引入 Spring 依赖纯 JDK EasyExcel这样不管你是 Spring Boot 项目还是普通 Java 项目都能直接用。对外入口我设计成两个重载方法。第一个方法接收完整的数据集合适合中小数据量场景一次性把表头和数据传给工具类第二个方法接收一个分批获取数据的函数式接口适合大数据量场景工具类内部按页调用这个接口拿数据并批量写入避免一次性把全量数据加载进内存。3.2 表头数据结构怎么定动态表头的核心是表头的数据结构。我选择了ListListString这个结构一开始可能有点绕我举个例子。假设我要生成一个这样的表头| 序号 | 用户信息 | 注册信息 | | | 姓名 | 年龄 | 注册时间 |对应到ListListString就是ListListString headList new ArrayList(); // 第一列只有一层占两行 headList.add(Arrays.asList(序号)); // 第二列父列用户信息子列姓名 headList.add(Arrays.asList(用户信息, 姓名)); // 第三列父列用户信息子列年龄 headList.add(Arrays.asList(用户信息, 年龄)); // 第四列父列注册信息子列注册时间 headList.add(Arrays.asList(注册信息, 注册时间));内层 List 的长度代表这个列跨几层同一父列下的子列 EasyExcel 会自动帮你合并单元格。这个数据结构是 EasyExcel 官方支持的动态表头写法理解起来也不难外层一个元素是一整列内层的字符串从上到下就是这一列从父级到子级的表头内容。3.3 导出主流程工具类内部的主流程其实很简洁。第一步根据文件名和响应对象初始化ExcelWriter第二步用传入的headList构建WriteSheet第三步写入数据第四步调用finish方法把缓冲区的内容刷到输出流并释放资源。整个过程最难的不是代码而是调参和防坑。在第 4 部分我会先给出完整可用的实现代码第 5 部分再集中讲我踩过的坑这样你在使用的时候遇到问题可以回头对照。4. 动态表头的核心实现直接给可落地的工具类代码4.1 基础版本一次性传入表头和数据先给一个最直接的版本适用于大多数中小数据量的导出场景。package com.example.excel; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWriter; import com.alibaba.excel.write.metadata.WriteSheet; import com.alibaba.excel.write.style.HorizontalCellStyleStrategy; import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.IndexedColors; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; /** * Excel 导出工具类核心特性动态表头。 * 表头结构由调用方传入 ListListString不绑定任何实体类注解。 */ public final class ExcelExportUtils { private ExcelExportUtils() { } /** * 导出 Excel一次性写入全部数据适合中小数据量。 * * param response HTTP 响应对象 * param fileName 导出文件名不需要带 .xlsx 后缀 * param sheetName Sheet 名称 * param headList 动态表头外层 List 是一列内层 List 从父级到子级排列 * param dataList 数据行每行 List 的元素顺序必须与 headList 的列顺序一致 */ public static void exportWithData(HttpServletResponse response, String fileName, String sheetName, ListListString headList, ListListObject dataList) throws IOException { // 1. 设置 HTTP 响应头让浏览器识别为下载 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String encodedFileName URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) .replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 encodedFileName); // 2. 构建样式策略 HorizontalCellStyleStrategy styleStrategy buildDefaultStyleStrategy(); // 3. 写入并释放资源 ExcelWriter excelWriter EasyExcel.write(response.getOutputStream()).build(); WriteSheet writeSheet EasyExcel.writerSheet(sheetName).head(headList).build(); excelWriter.write(dataList, writeSheet); excelWriter.finish(); } /** * 构建默认样式表头灰底加粗居中内容居中。 */ private static HorizontalCellStyleStrategy buildDefaultStyleStrategy() { // 表头样式 WriteCellStyle headStyle new WriteCellStyle(); headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); WriteFont headFont new WriteFont(); headFont.setBold(true); headFont.setFontHeightInPoints((short) 11); headStyle.setWriteFont(headFont); // 数据行样式 WriteCellStyle contentStyle new WriteCellStyle(); contentStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); return new HorizontalCellStyleStrategy(headStyle, contentStyle); } }这里有几个细节需要说明一下。响应头里的filename*utf-8是解决中文文件名乱码的标准写法我一开始直接用filename加 URL 编码后的文件名发现部分浏览器会把文件名里的%E6%9F%90这种字符直接显示出来用filename*配合URLEncoder编码后就正常了。另外ExcelWriter的finish()方法一定要在 finally 或者正常流程末尾调用它会触发流资源的释放漏掉的话会导致导出的文件损坏、打不开。4.2 大数据量版本分批拉取边查边写一次性传入所有数据虽然简单但假如你要导出的是几十万行用户数据把所有数据先查出来放到内存里再传给工具类内存很容易扛不住。EasyExcel 的优势在于它可以分批写入每批写完就释放内存压力很小。我设计了一个函数式接口DataPageProvider由调用方实现分批查询逻辑FunctionalInterface public interface DataPageProvider { /** * 获取一页数据。 * * param pageNum 页码从 1 开始 * param pageSize 每页行数 * return 当前页数据列表如果没有更多数据返回 null 或空集合 */ ListListObject fetch(int pageNum, int pageSize); }然后增加一个分批导出的重载方法public static void exportByPage(HttpServletResponse response, String fileName, String sheetName, ListListString headList, int pageSize, DataPageProvider dataPageProvider) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String encodedFileName URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) .replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 encodedFileName); HorizontalCellStyleStrategy styleStrategy buildDefaultStyleStrategy(); ExcelWriter excelWriter EasyExcel.write(response.getOutputStream()) .registerWriteHandler(styleStrategy) .build(); WriteSheet writeSheet EasyExcel.writerSheet(sheetName).head(headList).build(); int pageNum 1; while (true) { ListListObject pageData dataPageProvider.fetch(pageNum, pageSize); if (pageData null || pageData.isEmpty()) { break; } excelWriter.write(pageData, writeSheet); pageNum; // 每批数据用完即可被 GC内存只保留当前批的数据 } excelWriter.finish(); }这样调用方就可以用 lambda 把 MyBatis-Plus 的分页查询逻辑传进来比如ExcelExportUtils.exportByPage(response, 用户导出, 用户, headList, 5000, (pageNum, pageSize) - { PageUserDO page userMapper.selectPage(new Page(pageNum, pageSize), null); if (page.getRecords().isEmpty()) { return null; } return page.getRecords().stream() .map(user - Arrays.asList( user.getId(), user.getName(), user.getPhone() )) .collect(Collectors.toList()); });这种方式下无论导出的数据量多大内存里同一时刻只保留一个 pageSize 批次的数据极大的降低了 OOM 概率。4.3 一个复杂的动态表头实际调用示例为了让你对动态表头有一个直观的概念我放一个真实场景的调用代码。假设运营后台需要导出一份带合并表头的用户分析表ListListString headList new ArrayList(); headList.add(Collections.singletonList(序号)); headList.add(Arrays.asList(用户信息, 姓名)); headList.add(Arrays.asList(用户信息, 年龄)); headList.add(Arrays.asList(用户信息, 手机号)); headList.add(Arrays.asList(行为数据, 最近登录时间)); headList.add(Arrays.asList(行为数据, 订单数)); ListListObject dataList new ArrayList(); dataList.add(Arrays.asList(1, 张三, 28, 138****1234, 2025-01-15 10:24:00, 12)); dataList.add(Arrays.asList(2, 李四, 32, 139****5678, 2025-01-14 22:10:00, 5)); ExcelExportUtils.exportWithData(response, 用户分析报表, 用户分析, headList, dataList);这段代码导出的 Excel 里用户信息和行为数据两个父列会自动跨行合并效果和你在 Excel 里手动合并单元格是一样的。运营再提我要加一列注册来源这个需求时你只需要在 headList 和数据列表里各加一个元素工具类一行都不用改。5. 踩坑记录动态表头远没有看起来那么简单5.1 坑一动态表头下样式和列宽全部失效第一次跑通动态表头导出时导出的文件确实能打开但表头灰白的底色没了字体也不加粗了数据全部挤在左侧列宽也是默认宽度列名都显示不全。原因是当你通过head(ListListString)传入表头时EasyExcel 不会自动套用基于注解设计的样式策略。解决方式就是我工具类里那套HorizontalCellStyleStrategy但这里有一个容易忽略的细节在分批导出版本里一定要通过excelWriter的registerWriteHandler(styleStrategy)来注册样式而不是在writerSheet之后才传。我之前在测试时把样式策略放在writerSheet(...).build()后面传入结果数据行有样式、表头没有样式排查了半天才意识到注册顺序会影响样式作用范围。列宽的坑也值得一提。如果你不对列宽做任何处理动态表头模式下所有列宽都是默认值中文表头几乎一定显示不全。最简单的方案是给 excelWriter 注册一个LongestMatchColumnWidthStyleStrategy它会让列宽自动匹配本列最长的内容包括表头和数据。但这个策略在数据量大的时候会遍历每一行的内容来计算宽度性能有损耗。如果数据量特别大建议手动按业务场景指定列宽而不是用自适应策略。5.2 坑二表头和数据列顺序错位数据填到错误的列动态表头模式下最容易踩的坑就是这个headList 的顺序和 dataList 内层 List 的顺序必须完全一致否则数据会错位。这不是工具类能帮你兜底的因为工具类根本不知道你的业务字段叫什么它只认顺序。我自己的排查经验是先在调用方写一个简单的断言方法确认 headList 的 size 和 dataList 每一行的 size 相等如果不相等直接抛异常不要等导出完才发现。耗时不多但能避免大量低级错误。如果用到分批导出还需要注意每一批数据行的列数都要一致否则后续的行会发生错位。另外DataPageProvider 分批查询时每一页返回的数据行数不能超过 pageSize否则会出现写入乱序的问题。这个在实现查询函数时稍微注意一下就行分页查询框架一般不会出现这种问题但如果是自己写 SQL 的就要多留个心眼。5.3 坑三单元格内容以 或 开头被 Excel 当成公式导出用户备注时如果用户备注是逾期用户或者所有人Excel 打开文件的时候会把逾期用户解析成公式导致单元格报错或显示异常。这个问题不是 EasyExcel 特有的用 POI 也一样会遇到属于 Excel 本身的安全机制。解决方式是在写入数据前对单元格内容做一次前导字符转义。做一个简单的字符串处理private static Object safeCellValue(Object value) { if (value instanceof String) { String str (String) value; if (str.startsWith() || str.startsWith() || str.startsWith()) { return str; } } return value; }注意这个方法只对字符串类型做处理数字、日期等类型直接原样返回。我在工具类的 write 环节之前统一对数据行做一遍清洗和表头校验放在一起。这个小函数帮我避免了好几次线上数据问题。5.4 坑四ExcelWriter 的 finish() 漏调用导致文件损坏实际生产环境里如果导出过程中业务代码抛了异常比如数据库查询超时ExcelWriter 的输出流没有被正确关闭用户下载到的就是一个损坏的文件。有人会想我用 try-with-resources 不就行了但 ExcelWriter 实现了 Closeable 接口可以放进 try-with-resources 里不过 EasyExcel 更推荐在 finally 里调用 finish()因为 finish() 不仅仅是关闭流还会做一些收尾工作。以我的经验最稳妥的写法是这样ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(outputStream).build(); // ... 写入逻辑 } finally { if (excelWriter ! null) { excelWriter.finish(); } }如果是在 Web 导出场景异常情况下还需要在 finally 里调用 response.reset()防止已经写出去的部分字节导致浏览器解析错误。这个细节我可以说是踩了三次才长记性。5.5 坑五动态表头模式下日期格式显示成一串数字如果你在数据列表里传的是Date类型默认情况下 EasyExcel 会按内置格式输出但如果传的是 LocalDateTime 或者时间戳字符串显示效果就不可控。我在工具类里没有做强制转换因为要求调用方自己控制数据格式。但为了统一体验我在实际项目中会让调用方把时间类数据先格式化成字符串这样最简单可控也不容易踩明明传了时间却显示成数字的坑。6. 进阶玩法从动态表头走向表头配置化6.1 表头定义搬进 JSON 或 YAML 配置动态表头解决了不用改代码改表头的问题但如果你还是把 headList 的构建逻辑写死在调用方代码里本质上也只是把改注解变成了改 Java 代码。要让表头变更彻底脱离开发得把表头定义搬到配置文件中。比如在 resources 目录下维护一个export-head.json内容大致是{ columns: [ { title: [用户信息, 姓名], dataIndex: name }, { title: [用户信息, 年龄], dataIndex: age }, { title: [行为数据, 订单数], dataIndex: orderCount } ] }运行时通过 Jackson 把这个文件读成配置对象再转换成 EasyExcel 需要的ListListString。数据部分也需要配套改造从写死每列的值变成根据 dataIndex 从 Map 或对象里取对应字段的值这一步可以结合反射或直接把查询结果转成 Map 来做。这样做的好处非常明显运营想加一列只要告诉运维改一下 JSON 文件重启都不需要配合配置中心就能生效。我见过很多大厂的报表系统就是这么做的——前端配置列后端按配置导出两边共用同一套元数据。6.2 表头存数据库做成可维护的元数据比配置文件更进一步的做法是把表头定义存进数据库做成一张导出模板表字段大概包含模板编码、列序号、父表头名称、子表头名称、关联的数据字段名、是否启用等。运营或者管理员可以在后台页面维护这张表增删列、调整顺序都走可视化界面后端导出时根据模板编码查出表头定义再查数据填充。这种方式配合动态表头工具类非常顺手因为工具类已经没有固定结构了天然适配从数据库查出什么表头就导出什么表头的模式。唯一要注意的是缓存问题表头配置不要每次都查数据库可以加一层本地缓存配置变更时再刷新缓存。我实现的时候用的是 Caffeine 本地缓存热点数据基本无感知。6.3 多模板管理同一份数据多个导出视角表头配置化之后很自然就会进化出多模板管理的能力。比如用户数据导出这个场景可以设计用户基础信息模板、用户行为分析模板、运营安全审计模板三个模板它们对应不同的列组合、不同的表头层级。后端导出接口只要多接收一个模板编码参数工具类统一路由到对应模板查配置、取数据、导出。这样做还有一个额外的收益权限控制变得简单了。管理员模板可以看到手机号和身份证号普通运营模板看不到权限校验只需要在返回模板配置之前做一次判断前端导出按钮的可见列随之变化。整个体系扩展起来非常顺畅。从我个人的使用体验来看把表头做成配置、把导出工具类做成通用底座这个方向是对的。它不仅解决了当时运营提的选择性导出需求还让后续几乎所有报表导出需求变成了配置工作而不是编码工作。最后再分享一个建议如果你也在做类似的重构不用一步到位就把配置中心、数据库模板全上了。先把工具类的动态表头能力做好保证它可以通过参数控制表头结构后面再逐步把配置来源从硬编码升级到 JSON、再到数据库每一步都可以独立上线风险可控。这套工具类在我这边已经稳定跑了半年多中间经历了多轮表头调整一行导出代码都没改过这也是我特别想把这段经验记录下来的原因。
