1. 程式人生 > 其它 >大檔案上傳和斷點續傳

大檔案上傳和斷點續傳

簡要思路

為什麼大檔案上傳需要做特殊處理呢?檔案的上傳,是通過一個 POST 提交表單,把檔案的二進位制資料寫在請求裡面來發送的。對於主流的 Web 伺服器,一般都會有一個請求體 body 的大小限制,比如限制在 2MB 以內,那麼一個超過 2MB 的檔案就無法塞進一個請求了,需要對這個檔案進行分片,通過多個 POST 來傳輸。那是否可以通過調整 body 的大小來適應大檔案呢?其實是可以的,不過會有缺點。連結 [1, 2, 3] 指出,如果允許請求體過大,可以針對介面進行慢速 POST DOS 攻擊,即控制很多主機向伺服器慢速傳送請求體,長時間佔用一個連線,消耗伺服器的資源(Tomcat 有最大的 socket 連線數,BIO 預設有 200,NIO 預設有 10000)。不過連結 [4] 指出,慢速 POST DOS 攻擊並不是一種最有效的攻擊方式,人們有大把別的更好的攻擊方式,如果要進行 DOS 攻擊,那為什麼不選擇別的方式呢。這啟發我們,在考慮系統安全性的時候,要考慮攻擊者是否有比這更好的攻擊方法,如果有,那麼存在這些缺陷也是可以容忍的。

總結一下,大檔案上傳,可行的策略是分片上傳,當然調整 NGINX 的 client_max_body_size 完全也可行,即使確實存在慢速 POST DOS 攻擊這種缺陷。本文采用的方式是分片上傳。為什麼採用分片上傳呢?因為分片上傳有額外的好處,斷點續傳,多執行緒上傳。斷點續傳,不管是上傳還是下載,原理都是一樣的,通過隨機寫入檔案來實現。比如多執行緒下載檔案,我們可以先開一塊和檔案大小相同的檔案,接著使用不同執行緒下載檔案不同部分,寫入到對應部分即可。在 Java 中使用 RandomAccessFile 來實現隨機寫入。斷點上傳的續傳應該如何實現呢?首先在瀏覽器端,我們可以使用 JavaScript 檔案相關的 API slice,對檔案進行分塊,之後分塊上傳,呼叫對應的 API 進行分塊上傳。如果使用者重新整理了網頁,或者關掉了瀏覽器,傳輸中斷了,下次傳輸的時候,通過呼叫介面獲取缺失的檔案分塊號,根據獲取到的檔案分塊號選擇對應的塊進行上傳。

這裡有一個問題需要解決,如何知道使用者上傳的是同一個檔案呢?在瀏覽器端,因為安全策略,是不能獲取本地路徑來唯一標識這個檔案的。如果對整個檔案計算雜湊值,一方面記憶體可能會爆,比如讀入一個 4G 的檔案來計算雜湊,另一方面速度很慢,連結 [5] 文末有不同大小檔案的資料,1G 的檔案大概需要 30 秒,這可能影響了使用者體驗。我設計的幾個介面,可以保證檔案的完整性,所以完全沒有必要計算整個檔案計算雜湊值,取前 5 ~ 10 MB 計算雜湊作為唯一標識。

後端實現

後端實現了四個介面。

  • 嘗試獲取檔案對應的 id 號,對應的元組儲存了檔案的路徑。根據雜湊,嘗試獲取 id 號,根據具體任務再加上一些其他資訊共同組成唯一標識。
  • 上傳大檔案,任務初始化。在本地建立一個同等大小的檔案。介面需要,雜湊值,檔名,檔案大小的資訊,以及其他非檔案相關的資訊。
  • 上傳大檔案的一個分塊。使用 RandomAccessFile 寫入到對應的位置。
  • 獲取缺失的檔案塊號。使用 Redis 來儲存已經接收的檔案塊號,設定超時時間為四個小時,每次接收檔案塊都會重置計時。

此外還是用了 JWT 來進行使用者鑑權,避免每次介面請求都走資料庫鑑權的那個方法。檔案的完整性有 TCP 和邏輯來保證,TCP 可以保證每一塊是正確的,而我的邏輯可以保證整個檔案每一塊都有,因此整個檔案也就是完整的了。

前端實現

使用 axios 來實現介面呼叫,使用 crypto 計算雜湊值。

連結 [6] 給出了計算檔案雜湊的方法,連結 [7] 是 crypto 關於雜湊值計算的介面,具體實現的時候使用的摘要演算法是 SHA-256。

因為整個邏輯流程有地方不可以非同步,很多操作依賴前面的結果。對於那些操作,可以採用 async 非同步方法,搭配 await 關鍵詞,等待非同步執行結束。

計算雜湊的方法,並且將結果轉為十六進位制的字串,下面的程式碼結合了 [6, 7] 兩個連結的內容,BLOCK_SIZE 設定為 1MB。

let firstMegaBlock = file.slice(0, 10 * that.BLOCK_SIZE)
var firstMegaBlob = new FileReader()
firstMegaBlob.readAsArrayBuffer(firstMegaBlock)
firstMegaBlob.onloadend = async function() {
    let hashArrayBuffer = await crypto.subtle.digest("SHA-256", firstMegaBlob.result)
    const hashArray = Array.from(new Uint8Array(hashArrayBuffer))
    const hashStr = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
    console.log(hashStr)
    hash = hashStr
}

參考連結

  1. https://www.cnblogs.com/yjf512/archive/2013/03/29/2988296.html#:~:text=在這種情境下,你的業務中的所有POST請求都是不安全的!!只要進行DDOS攻擊,業務就會癱瘓。
  2. https://stackoverflow.com/questions/51069155/maximum-recommended-client-max-body-size-value-on-nginx#:~:text=large body upload attack
  3. https://zhuanlan.zhihu.com/p/30635804#:~:text=攻擊的影響。-,3.2.3 慢速POST請求攻擊,-慢速POST請求****
  4. https://security.stackexchange.com/questions/182696/what-are-the-potential-vulnerabilities-of-allowing-a-large-http-body-size
  5. https://medium.com/@0xVaccaro/hashing-big-file-with-filereader-js-e0a5c898fc98
  6. https://stackoverflow.com/questions/20623467/calculate-the-hash-of-blob-using-javascript
  7. https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest