前段時間我需要實現大檔案上傳的需求,在網上查詢了很多資料,並且也發現已經有很多優秀的部落格講了大檔案上傳下載這個功能。
我的專案是個比較簡單的專案,並沒有採用特別複雜的實現方式,所以我這篇文章的目的主要是講如何最簡單地實現大檔案上傳與下載這個功能,不會講太多原理之類的東西。
在實際場景中,上傳大檔案主要會遇到的問題有:
業界最普遍的方案就是切片上傳,簡單地說就是把檔案切割成若干個小檔案,再將小檔案們傳輸到後端,最後按照順序把小檔案們重新拼成這個大檔案。
所以具體的實現邏輯如下:
把大檔案進行切片,對切片的檔案內容進行加密生成一個標識串,用於標識唯一的切片
伺服器端在臨時目錄裡儲存各段檔案
瀏覽器端所有分片上傳完成,傳送給伺服器端一個合併檔案的請求
伺服器端根據分片順序進行檔案合併
刪除分片檔案
也有其他合併檔案的方式,本文不做討論,詳情可以參考如何做大檔案上傳。
前端需要做的部分是:
在這裡我使用了一個開源庫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(`合併儲存檔案失敗`);
});
後端需要提供三個介面,分別是:
前兩個介面的邏輯都很簡單,第一個介面是判斷檔案目錄是否存在,第二個介面是把檔案放到指定目錄。
第三個介面的合併邏輯也不難,就是按照順序讀取切片檔案然後寫入,程式碼如下:
// 建立一個空檔案
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)
window.open
不會下載檔案,而會預覽檔案,行為不符合預期。分片下載的邏輯類似於上文所提到的切片上傳,具體的實現邏輯如下:
前端程式碼按照實現思路來講,可以實現為四個函數:
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>
);
blob
,不同瀏覽器對可以下載的檔案大小有限制,比如Chrome裡是2GBreact-chunks-to-file
介紹後端主要是提供兩個介面:獲取檔案大小和下載檔案的切片。
也可以合為一個介面,檔案大小從請求 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請求已經預設實現了這部分,不需要再自己實現,程式碼僅供參考。