在 Spring Boot 应用中同时上传文件、JSON和表单数据

一般我们会使用 multipart/form-data 请求来上传文件。multipart/form-data 请求可以有多个子请求体,每个子请求体都可以有自己的 header 和 body。

本文将带你了解如何在 Spring Boot 应用中使用 multipart/form-data 请求同时上传文件、JSON、表单数据。

服务端 Controller

定义文件上传 controller。

package cn.springdoc.demo.controller;

import java.io.IOException;
import java.io.InputStream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import cn.springdoc.demo.model.Meta;

@RestController
@RequestMapping("/upload")
public class UploadController {

    private static final Logger log = LoggerFactory.getLogger(UploadController.class);

    /**
    * 文件上传
    * @param file
    * @param response
    * @return
    * @throws IOException
    */
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String upload (  @RequestPart("logo") MultipartFile file,// 文件
                            @RequestPart("meta") Meta meta,         // 元信息
                            @RequestPart("title") String title      // 标题
                        ) throws IOException {
        
        log.info("Logo 文件名称:{}", file.getOriginalFilename());
        log.info("Logo 表单名称:{}", file.getName());
        log.info("Logo 文件大小:{}", file.getSize());
        log.info("Logo 文件类型:{}", file.getContentType());
        
        log.info("Meta:{}", meta);
        log.info("Title:{}", title);
        
        
        // 丢弃上传的文件数据
        try(InputStream in = file.getInputStream()){
            int ret = StreamUtils.drain(in);
            log.info("丢弃字节:{}", ret);
        }
        return "success";
    }
}

在 controller 中定义了一个文件上传方法。有3个参数,分别是上传的文件、JSON 参数、表单参数。

注意,我们使用的是 @RequestPart 注解,而不是 @RequestParam。spring 会根据 multipart 请求中每个 part 的 content-type 来选择 HttpMessageConverter 对参数进行封装。也就说,使用 @RequestPart 会自动把 multipart 请求中的 JSON 参数封装为 Java 对象。

其中 JSON 对象(Meta)的定义如下。

package cn.springdoc.demo.model;

public class Meta {

    private String host; // 主机地址
    private String keywords; // 关键字
    private String description; // 介绍

    // 省略get/set/toString方法
}

使用 RestTemplate

使用 RestTemplate 同时上传文件、JSON 以及表单参数。

package cn.springdoc.test;


import org.springframework.core.io.FileSystemResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

public class MultipartTest {

    public static void main(String[] args) {
        
        RestTemplate restTemplate = new RestTemplate();

        // 消息头
        HttpHeaders headers = new HttpHeaders();
        // Content-Type 为 multipart/form-data
        headers.setContentType(MediaType.MULTIPART_FORM_DATA); 

        // body builder
        MultipartBodyBuilder builder = new MultipartBodyBuilder();

        // 文件参数
        builder.part("logo", new FileSystemResource("C:\\Users\\KevinBlandy\\Desktop\\512.png"), MediaType.IMAGE_PNG);
        
        // json 参数
        // 这里的 content-type 很重要,spring 会根据此使用对应的 HttpMessageConverter 封装参数为对象
        builder.part("meta", "{\"host\": \"springdoc.cn\", \"keywords\": \"spring,spring boot\", \"description\": \"Everything about spring\"}", MediaType.APPLICATION_JSON);
        
        // 普通表单参数
        builder.part("title", "Spring 中文网");
        
        // 构建完整的 http 消息
        HttpEntity<MultiValueMap<String, HttpEntity<?>>> httpEntity = new HttpEntity<>(builder.build(), headers);

        // 发起请求,获取响应
        ResponseEntity<String> responseEntity = restTemplate.postForEntity("http://localhost:8080/upload", httpEntity, String.class);

        System.out.println(responseEntity.getBody());
    }
}

客户端的代码很简单,通过 MultipartBodyBuilder 来设置文件、JSON、表单参数,注意要正确地设置每个参数对应的 content-type。

测试

启动服务端后,执行客户的测试方法,服务端输出日志如下:

