Ruoyi框架下基于EasyExcel模板的复杂统计报表导出实践
在实际项目里尤其是基于前后端分离架构的管理系统中导出Excel从来不是简单地把表格数据罗列出来就行。真正的麻烦来自那些“统计类”报表表头有层级、行有分组、列有汇总甚至同一个Excel里要放多个sheet每个sheet的样式还不一样。前阵子我就是在一个Ruoyi前后端分离的项目里接了这么个需求财务那边要一份带复杂表头、多级汇总的月度统计报表数据不难查难点全在“怎么把它塞进Excel里还得保持格式基本不跑偏”。折腾了几天最后沉淀下来的方案就是今天要聊的用Excel模板配合Java代码在Ruoyi框架里做复杂统计类文档导出。先把这个方案适合谁、解决什么问题说清楚。如果你只是导出一个简单的数据列表比如用户列表、订单流水直接动态创建表头、一行行写数据就够用犯不上用模板。但如果你要导出的是这样的报表——行首列是地区分组每个分组下面有汇总行列方向上有“本期数”“同期数”“同比增减”这种复杂的横向层级或者表格样式、列宽、页眉页脚要严格和线下发出去的纸质版一致——那就得考虑模板方案。用模板最核心的好处是格式和样式完全由Excel模板控制Java代码只负责填数据彻底摆脱代码里一个单元格一个单元格设置样式的噩梦。1. 整体设计思路与方案选型1.1 为什么是“模板导出”而不是代码动态生成先说结论对于复杂统计报表模板导出是成本最低、可控性最强的路径。代码动态生成比如用POI直接创建Workbook所有表头、合并单元格、列宽、边框都得一行行写。遇到复杂表头比如三层表头的统计月报光处理合并区域就要写上百行代码而且一旦样式要调整就得改代码重新发布。这在实际业务里是灾难——报表这东西需求方隔三差五就会提“表头加一列”“汇总行换个位置”每次都改代码开发很快就会崩溃。模板导出就不一样了。先把一份做好的Excel放在资源目录里模板里预留好数据占位符Java代码读取模板、填充数据、另存为新文件。格式调整在Excel里完成代码几乎不用动。用一个生活化的类比代码动态生成相当于你去餐厅让厨师从零给你做一道菜原料、切法、摆盘全在后厨完成你想换个摆盘得重新下单模板导出则是你带了个便当盒过去告诉服务员哪个格子放什么菜厨师只负责往格子里填。前者灵活但贵后者固定但快报表这种格式相对固定的场景明显该选后者。具体到技术选型我选的是阿里巴巴的EasyExcel。为什么不用Apache POI直接干POI是EasyExcel的底层基础但POI直接操作Excel有两个痛点一是内存占用高一张十万行数据的sheetPOI会把整个工作簿的对象全都加载进内存服务器内存不够的话直接OOM二是API偏底层写起来啰嗦。EasyExcel在POI之上做了封装底层用SAX模式解析写入时也是流式处理内存占用大幅下降。更关键的是EasyExcel原生支持模板填充提供了fill()方法支持List数据逐行填充、单对象填充、模板内嵌样式继承这恰好命中统计类报表的核心诉求。我见到有人在这个场景里坚持用POI硬写最后表头确实画出来了但列宽、行高、自适应这些细节几乎全放弃导出的文件基本是“能看但不好看”。用模板就完全不一样模板长什么样导出来就是什么样。1.2 Ruoyi框架下模板导出的整体链路既然项目是基于Ruoyi前后端分离框架那就要顺着它的现有架构把导出功能嵌进去。Ruoyi的分离版后端是Spring Boot MyBatis前端是Vue Element UI。它的导出功能通常走这样一条链路前端点击导出按钮向后端接口发起GET或POST请求带查询参数后端从数据库查出统计数据组织成回调模型调用EasyExcel写入响应输出流前端拿到二进制流后触发浏览器下载。在Ruoyi里模板文件的存放位置建议放在resources/excel/templates/目录下打包后仍在classpath里运行时可以通过ClassPathResource读取。如果模板需要动态修改放到ruoyi.profile配置的本地目录里也行但大多数场景下模板作为资源打包进jar更省心毕竟报表模板属于程序资产不应该让运维手工去改。前端那侧Ruoyi的request.js封装了axios。导出Excel请求和普通JSON请求有个关键差异响应类型必须是arraybuffer或blob。常规的download方法也可以用但如果你想在导出后拿到文件名从响应头的Content-Disposition里解析或者想统一处理“下载失败”的提示建议单独封装一个导出方法。整体流程并不复杂但有一个很容易被忽略的环节统计数据的组装。复杂统计报表的“复杂”二字一半在表头样式另一半在数据模型。模板填充能解决样式问题但数据如果组织不到一个合理的层级结构填充代码写起来也会很难受。所以设计数据模型时要先拆解模板的填充结构按报表的行列维度去组织数据不是简单查询出来就完事。我在第三节会专门展开这部分。2. 模板设计与填充机制详解2.1 模板里写什么、占位符怎么摆这一步是整个方案的重头戏模板设计得好后面的代码能省一半事。模板用纯Excel做就行WPS也可以但保存格式一定要选.xlsx。.xls老格式在EasyExcel里虽然也支持但处理复杂样式时不如.xlsx稳定而且报表需求方拿到的Excel基本都是新版本Office没必要冒兼容性风险。模板里的占位符分两种情况。单对象数据填充用{name}这种花括号语法比如在表格的某个单元格里写{totalAmount}fill时传入一个对象即可。列表数据填充用{.listField}点开头表示遍历填充常见用法是在模板里预留一行的位置把表头字段名写成{.rowData}fill方法会按列表长度自动向下扩展行——这是模板导出最核心的机制。扩展的时候模板行本身的样式边框、字体、对齐、行高会被保留复制到新行所以模板里这一行必须把最终样式全都设置好。多级分组、小计、合计这类需求模板填充要配合“List填充 手动插入合计行”的组合思路。大部分复杂统计报表的结构是一级分组、每个分组内若干明细行、分组的小计行、整个报表的总计行。EasyXML的fill处理不了“每隔N行插入一个汇总行”这种非规则结构所以现实做法是把明细行做成列表填充的数据区汇总行在模板里就画好但数据在代码里算好后需要在模板里预留多个汇总行位置或者更常见的是代码里手工创建一个Workbook在填充完明细后用POI的API向指定位置插入汇总行。第二种做法更灵活后面专门讲。模板里还要注意一个点数值和样式的预设。比如百分比列、金额列在模板中先把单元格格式设置好——百分比、保留两位小数、千分位分隔符。EasyExcel填充数据时如果模板单元格已经预设了数字格式填充后的数据类型为数字型格式是能继承的。如果你在代码里new了一个CellStyle再塞值反而容易把模板预设格式覆盖掉。我的经验是模板里能预设的都预设代码里只操作数据不碰样式这样逻辑最干净。2.2 填充机制背后的执行原理用EasyExcel做模板填充底层其实分两条路径fill()方法是真正的模板填充它会解析模板中{}占位符按传入的数据对象动态写入单元格write()方法则是纯粹的“创建新表写数据”不读现有模板。两者混用的场景也存在但主流复杂报表几乎全是fill()。fill的机制有几种模式值得注意。FillConfig可以控制填充的方向默认是按行往下填。如果列表不止一个比如一个sheet里有两个列表区域需要用FillConfig的forceNewRows参数或者每次fill前sheet(0)切换Sheet来区别数据区间。还有种用法是“多次fill同一个区域”比如第一遍填充表头第二遍填充明细这要求模板中占位符的处理逻辑必须清晰不然容易出现上一次填充的残留数据顶掉新数据的情况。还有个容易被忽略的点fill模板填充时如果你传入的List为空EasyExcel会直接跳过填充区域模板中那行占位符就会显示成空行或者变成一个空白样式。实际报表里如果当期“无数据”这时候通常希望显示一行“无数据”提示或者所有零值。所以我一般会在代码里判断如果数据为空就放一个包含一条零值记录的List保证模板区域至少有一行再配合条件渲染来处理显示。3. 基于Ruoyi框架的完整落地流程3.1 添加依赖与资源目录准备在Ruoyi后端工程的pom.xml里引入EasyExcel依赖版本号选个稳定版即可比如3.x系列。引入的时候注意一个细节EasyExcel本身依赖POIPOI版本的冲突经常会冒出来比如和项目中已有的POI依赖版本不一致导致NoSuchMethodError。Ruoyi如果集成了其他Excel工具最好统一排除掉旧版POI以EasyExcel传递依赖的版本为准。资源目录准备很简单在src/main/resources下建一个excel/templates文件夹把做好的模板文件放进去。然后需要写一个从classpath读取模板文件的工具方法。Ruoyi里通常有FileUploadUtils之类的工具类但读取classpath资源这种基础操作自己写三行就够了ClassPathResource resource new ClassPathResource(excel/templates/statistics_monthly_template.xlsx); InputStream inputStream resource.getInputStream();这里有一个要提醒的坑如果用Spring的ResourcePatternResolver去匹配classpath*:路径注意不要和工程自身的打包方式冲突。常规ClassPathResource在Spring Boot打包成jar后也能正常读取但若模板文件被包含在依赖jar里用ClassPathResource(excel/templates/xxx.xlsx)会找不到得改用Thread.currentThread().getContextClassLoader().getResourceAsStream()。经验是模板放自身工程的resources里基本不会出问题。3.2 后端导出接口的完整写法在Ruoyi的Controller层写导出接口方法返回类型是void通过HttpServletResponse输出字节流。核心步骤可以拆成四步第一步做权限校验和参数校验。Ruoyi里使用PreAuthorize(ss.hasPermi(statistics:report:export))注解控制访问权限。参数校验同样不能省导出的时间范围为空、类型错误这些情况在Controller层就拦下来不要等查数据了才发现参数不对。第二步查询统计数据。这个逻辑和普通列表接口一样调用Service层传入查询参数返回统计结果。统计结果通常不是一个扁平的List而是嵌套结构比如“每个地区下挂着多个子分类每个子分类还有一行合计”。这地方建议直接用VO类去接收不要用MapMap虽然写着省事但后续在模板填充时取字段全靠字符串key一旦写错一个字母运行期才发现调试成本高。VO的字段名直接对应模板占位符比如totalCount、regionName代码可读性也强。第三步核心的Workbook创建和填充。模板填充用EasyExcel但需要把Workbook拿在手里处理一些模板fill搞不定的操作时可以通过ExcelWriter间接拿到。完整示例代码我放到下面。public void exportMonthlyReport(HttpServletResponse response, ReportQueryVO queryVO) throws IOException { // 1. 查询统计数据 ListRegionStatVO regionStats reportService.fetchRegionStats(queryVO); MapString, Object summary reportService.fetchSummary(queryVO); // 2. 设置响应头 response.setContentType(application/vnd.ms-excel); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(月度统计报表_ queryVO.getMonth(), UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); // 3. 读取模板并执行填充 ClassPathResource templateResource new ClassPathResource(excel/templates/monthly_stat.xlsx); InputStream templateStream templateResource.getInputStream(); EasyExcel.write(response.getOutputStream()) .withTemplate(templateStream) .build() .fill(regionStats, FillConfig.builder().forceNewRows(Boolean.TRUE).build()) .fill(summary) .finish(); }上面代码里用了.fill(regionStats, FillConfig)和.fill(summary)两次填充。第一次填充明细行的列表区域第二次填充总计等单值区域。注意FillConfig.builder().forceNewRows(Boolean.TRUE)这个参数特别关键。如果不设置多层列表填充时第二组数据会尝试在原有的行上做覆盖而不是向下追加新行报表数据容易错乱。经验值只要一个模板里有多个列表填充区或者列表填充区域不是从模板第一行开始的就建议把forceNewRows设为true。第四步是很多人会忽略的response输出流用完要关ExcelWriter用完之后要调finish()。用EasyExcel的链式写法finish方法内部会释放资源但如果用了ExcelWriter对象手动操作就千万记得stream.close()不然并发导出几次之后文件句柄泄漏的坑就来了。3.3 前端下载封装与对接细节Ruoyi前端基于Vue2 axios目录结构里utils/request.js封装了带token、统一错误处理的axios实例。但那个实例默认响应类型是JSON导出Excel需要特殊处理。我在项目里的做法是单独封装一个exportFile方法核心逻辑如下export function exportFile(url, params, filename) { return request({ url: url, method: post, data: params, responseType: blob, timeout: 120000 }).then((res) { const blob new Blob([res], { type: application/vnd.ms-excel }); // 如果后端返回的是JSON说明业务报错 if (res.type.includes(application/json)) { return Promise.reject(new Error(导出失败)); } const link document.createElement(a); link.href URL.createObjectURL(blob); link.download filename; link.click(); URL.revokeObjectURL(link.href); }); }这里面有个细节设置responseType: blob后如果后端代码出异常返回的是一段JSON字符串封装的错误信息不是Excel的二进制流。前端拿到之后如果不判断res.type就会把JSON当Excel下载用户打开就是乱码。所以一定先判断blob类型。在这里我额外加了一层后端把自定义异常信息也以JSON返回前端捕获后提示用户“导出失败查询条件不合法”比让用户下载一个打不开的文件友好得多。文件名那栏后端已经通过Content-Disposition返回前端其实可以从响应头解析。如果嫌解析响应头麻烦也可以像我上面的写法一样用调用方传入的文件名字符串简单直接。4. 复杂统计报表的几个硬骨头4.1 多层表头和动态分组怎么处理前面说的都是常规填充。现在聊真正的硬骨头多级表头、动态列、单元格合并。先说多级表头。如果表头层级完全固定比如“月份”下面挂“销售额”“利润”那模板里直接画好就行代码层面什么都不用做这是模板方案最大的红利。但如果表头本身是动态的——比如列是最近12个月的月份名每个月要在表头右侧追加两列——模板就不好画了。这种场景我的解法是模板只做静态部分动态列头在填充前用POI的API先插入再逐列设置表头文字和样式。Workbook workbook WorkbookFactory.create(templateStream); Sheet sheet workbook.getSheetAt(0); // 在固定位置插入新列 sheet.shiftRows(0, 0, 0, true, false); Row headerRow sheet.getRow(0); for (int i 0; i monthCount * 2; i) { Cell cell headerRow.createCell(7 i); cell.setCellValue(monthName 销售额/利润); // 设置样式... }这种做法的好处是模板继续管静态部分代码只管动态部分各司其职。坏处是动态表头那块的样式不能完全复用模板还得在代码里复制样式。我这里提供一个小技巧在模板中预留一列“样式源”把动态表头需要的模板单元格预先写好样式代码里用CellStyle直接复制。再讲动态分组。统计报表里“按地区分组”“按产品线分组”这种需求太常见了。做法是模板里画好“明细行”和“小计行”两个样板行代码里遍历分组数据填充完一个分组后把“小计行”复制到当前位置填充汇总值然后继续下一个分组。核心API是POI的sheet.copyRows和sheet.shiftRows两个方法。这两个方法在Excel里相当于“复制行”和“插入空行”的组合操作。使用时要注意复制行会把样式一并复制过来这样小计行的样式和模板一致shiftRows会把目标区域的行往下移动腾出位置。实际写出来也不算太复杂但顺序不能乱——必须先挪出空位再复制样板行再填值。先复制后移动会把刚复制好的行又顶出去。这里还有细节分组小计行需要合并单元格比如“本组合计”要横跨第一列到第三列。合并单元格的Region在复制样式后要重新创建因为POI里的合并区域不会自动跟随行偏移。建议把合并操作统一封装成一个方法传入起始行、终止行、起始列、终止列内部调用addMergedRegion。4.2 多个Sheet和合计行的完整示例多Sheet的复杂统计报表很常见比如“总表 区域明细表”。EasyExcel的ExcelWriter原生支持多Sheet操作ExcelWriter writer EasyExcel.write(response.getOutputStream()) .withTemplate(templateStream) .build(); WriteSheet sheet1 EasyExcel.writerSheet(0, 总览).build(); WriteSheet sheet2 EasyExcel.writerSheet(1, 区域明细).build(); writer.fill(totalData, sheet1); writer.fill(regionDetails, sheet2); writer.finish();但注意一个大坑withTemplate的模板里如果只有一个Sheetwriter.fill(..., sheet1)填充第一种Sheet如果再对sheet2操作时模板实际上没有Sheet2得先用POI在模板里建好。所以多Sheet模板导出最稳的做法是模板文件本身就包含多个Sheet每个Sheet画好各自的表头和占位符代码只需分别操作。模板里有多个Sheet时FillConfig.forceNewRows的用法也要相应调整不同Sheet的数据是独立的不会互相影响所以只要每个Sheet内部的数据区域不重叠就行。合计行的实现我在第一节埋了个伏笔现在说清楚。多数统计报表的合计行分为小计行和总计行。小计行可以在模板里预留多个位置——前提是分组数量固定分组数量不固定就必须用动态插入。我建议统一走动态插入路线这样模板只需要建一个“小计行样板”放在数据区下方或者旁边不要直接放在数据区中段。代码逻辑是填充完一组明细把样板行复制到数据区末尾写入合计值再开始下一组。等到所有分组都处理完最后再在整表末尾插入总合计行。动态插入行时要注意行高。设置的数值如果和模板行不一致会出现“合计行显得很高/很挤”的问题。所以在复制样板行时先用newRow.setHeight(模板行.getHeightInPoints())把行高带过来保证视觉一致。字体颜色也可以做区分明细行黑色合计行蓝色加粗这样用户看不懂报表时找合计算账也方便。4.3 大数据量下的导出性能优化统计类报表虽然不如明细流水数据量那么大但几十万行还是有的。EasyExcel的优势这时就体现出来了底层流式写入不把所有行对象常驻内存。但要注意几个性能细节第一模板填充模式下forceNewRowstrue时每填充一行就要进行一次行偏移操作大数据量下这个操作有性能损耗。如果模板的数据区就是从第3行开始且没有复杂的多列表填充不需要开forceNewRows性能能快不少。第二统计查询层面的优化。导出报表时后端一般会先到数据库里执行汇总SQL。SQL里的GROUP BY和ORDER BY会影响数据顺序导致Java侧拿到数据后还要做一次内存排序才能匹配模板的分组结构。如果查询已经按分组字段排好序Java代码可以直接顺序遍历组装避免额外的内存排序。这个在小数据量下无所谓几十万行时就明显了。第三万不得已才用的分页导出。如果导出行数实在太多比如单Sheet超过50万行Excel本身也会变得卡顿。此时宁可拆成多个Sheet每个Sheet几万行也不要让一个Sheet撑爆内存。拆分维度可以是日期、地区、部门并且在多个Sheet里都保留合计行这样用户在大文件里CtrlF搜索时也能较快定位。4.4 样式、列宽、固定表头的细节模板方案一个经常被忽视的收益模板里设置好的列宽、筛选器、冻结窗口填充数据后都能保留。表头行设置冻结窗口用户往下翻看几十上百行数据时表头始终还在这在统计报表体验上是质的提升。需要注意的一个细节是EasyExcel填充时如果数据内容比模板单元格预设的宽度长出很多列宽不会自动扩展。统计报表里单元格内容一般是长度可控的数值、日期影响不大。但如果有字符串字段比如“机构名称”特别长导出后发现被截断显示处理办法有二代码里动态设置列宽或者模板里把列宽容许值调大到够用。两种我都试过更推荐第二种——模板方案最大的优点就是样式交给模板管代码里写列宽逻辑等于又把复杂度搬回代码了。除非你是做无限动态列的报表列宽才不得不在代码里算。另外我习惯在导出接口里给响应头加Cache-Control: no-cache防止部分浏览器缓存了旧的导出结果。这个细节很容易忽略但用户反馈“导出的文件没变”时十有八九就是缓存问题。5. 常见问题与排查技巧实录5.1 模板填充数据错位或样式丢失这是模板导出最常见的故障之一。数据错位通常是模板中占位符所在行的位置和实际填充列不对应。排查思路先打开模板文件对照XML看每个单元格文本里占位符的写法是否和代码里传入的字段名完全一致。注意大小写敏感比如代码里是totalCount模板里写成了TotalCountfill时匹配不上那个单元格就保持原样。样式丢失分两种情况一是模板行设置的“边框”在填充后不见了这通常是因为EasyExcel在填充时对目标区域做了重写旧单元格的样式被覆盖了。解决办法是把占位符写在模板行的“内容单元格”里且这一行的样式不要只依赖“整行设置”要逐单元格设置边框和字体——尤其是字体加粗、颜色设置要加在单元格上不要仅加在行上。二是复制行后样式丢失原因是POI的copyRows有局限性它复制行不能带列宽和部分样式属性。遇到这种可以把模板里样板行下方预留2-3行空行复制时同时复制三行减少样式丢失概率。5.2 文件下载打不开或打开提示损坏排查步骤分两层。第一层后端返回的流是否完整。很多人的接口是Controller里直接response.getOutputStream()写数据但方法执行完响应流没有flush就返回了少量数据文件看着没问题数据量大时文件会缺尾巴。正确做法是写完数据后在finally块里out.flush()并且用ExcelWriter.finish()确保所有数据都已写入。第二层前端接收的二进制流是否被JSON格式的异常响应污染了。前面3.3节提过后端异常时返回JSON前端拿到后直接当blob下载导致文件损坏。排查时先看抓包工具Network里的Response如果Content-Type是application/json那一定是后端报错了把后端日志翻出来看真正的异常是什么。我排查过一个case最后发现是SQL里查统计字段时出现了数值溢出后端直接500前端拿到的JSON错了还看不出来特别误导人。5.3 动态插入行后的公式、合并区域错乱统计报表的合计行有时候不是纯数值而是Excel公式比如SUM(D5:D10)。动态插入行时模板里样板的公式引用单元格地址并不会自动改。如果插入行把原来的引用区域顶开了公式结果就错了。我的方案是代码里填充完成所有行之后最后用POI遍历合计行的单元格按实际数据区域重新写公式。虽然模板里写公式好看但动态行插入场景下公式还是代码生成更可靠。合并区域错乱则是动态插入行的另一个连锁反应。凡是复制行或shiftRows后面的合并区域不会自动跟着变。我的做法是把所有需要合并的区域集中到最后处理而且每次合并前先检查合并区域是否和已有的区域冲突防止IllegalArgumentException。这个报错经常在报表行数多、分组数量多的时候冒出来建议统一捕获打日志时把数据清单的行号、分组信息带上不然排查起来很痛苦。5.4 并发导出的内存和句柄问题Ruoyi系统多半是多用户同时在线的。如果导出接口做得粗糙——每次请求都new一个大报表的Workbook且不释放——一旦并发上来服务器直接内存告急。我的实践是用try-with-resources管理模板输入流和输出流确保异常时也会释放IO。大数据量导出时先估算行数超过阈值比如单Sheet超10万行直接返回提示让用户缩小查询范围。统计报表本来也不该一次导出无限量的数据合理限制条件反而是对用户好。如果单个导出任务真的很重考虑做异步导出先把文件生成到服务器本地目录生成完通知前端下载。Ruoyi本身有异步任务管理的机制可以用Async注解开个独立线程池执行导出。这个方案更加稳妥但代码复杂度会高一些适合报表量特别大的企业级场景。还有个小坑如果是Linux服务器导出文件生成后要记得用FileSystemUtils.deleteRecursively或者临时文件保存到系统java.io.tmpdir不要直接扔在用户目录下时间长了磁盘被撑爆这种故障处理起来又麻烦又尴尬。6. 模板方案的项目实践总结这套方案在我手头这个Ruoyi项目里落地之后一个最直观的感受是报表需求后续的改动成本大幅降低。第一次接入花了两天主要是调模板样式和数据填充的对齐关系。后续财务那边提“合计行加个‘上年同期’列”“分组下面加子小计”基本只需要改模板、改查询SQL、加一两个VO字段再也不用回到代码里挪单元格了。我在实际使用中还有一个体会做这种复杂统计报表时最关键的工作往往不是写代码而是和需求方把“表长什么样子”彻底对齐。模板方案给了双方一个共同语言——你直接把模板Excel发过去让需求方在模板上标注哪里该放什么数据比对着文字需求猜要高效得多甚至可以让对方直接在模板里填一版假数据开发照着填充逻辑一次性就能写对。最后分享一个小技巧模板文件的版本管理要纳进代码仓库。很多人习惯用Windows画完模板直接拷贝到服务器时间一长模板更新了几版代码库里还是旧版部署时覆盖一下线上报表格式就乱了。模板也是代码资产放Git里、打tag跟着每次需求变更走这个“看不见的文件”才不会在某个凌晨的大版本发布时给你来个措手不及。这套“模板导出复杂统计报表”的方案不只是针对Ruoyi框架换成其他任何Spring Boot体系的前后端分离项目都能直接套用。把样式留在模板里把数据交给代码中间的动态操作控制在一个相对小的范围内——报表导出这件事就能从让人头疼的脏活变成可维护、可交付的常规需求。