资讯动态

Apache POI实战:Excel样式设置与生成Excel转MultipartFile上传链路解析

发布时间:2026/10/24 5:03:54 来源:尧图企业网站定制
做后端开发这些年跟 Excel 打交道几乎是躲不掉的日常。无论是导出报表、批量导入还是定时任务生成数据文件Apache POI 基本上是 Java 生态里绕不开的那个库。但说实话很多人用 POI 就是停留在“能跑”的程度样式乱、性能差、转 MultipartFile 的时候踩一堆坑代码越写越臃肿。今天把我实际项目中常用的 POI 格式设置和一段完整的“生成 Excel → 转 MultipartFile → 上传服务器”的链路写法整理出来里面有踩过的坑也有我觉得用着比较顺手的处理方式给正好卡在这块的朋友做个参考。1. 方案选型为什么绕不开 POI1.1 POI 的核心对象模型想用好 POI第一步不是急着写代码而是先搞清楚它的对象模型本质上就是一个层级嵌套结构和 Excel 文件的物理结构一一对应。一个 .xlsx 文件从上到下是Workbook工作簿→Sheet工作表→Row行→Cell单元格。对应到 POI 的类就是XSSFWorkbook对应工作簿管理所有工作表一个对象就代表整个 Excel 文件。XSSFSheet对应工作表也就是一个 .xlsx 文件底部的那些 Tab。XSSFRow对应一行行号从 0 开始计数。XSSFCell对应一个单元格通过getCell()拿到需要指定列索引。理解了这层关系后面写代码就是顺着这个结构一层层往下走。创建单元格、填充数据、设置样式全都是在这个模型上操作。很多新手上来就找“一次生成整个表”的 API其实没有这种东西POI 的设计就是让你用面向对象的方式逐步构建文件内容。1.2 常用版本差异HSSF / XSSF / SXSSF 怎么选POI 针对不同历史版本提供三套核心 API选型直接影响性能和兼容性这块建议提前定好。版本对应文件格式适用场景注意事项HSSF.xlsExcel 97-2003老系统兼容、超小文件最大支持 65536 行表结构老目前新项目建议淘汰XSSF.xlsxExcel 2007常规报表导出、数据量中等支持 1048576 行支持 OOXML 全部特性SXSSF.xlsx流式写入大文件导出10万行以上牺牲了部分样式 API 和随机读取能力只能追加写入平时导报表、做模板基本用XSSFWorkbook就够了。SXSSF 只有在数据量特别大时才需要考虑而且样式支持有限合并单元格也有限制别动不动就上 SXSSF增加复杂度不说有些功能还反而不能用。还有个容易忽略的点如果项目里同时存在 .xls 和 .xlsx 两种格式的需求我习惯给工具类增加一个简单的格式判断文件扩展名或者文件流前几字节识别出来然后分别用 HSSFWorkbook 或 XSSFWorkbook 去加载避免用错了构造器导致文件全乱。2. 常用的 POI 格式设置把样式这一关啃下来样式设置是很多人的痛点网上资料零零散散复制过来要么编译不过要么样式不生效。我把自己常用的几个场景整理成一套固定的写法用的时候直接套基本不会出问题。2.1 字体、对齐、边框这些基础配置POI 里字体用XSSFFont对齐用XSSFCellStyle边框也在XSSFCellStyle上设置。给你一个我整理过的“标准报表表头样式模板”。package com.example.excel.util; import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFFont; import org.apache.poi.xssf.usermodel.XSSFWorkbook; /** * Excel 样式工具统一管理表头、正文、高亮等格式 */ public class ExcelStyleUtil { private ExcelStyleUtil() {} public static final String HEADER_FONT_NAME 微软雅黑; public static final short HEADER_FONT_SIZE 11; public static final short HEADER_BG_COLOR IndexedColors.GREY_25_PERCENT.getIndex(); /** * 创建表头样式灰底、粗体、居中、全边框 */ public static XSSFCellStyle createHeaderStyle(XSSFWorkbook workbook) { XSSFCellStyle style workbook.createCellStyle(); // 字体 XSSFFont font workbook.createFont(); font.setFontName(HEADER_FONT_NAME); font.setFontHeightInPoints(HEADER_FONT_SIZE); font.setBold(true); font.setColor(IndexedColors.BLACK.index); style.setFont(font); // 对齐 style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); // 边框 style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); // 背景色 style.setFillForegroundColor(HEADER_BG_COLOR); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); // 自动换行 style.setWrapText(true); return style; } /** * 创建正文样式细边框、居中、可换行 */ public static XSSFCellStyle createBodyStyle(XSSFWorkbook workbook) { XSSFCellStyle style workbook.createCellStyle(); XSSFFont font workbook.createFont(); font.setFontName(HEADER_FONT_NAME); font.setFontHeightInPoints((short) 10); style.setFont(font); style.setAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); style.setWrapText(true); return style; } }写代码的时候有个细节很容易被忽略workbook.createFont()必须在创建cellStyle之后调用顺序反了在某些 POI 版本下字体对象创建不出来。另外建议全局复用Font和CellStyle别每设置一个单元格就创建一次新样式不然生成的文件体积会膨胀好几倍打开也慢。2.2 单元格背景色与颜色坑背景色是很多人卡壳的地方。设置背景颜色核心是两个方法配合使用style.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND);关键点在于setFillPattern不能缺。只设置setFillForegroundColor不设置填充模式的话颜色是不会显示的。我刚用 POI 那会儿就吃过这个亏纠结了半天以为是颜色代码写错了。如果主题色不在预定义的IndexedColors里需要自定义颜色。POI 的 XSSF 版本支持 ARGB 格式比如XSSFCellStyle style workbook.createCellStyle(); style.setFillForegroundColor(new XSSFColor(new java.awt.Color(230, 242, 255), new DefaultIndexedColorMap())); style.setFillPattern(FillPatternType.SOLID_FOREGROUND);注意这里要用java.awt.Color构造时传入 RGB 值并且new DefaultIndexedColorMap()是必要的参数否则某些版本会报空指针。2.3 合并单元格与列宽行高合并单元格用Sheet.addMergedRegion这个操作在导出多行表头、分组标题时很实用。// 合并第一行 第0列到第5列 sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 5)); // 合并后要记得设置行高和边框否则显示样式会缺角注意合并单元格之后原来区域里只有左上角那个单元格可以写入内容其他格子会被覆盖。另外合并后边框样式会错位需要手动把合并区域每一个格子都设置边框这是个容易踩的经典坑。推荐封装一个工具方法传入合并区域循环给每个单元格设置样式然后再调合并。列宽行高方面POI 的列宽单位比较反人类既不是像素也不是字符数。实际换算经验列宽值可以用字符数乘以 256 来估算中文文本要求更宽一般按 3-5 个字符的宽度余量来设置。sheet.setColumnWidth(0, 20 * 256); // 第一列20个字符宽 sheet.setColumnWidth(1, 30 * 256); // 第二列 sheet.setDefaultRowHeight((short) 20); // 默认行高磅 sheet.getRow(0).setHeight((short) 600); // 表头行高设高一些显得大气行高的单位是磅point一般表头行不到 20 就已经很紧凑了导大段中文时建议把自动换行打开让 Excel 自己撑高行高。但如果用的是 SXSSFWorkbook行高自动撑高在大数据量场景下不可靠还是自己算好行高更稳妥。2.4 常用格式的设置速查表我把常用的格式设置归纳成一个速查表方便开发时快速查看需求关键 API备注字体workbook.createFont()后style.setFont(font)字体在 Excel 中必须提前定义不能直接给字符串加粗font.setBold(true)或者font.setBoldweight(XSSFFont.BOLDWEIGHT_BOLD)推荐前者颜色font.setColor(IndexedColors.RED.index)自定义用new XSSFColor(...)背景色style.setFillForegroundColor(...)style.setFillPattern(...)两个方法都必须调用边框style.setBorderTop/Bottom/Left/Right(...)四边独立设置别漏边对齐style.setAlignment(...)/style.setVerticalAlignment(...)水平垂直是比较稳妥的做法冻结窗格sheet.createFreezePane(0, 1)冻结首行浏览大数据时很实用自动筛选sheet.setAutoFilter(CellRangeAddress.valueOf(A1:H1))注意如果筛选区域和数据区域不一致会报警告单元格类型cell.setCellType(CellType.STRING)数字、日期、布尔需显式设置类型日期格式style.setDataFormat(workbook.createDataFormat().getFormat(yyyy-MM-dd))必须搭配cell.setCellValue(LocalDateTime)使用才有效3. 生成 Excel 并转 MultipartFile完整实操3.1 构建数据并写入 Workbook理论上这是一道必答题从数据库或者外部接口拿到数据列表然后逐行写入 Excel。我习惯的做法是多列数据的导出用循环来填充行核心逻辑是“行头部数据体”。下面是一个典型示例把订单列表直接导成 Excel然后用ByteArrayOutputStream输出不落磁盘package com.example.excel.service; import com.example.excel.entity.OrderDTO; import com.example.excel.util.ExcelStyleUtil; import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.xssf.usermodel.XSSFCell; import org.apache.poi.xssf.usermodel.XSSFRow; import org.apache.poi.xssf.usermodel.XSSFSheet; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.List; public class ExcelExportService { /** * 生成订单Excel返回字节数组 */ public byte[] buildOrderExcel(ListOrderDTO orderList) throws IOException { // 1. 创建 Workbook try (XSSFWorkbook workbook new XSSFWorkbook(); ByteArrayOutputStream out new ByteArrayOutputStream()) { // 2. 创建 Sheet XSSFSheet sheet workbook.createSheet(订单数据); sheet.setColumnWidth(0, 12 * 256); sheet.setColumnWidth(1, 20 * 256); sheet.setColumnWidth(2, 16 * 256); sheet.setColumnWidth(3, 14 * 256); sheet.setColumnWidth(4, 20 * 256); // 3. 表头 String[] headers {订单编号, 客户名称, 订单金额, 订单状态, 创建时间}; XSSFRow headerRow sheet.createRow(0); headerRow.setHeight((short) 400); for (int i 0; i headers.length; i) { XSSFCell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(ExcelStyleUtil.createHeaderStyle(workbook)); } // 4. 数据行 if (orderList ! null) { for (int i 0; i orderList.size(); i) { OrderDTO order orderList.get(i); XSSFRow row sheet.createRow(i 1); row.createCell(0).setCellValue(order.getOrderId()); row.createCell(1).setCellValue(order.getCustomerName()); row.createCell(2).setCellValue(order.getAmount()); row.createCell(3).setCellValue(order.getStatus()); row.createCell(4).setCellValue(order.getCreateTime() null ? : order.getCreateTime().toString()); // 可以按行设置样式 for (int j 0; j 5; j) { row.getCell(j).setCellStyle(ExcelStyleUtil.createBodyStyle(workbook)); } } } // 5. 冻结首行方便查看 sheet.createFreezePane(0, 1); // 6. 写出到字节数组 workbook.write(out); return out.toByteArray(); } } }有两点要注意第一try-with-resources很香能帮你自动关闭Workbook和输出流避免内存泄漏。第二Workbook.write(out)和out.toByteArray()的调用顺序别反了必须先write再取字节否则拿到的数组是空的。3.2 输出方式选择文件还是字节流很多教程会让你把 Excel 先写到磁盘再上传这个思路本身没错但真在生产项目里频繁在服务器上落地临时文件会带来不少隐患。输出方式优点缺点字节流推荐内存中完成不落盘无临时文件清理成本大文件时内存占用高临时文件大文件场景内存压力小需要手动删除临时文件容易遗漏存在安全风险两者最佳实践是不同的。常规报表几百 KB 到几 MB直接用字节流就完事了如果明确数据量很大几十万行那就老老实实落临时文件上传完finally删除还要做服务器定期清理兜底。我的个人习惯是字节流转成 MultipartFile 的方案简单直接异常链路短在绝大多数需求里反而是最稳的。比起临时的磁盘文件不用考虑删除策略省心不少。3.3 MultipartFile 怎么来三种实现方式MultipartFile是 Spring MVC 里做文件上传的标准接口但我们自己生成的 Excel 并没有走 Spring 的文件解析流程所以需要手动构造一个MultipartFile实现。实际开发中有三种做法方式一MockMultipartFile最常用org.springframework.mock.web.MockMultipartFile是一个现成的实现类虽然设计初衷是测试用的但因为MultipartFile接口实现起来太麻烦很多人直接在生产代码里用它实测可用不算最佳实践但胜在简单。MultipartFile multipartFile new MockMultipartFile( file, order_ System.currentTimeMillis() .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, excelBytes );第一个参数是表单字段名一般写file要和接收端的RequestParam(file)对上。第二个参数是文件名要注意后缀必须是.xlsx而不是.xls否则拿到文件的系统可能识别失败。方式二CommonsMultipartFile兼容老代码如果你的项目用的还是 Spring 4 之前的写法或者依赖了commons-fileupload可以用CommonsMultipartFile。它内部构造需要一个FileItem相对麻烦一些一般老项目才需要为了兼容旧接口我偶尔也会用它。FileItem fileItem new DiskFileItem(file, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, false, order.xlsx, (int) excelBytes.length, null); fileItem.getOutputStream().write(excelBytes); MultipartFile multipartFile new CommonsMultipartFile(fileItem);这里DiskFileItem的构造参数有个容易踩的坑第四个参数是文件名第五个是预览大小如果传 0 或者传错类型上传后文件名和 MIME 类型都可能不对。方式三手写实现类正规做法、推荐生产使用手写一个MultipartFile实现类看起来代码多一点但最干净完全不依赖测试包也没有历史包袱。只需要实现 12 个抽象方法核心就是重写getBytes()和getInputStream()其他方法按需求给出默认值即可。package com.example.excel.support; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; /** * 字节数组包装的 MultipartFile用于文件流上传场景 */ public class ByteArrayMultipartFile implements MultipartFile { private final String name; private final String originalFilename; private final String contentType; private final byte[] bytes; public ByteArrayMultipartFile(String name, String originalFilename, String contentType, byte[] bytes) { this.name name; this.originalFilename originalFilename; this.contentType contentType; this.bytes bytes; } Override public String getName() { return name; } Override public String getOriginalFilename() { return originalFilename; } Override public String getContentType() { return contentType; } Override public boolean isEmpty() { return bytes null || bytes.length 0; } Override public long getSize() { return bytes null ? 0 : bytes.length; } Override public byte[] getBytes() throws IOException { return bytes; } Override public InputStream getInputStream() throws IOException { return new ByteArrayInputStream(bytes); } Override public void transferTo(File dest) throws IOException, IllegalStateException { java.nio.file.Files.write(dest.toPath(), bytes); } }三种方式我都用过。日常快速交付我确实直接MockMultipartFile一把梭但做基础工具组件建议还是手写实现类在工程规范上更严谨也避免了生产环境 mock 包的争议。3.4 上传到服务器的完整实现把前面几块串起来完整的链路就是先查数据再写 Excel然后封装成MultipartFile最后调用上传接口。下面是完整的服务类已按真实项目风格整理。package com.example.excel.service; import com.example.excel.entity.OrderDTO; import com.example.excel.util.ByteArrayMultipartFile; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.client.RestTemplate; import org.springframework.http.*; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import java.util.List; Service public class FileUploadService { private final RestTemplate restTemplate new RestTemplate(); private final ExcelExportService excelExportService new ExcelExportService(); /** * 生成Excel并通过multipart方式上传到目标服务器 */ public boolean uploadOrderExcel(ListOrderDTO orderList, String uploadUrl) { try { // 1. 生成Excel字节数组 byte[] excelBytes excelExportService.buildOrderExcel(orderList); // 2. 构造成 MultipartFile String fileName order_ System.currentTimeMillis() .xlsx; MultipartFile multipartFile new ByteArrayMultipartFile( file, fileName, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, excelBytes ); // 3. 组装 multipart 请求体 MultiValueMapString, Object body new LinkedMultiValueMap(); body.add(file, multipartFile.getResource()); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntityMultiValueMapString, Object requestEntity new HttpEntity(body, headers); // 4. 发送 POST 请求 ResponseEntityString response restTemplate.exchange( uploadUrl, HttpMethod.POST, requestEntity, String.class ); return response.getStatusCode() HttpStatus.OK; } catch (Exception e) { // 记得记日志 return false; } } }这里有个很容易踩的坑第三步body.add(file, multipartFile.getResource())是常规做法但getResource()在MockMultipartFile和ByteArrayMultipartFile上返回的 Resource 处理略有不同。如果后续要附带更多业务字段比如单据号、文件类型可以通过body.add(xxx, value)继续追加。如果目标服务器是 Spring 的MultipartFile接收字段名file就必须要对应。如果需要用Feign或者WebClient等其他 HTTP 客户端唯一不变的组件就是那个MultipartFile对象换客户端只换发送层代码即可。这也是为什么我说“生成 Excel → 包装成 MultipartFile”这一步很重要它是所有上传方案共用的底层结构。4. 常见问题与排查技巧实录POI 这玩意说难不算难但坑是真的多。我把自己实际踩过的和帮别人排查过的几类典型问题整理了一下你遇到的百分之九十的场景都能在下面找到答案。4.1 样式不生效的几类典型原因场景一背景色没显示代码里设置了setFillForegroundColor但背景色没有任何变化大概率是setFillPattern没调用。颜色填充和填充模式是绑定关系缺一个都不触发。场景二字体没变化createFont()之后设置了加粗但 Excel 里看着并没有加粗有可能是单元格本身已经有样式覆盖了。POI 的创建顺序是“设置字体 → 设置样式 → 再设置单元格”后一次覆盖前一次是常态。数据行循环里如果每个单元格都新建一个新样式性能差且很容易复用错对象。场景三边框只有部分显示合并单元格的情况下如果只是给合并区域左上角设置边框边上和内部不会有边框。解决方法是遍历合并区域的每个 Cell 都设置边框或者封装一个setRegionBorder方法。4.2 上传后 Excel 打不开怎么办这是最让人崩溃的问题文件上传成功服务器反馈正常但下载下来打开提示“文件已损坏”。最常见的原因是 MIME 类型或者扩展名不匹配。.xlsx文件的正确 MIME 是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet不是application/octet-stream。但也别盲目信这个因为很多网上的老博客用application/vnd.ms-excel这是 .xls 的 MIME混着用就会出问题。第二个常见原因是字节数对不上写了内容但没调workbook.write(out)就直接toByteArray()拿到的字节数组是空数组上传后自然是损坏文件。第三个原因是文件流没有关闭Workbook占用文件句柄在 Windows 服务器上尤其容易触发“文件被占用”的报错。Java 7 一定要用try-with-resources。4.3 内存溢出问题大文件场景POI 生成 Excel 时内存溢出也是很多团队踩过的坑。大量样式对象堆积、大批数据驻留内存都可能触发OutOfMemoryError。我的处理经验分三层样式复用同一个工作簿中尽量复用同一个CellStyle对象而不是每行每列都新建。表头、正文、高亮、日期这四类样式通常就够用了。数据分批写入一次从数据库查十万条记录放到内存里再写 Excel再配合 XSSFWorkbook 基本必爆。用MyBatis的流式查询或者分页查写一批丢一批。大数据量上 SXSSF十万行以上的导出用SXSSFWorkbook(100)设置窗口大小 100 行每写 100 行就会把前面的数据刷到磁盘内存占用一下就降下来了。代价是合并单元格、部分样式 API 不可用且生成的临时文件要注意清理。4.4 问题排查速查表结合我上面的经验整理成一张速查表遇到问题对号入座异常现象可能原因排查思路背景色不显示setFillPattern未调用补setFillPattern(SOLID_FOREGROUND)字体没生效样式被后续覆盖检查创建顺序确保样式设置在写入单元格之前合并后边框缺失合并区域内每个 Cell 未设边框遍历区域所有 Cell 设置统一边框文件损坏打不开扩展名与内容格式不一致检查 .xlsx 扩展名和 MIME 是否对应文件内容空没有 write 就取 bytes确认workbook.write(out)后再toByteArray()上传后服务器收不到文件字段名和接收端不一致检查RequestParam(file)和body.add(file...)数据量大就 OOM样式对象过多/数据一次查太多样式复用 分批写入 SXSSF日期显示成数字setCellType未设为日期格式配合DataFormat设置日期格式文件名后缀显示 .xlsMIME 或文件名拼接错误确保文件名以 .xlsx 结尾写在最后POI 这套东西写了几年之后最大的感觉就是它的坑集中在前几周过了磨合期后面基本都是重复劳动。你现在最应该做的就是把我上面这套“样式配置 字节流转 MultipartFile 上传”的代码骨架整理成你们项目自己的工具类一次投入后面所有报表导出都能复用。另外我自己每次做这类功能时都有个习惯就是在上传接口的响应里把文件大小、行数、耗时都打上日志上线后排查问题方便不少。希望这份整理能让你少走点弯路尤其是 MultipartFile 包装那一步真的值得好好收藏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