SpringBoot - POI实现Excel 导入与导出

目录

这次整理智慧社区项目里的 Excel 导入和导出。导入沿用老师的写法,分成上传文件、解析入库两个接口;导出按接口文档实现,先生成文件,再由前端下载。

模板先确定下来

项目用的是 .xls。前端限制了文件后缀,后端用 HSSFWorkbook 读取,所以文件本身也要是这个格式,不能把 .xlsx 改个名字就拿来用。POI 中 HSSFWorkbook 对应 .xls,XSSFWorkbook 对应 .xlsx,见 POI 官方指南。

模板第 1 行是标题,第 2 行是列名,第 3 行开始放住户数据,共 9 列:

列 下标 内容 对应字段
A 0 ID 导入时不使用这一列的值
B 1 小区名称 先查小区,再取得 communityId
C 2 所属楼栋 termName
D 3 房号 houseNo
E 4 姓名 userName
F 5 性别 sex
G 6 手机号码 mobile
H 7 居住性质 personType
I 8 备注 remark

老师代码中的 stitle 数组有 10 项,多了一项"状态",但它没有参与读取。实际用的模板只有上面这 9 列,因此下标 8 读到的仍然是备注。列的含义要对照模板和读取代码看,不能只看这个数组。

上传文件,为什么要先保存?

我一开始没明白为什么要先保存表格,它又不像图片一样需要预览。看了解析接口才发现,它只接收文件名,没有再接收文件内容。所以要先把文件存下来,第二次请求才能找到它。

text 复制代码
POST /sys/person/excelUpload
    接收文件 → 保存到磁盘 → 返回新文件名
    ↓
POST /sys/person/parsefile/{fileName}
    按文件名读取 → 解析表格 → 保存住户

这和表格本身需不需要预览没有关系。如果上传后就在同一个请求里解析,也可以直接读上传文件的输入流;这里按现有前端和接口分工实现。

Controller 类上有公共路径,文件目录从配置中取:

java 复制代码
@RestController
@RequestMapping("/sys/person")
public class PersonController {
    @Value("${upload.excel}")
    private String excel;
}
yaml 复制代码
upload:
  excel: D:/community/excel/

上传方法如下,放在这个 Controller 里面:

java 复制代码
@PostMapping({"/excelUpload", "/excelUpload/"})
public Result excelUpload(@RequestParam(value = "uploadExcel", required = false) MultipartFile file) {
    // 1. 检查有没有选择文件,以及文件内容是否为空。
    if (file == null || file.isEmpty()) {
        return Result.error("没有选中要上传的文件");
    }
    String oldFilename = file.getOriginalFilename();
    if (oldFilename == null || !oldFilename.toLowerCase(Locale.ROOT).endsWith(".xls")) {
        return Result.error("请上传xls文件");
    }
    if (file.getSize() > 10L * 1024 * 1024) {
        return Result.error("文件大小不能超过10MB");
    }

    // 2. 用UUID生成新名字,保留原文件的.xls后缀,避免同名覆盖。
    String newfilename = UUID.randomUUID().toString();
    String ext = oldFilename.substring(oldFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
    String newFilename1 = newfilename + ext;

    // 3. 确保配置的保存目录存在;new File只是表示路径,不会保存上传内容。
    File directory = new File(excel).getAbsoluteFile();
    if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) {
        return Result.error("Excel保存目录创建失败");
    }
    File file1 = new File(directory, newFilename1);

    // 4. 将上传文件真正保存到硬盘,并返回文件名给前端。
    try {
        file.transferTo(file1);
        return Result.ok().put("data", newFilename1);
    } catch (IOException | IllegalStateException e) {
        e.printStackTrace();
        return Result.error("Excel文件保存失败");
    }
}

接收文件和保存文件是两件事

MultipartFile file 表示请求中上传的文件。前端通过 multipart/form-data 提交,字段名是 uploadExcel:

html 复制代码
<el-upload name="uploadExcel" ...>

所以后端写 @RequestParam("uploadExcel")。这不是提交 JSON 的接口,不用 @RequestBody。required = false 允许方法自行判断没收到文件的情况,isEmpty() 则检查文件内容是否为空。

getOriginalFilename() 取得原文件名。代码检查后缀、大小后,生成 UUID 作为新名字,保留 .xls 后缀,避免直接沿用原名造成覆盖。检查后缀不等于已经确认内容有效,文件能否打开要等解析时才能知道。

我之前问过这一句是什么:

java 复制代码
file.transferTo(file1);

