前言
form
表單在網頁中是相當常見的應用,不只能夠傳輸純文字,也能夠達到檔案上傳的功能。不過也因為 form 的行為跟其他傳輸方式較為不同,有時候也產生疑惑與誤解。
這篇文章試著從閱讀規範理解來龍去脈後,深入理解 form 背後到底做了哪些事情,以及 Form Data 與其他傳輸方式的不同之處,最後再提及 HTML 的 <form/>
標籤背後做了哪些事情。
主要涵蓋下列幾個重點:
multipart/form-data
是什麼以及為什麼需要它- 如何理解請求格式
- 知道 form-data 解決了什麼問題
為什麼需要 Form Data?
資料的傳遞需要雙方對資料格式有一定的認知。在網路的世界裡,我們使用 protocol 來規範資料傳遞的形式。透過 HTTP 的 Content-Type
標頭,我們可以知道這個請求的內容是什麼,進而用對應的方式解讀資料。
MIME Type 定義了傳輸格式的種類:
Content-Type: application/json
代表請求內容是 JSONContent-Type: image/png
代表請求內容是圖片檔
其中 multipart/form-data
就屬於 Content-Type
的其中之一。
一般的 Content-Type
往往只能傳送一種形式的資料,但在網頁的應用當中我們還可能想要上傳檔案、圖片、影片在表單裡頭,這樣的需求促成了 multipart/form-data
規範的出現(RFC7578)
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 的作用
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
在 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
請求
知道了 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 傳過去)
最後一點我覺得蠻多初學者會忽略掉的,並不是將請求用 multipart/form-data
發送之後後端就能直接拿到檔案。需要經過解析之後才能得知檔案內容,換句話說是我們比較好操作的格式。例如在 node.js
當中,一個熱門的處理檔案上傳的套件為 multer,透過它來解析檔案內容。
application/x-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>
這個標籤為主題,探討瀏覽器如何處理這個 HTML 標籤,以及身為開發者的我們應該注意哪些事情。