全網最簡單的大檔案上傳與下載程式碼實現(React+Go)

2022-09-06 21:01:34

前言

前段時間我需要實現大檔案上傳的需求,在網上查詢了很多資料,並且也發現已經有很多優秀的部落格講了大檔案上傳下載這個功能。

我的專案是個比較簡單的專案,並沒有採用特別複雜的實現方式,所以我這篇文章的目的主要是講如何最簡單地實現大檔案上傳與下載這個功能,不會講太多原理之類的東西。

大檔案上傳

在實際場景中,上傳大檔案主要會遇到的問題有:

  • 體積大/網路不好時,上傳時間會非常久
  • 前端/後端某處設定了最大請求時長/最大讀寫時長等,造成檔案上傳超時
  • Nginx/後端某處對請求大小進行了限制,造成檔案因體積過大而上傳失敗
  • 上傳失敗後,需要重新開始上傳

實現思路

業界最普遍的方案就是切片上傳,簡單地說就是把檔案切割成若干個小檔案,再將小檔案們傳輸到後端,最後按照順序把小檔案們重新拼成這個大檔案

所以具體的實現邏輯如下:

  1. 把大檔案進行切片,對切片的檔案內容進行加密生成一個標識串,用於標識唯一的切片

  2. 伺服器端在臨時目錄裡儲存各段檔案

  3. 瀏覽器端所有分片上傳完成,傳送給伺服器端一個合併檔案的請求

  4. 伺服器端根據分片順序進行檔案合併

  5. 刪除分片檔案

也有其他合併檔案的方式,本文不做討論,詳情可以參考如何做大檔案上傳

具體實現

前端部分

前端需要做的部分是:

  • 把大檔案進行切片,對切片的檔案內容進行加密生成一個標識串
  • 上傳所有切片,最後傳送合併檔案的請求

在這裡我使用了一個開源庫react-chunk-upload,它提供了加密檔案函數和獲取檔案的相應切片內容的函數(如圖),這就不用我自己寫啦(偷懶小技巧)。

那麼前端部分完整的程式碼如下:

const [uploadProgress, setUploadProgress] = useState(0);
const [uploadText, setUploadText] = useState("");

const CHUNK_SIZE = 3 * 1024 * 1024; // 設定切片大小為 3Mb
const chunkMD5List = [];
const chunkNum = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < chunkNum; i++) {
  const start = i * CHUNK_SIZE; // 切片的開始位置
  const end = Math.min(file.size, start + CHUNK_SIZE); // 切片的結束位置
  const chunkBlob = blobSlice.call(file, start, end); // 獲取相應位置的切片檔案
  const chunkFile = new File([chunkBlob], "file", {
    lastModified: file.lastModified, 
  });
  const md5 = await hashFile(chunkFile, CHUNK_SIZE); // 獲取切片識別符號
  chunkMD5List.push(md5);
  await beforeUploadCheckApi(md5) // 上傳前檢查這個切片是否已存在的介面
    .then(async (res) => {
    if (res.code === SUCCESS_CODE) {
      if (!res.data.exist_status) { // 如果不存在才上傳
        await uploadChunkCSVApi(chunkFile, md5).then((res) => { // 上傳切片的介面
          if (res.code === SUCCESS_CODE) {
            const progress = Math.floor(((i + 1) / chunkNum) * 10000) / 100; // 計算上傳進度,這裡為了更好的使用者體驗,我特意預留了3%給最後的合併檔案步驟
            setUploadProgress(progress < 3 ? 0 : progress - 3);
          }
        });
      } else {
        const progress = Math.floor(((i + 1) / chunkNum) * 10000) / 100;
        setUploadProgress(progress < 3 ? 0 : progress - 3);
      }
    }
  })
    .catch(() => {
    setUploadText("上傳失敗");
  });
}
mergeChunkApi(f.name, JSON.stringify(chunkMD5List)) // 合併切片的介面
  .then((res) => {
  if (res.code === SUCCESS_CODE) {
    setUploadText(`上傳 ${file.name} 成功`);
    setUploadProgress(100); // 合併檔案需要一些時間,所以合併完再讓進度條到100
  }
})
  .catch(() => {
  setUploadText(`合併儲存檔案失敗`);
});

後端部分

後端需要提供三個介面,分別是:

  1. 判斷切片檔案是否已經上傳過
  2. 上傳切片檔案
  3. 合併切片檔案

前兩個介面的邏輯都很簡單,第一個介面是判斷檔案目錄是否存在,第二個介面是把檔案放到指定目錄

第三個介面的合併邏輯也不難,就是按照順序讀取切片檔案然後寫入,程式碼如下:

// 建立一個空檔案
filePath := ".....省略"
f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
if err != nil {
  fmt.Println("開啟檔案失敗: %v", err)
}

chunkMD5Array := []string{}
```
前端需要傳給後端一個切片名稱的有序陣列,此處省略具體處理過程
```

for _, chunkMD5 := range chunkMD5Array {
  chunkPath := fmt.Sprintf("/temp/%v", chunkMD5)
  chunk, err := os.Open(chunkPath)
  if err != nil {
    fmt.Println("開啟檔案的切片 %v 內容失敗: %v", chunkMD5, err)
  }

  content, err := ioutil.ReadAll(chunk)
  if err != nil {
    fmt.Println("讀取檔案的切片 %v 內容失敗: %v", chunkMD5, err)
  }

  _, err = f.Write(content)
  if err != nil {
    fmt.Println("寫入檔案的切片 %v 內容失敗: %v", chunkMD5, err)
  }
  chunk.Close()
}