它把上传内容保存到 file1 指向的位置。前面的 new File(directory, newFilename1) 只是组合路径,没有把文件内容写进去。transferTo(File) 的具体说明可查 Spring MultipartFile 文档。

上传成功后返回的是 JSON:

json 复制代码
{
  "code": 200,
  "msg": "操作成功",
  "data": "一个UUID.xls"
}

此时文件已经保存,住户还没有入库。前端在上传成功的回调 handleAvatarSuccess 中拿到 data.data,再调用:

javascript 复制代码
parsefile(data.data).then(res => {
  // 根据 res.status 显示解析结果
})

当前页面是上传成功后自动解析。弹窗里的"确定"负责通知列表刷新、关闭弹窗,不是等点了确定才开始保存住户。

解析文件,再保存住户

解析接口接收上传接口返回的文件名。老师先把单元格读进二维数组,再逐行组装 Person,最后调用 Service 保存。下面保留这套逻辑,注释集中在文件读取、行列对应和字段转换上。

java 复制代码
@PostMapping("/parsefile/{fileName}")
public Result parsefile(@PathVariable("fileName") String fileName,HttpSession session){
    // 创建人从登录会话中取。
    User user = (User) session.getAttribute("user");
    POIFSFileSystem fs = null;
    HSSFWorkbook wb = null;
    try {
        // 根据上传接口返回的文件名,打开服务器上的.xls文件。
        String basePath = excel + fileName;
        fs = new POIFSFileSystem(new FileInputStream(basePath));
        wb = new HSSFWorkbook(fs);
    } catch (Exception e) {
        e.printStackTrace();
    }
    // 取第一个工作表,确定行列范围。
    HSSFSheet sheet = wb.getSheetAt(0);
    Object[][] data = null;
    int r = sheet.getLastRowNum()+1;
    int c = sheet.getRow(0).getLastCellNum();
    // 跳过两行表头,数组只保存住户数据。
    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 {
                cell = row.getCell(j);
                try {
                    cell = row.getCell(j);
                    // 按单元格格式取得字符串,放入二维数组。
                    DataFormatter dataFormater = new DataFormatter();
                    String a = dataFormater.formatCellValue(cell);
                    data[i - headRow][j] = a;
                } catch (Exception e) {
                    data[i-headRow][j] = "";
                    if(j==0){
                        try {
                            double d = cell.getNumericCellValue();
                            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 = "";
    // 后面用数组下标加3,给出Excel中的实际行号。
    headRow = 3;
    // 此数组未参与读取;实际模板为九列,最后一列是备注。
    String[] stitle={"ID","小区名称","所属楼栋","房号","姓名","性别","手机号码","居住性质","状态","备注"};
    errinfo = "";
    for (int i = 0; i < row; i++) {
        // 每一行对应一个住户,状态和图片地址由代码设置。
        Person single = new Person();
        single.setPersonId(0);
        single.setState(1);
        single.setFaceUrl("");
        try {
            // 跳过ID列,按模板顺序取值。
            col=1;
            String communityName = data[i][col++].toString();
            // 表格中是小区名称,入库前先查出小区ID。
            QueryWrapper<Community> queryWrapper = new QueryWrapper<>();
            queryWrapper.eq("community_name", communityName);
            Community community = this.communityService.getOne(queryWrapper);
            if( community == null){
                errinfo += "Excel文件第" + (i + headRow) + "行小区名称不存在!";
                return Result.ok().put("status", "fail").put("data", errinfo);
            }
            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());
            // 此时col为8,对应实际模板的备注列。
            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","数据导入完成!");
}

从文件到单元格

java 复制代码
String basePath = excel + fileName;
fs = new POIFSFileSystem(new FileInputStream(basePath));
wb = new HSSFWorkbook(fs);
HSSFSheet sheet = wb.getSheetAt(0);

这里的文件已经在磁盘上了。FileInputStream 读字节,POIFSFileSystem 解析旧版 .xls 的文件结构,再交给 HSSFWorkbook 打开。getSheetAt(0) 取第一个工作表。

POI 对象与表格的关系是:

text 复制代码
HSSFWorkbook:工作簿
    HSSFSheet:工作表
        HSSFRow:一行
            HSSFCell:单元格

表格行号和数组下标

java 复制代码
int r = sheet.getLastRowNum() + 1;
int c = sheet.getRow(0).getLastCellNum();
int headRow = 2;
data = new Object[r - headRow][c];

getLastRowNum() 返回最后一行的下标,所以加 1。getLastCellNum() 已经是该行最后一个单元格下标加 1,不用再加。这些值确定读取范围,不是在统计有多少条非空住户记录。

前两行不放住户,所以 headRow 为 2,数组只分配 r - headRow 行。例如 Excel 第 3 行的下标是 2,进入数组后应该是第 0 行:

java 复制代码
data[i - headRow][j] = a;

后面 headRow 又被赋成 3,用途变了:它用来计算提示中的 Excel 行号。数组下标 0 加 3,正好对应 Excel 第 3 行。这两个地方分别处理"读数据"和"报行号",并没有重新读一遍表格。

单元格转成字符串

java 复制代码
DataFormatter dataFormater = new DataFormatter();
String a = dataFormater.formatCellValue(cell);

表格里可能有文本、数字等不同类型,DataFormatter 按单元格格式取得字符串,见 POI DataFormatter 文档。但它不能恢复 Excel 已经丢掉的前导零或精度。房号、手机号在表格里按文本填写更合适,例如房号 0101。

老师还在格式化异常后尝试将第一列按数字读取。不过后面并没有使用 Excel 的 ID,而是另行调用 single.setPersonId(0),所以这段数字转换不决定新增住户的主键。

从小区名称取得小区 ID

数组转 Person 时,col = 1 跳过了 ID 列,接下来各列按表头顺序赋值。小区名称需要多一步处理:

java 复制代码
QueryWrapper<Community> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("community_name", communityName);
Community community = communityService.getOne(queryWrapper);

Excel 写的是名称,住户表存的是 community_id。先按名称查到 Community,再把它的 ID 放入住户对象。community_name 是数据库列名,不是 Java 属性名。

没有对应小区时,代码返回具体行号;查出多条时,getOne() 默认可能报错。因此表格中填写的名称要能对应到数据库里的小区。

其余字段赋值后,这一句才真正调用入库逻辑:

java 复制代码
this.personService.save(single);

状态、图片地址和创建人不从表格读取。代码把状态设为 1、图片地址设为空字符串,创建人从登录会话里取。导入住户资料本身没有完成人脸采集。

这里的 ID 赋 0 是老师原写法,能否按预期自增与数据库配置有关。项目的普通新增接口则将 ID 设为 null。

导出查哪些数据?

接口文档规定请求地址为:

http 复制代码
GET /sys/person/exportExcel

参数包括 page、limit、userName、communityId、mobile,成功时返回:

json 复制代码
{
  "msg": "操作成功",
  "code": 200,
  "data": "20230719185659.xls"
}

看到 page 和 limit,再结合后面几个搜索字段,我推测这里应当带上分页和筛选条件。文档没有直接写明导出范围,这次就按"当前搜索条件的当前页"实现。例如搜索姓名包含"张"的住户,第 2 页、每页 10 条,就导出这组条件下第 2 页的数据。

返回的 data 是文件名,生成接口先返回 JSON。前端拿到名称后再请求文件,所以不能在这里直接返回 Excel 文件流。

小区名称用 Map 转,还是在 SQL 里查?

导出时遇到与导入相反的问题:住户对象里有 communityId,表格需要显示小区名称。

可以一次查出住户、一次查出小区,建一个 ID 到名称的 Map;也可以用 SQL 关联查询。我选了后者,因为项目的住户列表已经有这套联查,可以直接复用。Map 也能实现,而且"一次查全部小区"不等于"每导出一个住户就查一次小区",不能只靠查询次数就认定它很慢。

关联部分是:

sql 复制代码
FROM person p
LEFT JOIN community cm
    ON p.community_id = cm.community_id

LEFT JOIN 会保留住户记录,即使找不到关联小区也不把住户过滤掉,只是名称为空。

PersonVO 和 personVoMap

项目已经有 PersonVO,在住户字段之外增加小区名称:

java 复制代码
@Data
@EqualsAndHashCode(callSuper = true)
public class PersonVO extends Person {
    private String communityName;
}

我当时问过,没写 personVoMap 怎么用。后来核对了文件,它已经在 Mapper XML 顶部,是一段结果映射的名字:

xml 复制代码
<resultMap id="personVoMap"
           type="com.qcby.springbootCommunity.VO.PersonVO">
    <id property="personId" column="person_id"/>
    <result property="communityId" column="community_id"/>
    <result property="termName" column="term_name"/>
    <result property="houseNo" column="house_no"/>
    <result property="userName" column="user_name"/>
    <result property="sex" column="sex"/>
    <result property="mobile" column="mobile"/>
    <result property="personType" column="person_type"/>
    <result property="remark" column="remark"/>
    <result property="communityName" column="community_name"/>
</resultMap>

这里摘录了与导出有关的字段。最后一项就是把查询结果中的 community_name 赋给 PersonVO.communityName。查询标签写 resultMap="personVoMap",便会使用这套规则。

原有 Mapper 方法是:

java 复制代码
IPage<PersonVO> selectPersonPage(
    Page<PersonVO> page,
    @Param("query") PersonQuery query
);

Service 的 getPersonPage(page, query) 调用它。XML 按姓名、手机号模糊查询,按小区 ID 精确查询,结果按住户 ID 倒序排列;分页由项目已经配置的 MyBatis-Plus 分页插件处理。

既然要导出当前页,就没必要另外加一个查询全部住户的方法。继续调用 getPersonPage(),用 getRecords() 取出该页记录即可。

创建并保存导出文件

下面是 PersonController 中的完整方法,类上的公共路径仍是 /sys/person:

java 复制代码
@GetMapping("/exportExcel")
public Result exportExcel(@RequestParam(defaultValue = "1") Integer page,
                          @RequestParam(defaultValue = "10") Integer limit,
                          PersonQuery query) {
    if (page < 1 || limit < 1) {
        return Result.error("页码和每页条数必须大于0");
    }
    // .xls 最多65536行,标题、表头各占一行。
    if (limit > 65534) {
        return Result.error("每页最多导出65534条住户");
    }

    // 1. 复用已有联表分页查询,筛选条件、排序与住户列表一致。
    Page<PersonVO> pageParam = new Page<>(page, limit);
    IPage<PersonVO> personPage = personService.getPersonPage(pageParam, query);
    List<PersonVO> personList = personPage.getRecords();

    // 2. 准备目录及不重复的文件名;File负责组合目录和文件名。
    File directory = new File(excel);
    if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) {
        return Result.error("创建导出目录失败");
    }
    String fileName = UUID.randomUUID().toString() + ".xls";
    File targetFile = new File(directory, fileName);

    // 3. 工作簿在内存中生成;退出try时自动关闭,包括异常退出。
    try (HSSFWorkbook workbook = new HSSFWorkbook()) {
        HSSFSheet sheet = workbook.createSheet("住户信息");

        // 第1行:标题,合并A1到I1。与导入模板保持两行表头、九列结构。
        HSSFRow titleRow = sheet.createRow(0);
        titleRow.createCell(0).setCellValue("住户信息明细表");
        sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 8));

        // 第2行:列名。索引从0开始,列顺序须与下面的数据对应。
        String[] headers = {
            "ID", "小区名称", "所属楼栋", "房号", "姓名",
            "性别", "手机号码", "居住性质", "备注"
        };
        HSSFRow headerRow = sheet.createRow(1);
        for (int i = 0; i < headers.length; i++) {
            headerRow.createCell(i).setCellValue(headers[i]);
            sheet.setColumnWidth(i, 18 * 256);
        }
        sheet.setColumnWidth(8, 30 * 256);

        // 第3行开始:一条住户对应一行;房号和手机号按字符串写入。
        int rowIndex = 2;
        for (PersonVO person : personList) {
            HSSFRow row = sheet.createRow(rowIndex++);
            row.createCell(0).setCellValue(person.getPersonId());
            row.createCell(1).setCellValue(person.getCommunityName());
            row.createCell(2).setCellValue(person.getTermName());
            row.createCell(3).setCellValue(person.getHouseNo());
            row.createCell(4).setCellValue(person.getUserName());
            row.createCell(5).setCellValue(person.getSex());
            row.createCell(6).setCellValue(person.getMobile());
            row.createCell(7).setCellValue(person.getPersonType());
            row.createCell(8).setCellValue(person.getRemark());
        }

        // 4. 输出流写向磁盘文件;内层try先关闭流,外层再关闭工作簿。
        try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
            workbook.write(outputStream);
        }
    } catch (IOException e) {
        e.printStackTrace();
        return Result.error("导出文件失败");
    }

    // 5. 成功响应只返回文件名,前端拼接下载地址后发起第二次请求。
    return Result.ok().put("data", fileName);
}

这次新增的导入是:

java 复制代码
import java.io.FileOutputStream;
import org.apache.poi.ss.util.CellRangeAddress;

page 和 limit 用 @RequestParam 接收,PersonQuery 接收三个搜索字段。前端通过 GET 查询参数传入,不是 JSON 请求体,因此这里也不使用 @RequestBody。

表头和住户行

导入时从输入流打开现有工作簿,导出则创建一个新的 HSSFWorkbook。标题、表头仍然占两行,住户数据从下标 2 开始,列顺序与导入模板相同。

java 复制代码
new CellRangeAddress(0, 0, 0, 8)

四个参数是开始行、结束行、开始列、结束列,所以上面合并的是 A1 到 I1。18 * 256 是设置列宽时使用的单位,并不是 18 个像素。

每条 PersonVO 写一行。getCommunityName() 取得联查的小区名称,房号、手机号按字符串写入。没有查询结果时,仍然可以生成文件,只包含标题和表头。

write() 写到哪里?

java 复制代码
try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
    workbook.write(outputStream);
}

