做后端开发这些年,跟 Excel 打交道几乎是躲不掉的日常。无论是导出报表、批量导入还是定时任务生成数据文件,Apache POI 基本上是 Java 生态里绕不开的那个库。但说实话,很多人用 POI 就是停留在“能跑”的程度,样式乱、性能差、转 MultipartFile 的时候踩一堆坑,代码越写越臃肿。今天把我实际项目中常用的 POI 格式设置和一段完整的“生成 Excel → 转 MultipartFile → 上传服务器”的链路写法整理出来,里面有踩过的坑,也有我觉得用着比较顺手的处理方式,给正好卡在这块的朋友做个参考。
1. 方案选型:为什么绕不开 POI
1.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 | .xls(Excel 97-2003) | 老系统兼容、超小文件 | 最大支持 65536 行,表结构老,目前新项目建议淘汰 |
| XSSF | .xlsx(Excel 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(List<OrderDTO> 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(List<OrderDTO> 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 请求体 MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); body.add("file", multipartFile.getResource()); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers); // 4. 发送 POST 请求 ResponseEntity<String> 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设置日期格式 |
| 文件名后缀显示 .xls | MIME 或文件名拼接错误 | 确保文件名以 .xlsx 结尾 |
写在最后
POI 这套东西,写了几年之后最大的感觉就是:它的坑集中在前几周,过了磨合期,后面基本都是重复劳动。你现在最应该做的,就是把我上面这套“样式配置 + 字节流转 MultipartFile + 上传”的代码骨架整理成你们项目自己的工具类,一次投入,后面所有报表导出都能复用。另外我自己每次做这类功能时都有个习惯,就是在上传接口的响应里把文件大小、行数、耗时都打上日志,上线后排查问题方便不少。希望这份整理能让你少走点弯路,尤其是 MultipartFile 包装那一步,真的值得好好收藏。