/* 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 兼容方案。
整体业务流程
为什么 UUID 命名:防止同名文件直接覆盖旧文件。
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);
}
}
核心业务说明
导入流程
导出流程
⚠️ 项目踩坑重点(这一段是博客加分重点!面试也常问)
很多人写 POI 导入导出,上线踩坑基本都在这里,重点看!
优化改进方向
原生 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,绕过简单后缀校验,上传可执行恶意文件,造成服务器入侵。
优化方案
示例代码片段:
// 获取原始文件名后缀
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 / 线程池方案:
优点:不会受 HTTP 超时限制,适合 10 万行级别大批量导入。 小项目不想引入 MQ,可以用 Spring 自带@Async异步线程池快速实现。
4. 导入错误优化:导出错误行 Excel,方便用户修正
原有问题
原来代码只返回文本提示:第3行小区名称不存在。 如果有几十行错误,用户需要对照 Excel 一行一行找,体验很差。
优化方案
在导入校验过程中,收集所有错误行、错误原因。导入结束后:
- 如果存在错误:生成一份新 Excel,保留原始该行数据,新增一列【错误原因】;
- 将错误 Excel 保存到服务器,返回文件地址给前端,用户直接下载,修改后重新上传。
业务逻辑:
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条批量入库
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(私有化对象存储)
小项目单机部署,可以继续本地磁盘;生产集群项目,强烈上对象存储。
8. 路径穿越漏洞修复(安全重点)
原有问题
@PostMapping("/parsefile/{fileName}")
直接接收前端传入的 fileName 参数。 恶意用户传入参数:../etc/passwd 或者 ../application.yml,程序拼接路径后,会读取服务器任意配置文件,造成信息泄露。
修复方案
校验示例:
// 判断文件名不能包含路径跳转字符
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 万条,超过就提示用户增加筛选条件,防止恶意全表导出拖垮数据库。
补充:优化优先级
小结
这套方案适合中小型后台管理系统,实现 Excel 导入导出。原生 POI 自由度高,但是代码量大,坑比较多。如果业务经常处理大批量数据,推荐使用 EasyExcel 简化开发,规避 OOM 问题。


