1 概述
MIME 的定义、由来
MIME
(Multipurpose Internet Mail Extensions
) 多用途互联网邮件扩展类型。- MIME 是设定某种扩展名的文件用一种应用程序来打开的方式类型,当该扩展名文件被访问的时候,浏览器会自动使用指定应用程序来打开。
多用于指定一些客户端自定义的文件名,以及一些媒体文件打开方式。
- 诞生的原因:最初是为了解决在不同的电子邮件系统之间搬移报文时存在的问题。
- 被HTTP协议借用:正因为MIME在电子邮件系统中工作得非常出色,所以 HTTP 协议也采用了它,用它来描述、并标记万维网的多媒体内容。
即:在万维网( World Wide Web)中使用的HTTP协议中也使用了MIME的框架,标准被扩展为互联网多媒体资源类型。
MIME 类型的描述格式
MIME type
:= 一种主要的对象类型/
一种特定的子类型
例如:
text/plain
、text/html
、image/jpeg
、application/json
、application/x-www-form-urlencoded
、multipart/form-data
MIME 类型
- 总的可以划分为:
application/*
audio/*
chemical/*
image/*
message/*
model/*
multipart/*
text/*
video/*
- ...
2 Web开发实践篇
application/x-www-form-urlencoded
(浏览器的默认类型)
前端普通表单场景
- HTML 表单代码
<form action="http://localhost:8888/task/" method="POST">
First name: <input type="text" name="firstName" value="Mickey&"><br>
Last name: <input type="text" name="lastName" value="Mouse "><br>
<input type="submit" value="提交">
</form>
- 通过测试发现可以正常访问接口,在
Chrome
的开发者工具中可见,表单上传MIME
格式为application/x-www-form-urlencoded
(Request Headers中),参数的格式为key=value&key=value
。
- 我们可以看出,服务器知道参数用符号
&
间隔,如果参数值中需要&
,则必须对其进行编码。编码格式就是application/x-www-form-urlencoded
(将键值对的参数用&
连接起来,如果有空格,将空格转换为+加号;有特殊符号,将特殊符号转义为ASCII HEX
值)。
application/x-www-form-urlencoded
是浏览器默认的MIME类型。
ps:可以在这个网址测试表单:http://www.runoob.com/try/try.php?filename=tryhtml_form_submit
后端接口调用场景
- 在代码中使用
application/x-www-form-urlencoded
MIME 类型设置Request
属性调用接口。
private static String doPost(String strUrl, String content) {
String result = "";
try {
URL url = new URL(strUrl);
//通过调用url.openConnection()来获得一个新的URLConnection对象,并且将其结果强制转换为HttpURLConnection.
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setRequestMethod("POST");
//设置连接的超时值为30000毫秒,超时将抛出SocketTimeoutException异常
urlConnection.setConnectTimeout(30000);
//设置读取的超时值为30000毫秒,超时将抛出SocketTimeoutException异常
urlConnection.setReadTimeout(30000);
//将url连接用于输出,这样才能使用getOutputStream()。getOutputStream()返回的输出流用于传输数据
urlConnection.setDoOutput(true);
//设置通用请求属性为默认浏览器编码类型
urlConnection.setRequestProperty("content-type", "application/x-www-form-urlencoded");
//getOutputStream()返回的输出流,用于写入参数数据。
OutputStream outputStream = urlConnection.getOutputStream();
outputStream.write(content.getBytes());
outputStream.flush();
outputStream.close();
//此时将调用接口方法。getInputStream()返回的输入流可以读取返回的数据。
InputStream inputStream = urlConnection.getInputStream();
byte[] data = new byte[1024];
StringBuilder sb = new StringBuilder();
//inputStream每次就会将读取1024个byte到data中,当inputSteam中没有数据时,inputStream.read(data)值为-1
while (inputStream.read(data) != -1) {
String s = new String(data, Charset.forName("utf-8"));
sb.append(s);
}
result = sb.toString();
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
return result;
}
public static void main(String[] args) {
String str = doPost("http://localhost:8888/task/", "firstName=Mickey%26&lastName=Mouse ");
System.out.println(str);
}
multipart/form-data
定义
定义
- 定义
媒体类型
multipart/form-data
遵循multipart MIME
数据流定义(该定义可以参考Section 5.1 - RFC2046
),大概含义就是:媒体类型multipart/form-data
的数据体由多个部分组成,这些部分由一个固定边界值(Boundary
)分隔。
- 使用方式
- 媒体类型
multipart/form-data
常用于POST
方法下的HTTP请求,至于作为HTTP响应的场景相对少见。
multipart/form-data
请求体布局
# 请求头 - 这个是必须的,需要指定Content-Type为multipart/form-data,指定唯一边界值
Content-Type: multipart/form-data; boundary=${Boundary}
# 请求体
--${Boundary}
Content-Disposition: form-data; name="name of file"
Content-Type: application/octet-stream
bytes of file
--${Boundary}
Content-Disposition: form-data; name="name of pdf"; filename="pdf-file.pdf"
Content-Type: application/octet-stream
bytes of pdf file
--${Boundary}
Content-Disposition: form-data; name="key"
Content-Type: text/plain;charset=UTF-8
text encoded in UTF-8
--${Boundary}--
区别
- 媒体类型
multipart/form-data
相对于其他媒体类型(如application/x-www-form-urlencoded
)来说,最明显的不同点是:
- 请求头的
Content-Type
属性除了指定为multipart/form-data
,还需要定义boundary
参数- 请求体中的请求行数据是由多部分组成,
boundary
参数的值模式--${Boundary}
用于分隔每个独立的分部- 每个部分必须存在请求头
Content-Disposition: form-data; name="${PART_NAME}";
,这里的${PART_NAME}
需要进行URL编码,另外filename
字段可以使用,用于表示文件名称,但是其约束性比name
属性低(因为并不确认本地文件是否可用或者是否有异议)- 每个部分可以单独定义
Content-Type
和该部分的数据体- 请求体以
boundary
参数的值模式--${Boundary}--
作为结束标志
{% note warning flat %} RFC7578中提到两个multipart/form-data
过期的使用方式:
其一是Content/Transfer-Encoding
请求头的使用,这里也不展开其使用方式;
其二是请求体中单个表单属性传输多个二进制文件的方式建议换用multipart/mixed
(一个"name"对应多个二进制文件的场景)
- 特殊地:如果某个部分的内容为文本,其
Content-Type
为text/plain
,可指定对应的字符集,如Content-Type: text/plain;charset=UTF-8
可以通过_charset_
属性指定默认的字符集,用法如下:
Content-Disposition: form-data; name="_charset_"
UTF-8
--ABCDE--
Content-Disposition: form-data; name="field"
...text encoded in UTF-8...
ABCDE--
Boundary 参数取值规约
Boundary
参数取值规约如下:
Boundary
的值必须以英文中间双横杠--
开头,这个--
称为前导连字符Boundary
的值除了前导连字符以外的部分不能超过70
个字符Boundary
的值不能包含HTTP
协议或者URL
禁用的特殊意义的字符,例如英文冒号:
等- 每个
--${Boundary}
之前默认强制必须为CRLF
,如果某一个部分的文本类型请求体以CRLF
结尾,那么在请求体的二级制格式上,必须显式存在2个CRLF
,如果某一个部分的请求体不以CRLF
结尾,可以只存在一个CRLF
,这两种情况分别称为分隔符的显式类型和隐式类型。
说的比较抽象,见下面的例子:
# 请求头
Content-type: multipart/data; boundary="--abcdefg"
--abcdefg
Content-Disposition: form-data; name="x"
Content-type: text/plain; charset=ascii
It does NOT end with a linebreak # <=== 这里没有CRLF,隐式类型
--abcdefg
Content-Disposition: form-data; name="y"
Content-type: text/plain; charset=ascii
It DOES end with a linebreak # <=== 这里有CRLF,显式类型
--abcdefg
## 直观看隐式类型的CRLF
It does NOT end with a linebreak CRLF --abcdefg
## 直观看显式类型的CRLF
It DOES end with a linebreak CRLF CRLF --abcdefg
前端普通表单场景
CASE1 spring:MultipartFile
- 那么当服务器使用
multipart/form-data
接收POST
请求时,服务器怎么知道每个参数的开始位置和结束位置呢?
<form action="http://localhost:8888/task/" method="POST" enctype="multipart/form-data">
First name: <input type="text" name="firstName" value="Mickey&"><br>
Last name: <input type="text" name="lastName" value="Mouse "><br>
<input type="submit" value="提交">
</form>
- 我们在开发者工具中可以看出
multipart/form-data
不会对参数编码,使用的boundary
(分割线),相当于&
,boundary
的值是----Web**AJv3
。
CASE2 spring:MultipartHttpServletRequest
//org.springframework.boot:spring-boot-starter-web:2.6.0
@RestController
public class TestController {
@PostMapping(path = "/test")
public ResponseEntity<?> test(MultipartHttpServletRequest request) {
return ResponseEntity.ok("ok");
}
}
-
Postman的模拟请求如下:
-
后台控制器得到的请求参数如下:
后面编写的客户端可以直接调用此接口进行调试。
前端文件上传场景
- 上传文件时,也要指定MIME类型为
multipart/form-data
。
<form method="POST" action="http://localhost:8888/upload" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
- 如果是SpringMVC项目,要服务器能接受
multipart/form-data
类型参数,还要在spring上下文配置以下内容,SpringBoot项目则不需要。
- 定义解析器
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="defaultEncoding" value="utf-8"></property>
<!-- 设置最大上传文件大小 -->
<property name="maxUploadSize" value="100000"/>
</bean>
controller
层API
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.multipart.MultipartFile;
@Controller
public class YourController {
@PostMapping("/upload")
public String handleFileUpload(MultipartFile file) {
// 处理上传的文件, 例如保存文件
// file.transferTo(new File("your-desired-location"));
return "success"; // 返回处理结果页面
}
}
- 模拟:发起请求
我们可以通过FormData对象模拟表单提交,用原始的
XMLHttpRequest
来发送数据,让我们可以在Chrome开发工具中查看到具体格式:
<form id="form">
First name: <input type="text" name="firstName" value="Mickey"><br>
Last name: <input type="text" name="lastName" value="Mouse"><br>
<input type="file" name="file"><br>
</form>
<button onclick="submitForm()">提交</button>
<script>
function submitForm() {
var formElement = document.getElementById("form");
var xhr = new XMLHttpRequest();
xhr.open("POST", "/upload");
xhr.send(new FormData(formElement));
}
</script>
- Demo
后端接口调用场景
CASE1 封装请求体转换为字节容器的模块
- 这里的边界值全用显式实现,边界值直接用固定前缀加上
UUID
生成即可。简单实现过程中做了一些简化:
- 只考虑提交文本表单数据和二进制(文件)表单数据
- 基于上一点,每个部分都明确指定
Content-Type
这个请求头- 文本编码固定为
UTF-8
MultipartWriter
public class MultipartWriter {
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private static final byte[] FIELD_SEP = ": ".getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] CR_LF = "\r\n".getBytes(StandardCharsets.ISO_8859_1);
private static final String TWO_HYPHENS_TEXT = "--";
private static final byte[] TWO_HYPHENS = TWO_HYPHENS_TEXT.getBytes(StandardCharsets.ISO_8859_1);
private static final String CONTENT_DISPOSITION_KEY = "Content-Disposition";
private static final String CONTENT_TYPE_KEY = "Content-Type";
private static final String DEFAULT_CONTENT_TYPE = "multipart/form-data; boundary=";
private static final String DEFAULT_BINARY_CONTENT_TYPE = "application/octet-stream";
private static final String DEFAULT_TEXT_CONTENT_TYPE = "text/plain;charset=UTF-8";
private static final String DEFAULT_CONTENT_DISPOSITION_VALUE = "form-data; name=\"%s\"";
private static final String FILE_CONTENT_DISPOSITION_VALUE = "form-data; name=\"%s\"; filename=\"%s\"";
private final Map<String, String> headers = new HashMap<>(8);
private final List<AbstractMultipartPart> parts = new ArrayList<>();
private final String boundary;
private MultipartWriter(String boundary) {
this.boundary = Objects.isNull(boundary) ? TWO_HYPHENS_TEXT +
UUID.randomUUID().toString().replace("-", "") : boundary;
this.headers.put(CONTENT_TYPE_KEY, DEFAULT_CONTENT_TYPE + this.boundary);
}
public static MultipartWriter newMultipartWriter(String boundary) {
return new MultipartWriter(boundary);
}
public static MultipartWriter newMultipartWriter() {
return new MultipartWriter(null);
}
public MultipartWriter addHeader(String key, String value) {
if (!CONTENT_TYPE_KEY.equalsIgnoreCase(key)) {
headers.put(key, value);
}
return this;
}
public MultipartWriter addTextPart(String name, String text) {
parts.add(new TextPart(String.format(DEFAULT_CONTENT_DISPOSITION_VALUE, name), DEFAULT_TEXT_CONTENT_TYPE, this.boundary, text));
return this;
}
public MultipartWriter addBinaryPart(String name, byte[] bytes) {
parts.add(new BinaryPart(String.format(DEFAULT_CONTENT_DISPOSITION_VALUE, name), DEFAULT_BINARY_CONTENT_TYPE, this.boundary, bytes));
return this;
}
public MultipartWriter addFilePart(String name, File file) {
parts.add(new FilePart(String.format(FILE_CONTENT_DISPOSITION_VALUE, name, file.getName()), DEFAULT_BINARY_CONTENT_TYPE, this.boundary, file));
return this;
}
private static void writeHeader(String key, String value, OutputStream out) throws IOException {
writeBytes(key, out);
writeBytes(FIELD_SEP, out);
writeBytes(value, out);
writeBytes(CR_LF, out);
}
private static void writeBytes(String text, OutputStream out) throws IOException {
out.write(text.getBytes(DEFAULT_CHARSET));
}
private static void writeBytes(byte[] bytes, OutputStream out) throws IOException {
out.write(bytes);
}
interface MultipartPart {
void writeBody(OutputStream os) throws IOException;
}
@RequiredArgsConstructor
public static abstract class AbstractMultipartPart implements MultipartPart {
protected final String contentDispositionValue;
protected final String contentTypeValue;
protected final String boundary;
protected String getContentDispositionValue() {
return contentDispositionValue;
}
protected String getContentTypeValue() {
return contentTypeValue;
}
protected String getBoundary() {
return boundary;
}
public final void write(OutputStream out) throws IOException {
writeBytes(TWO_HYPHENS, out);
writeBytes(getBoundary(), out);
writeBytes(CR_LF, out);
writeHeader(CONTENT_DISPOSITION_KEY, getContentDispositionValue(), out);
writeHeader(CONTENT_TYPE_KEY, getContentTypeValue(), out);
writeBytes(CR_LF, out);
writeBody(out);
writeBytes(CR_LF, out);
}
}
public static class TextPart extends AbstractMultipartPart {
private final String text;
public TextPart(String contentDispositionValue,
String contentTypeValue,
String boundary,
String text) {
super(contentDispositionValue, contentTypeValue, boundary);
this.text = text;
}
@Override
public void writeBody(OutputStream os) throws IOException {
os.write(text.getBytes(DEFAULT_CHARSET));
}
@Override
protected String getContentDispositionValue() {
return contentDispositionValue;
}
@Override
protected String getContentTypeValue() {
return contentTypeValue;
}
}
public static class BinaryPart extends AbstractMultipartPart {
private final byte[] content;
public BinaryPart(String contentDispositionValue,
String contentTypeValue,
String boundary,
byte[] content) {
super(contentDispositionValue, contentTypeValue, boundary);
this.content = content;
}
@Override
public void writeBody(OutputStream out) throws IOException {
out.write(content);
}
}
public static class FilePart extends AbstractMultipartPart {
private final File file;
public FilePart(String contentDispositionValue,
String contentTypeValue,
String boundary,
File file) {
super(contentDispositionValue, contentTypeValue, boundary);
this.file = file;
}
@Override
public void writeBody(OutputStream out) throws IOException {
try (InputStream in = new FileInputStream(file)) {
final byte[] buffer = new byte[4096];
int l;
while ((l = in.read(buffer)) != -1) {
out.write(buffer, 0, l);
}
out.flush();
}
}
}
public void forEachHeader(BiConsumer<String, String> consumer) {
headers.forEach(consumer);
}
public void write(OutputStream out) throws IOException {
if (!parts.isEmpty()) {
for (AbstractMultipartPart part : parts) {
part.write(out);
}
}
writeBytes(TWO_HYPHENS, out);
writeBytes(this.boundary, out);
writeBytes(TWO_HYPHENS, out);
writeBytes(CR_LF, out);
}
}
这个类已经封装好三种不同类型的部分请求体实现,forEachHeader()
方法用于遍历请求头,而最终的write()
方法用于把请求体写入到OutputStream
中。
HttpURLConnection 实现
实现代码如下(只做最简实现,没有考虑容错和异常处理):
public class HttpURLConnectionTest {
private static final String URL = "http://localhost:9099/test";
public static void main(String[] args) throws Exception {
MultipartWriter writer = MultipartWriter.newMultipartWriter();
writer.addTextPart("name", "throwable")
.addTextPart("domain", "vlts.cn")
.addFilePart("ico", new File("I:\\doge_favicon.ico"));
DataOutputStream requestPrinter = new DataOutputStream(System.out);
writer.write(requestPrinter);
HttpURLConnection connection = (HttpURLConnection) new java.net.URL(URL).openConnection();
connection.setRequestMethod("POST");
connection.addRequestProperty("Connection", "Keep-Alive");
// 设置请求头
writer.forEachHeader(connection::addRequestProperty);
connection.setDoInput(true);
connection.setDoOutput(true);
connection.setConnectTimeout(10000);
connection.setReadTimeout(10000);
DataOutputStream out = new DataOutputStream(connection.getOutputStream());
// 设置请求体
writer.write(out);
StringBuilder builder = new StringBuilder();
BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
String line;
while (Objects.nonNull(line = reader.readLine())) {
builder.append(line);
}
int responseCode = connection.getResponseCode();
reader.close();
out.close();
connection.disconnect();
System.out.printf("响应码:%d,响应内容:%s\n", responseCode, builder);
}
}
执行响应结果:
响应码:200,响应内容:ok
可以尝试加入两行代码打印请求体:
MultipartWriter writer = MultipartWriter.newMultipartWriter();
writer.addTextPart("name", "throwable")
.addTextPart("domain", "vlts.cn")
.addFilePart("ico", new File("I:\\doge_favicon.ico"));
DataOutputStream requestPrinter = new DataOutputStream(System.out);
writer.write(requestPrinter);
控制台输出如下;
JDK内置HttpClient实现
JDK11+
内置了HTTP
客户端实现,具体入口是java.net.http.HttpClient
,实现编码如下:
public class HttpClientTest {
private static final String URL = "http://localhost:9099/test";
public static void main(String[] args) throws Exception {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.of(10, ChronoUnit.SECONDS))
.build();
MultipartWriter writer = MultipartWriter.newMultipartWriter();
writer.addTextPart("name", "throwable")
.addTextPart("domain", "test.cn")
.addFilePart("ico", new File("I:\\doge_favicon.ico"));
ByteArrayOutputStream out = new ByteArrayOutputStream();
writer.write(out);
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
writer.forEachHeader(requestBuilder::header);
HttpRequest request = requestBuilder.uri(URI.create(URL))
.method("POST", HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
System.out.printf("响应码:%d,响应内容:%s\n", response.statusCode(), response.body());
}
}
内置的HTTP组件几乎都是使用Reactive编程模型,使用的API都是相对底层,灵活性比较高但是易用性不高。
CASE2 HttpURLConnection
- 在代码中使用
multipart/form-data
MIME 类型设置Request
属性调用接口时,其中boundary
的值可以在设置Content-Type
时指定,让服务器知道如何拆分它接受的参数。
通过以下代码的调用接口:
private static String doPost(String strUrl, Map<String, String> params, String boundary) {
String result = "";
try {
URL url = new URL(strUrl);
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setRequestMethod("POST");
urlConnection.setConnectTimeout(30000);
urlConnection.setReadTimeout(30000);
urlConnection.setDoOutput(true);
//设置通用请求属性为multipart/form-data
urlConnection.setRequestProperty("content-type", "multipart/form-data;boundary=" + boundary);
DataOutputStream dataOutputStream = new DataOutputStream(urlConnection.getOutputStream());
for (String key : params.keySet()) {
String value = params.get(key);
//注意!此处是\r(回车:将当前位置移到本行开头)、\n(换行:将当前位置移到下行开头)要一起使用
dataOutputStream.writeBytes("--" + boundary + "\r\n");
dataOutputStream.writeBytes("Content-Disposition: form-data; name=\"" + encode(key) + "\"\r\n");
dataOutputStream.writeBytes("\r\n");
dataOutputStream.writeBytes(encode(value) + "\r\n");
}
//最后一个分隔符的结尾后面要跟"--"
dataOutputStream.writeBytes("--" + boundary + "--");
dataOutputStream.flush();
dataOutputStream.close();
InputStream inputStream = urlConnection.getInputStream();
byte[] data = new byte[1024];
StringBuilder sb = new StringBuilder();
while (inputStream.read(data) != -1) {
String s = new String(data, Charset.forName("utf-8"));
sb.append(s);
}
result = sb.toString();
inputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
private static String encode(String value) throws UnsupportedEncodingException {
return URLEncoder.encode(value, "UTF-8");
}
public static void main(String[] args) {
Map<String, String> params = new HashMap<>();
params.put("firstName", "Mickey");
params.put("lastName", "Mouse");
//自定义boundary,有两个要求:使用不会出现在发送到服务器的HTTP数据中的值;并在请求消息中的分割位置都使用相同的值
String boundary = "abcdefg";
String str = doPost("http://localhost:8888/testFile", params, boundary);
System.out.println(str);
}
通过debug,可以看出dataOutputStream的值如下:
参考与推荐文献
- RFC-7578
- RFC-2046
- 理解HTTP协议中的multipart/form-data - Weixin/Throwable
X 参考文献
- 《HTTP权威指南 - 人民邮电出版社》
- Post请求的3种编码格式:application/x-www-form-urlencoded和multipart/form-data和application/json - CSDN
- [Linux/Bash/Shell] curl & wget - 博客园/千千寰宇