关于 Spring MVC 处理 form 和文件的实例

关于 MediaType

在此之前,得先了解下 HTTP 的 MediaType,MediaType 用来标识内容,即请求体和响应体的编码格式,HTTP 请求的 Header 中的 Accept 和 Content-Type,HTTP 响应的 Header 中的 Content-Type 都使用 MediaType 来标识,其中 Accept 表示请求者希望响应者发送的数据的格式,Content-Type 代表请求体和响应体的内容格式,一些常见的格式见下。

GET 请求不应当携带请求体!

MediaType 描述
text/html HTML 文本
text/plain 纯文本,空格转换为+
application/json 序列化的 JSON 格式
application/x-www-form-urlencoded 键值对形式
multipart/form-data 似乎啥都有

MediaType 包含三个部分——type,subtype 和 charset,其中 type 是/前的值,subtype 是/后的值,比如对于 text/html,它的全格式是 text/html; charset=UTF-8,它的 type 是 text,subtype 是 html。

在这里,application/x-www-form-urlencodedmultipart/form-data是这篇笔记的主角。

x-www-form-urlencoded

application/x-www-form-urlencoded 是最经典的格式了,它是键值对集合,其中键和值使用=分隔,键值对之间使用&分隔。容易意识到,URL 中?后面的部分就是这种格式,而实际上确实如此。

application/x-www-form-urlencoded 是浏览器原生发送表单的默认格式,比如当前端编写这样的表单时,默认发送的就是这样的格式。application/x-www-form-urlencoded 对于 GET 请求和 POST 请求,其行为不一样,处理方法也不一样

form 元素有两个属性,method 和 enctype 需要配置,method 默认是 get,enctype 默认是 application/x-www-form-urlencoded,但对于 GET 请求,enctype 是无效的,

POST

考虑下面这个表单。

1
2
3
4
5
<form action="//localhost:8080/something" method="post">
<p>firstName: <input type="text" name="fname" /></p>
<p>lastName: <input type="text" name="lname" /></p>
<input type="submit" value="Submit" />
</form>

点击按钮后,会发出这样的请求,其中表单数据通过请求体去发送了,因此有 Content-Type。

1
2
3
4
5
POST /something2 HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
Content-Length: 23
......

相应接口的编写有这几种方式,可以看到这里一直都使用 RequestParam 去接受参数,这可能有些反直觉,因为在之前的实践中,我是认为 RequestParam 是专门用于获取 ? 后面的 kv 对的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE 可以省略
@PostMapping(value = "/something", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
String something(@RequestParam String fname, @RequestParam String lname) {
// ...
}

@PostMapping(value = "/something1")
String something2(@RequestParam Map<String, Object> kvPairs) {
// ...
}

// 使用 RequestBody 就是破坏游戏规则了——这里它不管是什么 Content-Type,直接拿到原始数据,在这里是
// fname=hello&lname=world
@PostMapping(value = "/something2")
String something1(@RequestBody String body) {
// ...
}

GET

然后看 get,考虑下面的这个表单。

1
2
3
4
5
<form action="//localhost:8080/something">
<p>firstName: <input type="text" name="fname" /></p>
<p>lastName: <input type="text" name="lname" /></p>
<input type="submit" value="Submit" />
</form>

点击 submit 后,会发出这个请求:

1
2
3
GET /something?fname=hello&lname=world HTTP/1.1
Host: localhost:8080
...... 总之没有 Content-type

需注意:请求中没有 Content-type 属性,该表单的数据通过 url 传递

因此,对于使用 get 请求的表单,相应的接口需这样编写:

1
2
3
4
@GetMapping(value = "/something")
Object something(@RequestParam String fname, @RequestParam String lname) {
// ...
}

可见,其编写方式和暴露一个普通的 GET 接口别无二致。

总结

对于 x-www-form-urlencoded,无论是 GET 请求还是 POST 请求,都使用 RequestParam 去拿到参数,最好应显式指定要接受的 MediaType。

form-data

form-data 也是键值对集合,但其相较于 x-www-form-urlencoded,能够方便地传递二进制数据(特别是文件),下面的实例不考虑 GET 请求。

考虑下面这样的表单。

1
2
3
4
5
6
<form action="//localhost:8080/form" method="post" enctype="multipart/form-data">
<p>firstName: <input type="text" name="fname" /></p>
<p>lastName: <input type="text" name="lname" /></p>
<p>file: <input type="file" name="file" /></p>
<input type="submit" value="Submit" />
</form>

点击 submit 按钮时,发送的请求的请求头如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /form HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:101.0) Gecko/20100101 Firefox/101.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: multipart/form-data; boundary=---------------------------54702276814275734151962949294
Content-Length: 272849
Origin: http://localhost:8081
Connection: keep-alive
Referer: http://localhost:8081/
Cookie: _ga=GA1.1.1104277080.1651411886
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-site
Sec-Fetch-User: ?1

请求体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
-----------------------------54702276814275734151962949294
Content-Disposition: form-data; name="fname"

hello
-----------------------------54702276814275734151962949294
Content-Disposition: form-data; name="lname"

world
-----------------------------54702276814275734151962949294
Content-Disposition: form-data; name="file"; filename="DC03A61BC35DBE767234514D3184BC84.jpg"
Content-Type: image/jpeg

...

处理其的接口如下:

