前端后端实现文件上传
2026/6/25 15:07:59 网站建设 项目流程

前端后端实现文件上传

​ 依旧打个比方:你(前端)打包文件(FormData)→ 填写快递单(请求头)→ 寄出(POST请求)→ 快递员(后端)→ 商家(后端)收到.

上次讲了下载的实现是二进制写出。那么反过来就是读取请求体二进制写入。书接上回!!!

前端:vue

前端上传文件必须设置请求头

// 前端代码constformData=newFormData()formData.append('file',file)fetch('/api/upload',{method:'POST',body:formData,headers:{'Content-Type':'multipart/form-data'// ← 告诉后端:我传的是文件!}})

为什么必须设置Content-Type: multipart/form-data

HTTP 请求本质

HTTP 请求 = 请求行 + 请求头 + 请求体 请求头(Header):告诉服务器"我是什么类型" 请求体(Body):实际的数据内容

服务器需要知道怎么解析请求体!


三种常见的 Content-Type

Content-Type请求体格式用途
application/json{"name":"张三"}传 JSON 数据
application/x-www-form-urlencodedname=张三&age=18传表单数据(URL 编码)
multipart/form-data二进制数据(文件)传文件

后端如何识别?

@PostMapping("/upload")publicAjaxResultupload(@RequestParam("file")MultipartFilefile){// Spring 根据请求头 Content-Type 决定如何解析// 如果是 multipart/form-data → 解析成 MultipartFile// 如果是 application/json → 解析成 @RequestBody}
请求头后端解析方式结果
multipart/form-data解析成MultipartFile✅ 成功接收
application/json尝试解析成 JSON❌ 报错:Current request is not a multipart request

请求头完整示例

POST /api/upload HTTP/1.1 Host: localhost:8080 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxx Content-Length: 12345 ------WebKitFormBoundaryxxx Content-Disposition: form-data; name="file"; filename="test.xlsx" Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet [文件二进制数据] ------WebKitFormBoundaryxxx--

关键部分

参数作用
multipart/form-data告诉后端:这是文件上传
boundary=xxx分隔符,用于分割多个文件/字段

如果没设置会怎样?

不设置(或设错)

// ❌ 错误:用 application/json 上传文件fetch('/api/upload',{method:'POST',body:JSON.stringify({file:fileData}),headers:{'Content-Type':'application/json'}})

后端报错

org.springframework.web.multipart.MultipartException: Current request is not a multipart request

因为后端期望multipart,收到的却是json,格式不匹配!


浏览器自动设置

// ✅ 用 FormData 时,浏览器会自动设置 Content-TypeconstformData=newFormData()formData.append('file',file)fetch('/api/upload',{method:'POST',body:formData// 不需要手动设置 headers!浏览器会自动加上:// Content-Type: multipart/form-data; boundary=xxx})
// 浏览器自动发送的请求头 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

所以一般情况下不需要手动设置Content-Type


但是 axios 可能会覆盖

// axios 拦截器统一设置了 Content-Typeaxios.interceptors.request.use(config=>{config.headers['Content-Type']='application/json;charset=utf-8'// ❌ 会覆盖returnconfig})// 上传文件时被覆盖了,后端就识别不了

解决方案:直接判断类型,不是就用默认设置的请求头。

if(!(config.datainstanceofFormData)){config.headers['Content-Type']='application/json;charset=utf-8'}

总结

问题答案
为什么要设置?告诉后端"这是文件",后端才知道怎么解析
设成什么?multipart/form-data
不设会怎样?后端报错:Current request is not a multipart request
谁负责设置?浏览器自动设置,但 axios 可能会覆盖
解决方案上传文件时不要手动设置,或判断 FormData 时删除

一句话:Content-Type: multipart/form-data告诉后端"我传的是文件,请用 MultipartFile 接收"

后端:Java

下载:后端 OutputStream → 前端读 Blob 上传:前端 FormData → 后端读 MultipartFile(本质是Blob)

本质上还是读取请求体里面的二进制文件。MultipartFile本质上还是实现了InputStreamSource可以看源码。

publicinterfaceMultipartFileextendsInputStreamSource{StringgetName();@NullableStringgetOriginalFilename();// 原始文件名@NullableStringgetContentType();// 文件类型booleanisEmpty();// 是否为空longgetSize();// 文件大小byte[]getBytes()throwsIOException;InputStreamgetInputStream()throwsIOException;// 获取输入流defaultResourcegetResource(){returnnewMultipartFileResource(this);}voidtransferTo(Filedest)throwsIOException,IllegalStateException;// 保存到磁盘defaultvoidtransferTo(Pathdest)throwsIOException,IllegalStateException{FileCopyUtils.copy(this.getInputStream(),Files.newOutputStream(dest));}}

那我们就要搞懂明白,MultipartFile是二进制文件。知道他的方法即可。本质上就是InputStream的封装,代表上传文件的二进制数据流。

HTTP 请求体(二进制) ↓ ServletRequest.getInputStream() ↓ Spring 解析 multipart/form-data ↓ MultipartFile 对象(封装了 InputStream) ↓ 你的业务代码

常用方法对照

方法作用本质
getBytes()获取文件字节数组把流读成 byte[]
getInputStream()获取输入流直接读流
transferTo(File)保存到磁盘InputStream → FileOutputStream
getOriginalFilename()原始文件名从请求头解析
getSize()文件大小流的长度
isEmpty()是否为空没文件就是空

知道这个就好办了,我们只需要处理MultipartFile这个类

​ 第一步读取请求体的参数,用MultipartFile接收。getOriginalFilename()直接读取文件名,然后用EasyExcel,去读取上期传的excel。然后就能快速处理表格,这就简单多了,读取固定的行、列、值。构建好对象,这样就能操作excel,处理业务逻辑。
​ 上期业务:收集好学生信息放到excel模板上传给系统自动添加账号密码,这个模板里面有很多条数据。后端读前端上传的Excel。封装成User对象添加到数据库即可。

file.getInputStream()获取输入流,本质直接读流

​ 我们从输入流中读取到这个对象转存为UserImportDto对象。然后用EasyExcel读取转换成我们要的类即可

/** * 批量导入用户 */@PostMapping("/import")publicAjaxResultimportUsers(@RequestParam("file")MultipartFilefile){// 1. 文件非空校验if(file.isEmpty()){returnAjaxResult.error("请选择要导入的文件");}// 2. 文件格式校验StringfileName=file.getOriginalFilename();if(fileName==null||!(fileName.endsWith(".xlsx")||fileName.endsWith(".xls"))){returnAjaxResult.error("请上传 .xlsx 或 .xls 格式的文件");}try{List<UserImportDto>importList=newArrayList<>();EasyExcel.read(file.getInputStream(),UserImportDto.class,newPageReadListener<UserImportDto>(dataList->{importList.addAll(dataList);})).sheet().doRead();// 调用 Service 处理,直接返回结果字符串StringresultMsg=userService.batchImport(importList);// 判断是否有成功记录if(resultMsg.contains("成功:0条")){returnAjaxResult.error(resultMsg);}else{returnAjaxResult.success(resultMsg);}}catch(IOExceptione){returnAjaxResult.error("导入失败:"+e.getMessage());}}

EasyExcel.read、PageReadListener接口,重点

我们看一下四种不同的写法:EasyExcel.read,注意:PageReadListener是个接口

// 写法1:Lambda(简洁)EasyExcel.read(file.getInputStream(),UserImportDto.class,newPageReadListener<>(dataList->{importList.addAll(dataList);})).sheet().doRead();// 写法2:拆开写(易理解)PageReadListener<UserImportDto>listener=newPageReadListener<>(dataList->{importList.addAll(dataList);});EasyExcel.read(file.getInputStream(),UserImportDto.class,listener).sheet().doRead();// 写法3:匿名内部类(不用 Lambda),接口不能创建接口,接口创建匿名内部实现类PageReadListener<UserImportDto>listener=newPageReadListener<UserImportDto>(){@Overridepublicvoidinvoke(List<UserImportDto>dataList,AnalysisContextcontext){importList.addAll(dataList);}};EasyExcel.read(file.getInputStream(),UserImportDto.class,listener).sheet().doRead();// 写法4:接口创建实现类// 先写一个实现类publicclassMyPageReadListenerimplementsPageReadListener<UserImportDto>{privateList<UserImportDto>importList;publicMyPageReadListener(List<UserImportDto>importList){this.importList=importList;}@Overridepublicvoidinvoke(List<UserImportDto>dataList,AnalysisContextcontext){importList.addAll(dataList);}}// 使用时MyPageReadListenerlistener=newMyPageReadListener(importList);EasyExcel.read(file.getInputStream(),UserImportDto.class,listener).sheet().doRead();

读取页

EasyExcel.read(file.getInputStream(),UserImportDto.class,listener).sheet()// ① 选择要读哪个Sheet.doRead();// ② 开始真正读取
// 什么都不写:默认读取第一个 Sheet.sheet()// 读取第 0 个 Sheet(也是第一个).sheet(0)// 读取第 2 个 Sheet(从0开始).sheet(2)// 读取指定名称的 Sheet.sheet("学生信息")// 读取第0页,从第2行开始.sheet(0).headRowNumber(2)// 执行:真正开始读取reader.doRead();// ← 只有执行这行,才会读取数据

读取完成后

List<UserImportDto>importList=newArrayList<>();// 空列表 size = 01次调用 listener.invoke()→ importList 加了100条 第2次调用 listener.invoke()→ importList 又加了100条 第3次调用 listener.invoke()→ importList 又加了50条(最后一页)// importList有了<UserImportDto>类的数据后,把这些对象给业务层处理,比如存数据库,存盘等等都可以。// 调用 Service 处理,直接返回结果字符串StringresultMsg=userService.batchImport(importList);

业务层处理:我这里做了处理,看插入多少条,失败多少条。最后结果返回给前端。

@OverridepublicStringbatchImport(List<UserImportDto>importList){List<UserDO>users=newArrayList<>();StringBuildererrors=newStringBuilder();intfailCount=0;for(inti=0;i<importList.size();i++){UserImportDtodto=importList.get(i);introwNum=i+2;// Excel 行号(从2开始,因为第1行是表头)// ===== 数据校验 =====// 1. 登录账号if(StringUtils.isBlank(dto.getUserName())){errors.append("第").append(rowNum).append("行:登录账号不能为空;");failCount++;continue;}// 检查账号是否已存在(跳过已存在的用户)UserDOexistUser=userMapper.selectByUserName(dto.getUserName());if(existUser!=null){errors.append("第").append(rowNum).append("行:账号 ").append(dto.getUserName()).append(" 已存在;");failCount++;continue;}// 2. 真实姓名if(StringUtils.isBlank(dto.getRealName())){errors.append("第").append(rowNum).append("行:真实姓名不能为空;");failCount++;continue;}// 3. 角色if(StringUtils.isBlank(dto.getRole())){errors.append("第").append(rowNum).append("行:角色不能为空;");failCount++;continue;}IntegerroleCode=convertRole(dto.getRole());// 4. 班级(学生必填,教师/管理员可选)if(roleCode==1&&StringUtils.isBlank(dto.getClassName())){errors.append("第").append(rowNum).append("行:学生必须填写班级;");failCount++;continue;}// ===== 构建 UserDO =====UserDOuser=newUserDO();user.setUserName(dto.getUserName());user.setRealName(dto.getRealName());user.setRole(roleCode);user.setClassName(dto.getClassName());user.setPassword("123456");// 默认状态:启用user.setStatus(1);// 创建人/更新人(可以从上下文获取当前登录用户)user.setCreateBy("admin_import");users.add(user);}// 批量插入成功的记录intsuccessCount=0;if(!users.isEmpty()){successCount=userMapper.insertBatch(users);}// 构建返回结果字符串StringBuilderresult=newStringBuilder();result.append("导入完成!成功:").append(successCount).append("条");if(failCount>0){result.append(",失败:").append(failCount).append("条");result.append("\n").append(errors.toString());}returnresult.toString();}/** * 角色名称转代码 */privateIntegerconvertRole(StringroleName){switch(roleName.trim()){case"学生":return1;case"教师":return2;case"管理员":return3;default:return1;}}

这是个通用的业务逻辑,当然现在封装的很完善,不需要自己手写输入输出流,这些东西都是现成工具,本质上还是输入输出流。

最后:还不知道上传下载怎么实现的赶紧翻我主页,业务流程都是完善的。记得收藏,后期可能会用,这些都封装好了,成了通用方法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询