周煜瑩,崔巖松,王丹志,陳科良
(北京郵電大學(xué) 電子工程學(xué)院,北京 100876)
在計算機網(wǎng)絡(luò)技術(shù)迅猛發(fā)展的今天,文件的上傳是一個重要的應(yīng)用交互場景. 對于普通的圖片或word文檔等幾十KB 或者幾MB 的文件上傳,使用Web 的組件即可完成流暢的上傳功能. 通常的文件上傳是一次性獲取整個文件上傳,傳輸過程簡單: 第1 步獲取本地文件,第2 步將文件轉(zhuǎn)換成字節(jié)傳輸,第3 步后端接收按順序接收字節(jié)到內(nèi)存中,最后將接收完的字節(jié)保存為文件[1]. 但在大文件傳輸?shù)膽?yīng)用中,例如在郵箱管理系統(tǒng)中上傳大容量的資源壓縮包,在網(wǎng)絡(luò)視頻發(fā)布系統(tǒng)中上傳視頻文件,在線制作電子相冊時需要上傳高清圖片,網(wǎng)絡(luò)硬盤服務(wù)系統(tǒng),局域網(wǎng)文件交換系統(tǒng)等[2]. 這些業(yè)務(wù)場景下的大文件傳輸很容易占據(jù)較大的帶寬資源,造成網(wǎng)頁訪問速度降低,也可能導(dǎo)致后端服務(wù)器響應(yīng)超時,前端頁面長時間無響應(yīng),甚至卡頓而導(dǎo)致頁面崩潰. 即便能夠上傳成功,用戶需要較長的等待時間,在此期間不能刷新頁面,只能等待請求完成. 這些問題嚴重降低了用戶體驗,因而大文件上傳一直是Web 應(yīng)用系統(tǒng)的一大痛點.
針對以上問題,本文基于Node.js 邊讀邊寫的流模式傳輸,采用HTML5 的File API 對上傳的大文件進行分片處理,通過上傳速率動態(tài)調(diào)整分片大小,同時充分利用帶寬,結(jié)合多并發(fā)上傳進一步縮短上傳時間,在服務(wù)端檢驗所有分片文件上傳完整后,再進行文件的合并,有效的提高了大文件的上傳速率,減少了用戶的等待時長.
目前,基于HTTP 協(xié)議的文件上傳方式有以下幾種:
(1)表單上傳
這是Web 開發(fā)中最常見的上傳方式,使用Form表單的input[type=“input”]打開文件選擇界面,通過POST 方法向指定資源提交表單數(shù)據(jù)[3]. 上傳的文件使用multipart 格式,編碼類型為“multipart/form-data”[4].
(2)無刷新的Ajax 上傳
區(qū)別于表單上傳,使用Ajax 的異步上傳,在提交表單數(shù)據(jù)不需要刷新和跳轉(zhuǎn)頁面. 提交數(shù)據(jù)時,可以使用FormData 對象模擬表單提交,發(fā)送表單的二進制文件內(nèi)容,通過XMLHttpRequest 實例將參數(shù)提交至服務(wù)端[5].
(3)Flash 上傳
在傳統(tǒng)表單的上傳功能基礎(chǔ)上,Flash 上傳方式在不刷新網(wǎng)頁的條件下,支持多個文件批量上傳以及顯示上傳進度等功能. 它采用Flash 作為中間代理層與服務(wù)端進行通信,以此為基礎(chǔ)的SWFUpload、Plupload及Uploadify 等文件上傳插件被廣泛應(yīng)用[6].
(4)第三方組件上傳/插件上傳
插件技術(shù)主要包括ActiveX、Applet 等,雖然可能受限于瀏覽器的安全性設(shè)置,但在學(xué)校及企業(yè)內(nèi)部網(wǎng)站環(huán)境中有一定的使用價值[7]. 例如ActiveX 組件,在VB 6.0 運行環(huán)境下,使用關(guān)鍵的Winsock 控件來建立與服務(wù)端之間的通信,通過Socket 連接發(fā)送文件數(shù)據(jù).文獻[8]對FileUpload,SWFUpload 及SlickUpload 三種組件的特性進行了分析和評估. FileUpload 控件使用簡單,但默認對上傳組件的大小有限制,因而需要通過修改配置文件中響應(yīng)時間和大小的限制實現(xiàn)大文件的上傳. SWFUpload 作為一個開源的JavaScript 和Flash庫,它結(jié)合了二者的功能,可以實現(xiàn)交互性更好的界面展示. Slickupload 是來自國外的商業(yè)組件,其在局域網(wǎng)的文件上傳中具有良好的表現(xiàn)[8].
Node.js 基于事件驅(qū)動的非阻塞I/O 模型,旨在支持能夠管理大量并發(fā)請求的輕量級服務(wù)器的簡單而快速的開發(fā)[9]. 受益于V8,Node.js 性能優(yōu)越,運行速度快,可以在服務(wù)端運行,匿名函數(shù)和閉包的使用使其在語言層面具備了異步、事件編程的特性[10]. 在處理二進制數(shù)據(jù)流時,常用的有stream 合并與buffer 合并兩種方式. Node.js 中使用buffer 庫實現(xiàn)原始數(shù)據(jù)的存儲方法,數(shù)據(jù)被保存在buffer 的實例中. Node.js 中的stream流是處理流式數(shù)據(jù)的抽象接口,在處理較大數(shù)據(jù)量的文件時,采用stream 合并比buffer 合并更有優(yōu)勢. Buffer需要一次性將數(shù)據(jù)全部放入內(nèi)存,如果數(shù)據(jù)流較大容易導(dǎo)致速度慢,內(nèi)存爆滿. 流模式合并數(shù)據(jù)則是一邊讀取數(shù)據(jù)一邊進行操作,在空間上只占用當(dāng)前處理數(shù)據(jù)區(qū)域的內(nèi)存大小,有效地降低了內(nèi)存的開銷[11]. 同時,對于傳輸過程中的加密及壓縮處理,stream 流具有更高的擴展性. 因此,本文選擇流合并,使用Node.js 的可讀流與可寫流,實現(xiàn)讀取和寫入同步,提高合并效率.
在HTML5 中提供了一種通過File API 規(guī)范與本地文件進行交互的標(biāo)準(zhǔn)方法,它的主要作用是將本地文件以文件對象的形式提供給 Web 應(yīng)用程序進行訪問,為瀏覽器端應(yīng)用程序的開發(fā)提供了無限可能[12].File API 提供了前端處理本地文件的能力,讓圖片預(yù)覽、分塊上傳、拖拽上傳等操作變?yōu)榭赡? 以下是本文所用到的對象簡介.
(1)FileList 是一個由File 對象組成的類數(shù)組對象.
(2)File 是FileList 中的一個對象,包含文件名稱(name)、大小(size)、類別(type)、修改時間(lastModified-Date)等基本信息.
(3)FileReader 用來讀取文件的API,將文件讀取到內(nèi)存中,提供將文件讀取為文本、base64 圖片編碼、Buffer 數(shù)據(jù)類型、二進制字符串等方法,可以實現(xiàn)預(yù)覽圖片、計算MD5 等等操作.
(4)Blob 是一個二進制數(shù)據(jù),File 對象就繼承自Blob 對象. 通過slice 方法,可以使二進制數(shù)據(jù)按照字節(jié)分塊,返回的對象中包含了源 Blob 對象中指定范圍內(nèi)的數(shù)據(jù)[13].
對分片文件的標(biāo)識也是整個文件處理過程中必不可少的一部分. 異步提交的數(shù)據(jù)中必須包含文件的唯一標(biāo)識來確認文件分片的順序,驗證是否上傳完畢[14].MD5 生成的hash 碼不可逆,可以作為文件上傳的有效標(biāo)識,這也是實現(xiàn)文件秒傳的基礎(chǔ). Spark-md5 是基于Javascript 的前端類庫,它基于文件的內(nèi)容生成相應(yīng)hash 值,利用File API 對文件進行分塊之后再進行MD5計算,與傳統(tǒng)的MD5 計算相比,它的傳輸效率很高,不容易引起瀏覽器卡頓、崩潰等問題.
Node.js 和JavaScript 都是單線程編程模型,HTML5的新特性Web worker 為瀏覽器實現(xiàn)多線程操作提供了支持. 在文件上傳過程中,多線程操作顯然比單線程更具有優(yōu)勢,且不容易造成阻塞. Web worker 允許在Web 程序中并發(fā)執(zhí)行多個JavaScript 腳本,每個腳本執(zhí)行過程都作為一個線程,各個線程之間彼此獨立,由JavaScript 引擎負責(zé)管理[15]. 線程一旦被創(chuàng)建,可以在主線程調(diào)用worker 線程,通過將消息發(fā)布到代碼指定的事件處理程序.
基于對大文件上傳常用方法與關(guān)鍵技術(shù)的研究,本文設(shè)計并實現(xiàn)了完整的前后端大文件上傳系統(tǒng). 該系統(tǒng)基于HTTP 協(xié)議,利用HTML5 的File API 對需要上傳的目標(biāo)大文件進行分片處理. 同時,充分發(fā)揮CPU多核的性能,創(chuàng)建Web worker 線程計算和處理分片的文件,避免主線程阻塞. 通過對分片文件的MD5 校驗及標(biāo)記,增加文件傳輸?shù)陌踩? 在此基礎(chǔ)上,通過自適應(yīng)分片結(jié)合多并發(fā)上傳進行優(yōu)化,提高了傳輸速率.在服務(wù)端,服務(wù)器接收前端傳輸?shù)姆制募?按分片順序依次存儲,當(dāng)收到前端的合并請求,服務(wù)端使用流模式將收到的所有文件切片進行合并. 此外,在上傳過的切片列表中進行查詢比對,對已經(jīng)上傳過的相同文件無需再傳,避免重復(fù)上傳. 整個系統(tǒng)的流程示意圖如圖1 所示.
圖1 系統(tǒng)流程圖
3.2.1 Hash 計算
為了使服務(wù)端對已上傳的內(nèi)容進行識別,必須要生成文件和切片的 hash 作為校驗. 這里使用Web worker 為JavaScript 創(chuàng)造多線程環(huán)境,調(diào)用Worker()構(gòu)造函數(shù),新建一個名為hash 的worker 線程. 在主線程調(diào)用worker 線程,通過postMessage()函數(shù)傳入文件內(nèi)容切片后得到的數(shù)組fileChunkList,worker 線程利用 FileReader 讀取每個切片的 ArrayBuffer 并不斷傳入Spark-md5 中,每計算完一個切片通過 postMessage 向主線程發(fā)送一個進度事件. 主線程通過onMessage函數(shù)監(jiān)聽子線程消息,待全部文件讀取完成后,子線程將最終的 hash 發(fā)送給主線程. 整個流程如圖2 所示.
圖2 Web worker 示意圖
3.2.2 自適應(yīng)分片
在實際的應(yīng)用場景中,所需要上傳的文件大小往往是不固定的,而分塊大小對文件傳輸有較大影響[16].因此,目前常用的設(shè)置固定大小的分片方法不具有靈活性. 自適應(yīng)分片算法的核心在于,根據(jù)上傳文件時的網(wǎng)絡(luò)狀況,實現(xiàn)切片大小的動態(tài)調(diào)整. 在當(dāng)前切片文件上傳完成時,通過獲取當(dāng)前切片文件所用上傳時間來調(diào)整下一個切片文件的大小,目的是為了每次上傳時切片大小與當(dāng)前網(wǎng)速相匹配,具有更好的傳輸效率[17].參考TCP 協(xié)議的慢啟動策略思想,從分片的小容量文件傳輸開始試探網(wǎng)絡(luò)狀況,根據(jù)實際測得結(jié)果動態(tài)調(diào)整下一次分片的大小[18]. 比如,如果理想的狀態(tài)下每20 s 上傳一個文件塊,其初始文件大小為1 MB,實際計算的上傳時間僅為10 s,那么可以動態(tài)的調(diào)整下一個分片的大小為2 MB. 另一種可能是實際上傳所用時間為40 s,那么說明當(dāng)前網(wǎng)絡(luò)狀況不足以傳輸1 MB 文件,下一個文件的分片大小可以改為初始值的一半. 因而,在自適應(yīng)分片算法的計算方法中,設(shè)置一個初始切片文件大小為fileChunk,設(shè)置理想的上傳單個分片所需時間為ts,實際上傳過程中每個切片所用時間為t,那么當(dāng)前切片的上傳速率rate可以表示為t/ts. 此時下一切片的文件大小newFileChunk的計算方式為:
本文參照文獻[4] 的參數(shù),設(shè)置初始文件大小設(shè)為1 MB,理想的參照上傳時間ts為2 s,實際上傳中所用時間t通過new Date().getTime()獲取上傳請求前后的時間戳,得到當(dāng)前切片上傳時間. 利用式(1)不斷計算得到新的下一切片大小,達到切片大小動態(tài)調(diào)整的效果.
切片調(diào)整部分關(guān)鍵代碼摘錄如下:
3.2.3 多并發(fā)上傳
為充分利用網(wǎng)絡(luò)帶寬,采用多并發(fā)的方式進行文件上傳. 并發(fā)上傳的并發(fā)數(shù)受瀏覽器支持的最大并發(fā)數(shù)限制,超過這個值,執(zhí)行過程中的并發(fā)請求需要等待.文獻[7]中采用固定分片大小結(jié)合多并發(fā)上傳,研究得到在雙核處理器條件下,并發(fā)數(shù)為3 時上傳文件的耗時出現(xiàn)拐點,也即上傳時間出現(xiàn)明顯的減少. 本文設(shè)置max為最大并發(fā)數(shù),通過while 循環(huán)執(zhí)行并發(fā)請求,設(shè)置counter計數(shù),當(dāng)max>0 并且當(dāng)前計數(shù)值小于請求長度時進入循環(huán)體. 進入執(zhí)行循環(huán)max值減少1,每次傳輸完成,釋放并發(fā)通道,以此保證并發(fā)數(shù)在設(shè)定值. 通過對max取值3 到6 進行分別測試,得到上傳耗時在max取值為5 時出現(xiàn)明顯減少. 以此為基礎(chǔ)結(jié)合自適應(yīng)分片,在代碼實現(xiàn)中設(shè)置并發(fā)數(shù)為5,使得文件的分片大小每5 片為一組進行自適應(yīng)大小的變化,實際耗時t通過5 個切片文件的上傳總耗時求平均得到. 通過這樣的改進方法,得到更短的上傳耗時.
多并發(fā)上傳結(jié)合自適應(yīng)分片算法的流程示意圖如圖3 所示.
圖3 流程示意圖
3.3.1 接收切片文件
對前端傳遞的FormData,服務(wù)端使用multiparty包進行處理,創(chuàng)建target 文件夾作為文件上傳的存儲目錄. 前端在發(fā)送每個切片時都攜帶了唯一標(biāo)識hash,服務(wù)端將處理后的分片對象從臨時路徑移動到切片文件夾中.
3.3.2 合并切片
服務(wù)端接收到來自前端的合并請求后,對切片所在文件夾下的所有切片進行合并. 首先采用sort()方法根據(jù)切片的下標(biāo)進行排序,避免從目錄讀取的文件順序發(fā)生錯亂[19]. 使用 fs.createWriteStream 生成可寫流,通過fs.createReadStream 生成可讀流,將切片文件夾內(nèi)的切片傳輸?shù)侥繕?biāo)文件夾中并合并. createWriteStream方法的兩個參數(shù)控制可讀流傳輸?shù)娇蓪懥髦付ǖ奈恢?這樣做能保證在并發(fā)合并多個可讀流時,不必按照流的順序一個接一個傳輸也能使切片傳輸?shù)秸_的位置[20]. 與確定上一個寫入完成再讀取下一個流的方式相比,多并發(fā)上傳大大提高了傳輸效率.
3.3.3 文件秒傳
文件hash 值與文件后綴作為目錄,使用fse.exists-Sync 檢測文件目錄是否存在,如果存在,則將標(biāo)志位置為false,不需要再次上傳. 如果不存在,則將標(biāo)志位置為true. 在此基礎(chǔ)上,文件秒傳的實現(xiàn)只需要在用戶選擇上傳已存在的相同資源時,直接提示上傳成功. 在前文服務(wù)端驗證hash 的基礎(chǔ)上,如果發(fā)現(xiàn)hash 相同的文件,說明該文件資源已經(jīng)上傳,可以直接返回上傳成功.
本文使用文獻[7]中的設(shè)計方法作為對照,將固定分片上傳與自適應(yīng)分片上傳的方法進行對比. 選取了3 個100 MB 以上不同大小的文件進行測試,文獻[7]所用方法測得的時間記做原始方法用時,本文提出的方法記做改進方法. 原始方法采用固定分片大小2 MB,同時選擇并發(fā)數(shù)為5 進行多并發(fā)上傳; 改進方法選擇相同的并發(fā)數(shù),采用改進的自適應(yīng)分片算法,以2 MB大小為起始分片大小進行上傳. 瀏覽器選擇Chrome,通過控制臺的網(wǎng)絡(luò)network 面板查看分片的請求狀態(tài)以及實驗結(jié)果. 在同樣的網(wǎng)絡(luò)環(huán)境下,每個文件采用兩種上傳方式分別進行3 次測試,統(tǒng)計其平均值作為對照,測試結(jié)果如表1 所示.
在文件上傳的測試過程中,針對同一文件,再次上傳時,經(jīng)過對MD5 碼的校驗,可以直接實現(xiàn)秒傳,系統(tǒng)彈窗提示上傳成功. 表1 中傳輸時間的計算是從開始上傳到服務(wù)端接口合并完成文件的整個過程. 由表1可以看出,本系統(tǒng)可以支持500 MB 以上大文件的上傳,不同大小的文件上傳所用時間改進方法均少于原始方法耗時,并且隨著源文件大小的增大更大時比固定分片上傳具有更明顯的上傳時間優(yōu)勢. 通過后端流合并的方式對分片文件進行合并,得到的上傳文件與源文件一致,MD5 值的唯一標(biāo)識也保證了文件秒傳的實現(xiàn),避免對同一文件的重復(fù)上傳,節(jié)約了時間成本.
表1 實驗結(jié)果(s)
本文研究并介紹了常用的大文件上傳方法以及存在的問題,對本系統(tǒng)所用到的關(guān)鍵技術(shù)Node.js 及File API 進行了闡述,通過對前后端上傳過程的具體研究,實現(xiàn)了基于Node.js 的大文件上傳系統(tǒng),通過對多并發(fā)上傳與自適應(yīng)切片相結(jié)合的算法,實現(xiàn)了更具有靈活性和更高傳輸效率的大文件上傳. 同時針對大文件的MD5 標(biāo)識計算,利用Web worker 多線程計算的方式有效地避免主線程的卡頓. 該系統(tǒng)靈活度高,適用性強,能夠在文件上傳過程中提高上傳效率,提升用戶體驗.