springboot上傳文件大小設置:tomcat上傳文件大小限制10M

前言

這兩天在另一個社區看到了一個關於 Tomcat 的提問,還挺有意思。正好自己之前也沒思考過這個問題,今天就結合 Tomcat 機制來聊聊這個“為什麼”。

Tomcat 中是怎麼處理文件上傳的?

本文對 HTTP 協議中的文件上傳標準和 Tomcat 機制的分析內容較多,比較基礎,不需要的大佬們可以直接跳到文末。

HTTP 協議中的文件上傳

眾所周知,HTTP 是一個文本協議,那文本協議如何傳輸文件呢?

直接傳……是的就這麼簡單。文本協議只是在應用層的角度,到了傳輸層都是數據都是字節,沒什麼區別,並不用進行額外的編解碼。

multipart/form-data 方式

HTTP 協議中規定了一種基於表單的文件上傳方式(Form-based File Upload)。在 form 中定義一個 ENCTYPE 屬性,值為 multipart/form-data,然後增加一個 type 為 file 的 <input> 標籤。

 <FORM ENCTYPE="multipart/form-data" ACTION="_URL_" METHOD=POST>

   File to process: <INPUT NAME="userfile1" TYPE="file">

   <INPUT TYPE="submit" VALUE="Send File">

 </FORM>

這個 multipart/form-data 類型的表單和默認的 x-www-form-urlencoded 有些不同。雖然都作為表單,可以上傳多個字段,但前者可以上傳文件,後者卻只能傳輸文本

現在來看看這個表單文件上傳方式的協議,下圖是一個簡單的 multipart/form-data 類型的請求報文:

Tomcat 中是怎麼處理文件上傳的?

從上圖可以看到,HTTP header 部分沒什麼變化,只是 Content-Type 中增加了一段 boundary 標籤,但 payload 部分卻完全不同

boundary 在 multipart/form-data 中作用是分隔表單的多個字段,在 payload 部分中,首尾兩行各有一個 boundary,每個字段(part/item)之間也會有一個 boundary

Server 端在讀取時,只需要先從 Content-Type 中拿到 boundary ,然後通過這個 boundary 去拆分 payload 部分就可以獲取所有的字段。

每個字段的報文中,有一個 Content-Disposition字段,作為這個字段的 Header 部分。其中記錄了當前字段名(name),如果是文件的話還會有一個 filename 屬性,同時再下一行會附帶一個 Content-Type 來標識文件的類型

雖然 x-www-form-urlencoded 和 multipart 兩種類型的表單都可以完成字段的傳輸,但 multipart 不僅可以傳輸文本字段,還可以傳輸文件。而且這個 multipart 傳輸文件的方式也是“標準”的,各種 Server 都可以支持,直接讀取文件。

而 x-www-form-urlencoded 只可以傳輸基礎的文本數據,不過你要是強行把文件當做文本,用這個類型傳也沒人能攔你,但作為文本傳輸時後端必然用字符串方式解析,byte -> str 時的編碼開銷完全沒必要,而且可能會導致編碼錯誤……

在 x-www-form-urlencoded 類型的報文中,並沒有 boundary,多個字段會通過 & 符號拼接,並且對key/value 都進行 urlencode 編碼

Tomcat 中是怎麼處理文件上傳的?

雖然 x-www-form-urlencoded 增加了一步編碼的過程,但不會給每個字段增加header,也沒有 boundary,報文體積相對 multipart 方式來說小了很多。

除了這個 multipart,還有一種直接上傳文件的形式,不過不太常用

binary payload 方式

除了 multipart/form-data之外,還有一種 binary payload 的上傳方式。這個 binary payload 是我自己起的名字……因為在 HTTP 協議中並沒有找到這種方式的說明(如果有找到的大佬評論區貼個連接),不過很多 HTTP 客戶端都支持。

比如 Postman:

Tomcat 中是怎麼處理文件上傳的?

