天天看點

打造一款簡單易用功能全面的圖檔上傳元件

多年前我曾搞過Winform,也被WPF折磨得死去活來。後來我學會了對她們冷眼旁觀,就算老鸨巨硬說又推了一個新頭牌UWP,問我要不要試試,我也不再回應。時代變了,她們古闆的舞步已經失去了往日的魅力,那些為了适應潮流勉強加上的幾個動作反而顯得更加可笑、和可悲。我四處流浪,跟着年輕的小夥們去到遠處的移動村、微服務村、AI村,一呆就是幾月幾年。直到某天有人告訴我,有位妙齡女郎孤身一人在那座荒廢的村落安頓下來,她的名字叫——electron。

部落客十一宅家寫了一個圖文釋出器,關鍵是圖檔上傳區域,如下:

打造一款簡單易用功能全面的圖檔上傳元件

該區域功能相對獨立,完全可以封裝為元件以供其它項目使用,且易于維護。本人計劃包含的功能如下:

可拖拽圖檔和檔案夾到上傳區域

圖檔可拖拽調整順序

可删除,可設為封面

上傳圖檔至OSS

根據圖檔大小生成若幹比率壓縮圖,同樣上傳至OSS(使用者對此無感覺)

若圖檔大小超過門檻值,自動分片,分片上傳為不同檔案(為後續并行下載下傳做好準備)

加密後上傳(防盜鍊、防和諧)

[壓縮、加密、分片、上傳]進度顯示

秒傳或提示沖突不予上傳(需服務端接口)

暫停、錯誤提示、重傳等輔助功能

以上功能需求前8條基本完成,如果要封裝為元件供第三方使用的話,最好還要支援:

國際化&本地化

插件機制

可自定義模闆&皮膚

部落客是一個拿來主義者,對盲目造輪子的行為一向嗤之以鼻。考慮到網際網路這麼多年,一般網站都有檔案/圖檔上傳功能,開源出來的應該不在少數,選一兩款優良的自己再稍微改改,分分鐘搞定。結果網上搜了一圈,出乎意料,都不是很滿意,少數幾個知名點的,要麼是工具而非元件形式不好內建(如PicGo),要麼功能太簡單(如Layui,不知道上傳元件是否開源,不過我是他家的會員),要麼太過複雜和花裡胡哨(如bootstrap-fileinput)。其實按照我的要求,就算找到勉強湊合的,也要深度改造過,有這時間還不如老老實實自己撸。

當然就算現成的輪子不好轉,借鑒還是可以的。由于幾年前我曾使用bootstrap-fileinput上傳檔案到oss,對它還算有一點了解。github上看了下,發現這個元件一直在更新,官方文檔比記憶中要稍顯清晰些,但巨多的配置項依舊讓我眼花缭亂。深入其源碼,核心檔案的代碼行數已經6000+,要理清短時間内是不可能了。而且其中關鍵的異步任務(主要是上傳)基于<code>jQuery.Deferred</code>,jQuery.Deferred又是對<code>Promise</code>的封裝,bootstrap-fileinput用起來複雜許多。而我們的異步任務除了上傳外,至少還有壓縮、加密、分片,本着實操ES6之Promise一文打下的良好基礎,這部分代碼就自己寫好了(迷之自信:)。是以,剩下能借鑒的就隻有邊角料的UI、拖拽代碼了,而這兩塊也着實可以再剪幾刀。

由于本元件一開始是在Nodejs/Electron環境下開發的,是以就沒考慮過一些古老浏覽器的感受,而是假設執行環境支援File/FileReader/FormData等類型及相關API。

重點是在使用者“拖”着[若幹]檔案[夾],在拖拽區域内釋放時,如何擷取相關檔案資訊,代碼如下:

若拖拽的項目不包含檔案夾,那麼直接傳回<code>dataTransfer.files</code>,否則遞歸加載所有檔案:

這裡了解上的難點是異步遞歸調用,且同時使用了<code>Promist.then</code>(不阻塞)及<code>await</code>(阻塞)模式,且同時有兩個函數交錯遞歸——<code>_scanDroppedItems</code>和<code>readDir</code>。老實說,這個函數當時也是憑感覺寫,此處就不展開講了,道可道,非常道:)

使用了<code>compressorjs</code>庫,代碼如下:

看注釋,不是所有圖檔過來都無腦壓縮,本身size已經在壓縮級别内了就直接傳回。另外scale變量表示短邊長度,是業務需求,可無視。

使用<code>AES</code>加密标準,首先要知道,AES是基于資料塊的加密方式,每個加密塊大小為128位。它又有幾種實作方式:

<code>ECB</code>:是一種基礎的加密方式,明文被分割成分組長度相等的塊(不足補齊),然後單獨一個個加密,一個個輸出組成密文。

<code>CBC</code>:是一種循環模式,前一個分組的密文和目前分組的明文異或操作後再加密,這樣做的目的是增強破解難度。需要初始化向量IV,參看加密算法IV的作用

<code>CFB</code>/<code>OFB</code>實際上是一種回報模式,目的也是增強破解的難度。

使用<code>crypto</code>庫的AES加密。

<code>cipher.setAutoPadding(true)</code>表示明文分塊後位數不足自動補足,标準的補足算法有多種,crypto使用<code>PKCS7</code>。設為false的話,就要自己考慮如何補足。參看Node.js Crypto, what's the default padding for AES?

上述代碼采用的是CBC模式,如果考慮到效率,可使用ECB模式,明文分塊之後,各個塊之間互相獨立,互不影響,可并行計算加密,但安全性稍差,不過在我們的場景下夠用了。

ps:OSS提供了對上傳檔案的服務端加密(需要設定<code>x-oss-server-side-encryption</code>)。當下載下傳時,OSS會先在服務端解密再傳輸,整個加解密過程可以做到使用者端無感,是以它的目的隻是保證檔案在OSS伺服器上的安全,怕伺服器被盜?還是對OSS本身的存儲安全性不自信?不是很懂OSS工程師的想法。

網上資料欠缺,不知Blob是否把資料全部加載進記憶體中,而沒有其它記憶體方面的考量,至少以URL形式擷取的Blob是如此,參看https://javascript.info/blob#blob-as-url。同時,若手動構造Blob,也隻能将所有資料一股腦給出[到記憶體中],而不是更簡單更高效的方式比如傳遞檔案路徑,然後按需擷取資料。當然,這應該是安全方面考量,避免js随意調動本地檔案。但對我們現在的場景來說就有點麻煩了。

使用者選擇要上傳的檔案後,上一步我們對它們進行了加密,并另存為臨時檔案到磁盤中,此時要再将該檔案主動轉為Blob或File對象[用于後續上傳]就比較麻煩。在Nodejs下還好,大不了将檔案全部加載到記憶體中,通過位元組數組轉換,但在浏覽器環境下由于屏蔽了對本地檔案的讀寫,這是不可能的。

<code>fs.createReadStream()</code>可接受<code>Buffer</code>類型的參數,然而并不是用于傳遞檔案内容的,而仍然隻能是檔案路徑。You can apparently pass the path in a Buffer object, but it still must be an acceptable OS path when the Buffer is converted to a string.

為了滿足不同場景下的使用,并考慮到開銷問題,最好能以流的形式,邊加密邊上傳,然而OSS的PostObject似乎不支援流模式(PutObject倒是可以,參看流式上傳)。不過我們可以實作<code>stream.Writable</code>模拟流上傳,其實内部是分片上傳,但這種方式并不推薦,參看下面stream一節。

是以目前來說最簡單直接有效的方式還是基于<code>Blob.slice()</code>分片,如下:

前面說到,分片的目的之一是并行下載下傳。其實<code>Http1.1(RFC2616)</code>引入的<code>Range &amp; Content-Range</code>開始支援擷取檔案的部分内容,這已經為對整個檔案的并行下載下傳以及斷點續傳提供了技術支援。上傳前分片似乎多此一舉了,其實不盡然。現在很多檔案服務提供商會限制單使用者的連接配接數和傳輸速率,如果基于Http1.1 Range做并行下載下傳,假設伺服器限制了同時最多3個連接配接,就算你開10個線程也于事無補;而我們的實體分片可以将一個檔案拆分到不同的伺服器甚至不同服務商,自主可控,同時也提高了盜鍊和爬蟲的難度。