// 寫入完畢,關閉檔案
f.Close()

// 合併後刪除切片檔案
for _, chunkMD5 := range chunkMD5Array {
  chunkPath := fmt.Sprintf("/temp/%v", chunkMD5)
  err := os.RemoveAll(chunkPath)
  if err != nil {
    fmt.Println("刪除切片檔案%v失敗:%v", chunkMD5, err)
  }
}

大檔案上傳就這麼簡單地搞定了,並且這個實現方法雖然不是斷點續傳,但是也會大大提高檔案的上傳速度。

大檔案下載

大檔案下載的方案則需要區分兩種情況:

window.open方法

②分片下載

其餘的下載方式,例如a標籤下載、表單下載等,都適用於較小檔案,這裡不討論。

window.open方法

使用window.open方法有一個前提條件:後端介面返回的是檔案流。那麼用window.open去開啟一個新視窗開啟這個連結,瀏覽器就會去處理下載的過程。前端的範例程式碼如下:

window.open('http://xxxxxxxxxx', '_blank')

需要注意的地方是後端介面需要指定請求的Content-Disposition屬性

在常規的HTTP應答中,Content-Disposition 響應頭指示回覆的內容該以何種形式展示,是以內聯的形式(即網頁或者頁面的一部分),還是以附件的形式下載並儲存到本地——來源 MDN(https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition)

優點

  1. 瀏覽器自己處理下載過程,不需要額外實現進度條等邏輯。
  2. 程式碼簡單。

缺點

  1. 會受到瀏覽器的相容性以及瀏覽器安全策略等因素的影響。
  2. 有時候window.open不會下載檔案,而會預覽檔案,行為不符合預期。
  3. 會新開啟一個頁面,有些開發者不喜歡這個行為。

分片下載

實現思路

分片下載的邏輯類似於上文所提到的切片上傳,具體的實現邏輯如下:

  1. 獲取檔案的大小
  2. 計算檔案的分片數(即需要傳送多少次下載分片的請求)
  3. 下載所有分片
  4. 按照順序合併所有分片
  5. 儲存合併好的檔案

前端部分

前端程式碼按照實現思路來講,可以實現為四個函數:

  • 獲取要下載的檔案大小
  • 下載檔案指定位置的分片blob
  • 合併所有分片blob
  • 儲存blob為檔案

在這裡,我把這個流程封裝為了一個開源庫react-chunks-to-file,提供後端的介面地址即可完成下載操作。

範例程式碼:

// 進度
const [percent, setPercent] = useState<number>();
// 狀態
const [status, setStatus] = useState<number>();

return(
  <ChunksDownload
    reqSetting={{
      getSizeAPI: `${APP_DOMAIN}/csv/size?`,                  // 獲取檔案大小的介面url
      getSizeParams: {
        token: getToken(),
        id: csvId,
      },
      chunkDownloadAPI: `${APP_DOMAIN}/csv/download_chunk?`,  // 下載分片檔案的介面url
      chunkDownloadParams: {
        token: getToken(),
        id: csvId,
      },
    }}
    fileName={csv.csv_name}
    mime={"text/csv"}         // 檔案型別
    size={3}                  // 分片大小
    concurrency={5}           // 並行數
    setStatus={setStatus}
    setPercent={setPercent}
    style={{ display: "inline" }}
    >
    <Button
      type="link"
      onClick={() => downloadCSV(csv.csv_name)}
      >
      下載
    </Button>
  </ChunksDownload>
);

缺點

  1. 由於使用了blob,不同瀏覽器對可以下載的檔案大小有限制,比如Chrome裡是2GB
  2. 使用這個開源庫,後端介面的定義需要符合要求,詳情請看react-chunks-to-file介紹

優點

  1. 使用簡單
  2. 可以自己定義控制下載進度條等其他互動UI,不會新開啟視窗
  3. 實現了並行下載

後端程式碼

後端主要是提供兩個介面:獲取檔案大小和下載檔案的切片。

也可以合為一個介面,檔案大小從請求 header 中的 Content-Length 裡獲取。

獲取檔案大小很簡單,省略不講。下面是下載檔案切片的範例請求:

後端介面從Range裡得知要提供檔案的什麼範圍的切片資料。讀取指定位置的檔案用Go實現的範例程式碼如下:

// GetFileChunk 獲取指定位置的檔案片段
func GetFileChunk(filePath string, start, end int64) ([]byte, error) {
	f, err := os.Open(filePath)
	if err != nil {

		return nil, err
	}

	// 跳轉檔案到指定位置
	_, err = f.Seek(start, 0)
	if err != nil {

		return nil, err
	}

	// 讀取指定長度的檔案
	byteSlice := make([]byte, end-start)
	_, err = f.Read(byteSlice)
	if err != nil {

		return nil, err
	}

	return byteSlice, nil
}

但是HTTP請求已經預設實現了這部分,不需要再自己實現,程式碼僅供參考。

參考資源

如何做大檔案上傳

JavaScript 中如何實現大檔案並行下載?

一文帶你層層解鎖「檔案下載」的奧祕