前面的 createRow() 和 setCellValue() 都在构造内存中的表格,write() 才把内容写出。这里传的是文件输出流,所以保存到服务器磁盘。如果传 response.getOutputStream(),则直接发给浏览器。

直接返回文件流也能做导出,但要改成与之配套的前端下载方式。本项目已有前端期待"JSON 里的文件名",所以按原接口约定保存文件,再返回名称。

外层 try-with-resources 管理工作簿,内层管理输出流。离开代码块时先关闭流,再关闭工作簿;发生异常也会关闭已经创建的资源。close() 是释放资源,不能代替 write()。

上传和导出的几处操作放在一起看:

操作 用途
new File(directory, fileName) 组合目标路径
file.transferTo(targetFile) 保存上传内容
new HSSFWorkbook(input) 读取已有工作簿
workbook.write(outputStream) 写出工作簿内容

前端怎样下载这个文件?

导出接口返回文件名后,前端执行:

javascript 复制代码
exportExcel(this.listQuery).then(res => {
  location.href = process.env.VUE_APP_BASE_API
    + '/community/upload/excel/' + res.data
})

exportExcel() 的请求配置是:

javascript 复制代码
export function exportExcel(data) {
  return request({
    url: '/sys/person/exportExcel',
    method: 'get',
    params: data
  })
}