比如 OkHttp:

OkHttpClient client = new OkHttpClient().newBuilder()
  .build();
MediaType mediaType = MediaType.parse("image/png");
RequestBody body = RequestBody.create(mediaType, "<file contents here>");
Request request = new Request.Builder()
  .url("localhost:8098/upload")
  .method("POST", body)
  .addHeader("Content-Type", "image/png")
  .build();
Response response = client.newCall(request).execute();

這種方式非常簡單,就是將整個 payload 部分,都用來存放文件數據。如下圖所示,整個 payload 部分都是文件內容:

Tomcat 中是怎麼處理文件上傳的?

這種方式雖然簡單,客戶端實現也簡單,但……服務端沒有很好的支持。比如 Tomcat 中,並不會將這種 binary file 的形式作為文件處理,而是當做普通的報文處理。

Tomcat 處理機制分析

Tomcat 在處理文本形式的報文時,會先讀取前面的 Header 部分,解析 Content-Length 來劃分報文邊界,剩下的 Payload 部分並不會一次性讀取,而是包裝了一個 InputStream ,在內部調用 Socket read 進行讀取 RCV_BUF 的數據(完整報文大小大於 readBuf Size時

Tomcat 中是怎麼處理文件上傳的?

對 HttpServletRequest 調用
getParameter/getInputStream 等涉及 Payload 部分讀取操作時,就會進行InputStream 內部的 Socket RCV_BUF 的讀取,讀取 Payload 的數據。

這種不一次性讀取所有數據暫存至內存中的方式,而包裝一個 InputStream 內部讀取 RCV_BUF 的方式,特點是不存儲數據,只是做一個包裝,應用層對 ServletRequest#inputStream 的 read 操作會轉發到對 Socket RCV_BUF 的read。

不過如果應用層完整的讀取了 ServletRequest#inputStream ,然後轉字符串,存儲至內存中的話,那這就和 Tomcat 沒什麼關係了。

對於 multipart 類型的請求,Tomcat 處理機制上比較特殊。由於 multipart 是為了傳輸文件而設計的,所以在處理這種類型請求時,Tomcat 增加了一個暫存文件的概念,在解析報文時,將 multipart 中的數據寫入到了磁盤中

如下圖所示,Tomcat 對每一個字段都包裝為一個 DiskFileItem –
org.apache.tomcat.util.http.fileupload.disk.DiskFileItem(這個 DiskFileItem 不區分是文件還是文本數據)。DiskFileItem 內又分為 Header 部分和 Content 部分。Content 中一部分存儲在內存,剩下的存儲至磁盤,通過一個 sizeThreshold 進行分割;不過這個值默認為0,也就是說默認會把內容部分全部存儲至磁盤。

Tomcat 中是怎麼處理文件上傳的?

那既然存儲至磁盤,讀取時也肯定也是從磁盤讀取了……效率自然是比較低的。所以如果只是文本型的報文,還是不要用 multipart 類型來傳輸了,這個類型會被轉存磁盤的。

還有一個冷知識,Tomcat 在處理 multipart 類型的報文時,如果某個字段不是文件,會將這個字段的key/value 添加到 parameterMap 中,也就是說通過
request.getParameter/getParameterMap 可以獲取到這些非文件的字段。

//org.apache.catalina.connector.Request#parseParts

if (part.getSubmittedFileName() == null) {
    String name = part.getName();
    String value = null;
    try {
        value = part.getString(charset.name());
    } catch (UnsupportedEncodingException uee) {
        // Not possible
    }
    ......
        parameters.addParameter(name, value);
}

要知道這個 getParameter 是只能獲取表單參數(FormParam)和查詢參數(QueryString)的,不過 multipart 也是 form,能獲取參數好像也沒啥毛病……

一個簡單的小結

Tomcat 對不同類型的請求處理方式:

  1. 如果參數是 GET queryString方式(url上拼參數),那麼所有參數都在報文頭中,會一次性全部讀取至內存
  2. 如果是 POST 類型的報文,Tomcat 只會對讀取 Header 部分,Payload 部分不會主動讀取,而是將 Socket 包裝成一個 InputStream 供應用層 read
    1. x-www-form-urlencoded 這種類型的報文,雖然不會主動讀取,但很多 Web 框架(比如 SpringMVC)會調用 getParameter,還是會出發 InputStream 的read,對 RCV_BUF 進行讀取
    2. 上面提到的 binary payload也是一樣,Tomcat 並不會主動發起 read 操作,需要應用層調用 ServletRequest#InputStream 進行 read操作讀取 RCV_BUF 的數據
    3. multipart 類型的報文,一樣不會主動讀取,調用HttpServletRequest#getParts 才會觸發解析/讀取;同樣的,很多 Web 框架會調用 getParts,所以會觸發解析

為什麼要先寫入臨時文件,直接包裝 InputStream 交給應用層讀取不行嗎?

如果應用層不(及時)讀取 RCV_BUF,那麼當收到的數據寫滿 RCV_BUF 時,就不會再返回 ACK 了,客戶端的的數據也會存儲在 SND_BUF 中,無法繼續發送數據,當 SND_BUF 被應用層寫滿時,這條連接就被阻塞了。

Tomcat 中是怎麼處理文件上傳的?

由於 multipart 一般是用於傳輸文件,但文件大小通常會遠大於 Socket Buffer 的容量。所以,為了不阻塞 TCP 連接,Tomcat 會一次性讀取完整的 Payload 部分,然後將其中所有的 Part 存儲至磁盤(Header在內存中,內容在磁盤)。

應用層只需要再從 Tomcat 提供的 DiskFileItem 讀取 Part 數據即可,這樣看起來雖然中轉了一層,但 RCV_BUF 中的數據卻可以被及時消費了。

從效率上說,中轉+存磁盤這種操作,一定比不中轉要慢的多,不過可以及時消費 RCV_BUF,保證 TCP 連接不被阻塞。

如果是在 HTTP2 的多路復用下,多個請求都使用同一個 TCP 連接,如果 RCV_BUF 沒有及時消費,那麼還會導致所有的“邏輯 HTTP 連接”都阻塞

那為什麼其他類型的報文不用暫存磁盤呢?

因為報文小啊,普通的請求報文不會太大的,常見的也就幾K 到幾十K ,而且對於純文本報文來說,讀取操作一定也是及時的且一次性全部讀取的,而 multipart 這種形式的報文不同,它是文本+文件混合的方式,而且還可能是多文件。

比如服務端在接收到文件後,還需要對文件進行轉存,轉存到某些雲廠商的對象存儲服務中,那麼此時有兩種轉存方式:

  1. 接收到完整文件數據,存儲至內存中,然後調用對象存儲的SDK
  2. 用流的方式,一邊 read ServletRequest#InputStream,一邊 write 到 SDK 的 OutputStream 中

方式 1,雖然及時讀取了 RCV_BUF,但是內存佔用過大,很容易把內存撐爆,非常不合理 方式 2,雖然內存佔用很小(最多只有一個 Read Buffer 的大小),但由於是邊讀邊寫,兩邊都是網絡,會導致 RCV_BUF 不能及時消費完成。

而且不光是 Tomcat ,連 Jetty 也是這麼處理 multipart,其他 Web Server 雖然沒看,但我想應該都會這麼處理。

原創文章,作者:投稿專員,如若轉載,請註明出處:https://www.506064.com/zh-hant/n/225041.html

(0)
打賞 微信掃一掃 微信掃一掃 支付寶掃一掃 支付寶掃一掃
投稿專員的頭像投稿專員
上一篇 2024-12-09 14:42
下一篇 2024-12-09 14:42

相關推薦

發表回復

登錄後才能評論