简介:这是一个用于 Spring Boot 项目的 Excel 导入导出代码示例,主要面向需要实现 Excel 文件读写功能的 Java 后端开发者,重点解决两类问题:一是解析 Excel 并把数据写入数据库,二是从数据库读取记录并导出为 Excel 文件,可覆盖日常报表导入导出场景。资源包共 22 个文件,整体大小约 40KB,主体是 10 个 Java 源码文件,用来完成文件解析、数据入库与导出生成逻辑;同时提供 2 个 xlsx 测试样例、1 个 SQL 建表脚本、1 个 Postman 接口调试集合,以及 yml、properties、xml 等配置文件,分别用于数据库连接、Maven 构建和项目环境参数;另有 mvnw 与 mvnw.cmd 等辅助命令文件,结构比较完整。使用前可按 README 的说明导入 Postman 集合与 SQL,再修改 application-local.yml 中的数据库地址、用户名和密码,配合 Maven 启动项目即可用 Postman 请求验证全流程;样例数据文件和详细的接口示例也包含在内,便于直接动手复现。目前已有 7582 人学习浏览,适合刚接触 Spring Boot、希望快速掌握 Excel 解析入库与导出操作的初中级后端开发者参考。
1. 解析Excel入库再导出:SpringBoot最常见的导入导出闭环
在企业后台系统里,涉及Excel的操作跑不出这三件事:导入、导出、把读到的数据落库。手头这个SpringBoot项目如果正好有这类需求,我建议别用CSV拼接再解析文本的方式,直接用Apache POI做一套导入导出闭环:接收上传文件、解析Workbook、校验单元格、批量写入数据库,再从库里把数据查出来填进Excel流式输出给浏览器下载。下面这套代码和踩坑记录,是把这两个最常见的动作完整走一遍之后再整理出来的,新手照着能跑通,熟手可以直接对照参数看边界。
2. 选型与项目准备:为什么是POI,依赖和表结构怎么定
在SpringBoot里做Excel导入导出,可选项不算少。常见的做法是Apache POI、阿里开源的EasyExcel、EasyExcel社区维护版FastExcel。我一般在大中型后端项目里优先选POI,原因和几个实际约束有关。
2.1 三个候选框架里为什么最终留下POI
POI是Apache组织维护的基础库,读写xls和xlsx分别走HSSF和XSSF两条API,但同一个Workbook抽象把两条路统一了。这意味着你不需要关心上传的文件是xls还是xlsx,用WorkbookFactory.create()就能自动识别。对大多数内部系统来说,这个识别能力一是省掉大量后缀判断的if,二是能兼容一批从老ERP导出来的、后缀是xls实际内容五花八门的文件。
EasyExcel的优势在占用内存少,它把工作簿里已经读完的行及时丢弃,导大数据时确实更省内存。但它有一个在生产环境里容易被忽略的坑:对xls(97-2003格式)的支持不如POI底层的HSSFWorkbook。很多线上项目还在接收老系统导出的xls文件,如果非要读老格式,EasyExcel最终还是绕不开POI,等于要维护两条解析链路。
FastExcel是EasyExcel的社区分叉,API封装方向更接近EasyExcel,做主流程开发速度快,但遇到诡异单元格格式时还是得绕过它去拿底层POI对象调试。与其这样,不如一开始就用POI写一个薄薄的工具层,后续换库时只重写工具层,业务方不用动。一个能同时处理xls和xlsx、单元格类型完全可控的POI工具类,是选型时的兜底方案。
除了读写本身,还要考虑团队熟悉程度。老Java开发基本都接触过POI,出什么问题搜索引擎里都能找到对应答案,这个维度容易被低估。如果团队已经用EasyExcel维护了几年代码,那就顺着现有代码继续扩,没必要为了统一而强行换。
2.2 最小依赖清单与user表设计
既然锁定POI,SpringBoot项目里的最小依赖就三件。用Maven写进pom.xml:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>poi-ooxml会传递引入poi核心包,但把poi单列出来是为了显式声明版本,防止项目里其他组件把poi的jar拉低。poi-ooxml-schemas这个旧依赖在POI 4.1以后不是必需,5.x已经内嵌了ooxml-schemas,不需要再单列,列了反而可能因为版本不一致导致ClassNotFoundException。
如果项目还在用Spring Boot 2.x,不建议把POI版本升到6.x,6.x把JDK基线提到17,很多企业项目还跑在Java 8上,POI 5.2.x对Java 8的支持稳定、坑也少。Spring Boot 3.x配Java 17时用POI 5.3.x以上没问题,核心代码不受影响。
对照导入导出功能,我建一张尽可能简单的用户表来做演示:
CREATE TABLE user_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', name VARCHAR(50) NOT NULL COMMENT '姓名', age INT DEFAULT 0 COMMENT '年龄', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';name字段上建唯一索引,是为了后面演示导入时遇到重复数据怎么处理。实际业务里的唯一标识往往落在邮箱或手机号上,这里拿name代替代替。表结构不要设计得太花哨,不然排错时很难分清到底是Excel解析问题还是数据库映射问题。先把最小闭环跑通,再逐步加字段。
对应的实体类用MyBatis-Plus简化,这样批量插入和条件查询都很省代码。配置好数据源之后,UserInfo实体上标注@TableName("user_info"),id字段加@TableId(type = IdType.AUTO)就能完成映射,不需要额外写XML。后面的导入代码直接使用接口继承的saveBatch方法,就是为了少写一层mapper.xml,把注意力集中在Excel解析本身。
3. 从Excel到数据库:解析、校验、批量入库的完整代码
导入这个动作可以拆成两段:一段是把Excel文件解析成Java对象列表,另一段是把列表按规则写进数据库。两段分开写,而不是在for循环里边读边insert,有几个好处:一是解析出错时事务边界清晰,二是方便在落库前做重复数据过滤,三是以后换其他解析框架时数据库部分不用动。
3.1 上传接口与WorkbookFactory统一读取
上传入口用Spring MVC的标准MultipartFile接收file参数。解析的第一步是拿输入流创建Workbook,WorkbookFactory会根据文件流里的二进制签名判断实际格式:
@PostMapping("/import") public ResponseEntity<String> importExcel(@RequestParam("file") MultipartFile file) { if (file == null || file.isEmpty()) { return ResponseEntity.badRequest().body("上传文件不能为空"); } List<UserInfo> list = excelImportService.parseAndSave(file); return ResponseEntity.ok("成功导入 " + list.size() + " 条数据"); }这段代码里不需要自己判断文件名后缀,WorkbookFactory底层读取文件的魔数后来决定用XSSFWorkbook还是HSSFWorkbook。真正的问题往往出现在“文件不是Excel”的场景:用户传过来一个改名成.xlsx的HTML页面,POI会直接抛异常,这其实是好事。所以一定不要在解析前只拿文件名做白名单校验,那样只能拦一部分,文件内容才是判断依据。
接口返回的list是成功落库的记录数。前端拿到这个数就能展示“导入成功N条”,比笼统返回一个成功状态更直观。
3.2 逐行读取与单元格类型处理
核心解析方法如下,注意注释里标记的几处:
public List<UserInfo> parseUsers(MultipartFile file) throws IOException { List<UserInfo> result = new ArrayList<>(); try (InputStream is = file.getInputStream(); Workbook workbook = WorkbookFactory.create(is)) { Sheet sheet = workbook.getSheetAt(0); int lastRow = sheet.getLastRowNum(); for (int i = 1; i <= lastRow; i++) { Row row = sheet.getRow(i); if (row == null || isBlankRow(row)) { continue; } UserInfo user = new UserInfo(); user.setName(getCellString(row.getCell(0))); user.setAge(getCellInt(row.getCell(1))); user.setEmail(getCellString(row.getCell(2))); result.add(user); } } return result; }我一般把第0行当作表头,所以循环下标从1开始,加一个小于等于lastRow的判断。这里isBlankRow用来跳过整行空白的记录,Excel里用户为了对齐格式产生的空行很常见,不跳过的话后面会往数据库插入一堆空对象。
getCellString和getCellInt是工具方法,一个处理字符串,一个处理数字。它们的关键在于不能拿getCellType去一刀切,单元格在Excel里至少有字符串、数字、日期、布尔、空白五种类型:
private String getCellString(Cell cell) { if (cell == null) { return null; } switch (cell.getCellType()) { case STRING: return cell.getStringCellValue().trim(); case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue()); } return BigDecimal.valueOf(cell.getNumericCellValue()) .stripTrailingZeros().toPlainString(); case BOOLEAN: return String.valueOf(cell.getBooleanCellValue()); default: return null; } } private Integer getCellInt(Cell cell) { if (cell == null) { return null; } switch (cell.getCellType()) { case NUMERIC: return (int) Math.round(cell.getNumericCellValue()); case STRING: String s = cell.getStringCellValue().trim(); if (s.isEmpty()) { return null; } return Integer.parseInt(s); default: return null; } }这两个方法的落点都在一个容易被忽略的坑上:单元格是数字类型时,getNumericCellValue返回double,直接转String会变成“1.0”或者“1.23456789E10”,科学计数法会让后面插入oracle等强类型数据库时报错。我习惯都用BigDecimal的toPlainString再转,避免小数点被丢,也不会把大数变成科学计数法。年龄这类整数列,则用Math.round四舍五入而不是强转int,防止Excel里的99.7这种小数被截断成99。
空行判断也不复杂,遍历行内单元格,只要有一个非空类型就证明这行有数据:
private boolean isBlankRow(Row row) { if (row == null) { return true; } for (Cell cell : row) { if (cell.getCellType() != CellType.BLANK && cell.getCellType() != CellType._NONE) { return false; } } return true; }这里请尽量不要用getPhysicalNumberOfCells去判断,返回的是行里做过操作的单元格数量,不等于内容有效数量,场景一旦复杂,边界值很容易翻车。
3.3 去重、校验与批量入库
解析得到list后,不能直接扔给saveBatch。真实数据里总有重复行,如果表结构上建了唯一索引,重复数据会直接让整批插入失败。所以先按业务唯一键去重,再写库:
@Transactional(rollbackFor = Exception.class) public List<UserInfo> parseAndSave(MultipartFile file) { List<UserInfo> list = parseUsers(file); if (list.isEmpty()) { throw new IllegalArgumentException("文件中没有有效数据"); } Map<String, UserInfo> uniqueMap = new LinkedHashMap<>(); for (UserInfo u : list) { uniqueMap.putIfAbsent(u.getName(), u); } List<UserInfo> distinctList = new ArrayList<>(uniqueMap.values()); for (UserInfo u : distinctList) { if (u.getAge() < 0 || u.getAge() > 150) { throw new IllegalArgumentException("年龄超出范围:" + u.getName()); } } userService.saveBatch(distinctList); return distinctList; }去重这里选择保留第一次出现的那条,符合多数导入工具“先到先得”的习惯。但要注意一件事:数据库中重复记录的报错在saveBatch执行到中途才发生,默认情况下MyBatis-Plus的saveBatch不是原子的。所以我直接在service方法上加了@Transactional,保证要么全部成功,要么全部回滚。这个选择很重要,一旦数据量几万行时出问题,回滚成本比逐条过滤大得多。
有人会觉得逐条insert更稳,实际上在MySQL里批量insert比逐条快一个数量级,saveBatch底层把SQL拼成一条多值插入,几千行数据性能差距就非常明显。1000个小批次逐条insert可能几百毫秒,批量插入可能几十毫秒。两个方案没有绝对好坏,但批量插入在导入场景里是主流。
4. 从数据库到Excel:流式导出与响应头设置的完整代码
导出方向的是常规操作:把数据库表查出来,填充表头和数据行,再通过HttpServletResponse写回给浏览器。这个方向最大的坑不在POI本身,而在并发和内存,所以下面用SXSSFWorkbook而不是XSSFWorkbook。
4.1 用SXSSFWorkbook控制内存与临时文件
SXSSFWorkbook是POI给大数据导出准备的流式工作簿,它在内部只保留N行在内存,其余写到磁盘临时文件。N称为窗口大小,传入构造函数的数值就是窗口大小:
@GetMapping("/export") public void exportUsers(HttpServletResponse response) throws IOException { List<UserInfo> list = userService.list(); try (SXSSFWorkbook workbook = new SXSSFWorkbook(100); OutputStream out = response.getOutputStream()) { Sheet sheet = workbook.createSheet("用户数据"); String[] headers = {"ID", "姓名", "年龄", "邮箱", "创建时间"}; Row headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); sheet.setColumnWidth(i, 20 * 256); } SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); int rowIdx = 1; for (UserInfo u : list) { Row row = sheet.createRow(rowIdx++); row.createCell(0).setCellValue(u.getId()); row.createCell(1).setCellValue(u.getName()); row.createCell(2).setCellValue(u.getAge()); row.createCell(3).setCellValue(u.getEmail() == null ? "" : u.getEmail()); row.createCell(4).setCellValue(u.getCreateTime() == null ? "" : sdf.format(u.getCreateTime())); } response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("user_export.xlsx", "UTF-8")); workbook.write(out); out.flush(); } finally { // 显式回收临时文件 } }SXSSFWorkbook(100)表示每100行就刷新一次到磁盘,内存占用基本恒定,跟最终导出总量无关。这里需要分清一个关键概念:它不能像XSSF那样随意访问行,创建完一行就无法回头修改。真需要动态修改表头或者调整单元格样式,只能把样式预先设好,数据行创建时直接使用。
代码里setColumnWidth乘了256,因为POI的列宽单位是1/256字符宽度。不乘的话列宽窄得几乎看不见。两处创建日期单元格我直接填字符串,这是省事但也留了隐患的做法:导出的Excel里日期会变成文本,用户后续用Excel筛选时间、做透视表时都受限制。要输出真正的日期单元格,需要用createCellStyle设置日期格式的DataFormat,然后用cellValue.setCellValue(Date)。这样做会多几行代码,但对下游处理更友好。
4.2 大数据量导出时的分页查询
上面代码用一个userService.list()把全表数据先查进内存,几万行没问题,几十万行时Java端内存和数据库端连接都吃不消。数据量大时要分页查询,逐批填充:
@GetMapping("/export-large") public void exportLarge(HttpServletResponse response) throws IOException { try (SXSSFWorkbook workbook = new SXSSFWorkbook(500)) { Sheet sheet = workbook.createSheet("用户数据"); String[] headers = {"ID", "姓名", "年龄", "邮箱", "创建时间"}; Row headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); long total = userService.count(); int batchSize = 5000; int rowIdx = 1; for (long offset = 0; offset < total; offset += batchSize) { Page<UserInfo> page = userService.page(new Page<>(offset / batchSize + 1, batchSize)); for (UserInfo u : page.getRecords()) { Row row = sheet.createRow(rowIdx++); row.createCell(0).setCellValue(u.getId()); row.createCell(1).setCellValue(u.getName()); row.createCell(2).setCellValue(u.getAge()); row.createCell(3).setCellValue(u.getEmail() == null ? "" : u.getEmail()); row.createCell(4).setCellValue(sdf.format(u.getCreateTime())); } } response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("user_export_large.xlsx", "UTF-8")); workbook.write(response.getOutputStream()); response.getOutputStream().flush(); } }分页每批5000行,是为了让数据库一次查询返回的数据量可控。这里真正的性能瓶颈不再POI,而是Page查询在count时可能触发的额外SQL。MyBatis-Plus的page会同时发出两条SQL:一条count,一条记录。对超大表来说count也慢,需要根据实际情况缓存总数或改用其他方式。SXSSFWorkbook(500)是指窗口500行,越大越占内存,越小越频繁刷磁盘,一般在100到500之间调,数据量级不同最优值完全不同。
响应头上还有一个细节:Content-Length不设置,浏览器会处于流式下载状态,看不到清晰进度条是正常的。如果非要在下载前就知道总量,先把数据导出到临时文件,统计字节数后再写响应头,但这会引入额外磁盘开销,不建议默认做。
5. 避坑与常见问题排查:导入导出里最容易翻车的5个位置
这套代码跑通不算难,难在生产环境里那些“玄学”问题。下面5条是我自己踩过的,按现象、原因、解决三步记录,遇到问题可以直接对照。
5.1 现象:excel里显示的数字读到Java里变成了科学计数法
明明99.5,读出来成了9.95E1,或者一个大ID从Excel导出后变成了1.23457E10,数据库里的字段被填成0或空串。
原因是Excel本身就把数字存成浮点类型,POI的getNumericCellValue返回双精度浮点,把double直接拼进字符串或SQL时自然按科学计数法显示。尤其在ID、身份证这类长数字列,问题更严重。
解决方式是在getCellString里使用BigDecimal转换后再调用toPlainString,这个方法专门用来把科学计数法的数字表示还原成普通十进制。另外在设计Excel模板时,让数字列保持“数值”格式而不是“文本”格式,能从源头减少误操作。但代码层面仍然要做防御,因为你没法控制用户上传的文件格式。
5.2 现象:日期列读出来变成一串数字,例如45658
Excel里明明显示2024-12-01,POI读出来却是一批数字。
原因是Excel把日期保存成自1900年1月1日起的天数序列,单元格的描述是数值类型,POSITIVE数值被当成普通数字交给getNumericCellValue,自然拿不到日期。
解决时需要在读取数字单元格前,先用DateUtil.isCellDateFormatted(cell)判断,它是通过单元格样式里的数据格式判断的,不是看数值本身。判断之后调用cell.getDateCellValue()就能拿到Java日期。这个判断要吃一堑长一智:有些Excel文件会把日期设置成“文本”格式,那种情况下isCellDateFormatted返回false,读出来的是字符串“2024-12-01”,所以代码里要同时支持字符串日期和数字日期两种形态,否则换个模板就翻车。
5.3 现象:导出5万行就内存溢出,或者临时文件把磁盘写满
平时几千行没问题,一到几万行就OOM。换方法之后又开始抱怨磁盘空间不足。
原因是XSSFWorkbook会把整个工作簿的所有行和所有单元格都放在Java堆内存里,行数越大占用越线性增长。SXSSFWorkbook虽然把行刷新到临时文件,但默认情况临时文件累积起来非常可观,几十万行几百MB是常态。
解决方式就是上面第4章的分页加SXSSFWorkbook窗口控制,该配置基本上能扛住几十万行的导出。另外很重要的一点:SXSSFWorkbook用完要记得调用dispose(),它在try-with-resources里虽然会close,但close和dispose是两回事,close释放的是内存,dispose删除的是临时文件。偶尔磁盘被撑爆,真凶往往就是不调dispose。
5.4 现象:同一个上传接口,xlsx正常,xls报错
用户上传2003版的xls文件,接口直接抛异常“Unsupported file type”,或者解析出来的内容错位。
原因是POI处理两种格式用的是不同底层模块,xls走HSSF,xlsx走XSSF。如果用WorkbookFactory.create统一入口,它能自动区分。但如果你为了图省事直接new XSSFWorkbook去读了xls,就会报错。
解决方式很明确:入口只用WorkbookFactory.create,接收到的InputStream不会提前硬编码指定格式。运气好能自动识别,但如果项目里还要接收旧格式的模板文件,就再用一个适配手段:先根据文件头判断,比如用POIFSFileSystem.hasPOIFSHeader来检测xls,再分别创建对应Workbook。老系统中的xls文件经常是损坏的或者非标准,这种兼容不是靠语法判断能解决的,能把问题定位到具体文件格式已经算避免了最大麻烦。
5.5 现象:并发下载时导出文件内容错乱或文件名重复
开发时一个人点没感觉,上线后多人同时点导出,发现下下来的文件要么内容重复,要么文件名互相覆盖。
原因是导出的文件名写成了固定的“user_export.xlsx”,两个用户同时点击,浏览器端看起来互不干扰,但服务端判断没有加会话隔离,响应头里如果文件名相同且通过一定渠道缓存,会互相覆盖。更隐匿的问题是SharedWorkbook被多个线程共用了,我见过有人在类里定义了一个静态workbook字段,直接导致线程间污染。
解决时文件名加UUID,保证每次请求都有独立文件标识,响应头里直接临时生成:
String fileName = "user_export_" + UUID.randomUUID().toString().replace("-", "") + ".xlsx";workbook类似,每次请求都new一个新的SXSSFWorkbook,不要放进Spring的单例Bean里。这是逃不开的教训:POI的Workbook是非线程安全的,静态复用引发的错误最高耗时排错了。
6. 最后一个技巧:把导入导出封装成可复用的模板方法
前面的解析代码是固定读三列的,换个表就要改一遍。真正干到第二个项目就会意识到,正确做法是把“遍历行并写入对象”这个过程抽象出来,让业务方只负责“如何从一行数据构造一个对象”,这就是用模板方法模式把POI的复杂度锁在工具层:
public <T> List<T> parseExcel(MultipartFile file, RowMapper<T> mapper) throws IOException { List<T> list = new ArrayList<>(); try (InputStream is = file.getInputStream(); Workbook workbook = WorkbookFactory.create(is)) { Sheet sheet = workbook.getSheetAt(0); int lastRow = sheet.getLastRowNum(); for (int i = 1; i <= lastRow; i++) { Row row = sheet.getRow(i); if (row == null || isBlankRow(row)) { continue; } T obj = mapper.mapRow(row); list.add(obj); } } return list; } @FunctionalInterface public interface RowMapper<T> { T mapRow(Row row); }调用侧只要能写出一个函数式接口的实现,就能把解析逻辑收成几行:
List<UserInfo> users = parseExcel(file, row -> { UserInfo u = new UserInfo(); u.setName(getCellString(row.getCell(0))); u.setAge(getCellInt(row.getCell(1))); u.setEmail(getCellString(row.getCell(2))); return u; });这个版本的模板方法同时把WorkbookFactory的创建、空行跳过、资源释放全部集中在工具类里,新的业务字段不需要再复制一份循环代码。要注意RowMapper里如果抛了运行时异常,整个方法的中止和事务回滚行为仍然是统一的,不会出现一部分行成功一部分失败的情况。
类似的模板方法也能套到导出上。先定义几个标准格式:xlsx格式、日期格式、表头样式,然后业务方只提供表头数组和数据列表,写表头的逻辑、设置列宽的逻辑就不要再写第二遍。
我对模板方法的习惯是:如果业务里已经出现三次相同的“循环行、拼实体”代码,就马上收敛成工具类。把读取、写入的细节锁在三四个方法里,比开出一堆抽象类更直观,也少一层继承带来的心智负担。碰到需要单独走特殊格式的Excel,再绕过工具层直接用workbook操作,不硬套模板。
这套导入导出方案帮我处理过几十个后台系统的数据交换需求。能扛得住常见业务量的关键,不是选了个多强的库,而是把“解析”和“落库”的边界切得清楚,把“导出”和“响应”的约束记得牢。希望你照着搭的时候,能在第一个版本里就避开我在线上环境交过的学费,少走几个坑。希望帮到你。
本文还有配套的精品资源,点击获取