前端后端实现文件上传
依旧打个比方:你(前端)打包文件(FormData)→ 填写快递单(请求头)→ 寄出(POST请求)→ 快递员(后端)→ 商家(后端)收到.
上次讲了下载的实现是二进制写出。那么反过来就是读取请求体二进制写入。书接上回!!!
前端:vue
前端上传文件必须设置请求头
javascript
// 前端代码
const formData = new FormData()
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-urlencoded |
name=张三&age=18 |
传表单数据(URL 编码) |
multipart/form-data |
二进制数据(文件) | 传文件 |
后端如何识别?
java
@PostMapping("/upload")
public AjaxResult upload(@RequestParam("file") MultipartFile file) {
// Spring 根据请求头 Content-Type 决定如何解析
// 如果是 multipart/form-data → 解析成 MultipartFile
// 如果是 application/json → 解析成 @RequestBody
}
| 请求头 | 后端解析方式 | 结果 |
|---|---|---|
multipart/form-data |
解析成 MultipartFile |
✅ 成功接收 |
application/json |
尝试解析成 JSON | ❌ 报错:Current request is not a multipart request |
请求头完整示例
http
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 |
分隔符,用于分割多个文件/字段 |
如果没设置会怎样?
不设置(或设错)
javascript
// ❌ 错误:用 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,格式不匹配!
浏览器自动设置
javascript
// ✅ 用 FormData 时,浏览器会自动设置 Content-Type
const formData = new FormData()
formData.append('file', file)
fetch('/api/upload', {
method: 'POST',
body: formData
// 不需要手动设置 headers!浏览器会自动加上:
// Content-Type: multipart/form-data; boundary=xxx
})
http
// 浏览器自动发送的请求头
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
所以一般情况下不需要手动设置 Content-Type!
但是 axios 可能会覆盖
javascript
// axios 拦截器统一设置了 Content-Type
axios.interceptors.request.use(config => {
config.headers['Content-Type'] = 'application/json;charset=utf-8' // ❌ 会覆盖
return config
})
// 上传文件时被覆盖了,后端就识别不了
解决方案:直接判断类型,不是就用默认设置的请求头。
javascript
if (!(config.data instanceof FormData)) {
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
txt
下载:后端 OutputStream → 前端读 Blob
上传:前端 FormData → 后端读 MultipartFile(本质是Blob)
本质上还是读取请求体里面的二进制文件。MultipartFile本质上还是实现了InputStreamSource可以看源码。
java
public interface MultipartFile extends InputStreamSource {
String getName();
@Nullable
String getOriginalFilename(); // 原始文件名
@Nullable
String getContentType(); // 文件类型
boolean isEmpty(); // 是否为空
long getSize(); // 文件大小
byte[] getBytes() throws IOException;
InputStream getInputStream() throws IOException; // 获取输入流
default Resource getResource() {
return new MultipartFileResource(this);
}
void transferTo(File dest) throws IOException, IllegalStateException; // 保存到磁盘
default void transferTo(Path dest) throws IOException, IllegalStateException {
FileCopyUtils.copy(this.getInputStream(), Files.newOutputStream(dest));
}
}
那我们就要搞懂明白,MultipartFile是二进制文件。知道他的方法即可。本质上就是 InputStream 的封装,代表上传文件的二进制数据流。
txt
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读取转换成我们要的类即可
java
/**
* 批量导入用户
*/
@PostMapping("/import")
public AjaxResult importUsers(@RequestParam("file") MultipartFile file) {
// 1. 文件非空校验
if (file.isEmpty()) {
return AjaxResult.error("请选择要导入的文件");
}
// 2. 文件格式校验
String fileName = file.getOriginalFilename();
if (fileName == null || !(fileName.endsWith(".xlsx") || fileName.endsWith(".xls"))) {
return AjaxResult.error("请上传 .xlsx 或 .xls 格式的文件");
}
try {
List<UserImportDto> importList = new ArrayList<>();
EasyExcel.read(file.getInputStream(), UserImportDto.class,
new PageReadListener<UserImportDto>(dataList -> {
importList.addAll(dataList);
}))
.sheet()
.doRead();
// 调用 Service 处理,直接返回结果字符串
String resultMsg = userService.batchImport(importList);
// 判断是否有成功记录
if (resultMsg.contains("成功:0条")) {
return AjaxResult.error(resultMsg);
} else {
return AjaxResult.success(resultMsg);
}
} catch (IOException e) {
return AjaxResult.error("导入失败:" + e.getMessage());
}
}
EasyExcel.read、PageReadListener接口,重点
我们看一下四种不同的写法:EasyExcel.read,注意:PageReadListener是个接口
java
// 写法1:Lambda(简洁)
EasyExcel.read(file.getInputStream(), UserImportDto.class,
new PageReadListener<>(dataList -> {
importList.addAll(dataList);
}))
.sheet()
.doRead();
// 写法2:拆开写(易理解)
PageReadListener<UserImportDto> listener = new PageReadListener<>(dataList -> {
importList.addAll(dataList);
});
EasyExcel.read(file.getInputStream(), UserImportDto.class, listener)
.sheet()
.doRead();
// 写法3:匿名内部类(不用 Lambda),接口不能创建接口,接口创建匿名内部实现类
PageReadListener<UserImportDto> listener = new PageReadListener<UserImportDto>() {
@Override
public void invoke(List<UserImportDto> dataList, AnalysisContext context) {
importList.addAll(dataList);
}
};
EasyExcel.read(file.getInputStream(), UserImportDto.class, listener)
.sheet()
.doRead();
// 写法4:接口创建实现类
// 先写一个实现类
public class MyPageReadListener implements PageReadListener<UserImportDto> {
private List<UserImportDto> importList;
public MyPageReadListener(List<UserImportDto> importList) {
this.importList = importList;
}
@Override
public void invoke(List<UserImportDto> dataList, AnalysisContext context) {
importList.addAll(dataList);
}
}
// 使用时
MyPageReadListener listener = new MyPageReadListener(importList);
EasyExcel.read(file.getInputStream(), UserImportDto.class, listener)
.sheet()
.doRead();
读取页
java
EasyExcel.read(file.getInputStream(), UserImportDto.class, listener)
.sheet() // ① 选择要读哪个Sheet
.doRead(); // ② 开始真正读取
java
// 什么都不写:默认读取第一个 Sheet
.sheet()
// 读取第 0 个 Sheet(也是第一个)
.sheet(0)
// 读取第 2 个 Sheet(从0开始)
.sheet(2)
// 读取指定名称的 Sheet
.sheet("学生信息")
// 读取第0页,从第2行开始
.sheet(0).headRowNumber(2)
// 执行:真正开始读取
reader.doRead(); // ← 只有执行这行,才会读取数据
读取完成后
java
List<UserImportDto> importList = new ArrayList<>(); // 空列表 size = 0
第1次调用 listener.invoke() → importList 加了 100 条
第2次调用 listener.invoke() → importList 又加了 100 条
第3次调用 listener.invoke() → importList 又加了 50 条(最后一页)
// importList有了<UserImportDto>类的数据后,把这些对象给业务层处理,比如存数据库,存盘等等都可以。
// 调用 Service 处理,直接返回结果字符串
String resultMsg = userService.batchImport(importList);
业务层处理:我这里做了处理,看插入多少条,失败多少条。最后结果返回给前端。
java
@Override
public String batchImport(List<UserImportDto> importList) {
List<UserDO> users = new ArrayList<>();
StringBuilder errors = new StringBuilder();
int failCount = 0;
for (int i = 0; i < importList.size(); i++) {
UserImportDto dto = importList.get(i);
int rowNum = i + 2; // Excel 行号(从2开始,因为第1行是表头)
// ===== 数据校验 =====
// 1. 登录账号
if (StringUtils.isBlank(dto.getUserName())) {
errors.append("第").append(rowNum).append("行:登录账号不能为空;");
failCount++;
continue;
}
// 检查账号是否已存在(跳过已存在的用户)
UserDO existUser = 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;
}
Integer roleCode = convertRole(dto.getRole());
// 4. 班级(学生必填,教师/管理员可选)
if (roleCode == 1 && StringUtils.isBlank(dto.getClassName())) {
errors.append("第").append(rowNum).append("行:学生必须填写班级;");
failCount++;
continue;
}
// ===== 构建 UserDO =====
UserDO user = new UserDO();
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);
}
// 批量插入成功的记录
int successCount = 0;
if (!users.isEmpty()) {
successCount = userMapper.insertBatch(users);
}
// 构建返回结果字符串
StringBuilder result = new StringBuilder();
result.append("导入完成!成功:").append(successCount).append("条");
if (failCount > 0) {
result.append(",失败:").append(failCount).append("条");
result.append("\n").append(errors.toString());
}
return result.toString();
}
/**
* 角色名称转代码
*/
private Integer convertRole(String roleName) {
switch (roleName.trim()) {
case "学生":
return 1;
case "教师":
return 2;
case "管理员":
return 3;
default:
return 1;
}
}
这是个通用的业务逻辑,当然现在封装的很完善,不需要自己手写输入输出流,这些东西都是现成工具,本质上还是输入输出流。
最后:还不知道上传下载怎么实现的赶紧翻我主页,业务流程都是完善的。记得收藏,后期可能会用,这些都封装好了,成了通用方法。