前端后端实现文件上传

前端后端实现文件上传

​ 依旧打个比方:你(前端)打包文件(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;
        }
    }

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

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