Node.js使用HTTP上傳G級的大檔案

oschina發表於2014-11-07

不管喜歡與否,javascript無處不在。 我們可以在客戶端的前臺應用中找到它,也可以在大量的框架、類庫中找到它,而且可以在伺服器端的後臺應用中找到它。

近年來, Javascript越來越流行,這似乎是由於 Javascript 生態系統正在幫助提高生產率、減少入門所需的時間。 在我的第一篇文章中,我介紹了使用 ASP.NET Web 後端 API 實現 G級檔案上傳,發表完這篇文章後,我決定試一下使用 Node.js 能否達到同樣的效果。  這意味著我需要實現 UploadChunk和 MergeAll方法,在 Node.js中我發表的 最後一篇文章談到了這一點。

開發環境

我們將使用 Visual Studio Express 2013 for Web 作為開發環境, 不過它還不能被用來做 Node.js 開發。為此我們需要安裝 Node.js Tools for Visual Studio。  裝好後 Visual Studio Express 2013 for Web 就會轉變成一個 Node.js IDE 環境,提供建立這個應用所需要的所有東西.。而基於這裡提供的指導,我們需要:

安裝完成後我們就會執行 Visual Studio Express 2013 for Web, 並使用 Node.js 的互動視窗來驗證安裝. Node.js 的互動視窗可以再 View->Other Windows->Node.js Interactive Window 下找到. Node.js 互動視窗執行後我們要輸入一些命令檢查是否一切OK.

Figure 1 Node.js Interactive Window

現在我們已經對安裝進行了驗證,我們現在就可以準備開始建立支援GB級檔案上傳的Node.js後臺程式了. 開始我們先建立一個新的專案,並選擇一個空的 Node.js Web應用程式模板.

Figure 2 New project using the Blank Node.js Web Application template

專案建立好以後,我們應該會看到一個叫做 server.js 的檔案,還有解決方案瀏覽器裡面的Node包管理器 (npm).

圖3 解決方案管理器裡面的 Node.js 應用程式

server.js 檔案裡面有需要使用Node.js來建立一個基礎的Hello World應用程式的程式碼.

Figure 4 The Hello World application

我現在繼續把這段程式碼從 server.js 中刪除,然後在Node.js中穿件G級別檔案上傳的後端程式碼。下面我需要用npm安裝這個專案需要的一些依賴:

  •  Express – Node.js網頁應用框架,用於構建單頁面、多頁面以及混合網路應用
  •  Formidable – 用於解析表單資料,特別是檔案上傳的Node.js模組
  •  fs-extra – 檔案系統互動模組

圖5 使用npm安裝所需模組

模組安裝完成後,我們可以從解決方案資源管理器中看到它們。


圖6 解決方案資源管理器顯示已安裝模組

下一步我們需要在解決方案資源管理器新建一個 ”Scripts” 資料夾並且新增  ”workeruploadchunk.js” 和   “workerprocessfile.js” 到該資料夾。我們還需要下載 jQuery 2.x 和  SparkMD5 庫並新增到”Scripts”資料夾。 最後還需要新增 ”Default.html” 頁面。這些都在我之前的 post 中介紹過。

建立Node.js後臺

首先我們需要用Node.js的”require()”函式來匯入在後臺上傳G級檔案的模組。注意我也匯入了”path”以及”crypto” 模組。”path”模組提供了生成上傳檔案塊的檔名的方法。”crypto” 模組提供了生成上傳檔案的MD5校驗和的方法。

// The required modules        
var   express = require('express');      
var   formidable = require('formidable');      
var   fs = require('fs-extra');      
var   path = require('path');  
var   crypto = require('crypto');

下一行程式碼就是見證奇蹟的時刻。

var app = express();

這行程式碼是用來建立express應用的。express應用是一個封裝了Node.js底層功能的中介軟體。如果你還記得那個由Blank Node.js Web應用模板建立的”Hello World” 程式,你會發現我匯入了”http”模組,然後呼叫了”http.CreateServer()”方法建立了 ”Hello World” web應用。我們剛剛建立的express應用內建了所有的功能。

