前言

在 Angular 用 Typescript 做 POST 上傳檔案的時候,在 Safari POST multipart/form-data 時遇到了 501 error code 的問題,但在 Chrome 傳送一樣的檔案卻一切都正常

當時在 Trace/Debug 這個問題時卡了三週多,剛好有時間特地來紀錄回顧一下

multipart/form-data

先簡要說明 multipart/form-data 這個格式

是由文件 RFC 7578 定義

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

顧名思義是傳送多媒體檔案 (MIME data streams),初衷是為了傳送 HTML form-based 的檔案 (也就是網頁開發常使用的 <form> 表單),由這個格式定義的內容可能包含多種不同的格式,比如 text string, integer number, img, pdf 檔案等等

RFC 2388 也闡述了 multipart/form-data 的格式

“multipart/form-data” contains a series of parts. Each part is expected to contain a content-disposition header [RFC 2183] where the disposition type is “form-data”, and where the disposition contains an (additional) parameter of “name”, where the value of that parameter is the original field name in the form. For example, a part might contain a header:
Content-Disposition: form-data; name=”user”
with the value corresponding to the entry of the “user” field.

在做 HTTP POST multipart/form-data 時,Browser 會自動在每個不同欄位的資料之間插入一個 boundary string 來方便讓後端的 Server side 做 split

e.g. ------WebKitFormBoundaryqYpuu3ZL3RyPf0S9 是由 Browser 自己加上去的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
------WebKitFormBoundaryqYpuu3ZL3RyPf0S9
Content-Disposition: form-data; name="offset"

48392
------WebKitFormBoundaryqYpuu3ZL3RyPf0S9
Content-Disposition: form-data; name="id"

b0fdcdcc-5cf9-4b4e-b972-423aa72fdb1d
------WebKitFormBoundaryqYpuu3ZL3RyPf0S9
Content-Disposition: form-data; name="size"

10238
------WebKitFormBoundaryqYpuu3ZL3RyPf0S9
Content-Disposition: form-data; name="checksum"
...

問題分析

我們發現 Safari 在 POST 我們其中一筆檔案時,Server 會 return 501 error code

對應的可能 cause 就是 “checksum mismatch”

Flow of calculating checksum:

  • Client 針對 “name=data” 這一欄位會去算一個 checksum,然後將結果放到 “name=checksum” 欄位供 Server 做比對
  • Server 接收檔案後,會去抓 “name=data”,用一樣的演算法去算 checksum,然後看看結果是不是跟 “name=checksum” 一樣

初分析

Q: 可能是我們用的演算法 library 在 Safari 算出來的結果不一樣?
A: NO. 讓 Safari/Chrome POST 完全一模一樣的檔案,算出來的 Checksum 是一樣的

Q: Safari 在 POST 的瞬間,在 “name=data” 內加了料?
A: NO. 拿 POST 後的檔案與 POST 前的比對並沒有不一樣的字符

確認以上兩種可能都是 NO 之後,我的人生就陷入了大卡關XD

深入分析

在跟俄羅斯主掌 Server 端的 Engineer 一起 Debug 了快兩週後終於越來越接近問題的核心

關鍵就是我們後來發現了 Safari 跟 Chrome 在計算 “name=data” 這個區塊 POST 出去放的 Content-Length 不一樣,才導致 Server return 501 error code

並不是單純的 “checksum mismatch”,但一開始他們看見是 501 error code,跟我們說是 Safari 算錯 checksum 😓,這一點我們 Server side 的 error response 應該要再嚴謹一點,這樣也會讓 Debug 少走點彎路

encoding 差異

我開始去看是不是 Safari 在 POST multipart/form-data 時編碼 (encoding) 的問題

發現我們為了要讓檔案格式容易閱讀,在蒐集的 Text strings 之間都適當的加入了 \n, \t

採納了俄羅斯同事的建議,把最容易有編碼差異的 End of line strings 通通都移除,Safari 就成功 POST 該檔案到 Server 了(???)… 當下真是既開心又傻眼XD

不過這樣不算是解決問題,是一個 Work around,於是我又繼續搗鼓到底是為什麼,我發現並不是 EOL 導致 Safari POST 出問題,因為我有試過在另外兩個較為單純的檔案 (Content-Type: text/string & json) 加入 EOL 去 POST 給 Server,它還是可以 return 200 ok

最後原因是 Safari 在 POST multipart/form-data Type 的檔案時,如果 String 含有 Line breaks,會算出錯誤的 Content-Length,這代表 Safari 可能跟 Chrome 用的 post script 在處理 Line breaks 計算上不太一樣

Data which includes too long segments without CRLF sequences must be encoded with a suitable content-transfer-encoding.

(天曉得我當初啃了多少 RFC 文件= =)

解法

如果要 POST 的多媒體檔案裡面有需要插入 Line breaks,安全的做法是先把字串都轉成單純的 “Binary Data”,而不是直接去傳 “String” 給 Server,其實在很多 Messaging 架構上都是傳輸單純的 Binary 然後再做 deserialize,比如 Google 的 Protocol buffers

The recommended action for an implementation that receives an “application/octet-stream” entity is to simply offer to put the data in a file, with any Content-Transfer-Encoding undone, or perhaps to use it as input to a user-specified process.

在 Typescript 內將 “name=data” 的部分包成 blob type,也就是 application/octet-stream 的形式

1
2
3
// FormData() is multipart/form-data type
const formData = new FormData();
formData.append('data', new Blob([data]));

這邊也需要 Server side 改一下 Parse script,針對 “name=data” 這個欄位的資料做相對應的處理,變成 String 後正確的算出 checksum

所以這個問題確實需要 Both Client/Server 的 effort 才有辦法解掉!

後記

一開始跟俄羅斯的同事有點小誤會

當時經過上面提到的初步分析後,我很肯定的回報說不是 Client code 算錯 Checksum 的問題,還附上了一堆 log & screenshots,但 Server 端也認為決不是他們的問題(這點大概也沒說錯),他可能認為我在推責任給他們 Team,事實上我只是想要 Server 端可以一起來確認到底怎麼回事,比如提供一下我們一傳過去的該檔案到 Server 後是怎麼被 Parsing 的 log

所幸一來一往也慢慢越來越接近 root cause,對方後來眉頭一皺發現案情不單純🧐,態度也比較客氣了

這次在解這個 issue,更有感於溝通技巧真的很重要,俄羅斯大佬不好惹捏,那位同事名字還叫 Vladimir lol