前言
在 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 | ------WebKitFormBoundaryqYpuu3ZL3RyPf0S9 |
問題分析
我們發現 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 | // FormData() is multipart/form-data type |
這邊也需要 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