今天我们来聊下关于HTML表单(form)中form-data的玩法。众所周知,处理表单应用,是一个很常用的功能,其实有很多插件可以使用,您也可以不用插件去处理,比如上篇文章,我们就分享过,如下:
但是我们今天不是讨论是否采用插件的话题,而是另一个主题:form-data。
前言 {#前言}
form
表单在网页中是相当常见的应用,不只能够传输纯文字,也能够达到档案上传的功能。不过也因为form 的行为跟其他传输方式较为不同,有时候也产生疑惑与误解。
这篇文章试着从阅读规范理解来龙去脉后,深入理解form 背后到底做了哪些事情,以及 form-data
与其他传输方式的不同之处,最后再提及HTML 的 <form/>
标签背后做了哪些事情。
主要涵盖下列几个重点:
-
是什么以及为什么需要它
-
如何理解请求格式
-
知道form-data 解决了什么问题
为什么需要form-data?
资料的传递需要双方对资料格式有一定的认知。在网路的世界里,我们使用protocol 来规范资料传递的形式。透过HTTP 的 Content-Type
标头,我们可以知道这个请求的内容是什么,进而用对应的方式解读资料。
MIME Type定义了传输格式的种类:
-
Content-Type: application/json
代表请求内容是JSON -
Content-Type: image/png
代表请求内容是图片档
其中 multipart/form-data
就属于 Content-Type
的其中之一。
一般的 Content-Type
往往只能传送一种形式的资料,但在网页的应用当中我们还可能想要上传档案、图片、影片在表单里头,这样的需求促成了 multipart/form-data
规范的出现(RFC7578【https://tools.ietf.org/html/rfc7578】)
form-data 请求解析 {#form-data-請求解析}
multipart/form-data
最大的用处在于使用者可以把复数个资料格式一次传送(一个请求)出去,主要用在HTML 的表单里头,或是在实作档案上传功能时使用到。
接下来我们来观察一下一个 multipart/form-data
的格式长怎么样。要发送一个Content Type 为 multipart/form-data
的请求,可以用HTML 的form 标签达成(或是使用JavaScript 的FormData):
<form enctype="multipart/form-data" action="/upload" method="POST">
<input type="text" name="name" />
<input type="file" name="file" />
<button>Submit</button>
</form>
当点击Submit 按钮时,浏览器会发送一个POST 请求:
POST /upload HTTP/1.1
Host: localhost:3000
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryFYGn56LlBDLnAkfd
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36
------WebKitFormBoundaryFYGn56LlBDLnAkfd
Content-Disposition: form-data; name="name"
Test
------WebKitFormBoundaryFYGn56LlBDLnAkfd
Content-Disposition: form-data; name="file"; filename="text.txt"
Content-Type: text/plain
Hello World
------WebKitFormBoundaryFYGn56LlBDLnAkfd--
因为网页上的请求都是基于HTTP,所以 multipart/form-data
也会是一个HTTP 请求,格式被规范在RFC 当中。
要理解一个 multipart/form-data
请求有两个重点:
-
知道boundary 的作用
-
知道每个格式的意义
boundary 的作用 {#boundary-的作用}
Content-Type: multipart/form-data; boundary=------WebKitFormBoundaryFYGn56LlBDLnAkfd
在Content-Type 当中,我们可以看到boundary 后面接着一坨奇怪的字串。这个boundary 的作用是什么呢?
前面有提到,multipart/form-data
的目的在于让不同格式的资料可以透过同一个请求发送,所以要有一个方式判断每个资料的界限在哪里,以query parameter 为例:a=b&c=d
的 &
就是一个分界点,让电脑有办法知道什么时候分割资料。每次电脑看到这个boundary 的时候就知道这个属性的资料已经读取完毕,可以开始读取下一个资料了。
在规范当中并没有完全限制boundary 的格式,但还是有定义长度跟允许的字元:
-
开头是两个hypen
-
总长度在70 以内(不包含hypen 本身)
-
只接受ASCII 7bit
因此像 helloworldboundary
这样的字串也是完全合法的boundary。
Content-Disposition {#content-disposition}
在 multipart/form-data
里面,Content-Disposition 的作用在于描述这个资料的格式。
Content-Disposition: form-data; name="name"
明了这是 form-data
里面一个field,名字为name
。
如果是档案的话后面还会额外加上filename
,并且在下一行加入 Content-Type
来描述档案的类型:
Content-Disposition: form-data; name="file"; filename="text.txt"
Content-Type: text/plain
空一行之后接着才是资料内容:
------WebKitFormBoundaryFYGn56LlBDLnAkfd
Content-Disposition: form-data; name="name"
Test
------WebKitFormBoundaryFYGn56LlBDLnAkfd
Content-Disposition: form-data; name="file"; filename="text.txt"
Content-Type: text/plain
Hello World
------WebKitFormBoundaryFYGn56LlBDLnAkfd--
范例当中我使用纯文字档上传,如果用图片档或是其他格式档案的话则会以binary 显示。
Content-Disposition: form-data; name="file"; filename="image.png"
Content-Type: image/png
PNG
IHDR¤@¬
ÃiCCPICC ProfileHTSÙϽétBoô*%ôÐ{³@B!!ØPGp,¨2 cd,(¶A±a :l¨¼<ÂÌ{ë½·Þ¿ÖY÷»;ûì½ÏYçܵÏ
(省略)
实作一个 multipart/form-data
请求 {#實作一個-multipartform-data-請求}
知道了 multipart/form-data
的请求格式之后,就可以自己写一个来观察看看了。在这边使用 node.js
当作范例:
const http = require('http');
const fs = require('fs');
const content = fs.readFileSync('./text.txt');
const formData = {
name: 'Kalan',
file: content,
};
let payload = '';
const boundary = 'helloworld';
Object.keys(formData).forEach((k) => {
let content;
if (k === 'file') {
content = [
`\r\n--${boundary}`,
`\r\nContent-Disposition: multipart/form-data; name=${k}; filename="text.txt"`,
`\r\nContent-Type: text/plain`,
`\r\n`,
`\r\n${formData[k]}`,
].join('');
} else {
content = [
`\r\n--${boundary}`,
`\r\nContent-Disposition: multipart/form-data; name=${k}`,
`\r\n`,
`\r\n${formData[k]}`,
].join('');
}
payload += content;
});
payload += `\r\n--${boundary}--`;
const options = {
host: 'localhost',
port: '3000',
path: '/upload',
protocol: 'http:',
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data; boundary=helloworld',
'Content-Length': Buffer.byteLength(payload),
},
};
const req = http.request(options, (res) => {});
req.write(payload);
req.end();
实作上很简单,就只是将规范定义的格式填入request body 而已,比较要注意的地方在于每个boundary 都会以两个hypen 开头,最后一个boundary 则会再以两个hypen 当作结尾。
之后我们透过Wireshark 观察封包内容是否有被正确解析:
可以看到Encapsulated multipart part 的部分,name=Kalan
与档案内容的部分都有被正确解析。这说明了几件事:
-
multipart/form-data
也是HTTP 请求的一种 -
只要符合格式不用浏览器也可以发送请求
-
档案内容必须在伺服器端解析(请求只是将一坨binary data 传过去)
application/x-www-form-urlencoded {#applicationx-www-form-urlencoded}
如果在表单当中使用GET 方法送出,那么所有表单的内容都以url encoded 的方式被传送。举例来说,以下的HTML 点击Submit 按钮后会变成/upload?name=Kalan&file=filename
,就算 enctype
指定 multipart/form-data
还是会以 application/x-www-form-urlencoded
的形式送出。
<form enctype="multipart/form-data" action="/upload" method="GET">
<input type="text" name="name" />
<input type="file" name="file" />
<button>Submit</button>
</form>
总结 {#總結}
这篇文章试着从规范理解multipart/form-data
,一起探讨form-data 解决了哪些问题,并试着自己建立一个符合规范的 multipart/form-data
请求,进而对这个构造比较特别的HTTP 请求有更深入的了解。
multipart/form-data
对于网页应用来说有几个好处:
-
不同格式的资料可以透过一个请求发送
-
可以达到使用者传送档案的需求
-
浏览器有统一的规范可以实作
对开发者来说,理解 multipart/form-data
有几个目的:
-
知道在网页上达成档案上传的原理
-
基于HTTP 请求如何规范不同格式资料传输
-
对于原理的掌握加快开发速度
下一篇文章会以 <form>
这个标签为主题,探讨form 标签与FormData 的应用,以及身为开发者的我们应该注意哪些事情。请持续关注Web前端之家动态吧!