欢迎光临
我们一直在努力

SpringBoot + Apache POI 实现 Excel 上传导入、解析入库、数据导出完整实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

前言

在后台管理系统中,Excel 批量导入数据、导出报表是非常高频的业务需求。本文基于Apache POI 原生 API,实现一套「文件上传→服务器暂存→解析 Excel 校验入库 + 数据库查询导出 Excel」完整接口。 项目使用 SpringBoot + MyBatis-Plus,适合小区人员管理这类业务场景,附带全套代码、业务逻辑、以及项目上线踩坑总结。

说明:本案例只演示.xls(Excel2003)文件读取,HSSFWorkbook,文末会讲 xlsx 兼容方案。

整体业务流程

  • Excel 上传接口 /excelUpload/ 前端选择本地 Excel 文件,通过MultipartFile上传到后端;后端用 UUID 重命名,保存到服务器指定目录,返回新文件名给前端。
  • 为什么 UUID 命名:防止同名文件直接覆盖旧文件。

  • 解析入库接口 /parsefile/{fileName} 前端拿到文件名,调用解析接口;后端读取服务器上的 xls 文件,POI 读取单元格,转二维数组;逐行校验数据(示例校验小区名称是否存在数据库),校验通过则批量保存到数据库。
  • Excel 导出接口 /exportExcel 前端传递查询条件,后端分页查询数据库数据,调用 Excel 工具类生成 Excel 文件保存到服务器,返回文件名,前端拼接地址下载文件。
  • Maven 依赖

    <!– Apache POI 处理xls –>
    <dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>4.1.2</version>
    </dependency>
    <!– MyBatis-plus、spring-web等基础依赖自行引入 –>

    Controller 完整代码(带详细注释,就是咱们前面写的)

    import org.apache.poi.hssf.usermodel.*;
    import org.apache.poi.poifs.filesystem.POIFSFileSystem;
    import org.apache.poi.ss.usermodel.DataFormatter;
    import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
    import com.baomidou.mybatisplus.core.metadata.IPage;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import org.springframework.web.bind.annotation.*;
    import org.springframework.web.multipart.MultipartFile;
    import javax.servlet.http.HttpSession;
    import java.io.File;
    import java.io.FileInputStream;
    import java.util.UUID;

    @RestController
    @RequestMapping
    public class ExcelController {

    // 文件存储路径,yml配置注入
    private String excel = "D:/community/upload/excel/";

    /**
    * Excel文件上传接口
    * 接收前端上传的excel文件,保存到服务器磁盘,返回UUID新文件名,供解析接口使用
    * @param file 前端表单上传文件,参数名uploadExcel
    * @return Result 返回新文件名
    * @throws Exception
    */
    @PostMapping("/excelUpload/")
    public Result excelUpload(@RequestParam("uploadExcel") MultipartFile file) throws Exception {
    // 判断:没有选择文件或者文件名为空
    if (file == null || file.getOriginalFilename().equals("")) {
    return Result.error("没有选中要上传的文件");
    } else {
    // 使用UUID生成新文件名,防止文件名重复覆盖原有文件
    String newFilename = UUID.randomUUID().toString();
    // 获取浏览器上传的原始文件名,例如:personInfo.xls
    String oldFilename = file.getOriginalFilename();
    // 截取原始文件名的后缀,例如 .xls
    String ext = oldFilename.substring(oldFilename.lastIndexOf("."));
    // 拼接:UUID + 文件后缀,得到完整新文件名
    newFilename = newFilename + ext;
    // 创建文件对象,对应excel存储目录
    File dir = new File(excel);
    // 判断目录不存在,则创建文件夹
    if (dir == null || !dir.exists()) {
    dir.mkdirs();
    }
    // 将上传的临时文件写入服务器磁盘目录
    file.transferTo(new File(excel + newFilename));
    // 返回新文件名给前端,前端拿着这个文件名调用parsefile解析入库
    return Result.ok().put("data", newFilename);
    }
    }

    /**
    * 解析服务器上已上传的xls文件,读取数据、业务校验、入库
    * @param fileName 服务器上保存的excel文件名(路径变量)
    * @param session session会话,获取登录用户信息
    * @return Result 导入结果,成功/失败信息
    */
    @PostMapping("/parsefile/{fileName}")
    public Result parsefile(@PathVariable("fileName") String fileName, HttpSession session){
    // 从session取出登录用户对象
    User user = (User) session.getAttribute("user");
    // POI读取xls文件系统对象
    POIFSFileSystem fs = null;
    // HSSFWorkbook专门处理 .xls 格式Excel
    HSSFWorkbook wb = null;
    try {
    // 拼接文件完整路径:基础目录 + 文件名
    String basePath = excel + fileName;
    // 创建文件输入流读取excel,封装到POIFSFileSystem
    fs = new POIFSFileSystem(new FileInputStream(basePath));
    // 构建Excel工作簿
    wb = new HSSFWorkbook(fs);
    } catch (Exception e) {
    // 读取文件异常打印堆栈
    e.printStackTrace();
    }
    // 获取Excel第一个工作表sheet
    HSSFSheet sheet = wb.getSheetAt(0);
    // 定义二维数组,用来存放解析出来的表格数据
    Object[][] data = null;
    // getLastRowNum获取最后一行下标,+1得到总行数
    int r = sheet.getLastRowNum()+1;
    // 获取第0行(第一行)的单元格数量,作为总列数
    int c = sheet.getRow(0).getLastCellNum();
    // headRow=2:从下标2行(Excel第3行)开始读取数据,跳过前面两行表头
    int headRow = 2;
    // 初始化二维数组:行数=总行数-跳过表头行数,列数为总列数
    data = new Object[r – headRow][c];
    // 循环读取每一行数据
    for (int i = headRow; i < r; i++) {
    // 获取当前行对象
    HSSFRow row = sheet.getRow(i);
    // 遍历当前行每一列单元格
    for (int j = 0; j < c; j++) {
    // 定义单元格对象
    HSSFCell cell = null;
    try {
    // 获取第i行第j列单元格
    cell = row.getCell(j);
    try {
    // DataFormatter自动格式化单元格,数字、日期统一转为字符串
    DataFormatter dataFormater = new DataFormatter();
    // 获取格式化后的单元格字符串
    String a = dataFormater.formatCellValue(cell);
    // 将单元格值存入二维数组
    data[i – headRow][j] = a;
    } catch (Exception e) {
    // 当前单元格读取异常,赋值空字符串
    data[i-headRow][j] = "";
    // 如果是第0列做特殊处理
    if(j==0){
    try {
    // 获取单元格数字
    double d = cell.getNumericCellValue();
    // 转int再转字符串,去掉小数
    data[i – headRow][j] = (int)d + "";
    }catch(Exception ex){
    // 读取数字失败,置空
    data[i-headRow][j] = "";
    }
    }
    }
    } catch (Exception e) {
    // 读取该行该列整体异常,打印行列号和错误信息
    System.out.println("i="+i+";j="+j+":"+e.getMessage());
    }
    }
    }
    // 获取解析后二维数组的总行数(待导入数据行数)
    int row = data.length;
    // 列索引变量,依次读取每一列
    int col = 0;
    // 错误信息字符串,保存导入错误提示
    String errinfo = "";
    // headRow=3:用于提示前端Excel原始行号偏移
    headRow = 3;
    // 表头数组,定义每一列对应的中文名称
    String[] stitle={"ID","小区名称","所属楼栋","房号","姓名","性别","手机号码","居住性质","状态","备注"};
    // 循环遍历解析出来的每一行数据,准备入库
    for (int i = 0; i < row; i++) {
    // 新建Person实体,封装一条人员数据
    Person single = new Person();
    // personId自增,这里临时赋值0
    single.setPersonId(0);
    // 设置状态默认值1
    single.setState(1);
    // 人脸图片地址默认空
    single.setFaceUrl("");
    try {
    // 从下标1列开始读取,跳过ID列
    col=1;
    // 读取小区名称
    String communityName = data[i][col++].toString();
    // MyBatisPlus条件构造器
    QueryWrapper<Community> queryWrapper = new QueryWrapper<>();
    // 查询条件:community_name等于读取到的小区名称
    queryWrapper.eq("community_name", communityName);
    // 根据小区名称查询小区记录
    Community community = this.communityService.getOne(queryWrapper);
    // 判断数据库没有该小区
    if( community == null){
    // 拼接错误信息,标记Excel第几行出错
    errinfo += "Excel文件第" + (i + headRow) + "行小区名称不存在!";
    // 直接返回,终止导入
    return Result.ok().put("status", "fail").put("data", errinfo);
    }
    // 设置人员所属小区ID
    single.setCommunityId(community.getCommunityId());
    // 读取所属楼栋
    single.setTermName(data[i][col++].toString());
    // 读取房号
    single.setHouseNo(data[i][col++].toString());
    // 读取姓名
    single.setUserName(data[i][col++].toString());
    // 读取性别
    single.setSex(data[i][col++].toString());
    // 读取手机号
    single.setMobile(data[i][col++].toString());
    // 读取居住性质
    single.setPersonType(data[i][col++].toString());
    // 读取备注
    single.setRemark(data[i][col++].toString());
    // 设置创建人为当前登录用户名
    single.setCreater(user.getUsername());
    // 保存人员数据到数据库
    this.personService.save(single);
    } catch (Exception e) {
    // 单行数据处理异常,打印堆栈
    e.printStackTrace();
    }
    }
    // 全部行循环完成,返回导入成功
    return Result.ok().put("status", "success").put("data","数据导入完成!");
    }

    /**
    * 导出Excel接口
    * @param form 前端传入的查询条件实体
    * @return Result 返回生成好的excel文件名,前端拼接路径下载
    */
    @GetMapping("/exportExcel")
    public Result exportExcel(PersonForm form) {
    // 根据前端筛选条件分页查询人员数据,最多查询10000条
    IPage<Person> result = personMapper.findPage(new Page<>(1, 10000), form);
    // 调用Excel工具类,复制模板并填充数据,返回生成的新文件名
    String filename = ExcelUtil.ExpPersonInfo(result.getRecords(), excel);
    // 判断文件名是否为空,导出失败
    if (filename == null || filename.equals("")) {
    return Result.error("导出失败");
    }
    // 返回文件名给前端,前端拼接路径下载文件
    return Result.ok().put("data", filename);
    }
    }

    核心业务说明

    导入流程

  • 文件先上传到服务器磁盘,不是直接在上传流中解析;好处是前端可以分两步:上传文件成功后,再触发解析。适合大文件场景,可以做进度提示。
  • 用DataFormatter格式化单元格,自动处理数字、文本,避免单元格类型带来读取乱码问题。
  • 业务校验示例:校验小区名称在数据库是否存在,不存在直接返回错误,终止导入。
  • 导出流程

  • 接收前端筛选条件,分页查询数据库(限制最多 10000 条,防止一次性查询海量数据 OOM)
  • ExcelUtil 工具类读取预设模板,填充数据库数据,生成文件保存服务器
  • 返回文件名给前端,前端拼接文件地址直接下载
  • ⚠️ 项目踩坑重点(这一段是博客加分重点!面试也常问)

    很多人写 POI 导入导出,上线踩坑基本都在这里,重点看!

  • 空指针异常风险 如果 excel 读取失败,wb对象为 null,直接调用wb.getSheetAt(0)直接空指针。需要增加非空判断。
  • 流没有关闭,文件锁问题 POI 的FileInputStream、Workbook没有 close,多次上传导出后,文件被操作系统占用,无法删除覆盖。必须 finally 关闭流和 workbook。
  • 事务缺失,数据脏读 当前代码没有事务。导入中途报错,前面已经 save 成功的数据不会回滚,造成半导入脏数据。解决方案:@Transactional,整批要么全部入库,要么全部回滚。
  • 只支持 xls,不支持 xlsx HSSFWorkbook 只支持.xls(最大 65535 行);xlsx 需要XSSFWorkbook,大数量导出用 SXSSFWorkbook 防止内存溢出。
  • 路径穿越安全漏洞 parsefile直接接收 fileName,没有过滤../,恶意用户传入../web.xml,读取服务器任意文件。必须校验文件名,禁止../。
  • 单元格 null,调用 toString () 空指针 data[i][col++].toString(),单元格为空时直接空指针。读取单元格前先判空。
  • 文件存储在本地磁盘,集群部署有问题 单机开发没问题;如果多台服务器集群,A 服务器上传文件,B 服务器解析找不到文件,生产建议使用对象存储 OSS。
  • 优化改进方向

    原生 POI 虽然灵活,但是手写代码容易踩坑,上面的基础版本只适合学习和简单小流量场景。在生产环境,我们需要从安全、稳定性、性能、用户体验几个维度优化,下面逐条讲解。

    1. 替换 POI,使用 EasyExcel(首选)

    原有问题

    原生 POI 读写 Excel,会把整个 Excel 全部加载进内存。

    • 如果 Excel 有几万行数据,很容易产生OOM 内存溢出;
    • 单元格类型、空单元格、日期、数字格式都要自己手动处理,代码冗长;
    • 需要手动管理流关闭、workbook 释放,一旦忘记就会出现文件锁。
    EasyExcel 是什么?

    阿里开源的 Excel 读写框架,底层封装了 POI,采用 SAX 逐行读取,不会一次性加载全部数据到内存,大数据场景内存占用极低。

    • 读 Excel:一行一行读取,读一行处理一行,不用一次性把全表读到二维数组;
    • 自动处理单元格格式、空单元格,减少大量判空代码;
    • 简单注解绑定实体类,不用手动写getCell、DataFormatter。
    引入依赖

    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.2</version>
    </dependency>

    适用场景

    绝大多数后台管理系统导入导出,优先 EasyExcel。只有需要高度自定义 Excel 样式、复杂模板场景,才考虑原生 POI。


    2. 文件上传安全校验:文件后缀 + MIME 类型校验,防止恶意文件上传

    原有问题

    原代码只靠前端传的文件名后缀判断文件类型。 黑客可以把virus.exe改名为virus.xls,绕过简单后缀校验,上传可执行恶意文件,造成服务器入侵。

    优化方案
  • 双重校验:同时校验文件后缀 + 文件 MimeType,二者都要符合白名单;
  • 建立白名单,只允许:.xls、.xlsx,其余全部拒绝;
  • 校验后缀转小写,防止file.XLS大小写绕过。
  • 示例代码片段:

    // 获取原始文件名后缀
    String suffix = oldFilename.substring(oldFilename.lastIndexOf("."))
    .toLowerCase();
    List<String> allowSuffix = Arrays.asList(".xls",".xlsx");
    if(!allowSuffix.contains(suffix)){
    return Result.error("仅允许上传xls、xlsx格式Excel文件");
    }

    进阶:可以读取文件头部魔数(文件头),真正校验文件二进制内容,防止修改后缀伪装 Excel。


    3. 大数据导入异步化 + 任务状态,解决接口超时

    原有问题

    当前同步导入:上传文件后,解析 + 数据库入库全部在同一个 http 请求内执行。 如果 Excel 几万行,数据库批量插入耗时很长,http 请求超时,前端直接报错。

    • 部分数据可能已经入库,造成脏数据;
    • 用户只能等待页面转圈,不知道导入进度。
    优化方案

    采用异步任务 + MQ / 线程池方案:

  • 用户上传文件保存到服务器(这一步很快,同步完成);
  • 创建导入任务记录存入数据库,状态:待处理;
  • 提交异步任务(线程池或者 RocketMQ/RabbitMQ),后台慢慢解析、校验、入库;
  • 前端轮询调用接口,查询任务状态:处理中 / 成功 / 失败;
  • 任务完成后,数据库更新任务状态;如果失败,生成错误 Excel,存入记录供用户下载查看错误行。
  • 优点:不会受 HTTP 超时限制,适合 10 万行级别大批量导入。 小项目不想引入 MQ,可以用 Spring 自带@Async异步线程池快速实现。


    4. 导入错误优化:导出错误行 Excel,方便用户修正

    原有问题

    原来代码只返回文本提示:第3行小区名称不存在。 如果有几十行错误,用户需要对照 Excel 一行一行找,体验很差。

    优化方案

    在导入校验过程中,收集所有错误行、错误原因。导入结束后:

    • 如果存在错误:生成一份新 Excel,保留原始该行数据,新增一列【错误原因】;
    • 将错误 Excel 保存到服务器,返回文件地址给前端,用户直接下载,修改后重新上传。

    业务逻辑:

  • 遍历每行数据,校验不通过时,保存当前行对象 + 错误信息;
  • 全部解析完成后,判断是否存在错误数据;
  • 有错误,调用 EasyExcel 写入错误 Excel;
  • 返回信息:共1000条,成功980条,失败20条,请下载错误文件修改后重传。

  • 5. 定时任务清理临时文件,防止磁盘爆满

    原有问题

    每次上传都会在服务器磁盘生成 Excel 文件,导入完成后没有删除。 用户频繁上传测试,日积月累,大量无用 Excel 占用服务器磁盘空间,最终磁盘占满,服务宕机。

    优化方案

    使用 Spring 定时任务 @Scheduled,定期清理临时目录的过期文件。 策略:

    • 只清理超过 24 小时的上传临时 Excel;
    • 区分:临时上传文件 和 正式导出报表文件(正式报表保留更长时间)。

    @Component
    @EnableScheduling
    public class FileCleanTask {
    // 每天凌晨2点执行清理
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanTempExcel(){
    // 遍历excel临时目录,删除24小时前的文件
    }
    }


    6. 数据库层面优化:批量插入 + 事务控制,避免脏数据

    原有问题

    原代码循环每行调用personService.save(single),一行一 insert。

  • 数据库频繁提交事务,导入速度极慢;
  • 没有事务:导入到第 500 行报错,前面 499 行已经存入数据库,造成一半数据入库,脏数据。
  • 优化方案
  • 增加事务:方法上加@Transactional(rollbackFor = Exception.class) 整批导入,要么全部入库成功;一旦抛出异常,所有插入全部回滚,不会半残数据。
  • 批量保存:不要每行 save,攒一批(例如每 500 条),调用saveBatch批量插入,减少数据库 IO。
  • // 示例:每500条批量入库
    List<Person> batchList = new ArrayList<>();
    for(解析出来的每行数据){
    batchList.add(single);
    if(batchList.size() >=500){
    personService.saveBatch(batchList);
    batchList.clear();
    }
    }
    // 循环结束,把剩余不足500条的数据入库
    if(!batchList.isEmpty()){
    personService.saveBatch(batchList);
    }

    注意:MyBatis-plus 批量插入,数据库连接串需要配置rewriteBatchedStatements=true,否则批量插入不会生效。


    7. 分布式集群场景:本地文件存储改为对象存储 OSS

    原有问题

    当前方案把 Excel 文件保存在服务器本地磁盘。 单机开发没问题,一旦部署多台服务器集群(负载均衡): 用户上传请求打到 A 服务器,文件保存在 A;解析请求负载均衡分到 B 服务器,B 服务器找不到这个文件,直接报错。

    优化方案

    使用云对象存储:阿里云 OSS / 腾讯云 COS / MinIO(私有化对象存储)

  • 文件上传时,直接上传到 OSS,而不是本地磁盘;
  • 返回 OSS 文件 url 或者文件 key;
  • 解析、下载导出文件,全部操作 OSS;
  • 多台服务器共享同一份文件,集群无文件不同步问题。
  • 小项目单机部署,可以继续本地磁盘;生产集群项目,强烈上对象存储。


    8. 路径穿越漏洞修复(安全重点)

    原有问题

    @PostMapping("/parsefile/{fileName}")

    直接接收前端传入的 fileName 参数。 恶意用户传入参数:../etc/passwd 或者 ../application.yml,程序拼接路径后,会读取服务器任意配置文件,造成信息泄露。

    修复方案
  • 禁止文件名包含../、./、\\这类路径跳转字符;
  • 我们前面上传的时候用 UUID 命名,保存到服务器的文件名永远是 UUID,不再使用原始文件名; 前端传的 fileName 只能是 UUID 名称,不存在../,从根源杜绝路径穿越。
  • 校验示例:

    // 判断文件名不能包含路径跳转字符
    if(fileName.contains("..") || fileName.contains("/") || fileName.contains("\\\\")){
    return Result.error("非法文件名");
    }


    9. 大数据导出优化:SXSSFWorkbook / EasyExcel 分段导出,防止 OOM

    原有问题

    原生 HSSF 只支持最多 65535 行。如果数据量很大,一次性查询全部数据到内存,再生成 Excel,容易内存溢出。

    优化方案

    方案 1:POI 场景,使用SXSSFWorkbook,流式导出,只保留少量行在内存; 方案 2:推荐 EasyExcel,直接支持大数据流式写出。 方案 3:分页查询数据库,不要一次性查出全部数据。例如循环分页,每次查 1000 条,边查边写入 Excel,不一次性加载全表数据到内存。

    同时限制最大导出条数,例如最多一次性导出 1 万条,超过就提示用户增加筛选条件,防止恶意全表导出拖垮数据库。


    补充:优化优先级

  • 【最高优先级,必须改】事务控制、文件安全校验、路径穿越漏洞、流关闭释放资源
  • 【高推荐】替换 EasyExcel,简化代码,解决 OOM 问题
  • 【业务体验优化】错误 Excel 导出、批量插入
  • 【中大型项目】异步导入 + MQ、OSS 对象存储
  • 【运维优化】定时清理临时文件
  • 小结

    这套方案适合中小型后台管理系统,实现 Excel 导入导出。原生 POI 自由度高,但是代码量大,坑比较多。如果业务经常处理大批量数据,推荐使用 EasyExcel 简化开发,规避 OOM 问题。

    赞(0)
    未经允许不得转载:171主机测评 » SpringBoot + Apache POI 实现 Excel 上传导入、解析入库、数据导出完整实战
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址