其中policy是服務端上傳政策加上簽名傳回給前端的,OSS用其鑒别請求的合法性。

上傳進度采用<code>axios.post</code>回調實作,特别注意<code>onUploadProgress</code>的參數<code>ProgressEvent</code>,它的total和待上傳檔案的size是不同的,會多個1.4k左右,猜測是加了請求頭等資訊的位元組數。是以我們在計算上傳百分比時需按ProgressEvent.total而非檔案本身的size。

nodejs中,stream有<code>pipe</code>,管道的概念,說白了就是鍊式處理,隻不過這裡處理的是stream罷了。以前大家都使用<code>through2</code>庫自定義處理器,nodejs在v1.2.0開始引入了<code>Simplified Stream Construction</code>,可以替代through2。它聲明了<code>stream.Writable</code>, <code>stream.Readable</code>, <code>stream.Duplex</code>,<code>stream.Transform</code>四種流類型。

注意<code>stream.Duplex</code> 和 <code>stream.Transform</code> 的差別:stream.Duplex不要求輸入輸出流有關系,它們可以沒一毛錢關系,隻要實作stream.Duplex的類既能read又能write就可以了;stream.Transform繼承自stream.Duplex,從字面意思上說就是轉換,很明顯,輸入流經過某種轉換轉變為輸出流,輸入輸出是有關系的。上述加密一節用到的<code>Cipher</code>就實作了<code>stream.Transform</code>,是以我們可以友善地将源檔案加密并另存為一個新檔案。

我們要區分Nodejs的stream定義和HTML5的stream Web API,兩者有相似之處,但不能混用。以<code>ReadableStream</code>為例,前者pipe(<code>WritableStream</code>),傳回的是傳遞的可寫流,後者pipeTo(WritableStream),傳回的是Promise對象,且雖然它們都叫ReadableStream或WritableStream,但它們不是同一個東西。目前也沒有發現能友善轉換它倆的方法。

原本想通過實作<code>stream.Writable</code>模拟流上傳的形式實作分片上傳,但在我們的場景下其實沒有必要,反而可能影響效率。不過看一下如何實作也無妨。

在實作類的構造函數中增加一行<code>Writable.call(this, this._options.streamOpts)</code>

給實作類定義<code>_write</code>函數,比如:

每處理一段資料,就要callback一次,告知程式可以開始處理下一段資料了。如果全局block的變化會影響到上傳,那麼我們就必須等待本次上傳成功之後再進行下一個分片的上傳,這就降低了效率。

如果給callback傳遞了參數,則是表明本次處理發生了錯誤。

注意上遊的ReadableStream并不知道你處理資料的速度,是以如果未做處理的話,可能出現資料積壓(<code>back pressure</code>)的問題,即資料源源不斷地往記憶體輸入卻得不到及時處理的情況,此時<code>highWaterMark</code>選項就派上了用場。當積壓的資料大小超過highWaterMark預設值的話,<code>WritableStream.write()</code>會傳回false,用于告知上遊,上遊就可以暫停喂資料。同時上遊監聽下遊的<code>drain</code>事件,當待處理資料大小小于 highWaterMark 時下遊會觸發 drain 事件,上遊就可以重新開機輸出。pipe函數内部已經實作了這部分邏輯。參看NodeJS Stream 四:Writable

是以,highWaterMark指的并不是單次處理資料的大小。測試發現,單次write傳入的chunk大小&lt;=64k,這是為啥呢?在<code>w3c</code>項目中也有人對此提出了疑問,參看Define chunk size for ReadableStream created by <code>blob::stream()</code> #144

