公眾號首發、歡迎關注
大文件上傳
0、項目源碼地址
源碼地址 :https://github.com/zhuchangwu/large-file-upload
前端基於 vue-simple-uploader (感謝這個大佬)實現: https://github.com/simple-uploader/vue-uploader/blob/master/README_zh-CN.md
vue-simple-uploader底層封裝了uploader.js : https://github.com/simple-uploader/Uploader/blob/develop/README_zh-CN.md
1、如何唯一標識一個文件?
文件的信息后端會存儲在mysql數據庫表中。
在上傳之前,前端通過 spark-md5.js 計算文件的md5值以此去唯一的標示一個文件。
spark-md5.js 地址:https://github.com/satazor/js-spark-md5
README.md中有spark-md5.js的使用demo,可以去看看。
2、斷點續傳是如何實現的?
斷點續傳可以實現這樣的功能,比如用戶上傳200M的文件,當用戶上傳完199M時,斷網了,有了斷點續傳的功能,我們允許RD再次上傳時,能從第199M的位置重新上傳。
實現原理:
實現斷點續傳的前提是,大文件切片上傳。然后前端得問后端哪些chunk曾經上傳過,讓前端跳過這些上傳過的chunk就好了。
前端的上傳器(uploader.js)在上傳時會先發送一個GET請求,這個請求不會攜帶任何chunk數據,作用就是向后端詢問哪些chunk曾經上傳過。 后端會將這些數據保存在mysql數據庫表中。比如按這種格式:1:2:3:5
表示,曾經上傳過的分片有1,2,3,5。第四片沒有被上傳,前端會跳過1,2,3,5。 僅僅會將第四個chunk發送給后端。
3、秒傳是如何實現的?
秒傳實現的功能是:當用戶重復上傳一份相同的文件時,除了第一次上傳會正常發送上傳請求后,其他的上傳都會跳過真正的上傳,直接顯示秒成功。
實現方式:
后端存儲着當前文件的相關信息。為了實現秒傳,我們需要搞一個字段(isUploaded)表示當前md5對應的文件是否曾經上傳過。 后端在處理 前端的上傳器(uploader.js)發送的第一個GET請求時,會將這個字段發送給前端,比如 isUploaded = true。前端看到這個信息后,直接跳過上傳,顯示上傳成功。
4、上傳暫停是如何實現的?
上傳的暫停:並不是去暫停一個已經發送出去的正在進行數據傳輸的http請求~
而是暫停發送起發送下一個http請求。
就我們的項目而言,因為我們的文件本來就是先切片,對於我們來說,暫停文件的上傳,本質上就是暫停發送下一個chunk。
5、前端上傳並發數是多少?
前端的uploader.js中默認會三條線程啟動並發上傳,前端會在同一時刻並發 發送3個chunk,后端就會相應的為每個請求開啟三個協程處理上傳的過來的chunk。
在我們的項目中,會將前端並發數調整成了1。原因如下:
因為考慮到了斷點續傳的實現,后端需要記錄下曾經上傳過哪些切片。(這個記錄在mysql的數據庫表中,以 ”1:2:3:4:5“ )這種格式記錄。
Mysql5.7默認的存儲引擎是innoDB,默認的隔離級別是RR。如果我們將前端的並發數調大,就會出現下面的異常情況:
1. goroutine1 獲取開啟事物,讀取當前上傳到記錄是 1:2 (未提交事物)
2. goroutine1 在現有的記錄上加上自己處理的分片3,並和現有的1:2拼接在一起成1:2:3 (未提交事物)
3. goroutine2 獲取開啟事物,(因為RR,所以它讀不到1:2:3)讀取當前上傳到記錄是 1:2 (未提交事物)
4. goroutine1 提交事物,將1:2:3寫回到mysql
5. goroutine2 在現有的記錄上加上自己處理的分片4,並和現有的1:2拼接在一起成1:2:4 (提交事物)
可以看到,如果前端並發上傳,后端就會出現分片丟失的問題。 故前端將並發數置為1。
如果偏偏想追求極致的速度,可以考慮將后端更新isUpload字段的SQL換成 "select for update" 他可以鎖住你要更新的數據行
以及這一行上下的間隙,這樣就不會出現並發修改異常。前端也可以重新更換成多線程並發上傳的機制。理論上只要網絡帶寬允許你開啟五條線程,速度就快5倍。至於什么時候merge,加個if判斷一下,當上傳過的分片數 == totalChunks 就可以merge了。
6、單個chunk上傳失敗怎么辦?
前端會重傳chunk?
由於網絡問題,或者是后端處理chunk時出現的其他未知的錯誤,會導致chunk上傳失敗。
uploaded.js 中有如下的配置項, 每次uploader.js 在上傳每一個切片實際上都是在發送一次post請求,后端根據這個post請求是會給前端一個狀態嗎。 uploader.js 就是根據這個狀態碼去判斷是失敗了還是成功了,如果失敗了就會重新發送這個上傳的請求。
那uploader.js是如何知道有哪些狀態嗎是它應該重傳chunk的標記呢? 看看下面uploader.js需要的options 就明白了,其中的permantErrors
中配置的狀態碼標示:當遇到這個狀態碼時整個上傳直接失敗~
successStatuses
中配置的狀態碼表示chunk是上傳成功的~。 其他的狀態嗎uploader.js 就會任務chunk上傳的有問題,於是重新上傳~
options: {
target: 'http://localhost:8081/file/upload',
maxChunkRetries: 3,
permanentErrors:[502], // 永久性的上傳失敗~,會認為整個文件都上傳失敗了
successStatuses:[200], // 當前chunk上傳成功后的狀態嗎
...
}
7、超過重傳次數后,怎么辦?
比如我們設置出錯后重傳的次數為3,那么無論當前分片是第幾片,整個文件的上傳狀態被標記為false,這就意味着會終止所有的上傳。
肯定不會出現這種情況:chunk1重傳3次后失敗了,chunk2還能再去上傳,這樣的話數據肯定不一致了。
8、如何控制上傳多大的文件?
目前了解到nginx端的限制上單次上傳不能超過1M。
前端會對大文件進行切片突破nginx的限制。
options: {
target: 'http://localhost:8081/file/upload',
chunkSize: 512000, // 單次上傳 512KB
}
如果后續和nginx負責的同學達成一致,可以把這個值進行調整。前端可以后續將這個chunk的閾值加大。
9、如何保證上傳文件的百分百正確?
在上傳文件前,前端會計算出當前RD選擇的這個文件的 md5 值。
當后端檢測到所有的分片全部上傳完畢,這時會merge所有分片匯聚成單個文件。計算這個文件的md5 同 RD在前端提供的文件的md5值比對。 比對結果一致說明RD正確的完成了上傳。結果不一致,說明文件上傳失敗了~返回給前端任務失敗,提示RD重新上傳。
10、其他細節問題:
如何判斷文件上傳失敗了,給RD展示紅色?
如何控制上傳什么類型的文件?
如何控制不能上傳空文件?
上面說過了,當 uploader.js 遇到了permanentErrors
這種狀態碼時會認為文件上傳失敗了。
前端想在上傳失敗后,將進度條轉換成紅色,其實改一下CSS樣式就好了,問題就在於,根據什么去修改?在哪里去修改?
前端會將每一個file封裝成一個組件:如下圖中的files就是file的集合
整個的fileList會將會被渲染成下面這樣。
我們上傳的文件被vue-simple-uploader的作者封裝成一個file.vue組件,這個對象中會有個配置參數, 比如它會長下面這樣。
options: {
target: 'http://localhost:8081/file/upload',
statusText: {
success: '上傳成功',
error: '上傳出錯,請重試',
typeError: '暫不支持上傳您添加的文件格式',
uploading: '上傳中',
emptyError:'不能上傳空文件',
paused: '請確認文件后點擊上傳',
waiting: '等待中'
}
}
},
我們將上面的配置添加給Uploader.js
const uploader = new Uploader(this.options)
在file組件中有如下計算屬性的,分別是status和statusText
computed: {
// 計算出一個狀態信息
status () {
const isUploading = this.isUploading // 是否正在上傳
const isComplete = this.isComplete // 是否已經上傳完成
const isError = this.error // 是否出錯了
const isTypeError = this.typeError // 是否出錯了
const paused = this.paused // 是否暫停了
const isEmpty = this.emptyError // 是否暫停了
// 哪個屬性先不為空,就返回哪個屬性
if (isComplete) {
return 'success'
} else if (isError) {
return 'error'
} else if (isUploading) {
return 'uploading'
} else if (isTypeError) {
return 'typeError'
} else if (isEmpty) {
return 'emptyError'
} else if (paused) {
return 'paused'
} else {
return 'waiting'
}
},
// 狀態文本提示信息
statusText () {
// 獲取到計算出的status屬性(相當於是個key,具體的值在下面的fileStatusText中獲取到)
const status = this.status
// 從file的uploader對象中獲取到 fileStatusText,也就是用自己定義的名字
const fileStatusText = this.file.uploader.fileStatusText
let txt = status
if (typeof fileStatusText === 'function') {
txt = fileStatusText(status, this.response)
} else {
txt = fileStatusText[status]
}
return txt || status
},
},
status綁定在html上
<div class="uploader-file" :status="status">
對應的CSS樣式入下:
.uploader-file[status="error"] .uploader-file-progress {
background: #ffe0e0;
}
綜上:有了上面代碼的編寫,我們可以直接像下面這樣控制就好了
file.typeError = true // 表示文件的類型不符合我們的預期,不允許RD上傳
file.error = true // 表示文件上傳失敗了
file.emptyError = true // 表示文件為空,不允許上傳
11、后端數據庫表設計
CREATE TABLE `file_upload_detail` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`username` varchar(64) NOT NULL COMMENT '上傳文件的用戶賬號',
`file_name` varchar(64) NOT NULL COMMENT '上傳文件名',
`md5` varchar(255) NOT NULL COMMENT '上傳文件的MD5值',
`is_uploaded` int(11) DEFAULT '0' COMMENT '是否完整上傳過 \n0:否\n1:是',
`has_been_uploaded` varchar(1024) DEFAULT NULL COMMENT '曾經上傳過的分片號',
`url` varchar(255) DEFAULT NULL COMMENT 'bos中的url,或者是本機的url地址',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '本條記錄創建時間',
`update_time` timestamp NULL DEFAULT NULL COMMENT '本條記錄更新時間',
`total_chunks` int(11) DEFAULT NULL COMMENT '文件的總分片數',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
12、關於什么時候mergechunk
在本文中給出的demo中,merge是后端處理完成所有的chunk后,像前端返回 merge=1,這個表示來實現的。
前端拿着這個字段去發送/merge請求去合並所有的chunk。
值得注意的地方是:這個請求是在uploader.js認為所有的分片全部成功上傳后,在單個文件成功上傳的回調中執行的。我想了一下,感覺這么搞其實不太友好,萬一merge的過程中失敗了,或者是某個chunk丟失了,chunk中的數據缺失,最終merge的產物的md5值其實並不等於原文件。當這種情況發生的時候,其實上傳是失敗的。但是后端既然告訴uploader.js 可以合並了,說明后端的upload函數認為任務是成功的。vue-simple-uploader上傳完最后一個chunk得到的狀態碼是200,它也會覺得任務是成功的,於是在前端段展示綠色的上傳成功給用戶看~(然而上傳是失敗的), 這么看來,整個過程其實控制的不太好~
我現在的實現:直接干掉merge請求,前端1條線程發送請求,將chunk依次發送到后端。后端檢測到所有的chunk都上傳過來后主動merge,merge完成后馬上校驗文件的md5值是否符合預期。這個處理過程在上傳最后一個chunk的請求中進行,因此可以實現的控制前端上傳成功還是失敗的樣式~
如果偏偏想追求極致的速度,可以考慮將后端更新isUpload字段的SQL換成 "select for update" 他可以鎖住你要更新的數據行
以及這一行上下的間隙,這樣就不會出現並發修改異常。前端也可以重新更換成多線程並發上傳的機制。理論上只要網絡帶寬允許你開啟五條線程,速度就快5倍。至於什么時候merge,加個if判斷一下,當上傳過的分片數 == totalChunks 就可以merge了。