1
2
3
4
@PostMapping(value = "/form", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String form(@RequestParam String lname, @RequestParam String fname, @RequestPart MultipartFile file) {
// ...
}

可见,这里使用 RequestPart 去拿到值,文件使用 MultipartFile 去拿。但仍旧可以使用 RequestParam,下面对注释的摘录介绍了 Requestparam 和 RequestPart 的区别:

In Spring MVC, “request parameters” map to query parameters, form data, and parts in multipart requests. This is because the Servlet API combines query parameters and form data into a single map called “parameters”, and that includes automatic parsing of the request body.

Note that @RequestParam annotation can also be used to associate the part of a “multipart/form-data” request with a method argument supporting the same method argument types. The main difference is that when the method argument is not a String or raw MultipartFile / Part, @RequestParam relies on type conversion via a registered Converter or PropertyEditor while RequestPart relies on HttpMessageConverters taking into consideration the ‘Content-Type’ header of the request part. RequestParam is likely to be used with name-value form fields while RequestPart is likely to be used with parts containing more complex content e.g. JSON, XML).

一些更复杂的使用需参考官方文档(tmd 这里全是宝啊!),有两点需注意:

  • form-data 和 x-www-form-urlencoded 都允许重名的参数,可以通过 List 去全部获取到(需要使用 RequestParam
  • 多个文件能在同一个参数里,同一个参数的所有文件通过List<MultipartFile>去获取到,所有文件通过MultiValueMap<String, MultipartFile>去获取到(需要用 RequestParam

另外,对于这两种表单类型,想使用自定义类型(包括 Map)去接受参数时 Spring 会抱怨no matching editors or conversion strategy found,需要去在控制器中去自定义相应带 InitBinder 注解的方法并(定义和)注册相应 PropertyEditor,下面的代码注册了一个从字符串到 SomeBean 的 Binder,这个配置仅用于获取 query param 或 form data,使用 RequestBody 时不会被影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SomeBean.class
public class SomeBean {
public String a;
public String b;
}

@InitBinder
public void initBinder(WebDataBinder binder) {
JsonMapper jsonMapper = new JsonMapper();
binder.registerCustomEditor(SomeBean.class, new PropertyEditorSupport() {
@Override
public void setAsText(String text) throws IllegalArgumentException {
System.out.println("rua!!!!!!!!");
try {
super.setValue(jsonMapper.readValue(text, SomeBean.class));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
});
}

只要定义了类型 T 的PropertyEditor,则对List<T> Spring MVC 也能处理。

关于文件的下载

前端上传文件说到底只有通过表单以 MultipartFile 上传这一种方式,而下载文件的方法也是需要学习的,Spring MVC 向前端返回文件有数种方式,这里都列举一下。

HttpServletResponse

直接把 InputStream 写入 HttpServletResponse 的流是使用最多,但最不优雅的方式,如果要使用它的话,就必须得把 HttpServletResponse 传递到服务层(如果保证控制器层不负责业务的话),这实在让人难以接受:

1
2
3
4
5
6
7
8
String fileName = "...";
try(InputStream fileStream = new FileInputStream("...");
ServletOutputStream stream = response.getOutputStream()) {
response.setContentType("application/octet-stream");
response.setCharacterEncoding("utf-8");
response.setHeader("Content-disposition", "attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
StreamUtils.copy(fileStream, stream); // 这个函数来自 org.springframework.util.StreamUtils,用于把输入流的值写到输出流
}

Resource,byte[]

org.springframework.core.io.Resource可以用来返回文件,可能常用的实现包括ByteArrayResourceClassPathResourceFileSystemResourceInputStreamResource。虽然是题外话,但 Resource 类读取文件非常方便,应当代替直接使用 ClassLoader 去读取文件的行为

虽然ClassPathResourceFileSystemResource能拿到文件名,但是 Spring 仍会将其当作text/html来返回

1
2
3
4
5
@GetMapping(value = "/resource")
Resource resource() {
ClassPathResource resource = new ClassPathResource("application.properties");
return resource;
}

也可以直接返回byte[]

1
2
3
4
@GetMapping(value = "/byteArr")
byte[] byteArr() throws IOException {
return "hello".getBytes(StandardCharsets.UTF_8);
}

这两种都会默认按text/html去返回,如果想要它们返回文件,则需要在注解上添加produces = MediaType.APPLICATION_OCTET_STREAM_VALUE,这样虽然能让用户去下载到文件,但是文件名是无法修改的,这显然不太合适。

InputStream 作为返回值似乎是非法的。

ResponseEntity

要想自定义文件名,就需要能够对响应头进行更多自定义,如果不想注入 HttpResponse,就只能去返回一个 ResponseEntity 了,而 ResponseEntity 用于返回文件是轻而易举的:

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/resource")
ResponseEntity<Resource> entity() {
Resource file = new ClassPathResource("aloha.txt");
return ResponseEntity.ok()
.headers(header -> {
header.setContentDisposition(
ContentDisposition.attachment()
.filename(file.getFilename()).build());
header.setContentType(MediaType.APPLICATION_OCTET_STREAM);
})
.body(file);
}

我觉着这几乎是最友好的解决方案了,全程都没有使用魔法常量,并且符合通过返回值来回应前端请求这样的直觉,或许这就是最佳实践。


实际上可以再发散一下,操作文件总是和操作流有着密不可分的关系,Spring 对流作为请求体这种情景有很好的支持,包括但不限于异步等的支持,对其进行学习是有些必要的。

这里其实还有一个比较重要的问题:上面所介绍的方法在 OpenFeign 中可用吗?之后再说吧。

参考阅读


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!