還可以實作<code>_writev()</code>函數,用于有積壓資料時一次性處理完所有積壓資料,當然,何時調用不需要我們關心,WritableStream會自動處理。具體來說, If implemented and if there is buffered data from previous writes, <code>_writev()</code> will be called instead of <code>_write()</code>.`

<code>util.inherits(實作類, Writable)</code>

上面說到,ReadableStream在w3c标準裡和Nodejs裡都有,但是不同的類,不能通用。那如果要将<code>Blob.stream()</code>轉成Nodejs裡的ReadableStream怎麼辦呢?至少我沒找到一鍵轉換的方法。以下是借助<code>ArrayBuffer</code>到<code>Buffer</code>的轉換實作的。

其實這種方式失去了strem本身的意義,因為資料都已經全部在記憶體中了,直接操作反而來得更加友善。這也是分片實作為什麼不這麼做的原因之一,期待w3c和Nodejs在stream方面統一的那天吧。

原本本文是圍繞Electron展開的,題目都取了一陣子了——“Electron建構桌面應用程式實戰指南之實作酷炫圖文釋出器”。後來發現其實Electron沒啥好寫的,難點還是在業務的實作,不過有些坑仍然值得一提。

<code>npm</code>是 Node.js 标準的軟體包管理器。但由于預設的倉庫位址位于國外,package的下載下傳速度可能會比較慢。

淘寶團隊做了一個npm官方倉庫的鏡像倉庫,同步頻率目前為10分鐘。位址是<code>https://registry.npm.taobao.org</code>。使用<code>npm install -g cnpm --registry=https://registry.npm.taobao.org</code>安裝<code>cnpm</code>指令即可。

一般來說,隻要使用<code>npm config set registry https://registry.npm.taobao.org</code>改變預設倉庫位址,就可以使下載下傳速度加快。

打包出現打包過慢(幾個小時),原因很可能是因為依賴包都是通過cnpm安裝,删除cnpm安裝的依賴包,替換成npm安裝的依賴包即可。詳情參看electron打包:electron-packager及electron-builder兩種方式實作(for Windows)。

[使用electron-packager]打包後所有的代碼及資源檔案會在<code>ProductName\resources\app</code>下,若代碼中是以相對路徑定位依賴檔案,将以ProductName為基目錄查找,會報找不到的錯誤。是以我們一般在代碼中使用<code>path.join(__dirname, 'xxxx')</code>,使得在開發過程中還是部署之後都能正确定位檔案。

打包後就可以生成安裝包,參看【Electron】 NSIS 打包 Electron 生成exe安裝包(asar的步驟可以跳過)。如果覺得安裝包的體積過大,可在electron-packager打包前删除package-lock.json檔案,這将極大地減少node_modules目錄體積,進而減小最終生成的安裝包大小(網上說的其它一些方法有點複雜,沒有太去了解)。

本人使用一個名叫<code>node-stream-zip</code>的庫解析zip包,将其中一些操作封裝為Promise模式,然後發現初次加載時可以正常執行,reload後狀态就一直pending了。遇到這種問題可以嘗試設定app.allowRendererProcessReuse = false,猜測是由于electron重用渲染層程序導緻某些類庫異常。相關連結https://github.com/electron/electron/issues/18397

但這又會使得node-stream-zip第一次加載無法按預期執行,後來采用先預加載一個空白頁(其它頁面也可以)解決,如下:

electron有個bug一直沒有得到解決——原生alert彈出框會導緻頁面失去焦點,文本框無法輸入,需要整個視窗重新激活下才可以(比如最小化一下再還原,或者滑鼠點選其它應用後再傳回)。可以重定義alert覆寫原生實作,如下示例:

由于本元件寫的較為倉促,尚有不完善的地方,一些計劃的功能尚未實作或代碼較為醜陋(醜陋主要是因為依賴的架構、庫和協定标準不一緻,各自的“缺陷”使然),且和OSS關聯較為緊密,運作環境也框死在Nodejs下,沒有達到部落客心中開源的标準。若關注的朋友較多,那麼等忙完了這一陣,空閑時候再考慮完善後開源。

Stream highWaterMark misunderstanding

多線程下載下傳一個大檔案的速度更快的真正原因是什麼?

繼續閱讀