这就是两次请求:Axios 先取 JSON,location.href 再让浏览器访问文件。当前实现没有设置 responseType: 'blob',也不需要按 Blob 处理第一次响应。

服务器文件保存在 D:/community/excel/,前端访问的却是 /community/upload/excel/。这两个路径不必一致,中间通过资源映射对应起来:

java 复制代码
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
    @Value("${upload.face}")
    String face;

    @Value("${upload.excel}")
    String excel;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/community/face/**")
                .addResourceLocations("file:/" + face);

        registry.addResourceHandler(
                    "/community/upload/excel/**", "/community/excel/**")
                .addResourceLocations("file:/" + excel);
    }
}

第一条是原来的人脸图片映射,Excel 用后面这一条。保留了两个 Excel URL,它们指向同一目录,不会因此产生两份文件。file: 表示磁盘资源,配置中的目录末尾保留 /。相关配置见 Spring 静态资源文档。

例如文件名是 demo.xls,开发环境的访问过程是:

text 复制代码
/dev-api/community/upload/excel/demo.xls
    ↓ 前端代理去掉 /dev-api,转发到 localhost:8090
/community/upload/excel/demo.xls
    ↓ 后端资源映射
D:/community/excel/demo.xls

生成接口返回的是文件名,实际文件内容由这第二次请求取得。

相关推荐
打工仔折腾 AI1 小时前
让时序预测真正参与决策:TimechoAI 工程化落地与管线设计实践
人工智能·后端·python·算法·机器学习·性能优化
CarIise2 小时前
Spring MVC入门与执行流程
后端
雨中飘荡的记忆2 小时前
子代理和 Skills 怎么写?3 个实用例子
后端
IT_陈寒2 小时前
一文搞懂React的核心概念
前端·人工智能·后端
weixin_BYSJ19872 小时前
【计算机毕设】基于Spring_Boot_Vue的马拉松报名与选手成绩追踪系统05445
java·vue.js·spring boot·学习·spring·django·课程设计
高频因子挖掘机2 小时前
多只股票的 K 线日期对不上怎么办?用 Pandas MultiIndex 处理时间序列对齐
后端·github·api
高频因子挖掘机2 小时前
行情 API 返回了数据,为什么量化策略仍然会出错?用分层设计隔离数据故障
后端·github·api
十年Java程序媛11 小时前
Java枚举Enum完整教程|替代静态常量,开发与面试要点
java·开发语言·spring boot·后端·面试
我是人✓11 小时前
SpringBoot静态资源映射揭秘
java·spring boot·后端