c.s.demo.controller.UploadController     : Logo 文件名称:512.png
c.s.demo.controller.UploadController     : Logo 表单名称:logo
c.s.demo.controller.UploadController     : Logo 文件大小:19825
c.s.demo.controller.UploadController     : Logo 文件类型:image/png
c.s.demo.controller.UploadController     : Meta:Meta [host=springdoc.cn, keywords=spring,spring boot, description=Everything about spring]
c.s.demo.controller.UploadController     : Title:Spring 中文网
c.s.demo.controller.UploadController     : 丢弃字节:19825

如你所见,不论是文件,JSON 还是表单参数都准确地获取到了。

使用 Javascript

在 HTML 客户端中,使用 Javascript 也可以实现同时上传文件、JSON 和表单数据。

在服务端的 src/main/resources 目录下新建 public 目录。在 public 目录中新建一个 index.html 作为客户端,其内容如下:

public 目录是公共资源目录,该目录中的文件可以被浏览器直接访问,目录中的 index.html 是默认主页。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>文件、JSON、表单数据上传</title>
</head>

<body>
    <input type="file"/>

    <script type="text/javascript">

    // 监听文件选择事件
    document.querySelector('input').addEventListener('change', e => {
        let files = e.target.files;
        if (!files){
            return ;
        }
        
        // 选择的文件
        let file = files[0];
        
        // 构建 multipart 请求体
        let formData = new FormData();
        
        // 文件参数
        formData.set('logo', file);
        
        // json 参数
        // 注意,最后指定了 content type 为 json
        formData.set('meta', new Blob([JSON.stringify({'host': 'springdoc.cn', 'keywords': 'spring,spring boot', 'description': 'Everything about spring'})], {type: "application/json"}));
        
        // 普通表单参数
        formData.set('title', 'Spring 中文网')
        
        // 发起请求
        fetch('/upload', {
            method: 'POST',
            body: formData // 请求体
        }).then(resp => {
            if(resp.ok){
                resp.text().then(msg => {
                    console.log(msg);
                });
            } else {
                // TODO 异常响应
            }
        }).catch(err => {
            console.err(err);
        });
    });

    </script>
</body>
</html>
  1. 监听 <input type="file"/> 节点的 change 事件,在用户选择要上传的文件后,通过 javascript 获取到用户选择的文件。
  2. 通过 FormData 对象构建 multipart 请求体,注意要设置 json 请求体的 content-type。
  3. 使用 fetch 发起请求,监听响应。

测试

启动服务器端,打开浏览器访问 http://localhost:8080/

上传客户端

选择文件后就会立即执行上传,查看后端日志:

c.s.demo.controller.UploadController     : Logo 文件名称:512.png
c.s.demo.controller.UploadController     : Logo 表单名称:logo
c.s.demo.controller.UploadController     : Logo 文件大小:19825
c.s.demo.controller.UploadController     : Logo 文件类型:image/png
c.s.demo.controller.UploadController     : Meta:Meta [host=springdoc.cn, keywords=spring,spring boot, description=Everything about spring]
c.s.demo.controller.UploadController     : Title:Spring 中文网(来自 Javascript)
c.s.demo.controller.UploadController     : 丢弃字节:19825

一切OK。

最后,你可以在 谷歌浏览器 中通过 控制台网络 面板查看上传请求的 payload 来更直观地了解 multipart 请求:

------WebKitFormBoundaryIASBRR0cSKzNYBdy
Content-Disposition: form-data; name="logo"; filename="512.png"
Content-Type: image/png


------WebKitFormBoundaryIASBRR0cSKzNYBdy
Content-Disposition: form-data; name="meta"; filename="blob"
Content-Type: application/json

{"host":"springdoc.cn","keywords":"spring,spring boot","description":"Everything about spring"}
------WebKitFormBoundaryIASBRR0cSKzNYBdy
Content-Disposition: form-data; name="title"

Spring 中文网(来自 Javascript)
------WebKitFormBoundaryIASBRR0cSKzNYBdy--