現在我們已經建立了一個express應用,我們讓它呈現之前建立的”Default.html”,然後讓應用等待連線。

// Serve up the Default.html page  
app.use(express.static(__dirname, { index: 'Default.html' }));      

// Startup the express.js application  
app.listen(process.env.PORT || 1337);      

// Path to save the files  
var   uploadpath = 'C:/Uploads/CelerFT/';

express應用有app.VERB()方法,它提供了路由的功能。我們將使用app.post()方法來處理”UploadChunk” 請求。在app.post()方法裡我們做的第一件事是檢查我們是否在處理POST請求。接下去檢查Content-Type是否是mutipart/form-data,然後檢查上傳的檔案塊大小不能大於51MB。

// Use the post method for express.js to respond to posts to the uploadchunk urls and  
// save each file chunk as a separate file  
app.post('*/api/CelerFTFileUpload/UploadChunk*', function(request,response) {      

    if (request.method === 'POST') {      
        // Check Content-Type     
        if (!(request.is('multipart/form-data'))){      
            response.status(415).send('Unsupported media type');      
            return;      
        }      

        // Check that we have not exceeded the maximum chunk upload size  
        var maxuploadsize =51 * 1024 * 1024;      

        if (request.headers['content-length']> maxuploadsize){      
            response.status(413).send('Maximum upload chunk size exceeded');      
            return;      
        }

一旦我們成功通過了所有的檢查,我們將把上傳的檔案塊作為一個單獨分開的檔案並將它按順序數字命名。下面最重要的程式碼是呼叫fs.ensureDirSync()方法,它使用來檢查臨時目錄是否存在。如果目錄不存在則建立一個。注意我們使用的是該方法的同步版本。

// Get the extension from the file name  
var extension =path.extname(request.param('filename'));      

// Get the base file name  
var baseFilename =path.basename(request.param('filename'), extension);      

// Create the temporary file name for the chunk  
var tempfilename =baseFilename + '.'+      
request.param('chunkNumber').toString().padLeft('0', 16) + extension + ".tmp";      

// Create the temporary directory to store the file chunk  
// The temporary directory will be based on the file name  
var tempdir =uploadpath + request.param('directoryname')+ '/' + baseFilename;      

// The path to save the file chunk  
var localfilepath =tempdir + '/'+ tempfilename;      

if (fs.ensureDirSync(tempdir)) {      
    console.log('Created directory ' +tempdir);  
}

正如我之前提出的,我們可以通過兩種方式上傳檔案到後端伺服器。第一種方式是在web瀏覽器中使用FormData,然後把檔案塊作為二進位制資料傳送,另一種方式是把檔案塊轉換成base64編碼的字串,然後建立一個手工的multipart/form-data encoded請求,然後傳送到後端伺服器。

所以我們需要檢查一下是否在上傳的是一個手工multipart/form-data encoded請求,通過檢查”CelerFT-Encoded”頭部資訊,如果這個頭部存在,我們建立一個buffer並使用request的ondata時間把資料拷貝到buffer中。

在request的onend事件中通過將buffer呈現為字串並按CRLF分開,從而從 multipart/form-data encoded請求中提取base64字串。base64編碼的檔案塊可以在陣列的第四個索引中找到。

通過建立一個新的buffer來將base64編碼的資料重現轉換為二進位制。隨後呼叫fs.outputFileSync()方法將buffer寫入檔案中。

// Check if we have uploaded a hand crafted multipart/form-data request  
// If we have done so then the data is sent as a base64 string  
// and we need to extract the base64 string and save it  
if (request.headers['celerft-encoded']=== 'base64') {     

    var fileSlice = newBuffer(+request.headers['content-length']);      
    var bufferOffset = 0;      

    // Get the data from the request  
    request.on('data', function (chunk) {      
        chunk.copy(fileSlice , bufferOffset);      
        bufferOffset += chunk.length;      
    }).on('end', function() {      
        // Convert the data from base64 string to binary  
        // base64 data in 4th index of the array  
        var base64data = fileSlice.toString().split('\r\n');      
        var fileData = newBuffer(base64data[4].toString(), 'base64');      

        fs.outputFileSync(localfilepath,fileData);      
        console.log('Saved file to ' +localfilepath);      

        // Send back a sucessful response with the file name  
        response.status(200).send(localfilepath);      
        response.end();      
    });  
}

二進位制檔案塊的上傳是通過formidable模組來處理的。我們使用formidable.IncomingForm()方法得到multipart/form-data encoded請求。formidable模組將把上傳的檔案塊儲存為一個單獨的檔案並儲存到臨時目錄。我們需要做的是在formidable的onend事件中將上傳的檔案塊儲存為裡一個名字。

else {      
    // The data is uploaded as binary data.      
    // We will use formidable to extract the data and save it      
    var form = new formidable.IncomingForm();      
    form.keepExtensions = true;      
    form.uploadDir = tempdir;     

    // Parse the form and save the file chunks to the      
    // default location      
    form.parse(request, function (err, fields, files) {      
        if (err){      
            response.status(500).send(err);      
            return;      
        }      

    //console.log({ fields: fields, files: files });      
    });      

    // Use the filebegin event to save the file with the naming convention      
    /*form.on('fileBegin', function (name, file) {  
    file.path = localfilepath;  
});*/       

form.on('error', function (err) {      
        if (err){      
            response.status(500).send(err);      
            return;      
        }      
    });      

    // After the files have been saved to the temporary name      
    // move them to the to teh correct file name      
    form.on('end', function (fields,files) {      
        // Temporary location of our uploaded file             
        var temp_path = this.openedFiles[0].path;      

        fs.move(temp_path , localfilepath,function (err){      

            if (err) {      
                response.status(500).send(err);      
                return;      
            }      
            else {      
                // Send back a sucessful response with the file name      
                response.status(200).send(localfilepath);      
                response.end();      
            }     
        });     
    });      

// Send back a sucessful response with the file name      
//response.status(200).send(localfilepath);      
//response.end();      
}  
}

app.get()方法使用來處理”MergeAll”請求的。這個方法實現了之前描述過的功能。

// Request to merge all of the file chunks into one file  
app.get('*/api/CelerFTFileUpload/MergeAll*', function(request,response) {      

    if (request.method === 'GET') {      

        // Get the extension from the file name  
        var extension =path.extname(request.param('filename'));      

        // Get the base file name  
        var baseFilename =path.basename(request.param('filename'), extension);      

        var localFilePath =uploadpath + request.param('directoryname')+ '/' + baseFilename;      

        // Check if all of the file chunks have be uploaded  
        // Note we only wnat the files with a *.tmp extension  
        var files =getfilesWithExtensionName(localFilePath, 'tmp')      
        /*if (err) {  
            response.status(500).send(err);  
            return;  
        }*/ 

        if (files.length !=request.param('numberOfChunks')){     
            response.status(400).send('Number of file chunks less than total count');      
            return;      
        }      

        var filename =localFilePath + '/'+ baseFilename +extension;      
        var outputFile =fs.createWriteStream(filename);      

        // Done writing the file  
        // Move it to top level directory  
        // and create MD5 hash  
        outputFile.on('finish', function (){      
            console.log('file has been written');      
            // New name for the file  
            var newfilename = uploadpath +request.param('directoryname')+ '/' + baseFilename  
            + extension;      

            // Check if file exists at top level if it does delete it  
            //if (fs.ensureFileSync(newfilename)) {  
            fs.removeSync(newfilename);      
            //} 

            // Move the file  
            fs.move(filename, newfilename ,function (err) {      
                if (err) {      
                    response.status(500).send(err);      
                    return;      
                }      
                else {      
                    // Delete the temporary directory  
                    fs.removeSync(localFilePath);      
                    varhash = crypto.createHash('md5'),      
                        hashstream = fs.createReadStream(newfilename);     

                    hashstream.on('data', function (data) {      
                        hash.update(data)      
                    });      

                    hashstream.on('end', function (){     
                        var md5results =hash.digest('hex');      
                        // Send back a sucessful response with the file name  
                        response.status(200).send('Sucessfully merged file ' + filename + ", "     
                        + md5results.toUpperCase());      
                        response.end();      
                    });      
                }      
            });      
        });      

        // Loop through the file chunks and write them to the file  
        // files[index] retunrs the name of the file.  
        // we need to add put in the full path to the file  
        for (var index infiles) {     
            console.log(files[index]);      
            var data = fs.readFileSync(localFilePath +'/' +files[index]);      
            outputFile.write(data);      
            fs.removeSync(localFilePath + '/' + files[index]);      
        }      
        outputFile.end();      
    }  

})   ;

注意Node.js並沒有提供String.padLeft()方法,這是通過擴充套件String實現的。

// String padding left code taken from  
// http://www.lm-tech.it/Blog/post/2012/12/01/String-Padding-in-Javascript.aspx  
String.prototype.padLeft = function (paddingChar, length) {      
    var s = new String(this);      
    if ((this.length< length)&& (paddingChar.toString().length > 0)) {      
        for (var i = 0; i < (length - this.length) ; i++) {      
            s = paddingChar.toString().charAt(0).concat(s);      
        }      
    }     
    return s;  
}   ;

一些其它事情

其中一件事是,發表上篇文章後我繼續研究是為了通過域名碎片實現並行上傳到CeleFT功能。域名碎片的原理是訪問一個web站點時,讓web瀏覽器建立更多的超過正常允許範圍的併發連線。 域名碎片可以通過使用不同的域名(如web1.example.com,web2.example.com)或者不同的埠號(如8000, 8001)託管web站點的方式實現。

示例中,我們使用不同埠號託管web站點的方式。

我們使用 iisnode 把 Node.js整合到 IIS( Microsoft Internet Information Services)實現這一點。 下載相容你作業系統的版本 iisnode (x86) 或者  iisnode (x64)。 下載 IIS URL重寫包。

一旦安裝完成(假定windows版Node.js已安裝),到IIS管理器中建立6個新網站。將第一個網站命名為CelerFTJS並且將偵聽埠配置為8000。

圖片7在IIS管理器中建立一個新網站

然後建立其他的網站。我為每一個網站都建立了一個應用池,並且給應用池“LocalSystem”級別的許可權。所有網站的本地路徑是C:\inetpub\wwwroot\CelerFTNodeJS。

圖片8 資料夾層級

我在Release模式下編譯了Node.js應用,然後我拷貝了server.js檔案、Script資料夾以及node_modules資料夾到那個目錄下。

要讓包含 iisnode 的Node.js的應用工作,我們需要建立一個web.config檔案,並在其中新增如下得內容。

<defaultDocument>  
    <files>  
      <add value="server.js" />  
    </files>  
  </defaultDocument>  

  <handlers>  
    <!-- indicates that the server.js file is a node.js application to be handled by the       
    iisnode module -->     
    <add name="iisnode" path="*.js" verb="*" modules="iisnode" />  
  </handlers>  

  <rewrite>  
    <rules>  
      <rule name="CelerFTJS">  
        <match url="/*" />  
        <action type="Rewrite" url="server.js" />  
      </rule>  

      <!-- Don't interfere with requests for node-inspector debugging -->     
      <rule name="NodeInspector" patternSyntax="ECMAScript" stopProcessing="true">  
        <match url="^server.js\/debug[\/]?" />  
      </rule>  
    </rules>  
  </rewrite>

web.config中各項的意思是讓iisnode處理所有得*.js檔案,由server.js 處理任何匹配”/*”的URL。

圖片9 URL重寫規則

如果你正確的做完了所有的工作,你就可以通過http://localhost:8000瀏覽網站,並進入CelerFT ”Default.html”頁面。

web.config檔案被修改以支援如前面post中所解釋的大檔案的上傳,這裡我不會解釋所有的項。不過下面的web.config項可以改善 iisnode中Node.js的效能。

並行上傳

為了使用域名碎片來實現並行上傳,我不得不給Node.js應用做些修改。我第一個要修改的是讓Node.js應用支援跨域資源共享。我不得不這樣做是因為使用域碎片實際上是讓一個請求分到不同的域並且同源策略會限制我的這個請求。

好訊息是XMLttPRequest 標準2規範允許我這麼做,如果網站已經把跨域資源共享開啟,更好的是我不用為了實現這個而變更在”workeruploadchunk.js”裡的上傳方法。

// 使用跨域資源共享 // Taken from http://bannockburn.io/2013/09/cross-origin-resource-sharing-cors-with-a-node-js-express-js-and-sencha-touch-app/  
var   enableCORS = function(request,response, next){      
    response.header('Access-Control-Allow-Origin', '*');      
    response.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');      
    response.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-  
                    Length,    X-Requested-With'   )   ;  

    // 攔截OPTIONS方法
    if ('OPTIONS' ==request.method){      
        response.send(204);      
    }      
    else {      
        next();      
    }      
}   ;        

// 在表示式中使用跨域資源共享
app.   use   (   enableCORS   )   ;

為了使server.js檔案中得CORS可用,我建立了一個函式,該函式會建立必要的頭以表明Node.js應用支援CORS。另一件事是我還需要表明CORS支援兩種請求,他們是:

  • 簡單請求:

1、只用GET,HEAD或POST。如果使用POST向伺服器傳送資料,那麼傳送給伺服器的HTTP POST請求的Content-Type應是application/x-www-form-urlencoded, multipart/form-data, 或 text/plain其中的一個。

2、HTTP請求中不要設定自定義的頭(例如X-Modified等)

  • 預檢請求:

1、使用GET,HEAD或POST以外的方法。假設使用POST傳送請求,那麼Content-Type不能是application/x-www-form-urlencoded, multipart/form-data, or text/plain,例如假設POST請求向伺服器傳送了XML有效載荷使用了application/xml or text/xml,那麼這個請求就是預檢的。

2、在請求中設定自定義頭(比如請求使用X-PINGOTHER頭)。

在我們的例子中,我們用的是簡單請求,所以我們不需要做其他得工作以使例子能夠工作。

在  ”workeruploadchunk.js” 檔案中,我向  self.onmessage 事件新增了對進行並行檔案資料塊上傳的支援.

// We are going to upload to a backend that supports parallel uploads.  
// Parallel uploads is supported by publishng the web site on different ports  
// The backen must implement CORS for this to work  
else if(workerdata.chunk!= null&& workerdata.paralleluploads ==true){     
    if (urlnumber >= 6) {      
        urlnumber = 0;      
    }      

    if (urlcount >= 6) {      
        urlcount = 0;      
    }      

    if (urlcount == 0) {      
        uploadurl = workerdata.currentlocation +webapiUrl + urlnumber;      
    }      
    else {      
        // Increment the port numbers, e.g 8000, 8001, 8002, 8003, 8004, 8005  
        uploadurl = workerdata.currentlocation.slice(0, -1) + urlcount +webapiUrl +      
        urlnumber;      
    }      

    upload(workerdata.chunk,workerdata.filename,workerdata.chunkCount, uploadurl,      
    workerdata.asyncstate);      
    urlcount++;      
    urlnumber++;  
  }

在 Default.html 頁面我對當前的URL進行了儲存,因為我準備把這些資訊傳送給檔案上傳的工作程式. 只所以這樣做是因為:

  • 我想要利用這個資訊增加埠數量
  • 做了 CORS 請求,我需要把完整的 URL 傳送給 XMLHttpRequest 物件.

最後修改了 CelerFT 介面來支援並行上傳.

帶有並行上傳的CelerFT

這個專案的程式碼可以再我的 github 資源庫上找到

相關文章