ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

苍穹外卖Excel报表导出实战:数据统计模块从需求到实现

苍穹外卖Excel报表导出实战:数据统计模块从需求到实现 我早就想聊聊苍穹外卖里数据统计模块的Excel报表导出了。这个功能乍一看是真不起眼在需求文档里往往就一行字——“导出运营数据报表”但真正动手做的时候你会发现它背后牵涉业务口径梳理、时间维度聚合、Excel文件组装、前端下载链路、异常兼容处理乱七八糟一堆事。尤其是你如果把这份代码拿到真实项目里去评审光“统计口径”这一点就能被问出好几个版本。我这次就把自己在苍穹外卖项目里落地“数据统计-Excel报表”的完整思路和实操过程捋一遍包含我踩过的坑、填过的参数、最后封装的代码结构。做这套的东西的读者要么是刚做到这个模块的学生要么是准备把项目写进简历的初级开发希望这篇能帮你省掉几天的弯路。1. 需求拆解Excel报表到底要统计什么做报表功能最容易犯的错就是一上来就写代码。先别急Excel导出的核心难点永远不在POI的API而在于你要导出的那批数据“是怎么算出来的”。1.1 报表口径背后的运营语义苍穹外卖里的数据统计通常指的是针对“营业额”、“订单量”、“新增用户”这三个核心运营指标按时间维度做聚合然后以Excel文件的形式交给运营或老板去查看。这几个指标在数据库层面并不是同一个表里现成的字段而是需要从订单表、用户表中“二次加工”出来的。这时候就必须先把口径定义清楚营业额的统计口径通常是“已完成”和“已接单”状态的订单金额总和状态码以3开头比如30、36、38这种。在苍穹外卖这种设计里状态为0是待接单、1是待派送、2是派送中、3是已完成、4是已取消你要导出报表时4字头这种无效订单不能说它也是营业额。按3开头过滤是这套项目里常见且合理的选择。订单量的统计口径订单数量不等于下单数量。在苍穹外卖的实际数据里一次下单对应一条记录一条记录的状态会流转。报表要看的通常是有效订单量所以同样要过滤掉已取消的订单。新增用户数的口径按日去重统计用户表中创建时间落在查询区间内的记录这个相对简单但要注意“用户创建时间”和“用户首次下单时间”是两码事需求如果没说清楚很容易做岔。1.2 时间维度如何选苍穹外卖里常见的报表查询条件有两个今日、近一周、近一月再灵活一点就是自定义区间。做Excel导出时时间维度的处理比页面查询要更谨慎因为Excel表格天生是“按行组织”的你要把统计结果按“天”拆行。比如你查“近30天营业额”报表里的每一行应该代表某一天包含日期、营业额、订单数、新增用户数这几列。如果需求说“按周汇总”那聚合的粒度又要切成周。我个人在项目里推荐的做法是后端接收“开始日期”和“结束日期”遍历这个区间内的每一天查当天的统计数据组装成一个列表。虽然这样会发起多次查询但配合索引和日期范围过滤在数据量不大的阶段完全没有性能压力而且逻辑极其清晰后面你维护这个功能时会感谢自己当初没搞复杂的SQL大聚合。1.3 明确Excel文件的最终形态动手写代码之前先把Excel长什么样定下来。以苍穹外卖报表为例我最终落地的格式是第一行大标题“运营数据报表”合并单元格加粗居中。第二行报表生成时间、查询的时间范围等元信息。第三行表头分别是“日期”、“营业额元”、“订单数单”、“新增用户数人”。从第四行开始按日期顺序排列的数据行。最后一行合计行把营业额、订单数、新增用户数做汇总。注意Excel的单元格你看到的是“日期”但很多初学者会在这一列里填Java的时间对象结果POI写出去后变成一串数字。正确做法是先格式化为“yyyy-MM-dd”字符串再写入或者设置单元格格式为日期类型。这两种都行但字符串最省事、最不容易出乱码。2. 工具选型POI还是EasyExcel市面上做Excel导入导出的Java库不少但在苍穹外卖这个技术栈里主流方案就两个Apache POI和Alibaba EasyExcel。2.1 为什么我选择了Apache POI如果我是在自研项目里做报表导出我大概率会直接上EasyExcel因为它封装度高内存占用优化到位还支持模板填充。但在苍穹外卖这种偏教学和基础训练的项目里我自己更倾向用POI原因有三第一POI是最底层的操作方式Workbook、Sheet、Row、Cell这套模型是通用的Excel操作知识。你学会了POI以后再去看EasyExcel源码或者做复杂的样式设置都不会懵。第二报表数据量很小。苍穹外卖一天最多几百单哪怕导出一个月的数据也就几百行撑死就几千行。这个量级你用POI的XSSFWorkbook写Excel内存完全不是问题。第三企业里很多老项目里用的恰恰就是原生POI尤其是一些报表类需求喜欢对单元格样式、列宽、合并单元格做细粒度控制POI在这方面的自由度最高。2.2 两类方案的核心区别对比维度Apache POIEasyExcel操作模型Workbook/Sheet/Row/Cell贴近Excel底层结构基于事件模型注解解析封装更高上手难度略高需要理解Excel对象模型较低写个实体类加注解就能导出大数据量性能普通模式内存占用较大需开启SXSSFWorkbook流式读写内存控制优秀样式自由度高几乎每个单元格属性都能手动改中常用样式支持复杂样式不如POI灵活适合场景复杂报表、模板要求高的场景常规导入导出、大文件场景提示如果你在真实项目中用了EasyExcel导出报表时用注解的方式确实效率极高但一旦遇到“合计行合并单元格”、“不同列不同宽度”、“表头换行”这类需求你会发现注解配置起来反而不如手写几行POI代码来得直接。2.3 依赖导入的细节苍穹外卖用的Maven工程POI依赖是标准写法dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency提示poi-ooxml会自动引入poi核心包、poi-ooxml-schemas等依赖别去手动引一堆旧版本的poi和poi-ooxml版本不一致的时候会报ClassNotFoundException或者NoSuchMethodError而且这类错误极其隐蔽。3. 核心实现从Controller到Service的完整链路Excel报表导出的代码链路和普通接口不一样它是“查询数据 → 组装Excel → 写入HttpServletResponse输出流”这样一个三段式流程。3.1 Controller层响应流是关键直接上代码这是我项目里的Controller写法RestController RequestMapping(/admin/report) public class ReportController { Autowired private ReportService reportService; GetMapping(/export-excel) public void exportExcel(HttpServletResponse response) throws IOException { try { reportService.exportExcel(response); } catch (Exception e) { log.error(导出报表失败, e); response.setContentType(application/json); response.setCharacterEncoding(utf-8); response.getWriter().write({\code\:500,\msg\:\导出失败\}); } } }这个接口有个明显特征返回值是void因为数据不通过JSON返回而是直接把Excel的二进制流写进response。如果你在方法上加了ResponseBody或者返回R对象那下载就会变成一个带有乱码JSON的损坏文件。3.2 设置响应头弹窗下载就靠它后端写Excel文件到浏览器时前端能不能弹出下载框完全取决于这段响应头设置// 文件名含时间戳 String fileName 运营数据报表_ LocalDate.now() .xlsx; // URL编码处理中文文件名 String encodedFileName URLEncoder.encode(fileName, UTF-8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filename encodedFileName);这里有一个非常老的坑如果你直接网上复制代码可能看到的是“application/vnd.ms-excel”这个MIME类型其实是对的但它是.xls老格式的。我们是XSSFWorkbook生成的.xlsx严格说应该用application/vnd.openxmlformats-officedocument.spreadsheetml.sheet不过实测里两种都能打开。真正要命的是中文文件名。不URL编码的话浏览器里下载时文件名会变成一堆乱码。URL编码后前端拿到response就自带正确文件名了不需要前端自己再拼。3.3 Service层POI操作完整落地这是整套功能的核心我按完整流程贴一段能跑的代码public void exportExcel(HttpServletResponse response) throws IOException { // 1. 确定时间范围这里做的是近一周 LocalDate endDate LocalDate.now(); LocalDate startDate endDate.minusDays(7); // 2. 查询统计数据 ListReportDataVO list getDailyReportData(startDate, endDate); // 3. 创建Excel工作簿 XSSFWorkbook workbook new XSSFWorkbook(); XSSFSheet sheet workbook.createSheet(运营数据); // 4. 设置列宽 sheet.setColumnWidth(0, 20 * 256); sheet.setColumnWidth(1, 20 * 256); sheet.setColumnWidth(2, 16 * 256); sheet.setColumnWidth(3, 20 * 256); // 5. 创建样式 XSSFCellStyle titleStyle workbook.createCellStyle(); Font titleFont workbook.createFont(); titleFont.setBold(true); titleFont.setFontHeightInPoints((short) 16); titleStyle.setFont(titleFont); titleStyle.setAlignment(HorizontalAlignment.CENTER); XSSFCellStyle headerStyle workbook.createCellStyle(); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); headerStyle.setAlignment(HorizontalAlignment.CENTER); headerStyle.setVerticalAlignment(VerticalAlignment.CENTER); XSSFCellStyle dataStyle workbook.createCellStyle(); dataStyle.setAlignment(HorizontalAlignment.CENTER); dataStyle.setVerticalAlignment(VerticalAlignment.CENTER); // 6. 第一行大标题 Row titleRow sheet.createRow(0); titleRow.setHeightInPoints(28); Cell titleCell titleRow.createCell(0); titleCell.setCellValue(startDate 至 endDate 运营数据报表); titleCell.setCellStyle(titleStyle); sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 3)); // 7. 第二行导出时间 Row metaRow sheet.createRow(1); Cell metaCell metaRow.createCell(0); metaCell.setCellValue(导出时间 LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 3)); // 8. 第三行表头 String[] headers {日期, 营业额元, 订单数单, 新增用户数人}; Row headerRow sheet.createRow(2); headerRow.setHeightInPoints(22); for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } // 9. 数据行 int rowIndex 3; BigDecimal totalAmount BigDecimal.ZERO; Integer totalOrders 0; Integer totalUsers 0; for (ReportDataVO data : list) { Row row sheet.createRow(rowIndex); row.setHeightInPoints(18); Cell dateCell row.createCell(0); dateCell.setCellValue(data.getReportDate().toString()); dateCell.setCellStyle(dataStyle); Cell amountCell row.createCell(1); amountCell.setCellValue(data.get turnoverAmount().doubleValue()); amountCell.setCellStyle(dataStyle); totalAmount totalAmount.add(data.getTurnoverAmount()); Cell orderCell row.createCell(2); orderCell.setCellValue(data.getOrderCount()); orderCell.setCellStyle(dataStyle); totalOrders data.getOrderCount(); Cell userCell row.createCell(3); userCell.setCellValue(data.getNewUserCount()); userCell.setCellStyle(dataStyle); totalUsers data.getNewUserCount(); } // 10. 合计行 Row totalRow sheet.createRow(rowIndex); Cell labelCell totalRow.createCell(0); labelCell.setCellValue(合计); labelCell.setCellStyle(headerStyle); Cell totalAmountCell totalRow.createCell(1); totalAmountCell.setCellValue(totalAmount.doubleValue()); totalAmountCell.setCellStyle(headerStyle); Cell totalOrderCell totalRow.createCell(2); totalOrderCell.setCellValue(totalOrders); totalOrderCell.setCellStyle(headerStyle); Cell totalUserCell totalRow.createCell(3); totalUserCell.setCellValue(totalUsers); totalUserCell.setCellStyle(headerStyle); // 11. 写入响应流 workbook.write(response.getOutputStream()); workbook.close(); }这段代码是能跑通的不过有几个地方我特别说明一下单元格数值的精度问题。营业额我用的是BigDecimal写入的时候调doubleValue()转成double读到Excel里显示的是小数位。如果你不希望展示一堆小数点可以在设置值时用setCellValue(amount.divide(BigDecimal.ONE, 2, RoundingMode.HALF_UP).doubleValue())或者直接改单元格的数字格式比如dataStyle.setDataFormat(workbook.createDataFormat().getFormat(0.00))。合并单元格的坑。第6步和第7步都用了addMergedRegion这里有几件事必须注意合并区域不能越界同一行只能合并一次如果后面还要给合并后的单元格设置边框你要在创建CellStyle时就把边框设置好否则显示出来合并区域是没有边框的。3.4 查询统计数据的关键逻辑Service里那个getDailyReportData方法才是业务走向的真正重点。我这里给出核心思路private ListReportDataVO getDailyReportData(LocalDate startDate, LocalDate endDate) { ListReportDataVO list new ArrayList(); for (LocalDate curDate startDate; !curDate.isAfter(endDate); curDate curDate.plusDays(1)) { ReportDataVO vo new ReportDataVO(); LocalDateTime beginTime curDate.atStartOfDay(); LocalDateTime endTime curDate.atTime(LocalTime.MAX); // 营业额状态为3开头的订单金额求和 MapString, Object turnoverMap OrderMapper.selectAmountByStatusAndTime( beginTime, endTime, 3%); BigDecimal turnover turnoverMap null ? BigDecimal.ZERO : (BigDecimal) turnoverMap.get(amount); vo.setTurnoverAmount(turnover); // 订单数同样按状态统计 Integer orderCount OrderMapper.selectCountByStatusAndTime( beginTime, endTime, 3%); vo.setOrderCount(orderCount null ? 0 : orderCount); // 新增用户数按用户创建时间统计 Integer newUserCount UserMapper.selectCountByCreateTime(beginTime, endTime); vo.setNewUserCount(newUserCount null ? 0 : newUserCount); vo.setReportDate(curDate); list.add(vo); } return list; }这段逻辑里有几个可以明显优化的地方循环里每一次都发SQL10天就是10组查询如果时间范围更长性能损耗会变大。但苍穹外卖这个项目的量级完全可以接受。如果你想做性能优化可以一次性查出整个时间范围的数据在Java内存里按天聚合。这种用空间换时间的写法实际落地时会更漂亮。Mapper层我用的注解SQL效果是这样的Select(SELECT COUNT(*) FROM orders WHERE status LIKE #{status} AND order_time BETWEEN #{begin} AND #{end}) Integer selectCountByStatusAndTime(LocalDateTime begin, LocalDateTime end, String status);注意LIKE 3%这种写法在数据量大时用不上索引有SQL性能洁癖的人可能会觉得不舒服。但苍穹外卖的订单状态本身是有限个值你可以改成status IN (30, 36, 38)这种精确匹配效果完全不一样。不过具体有哪些状态码得看你数据库里的枚举是怎么定义的别照抄。4. POI导出过程中的高发问题与排查方法代码写完了但在真实项目里部署运行你会遇到各种各样奇奇怪怪的问题。我把我在这个模块里遇到过的和身边朋友踩过的坑集中列一下你就当是速查手册。4.1 Excel文件打开时报“文件损坏”提示这是最高频的一个问题表现形式是文件下载下来了双击打开却弹出“Excel 在 ‘xxx.xlsx’ 中发现不可读取的内容”。排查思路分三步第一看response.getOutputStream()有没有被提前关闭。很多人会在工具方法内部把workbook.close()写在write之前流一断写出来的文件就是半截的。第二看有没有往输出流里额外写入其他数据。代码里如果写过response.getWriter().write(...)再输出Excel那么两个流混在一起文件结构就坏了。Writer和OutputStream不能同时用这是Servlet规范里的铁律。第三看内存中Workbook是否正确关闭。注意是write之后close顺序反了一样损坏。4.2 下载的文件名乱码或者干脆不弹下载框乱码问题基本都是中文文件名没有URL编码这一点前面说过了。不弹下载框的问题大概率是响应头里少了Content-Disposition或者前端用了mock拦截、ajax下载而不是window.location这个要前后端配合看后端只管把响应头写好。4.3 Excel打开后日期列变成一堆“####”两种情况。第一种是列宽太窄日期显示不全Excel就用####代替。处理方法就是设置足够的列宽比如我在代码里写的sheet.setColumnWidth(0, 20 * 256)20个字符宽度基本够用。第二种是单元格里写入的是数字类型的日期序列值。POI里如果你直接setCellValue(new Date())写入的确实会是Excel能识别的日期序列号但显示格式不一定如你预期。最稳妥的做法是先把LocalDate转成String再写进去或者创建带日期格式的CellStyleCellStyle dateStyle workbook.createCellStyle(); dateStyle.setDataFormat(workbook.createDataFormat().getFormat(yyyy-MM-dd));4.4 大数据量下导出变慢甚至OOM报表功能刚开始都是几百行的但如果哪一天你做了一个“导出全部历史数据”的按钮一次性查几万行再组装Excel用默认的XSSFWorkbook会直接内存溢出。解决方式是换SXSSFWorkbook它是POI里专门为流式导出设计的不把所有Row对象都放在内存里只保留滑动窗口上的行SXSSFWorkbook workbook new SXSSFWorkbook(100); // 只保留最近100行实操项目中如果数据量超过5万行同时还要做样式的话SXSSFWorkbook也有它的短板比如不支持某些单元格操作。这个阶段你已经不是在写苍穹外卖了该考虑的是引入异步导出任务、生成文件后上传文件服务器、再给前端一个文件下载链接的模式。4.5 单个Sheet最大行数限制Excel 2007格式xlsx单个Sheet最多是1048576行、16384列。听着很多对吧但如果你导出的是按秒粒度的数据或者以后接了一堆IoT数据突破这个上限也只是时间问题。苍穹外卖不需要考虑这个但我见过的真实报表项目里确实有人踩过所以我顺手提一句。真遇到的时候你要按时间切片拆多个Sheet文件或者拆多个文件打包成ZIP。5. 代码结构优化与可维护性提升功能做完了、跑通了但在真实项目里这不算完。你写的这段代码后面是要被别人维护的所以代码的组织方式很重要。5.1 把POI操作抽成独立的ExcelUtil在苍穹外卖的ReportService里堆满POI的API调用其实不是一个好方案。我个人的习惯是把Excel相关操作全部抽到一个工具类里Service只负责业务数据组装和调用工具方法。比如public class ExcelUtil { public static void writeReportExcel(HttpServletResponse response, String title, String[] headers, ListString[] dataRows, String fileName) throws IOException { // POI的所有操作都在这里 } }这样做的好处很明显报表的样式调整、列宽改版等UI类改动只需要动工具类业务层完全不受影响。如果你以后要在另一个管理后台里导出同样的表格直接把这个工具方法拿过去用就行不用再写一遍CellStyle。5.2 VO对象的设计我代码里用了ReportDataVO它的定义大致是Data public class ReportDataVO { private LocalDate reportDate; private BigDecimal turnoverAmount; private Integer orderCount; private Integer newUserCount; }这里有个设计细节建议不要把数据库查询的结果对象直接拿来填充Excel。数据库DO里可能带id、status、remark等一堆业务字段直接暴露给报表层会让后续维护的人产生困惑。换一个干净的VO报表层只关心它有哪几个字段真的清爽很多。5.3 导出一律包一层Service方法很多人在写这种管理系统时喜欢直接在Controller里查数据、调POI几百行代码一次性堆完当时觉得很痛快后面想扩展一个“按门店维度导出”就发现Controller里的代码根本没法复用。正确的分层习惯是Controller只做参数接收和响应流处理Service只做业务数据组装和调用导出工具底层不再关心HTTP请求的事。这样即使以后你要把这个导出能力暴露给消息队列、定时任务去调用也完全不需要改逻辑。5.4 补充一点与前端下载链路的配合我一开始也踩过这种坑后端明明把文件流都写好了前端始终不弹下载框。最后发现是前端用了axios去请求这个接口但axios默认是不会处理文件流的拿到的是一个被包装过的blob对象你需要在前端额外用Response头里的文件名做一下拼装或者干脆用window.open直接请求接口地址。在实际项目里报表导出的按钮多半会加一个loading状态因为生成Excel确实需要一点时间你要知道POI写文件这步看起来毫秒级但前面的数据查询如果没优化好可能一卡就是几秒。所以把查询SQL控制在合理范围内比关心POI本身更关键。6. 一点个人心得这个模块我刚做完的时候觉得“无非就是查数据写Excel嘛”但在后面做联调、做演示、改需求的过程中才明白报表类功能真正考验人的地方是业务口径的理解和Excel细节的处理。如果你也是在练习苍穹外卖这个项目我强烈建议你在这个模块上多花一点时间把下面的点都亲手试一遍试着加一个“今日”和“近一月”的切换看一眼时间范围计算到底怎么传参最稳妥。试着给报表加一个“订单平均单价”列你会发现加列很容易但BigDecimal的除法和四舍五入也是一堆细节。试着把导出的Excel用Python的pandas读一遍你会对“单子格内容到底是字符串还是数字”有更直观的体感。我在实际开发过程中还有一个感受就是这类Excel导出功能特别适合作为你熟悉一个业务系统的“切入点”。你不需要理解系统的全部业务但通过梳理“哪些数据要导出、按什么口径统计、按什么维度展示”你很快就能把一个模块的数据库表结构和状态流转逻辑摸清楚。顺带说一句苍穹外卖里的图片上传功能也是一样的原理本地上传图片和导出Excel本质上都是后端接收文件或生成文件、再通过响应流交给前端的过程处理好了这层文件流的逻辑以后做文件下载、导入导出、报表推送都是一通百通的事。这篇内容是我在实现“苍穹外卖-数据统计-Excel报表”时积累的核心经验写到这里基本把从需求拆解到代码落地再到问题排查的过程都覆盖了。你照着这份思路做一遍过程中遇到的具体报错如果不知道怎么处理翻一下第4节的问题清单大部分都能解决。剩下的偏门问题大概率是你本地环境或者POI版本引起的先对齐版本再调试思路会清晰很多。
返回列表