半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

一起理解 HTML 當中的 form-data

一起理解 HTML 當中的 form-data

前言

form 表單在網頁中是相當常見的應用,不只能夠傳輸純文字,也能夠達到檔案上傳的功能。不過也因為 form 的行為跟其他傳輸方式較為不同,有時候也產生疑惑與誤解。

這篇文章試著從閱讀規範理解來龍去脈後,深入理解 form 背後到底做了哪些事情,以及 form-data 與其他傳輸方式的不同之處,最後再提及 HTML 的 <form/> 標籤背後做了哪些事情。

主要涵蓋下列幾個重點:

  • multipart/form-data 是什麼以及為什麼需要它
  • 如何理解請求格式
  • 知道 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

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 的時候就知道這個屬性的資料已經讀取完畢,可以開始讀取下一個資料了。

Group 2

規範當中並沒有完全限制 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 觀察封包內容是否有被正確解析:

form-data HTTP 請求封包解析

form-data HTTP 請求封包解析2

可以看到 Encapsulated multipart part 的部分,name=Kalan 與檔案內容的部分都有被正確解析。這說明了幾件事:

  • multipart/form-data 也是 HTTP 請求的一種
  • 只要符合格式不用瀏覽器也可以發送請求
  • 檔案內容必須在伺服器端解析(請求只是將一坨 binary data 傳過去)

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 標籤,以及身為開發者的我們應該注意哪些事情。