NodeJS服務器篇之簡單靜態文件合併

NodeJS是一個基於Chrome V8引擎的JavaScript運行環境,其使用了事件驅動、異步I/O機制,具有運行速度快,性能優異等特點,非常適合在分佈式設備上運行數據密集型的實時應用。

本文主要介紹一下通過搭建簡單的NodeJS服務器,實現靜態文件的合併,並通過瀏覽器訪問輸出的功能;同時,還會進行功能的完善,通過不斷的迭代開發,從易用性、性能、安全性等等方面,較爲全面的介紹一下NodeJS服務器的開發過程,爲以後的進一步學習做準備。

在下面的內容開始之前,假定您對JavaScript已經有了一定的瞭解,如果您之前沒有了解過,請先熟悉一下七天學會NodeJS,本文主要參考上述資料的最後一部分,爲作者的開源奉獻精神表示感謝。下面正式開始介紹服務器的具體實現:

需求

實現一個靜態文件合併的服務器,通過請求的鏈接(URL)指定需要合併的文件,之後把文件內容返回給客戶端。參考鏈接如下:

http://127.0.0.1:8300/??a.js,b.js

分析

鏈接中的??是一個分隔符,前面是需要合併的文件路徑,後面是需要合併的文件名,多個文件名之間用,分隔,因此服務器處理這個URL後返回的是各個文件的路徑;之後,通過遞歸讀取文件內容,再進行拼接合並;最後,通過響應數據輸出給客戶端。這是整個服務器的全部分析過程。

由於涉及到文件操作,所以需要fs模塊、path模塊;加上服務器模塊http,一共需要三個模塊:fs、path、http

第一版

源碼如下:

var fs = require('fs'),
    path = require('path'),
    http = require('http');

var MIME = {
    '.css': 'text/css',
    '.js': 'application/javascript'
};

// 合併文件內容
function combineFiles(pathnames, callback) {
    var output = [];

    (function next(i, len) {
        if (i < len) {
            fs.readFile(pathnames[i], function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    output.push(data);
                    next(i + 1, len);
                }
            });
        } else {
            const data = Buffer.concat(output);
            console.log(data);

            callback(null, data);
        }
    }(0, pathnames.length));
}

function main(argv) {
    // 從文件讀取配置參數
    // var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')),
    //     root = config.root || '.',
    //     port = config.port || 80;

    // 直接給定配置參數
    var root = __dirname;
    var port = 8300;

    http.createServer(function (request, response) {
         var urlInfo = parseURL(root, request.url);

         console.log(urlInfo);

         combineFiles(urlInfo.pathnames, function (err, data) {
             if (err) {
                 response.writeHead(404);
                 response.end(err.message);
             } else {
                 response.writeHead(200, {
                     'Content-Type': urlInfo.mime
                 });

                 response.end(data);
             }
         });
    }).listen(port);
}

// 解析文件路徑
function parseURL (root, url) {
    var base, pathnames, parts;

    if (url.indexOf('??') === -1) {
        url = url.replace('/', '/??');
    }

    parts = url.split('??');
    base = parts[0];
    pathnames = parts[1].split(',').map(function(value) {
        var filePath = path.join(root, base, value);
        return filePath;
    });

    return {
        mime: MIME[path.extname(pathnames[0])] || 'text/plain',
        pathnames: pathnames
    };
}

main(process.argv.slice(2));

/*
測試URL: 127.0.0.1:8300/??a.js,b.js
輸出:
    hello
    kelvin
    world
 */

以上代碼完整實現了服務器的功能,可以用測試URL請求,就會輸出其後的內容。其中,有幾點需要注意:

  • 命令行參數可以通過讀取JSON配置文件,或者直接在main函數內設定(缺點是修改不方便,配置不靈活)
  • 入口main函數開啓了http服務器;combineFiles函數負責異步讀取文件內容,併合並文件內容;parseULR函數負責解析URL,並返回文件的MIME類型(在返回數據給客戶端時,指定數據的類型)和文件名數組,。

服務器的工作流程如下:

發送請求       等待服務端響應         接收響應
---------+----------------------+------------->
         --                                        解析請求
           ------                                  讀取a.js
                 ------                            讀取b.js
                       ------                      讀取c.js
                             --                    合併數據
                               --                  輸出響應

第二版

由於第一版中,代碼是把文件內容全部讀取到內存後,再進行數據合併的,這會導致如下問題:

  • 當請求的文件較多,需要合併的數據量又比較大時,串行讀取文件會比較耗時,拖慢服務的相應時間
  • 每次都完整的把數據讀到內存緩存起來,當服務器併發數較大時,就會有較大的內存開銷

針對上面的第一個問題,如果改爲並行讀取方式,對於機械磁盤來說,需要不停的切換磁頭,反而會降低I/O效率。而對於固態硬盤,是存在多個並行的I/O的,對單個請求採用並行也不會提高效率。因此,採用流式讀取方式:一遍讀取,一遍輸出,把相應的輸出時機提前至讀取第一個文件的時刻,這樣就能解決上述的問題。

修改後的服務器工作流程如下:

發送請求 等待服務端響應 接收響應
---------+----+------------------------------->
         --                                        解析請求
           --                                      檢查文件是否存在
             --                                    輸出響應頭
               ------                              讀取和輸出a.js
                     ------                        讀取和輸出b.js
                           ------                  讀取和輸出c.js

可以看到,調整後的代碼是邊讀取邊輸出,即快速響應請求,有減少了內存的壓力。

源碼如下:

var fs = require('fs'),
    path = require('path'),
    http = require('http');

var MIME = {
    '.css': 'text/css',
    '.js': 'application/javascript'
};

function main(argv) {
    var root = __dirname;
    var port = 8300;

    http.createServer((request, response) => {
        var urlInfo = parseURL(root, request.url);

        validateFiles(urlInfo.pathnames, (err, pathnames) => {
            if (err) {
                response.writeHead(404);
                response.end(err.message);
            } else {
                response.writeHead(200, {
                    'Content-Type': urlInfo.mime
                });

                outputFiles(pathnames, response);
            }
        })
    }).listen(port);
}

function outputFiles(pathnames, writer) {
    (function next(i, len) {
        if (i <len) {
            var reader = fs.createReadStream(pathnames[i]);

            reader.pipe(writer, {end: false});
            reader.on('end', function() {
                next(i + 1, len);
            })
        } else {
            writer.end();
        }
    }(0, pathnames.length));
}

function validateFiles(pathnames, callback) {
    (function next(i, len) {
        if (i < len) {
            fs.stat(pathnames[i], (err, stats) => {
                if (err) {
                    callback(err);
                } else if (!stats.isFile()){
                    callback(new Error());
                } else {
                    next(i + 1, len);
                }
            });
        } else {
            callback(null, pathnames);
        }
    }(0, pathnames.length));
}

function parseURL (root, url) {
    var base, pathnames, parts;

    if (url.indexOf('??') === -1) {
        url = url.replace('/', '/??');
    }

    parts = url.split('??');
    base = parts[0];
    pathnames = parts[1].split(',').map(function(value) {
        var filePath = path.join(root, base, value);
        return filePath;
    });

    return {
        mime: MIME[path.extname(pathnames[0])] || 'text/plain',
        pathnames: pathnames
    };
}

main();

第三版

服務器的功能和性能已經得到初步滿足,接下來我們要考慮穩定性。由於沒有系統是絕對的穩定,都存在一定的宕機風險,而這一問題不可避免,所以我們要儘量減少宕機的時間,比如增加一個守護進程,在服務器掛掉後立即重啓。並且NodeJS官方也建議在出現異常時重啓,因爲這時系統處於一種不穩定的狀態。

所以,我們利用NodeJS的進程管理機制,將守護進程作爲父進程,將服務器進程作爲子進程,讓父進程監控子進程的運行狀態,在其異常時立即退出重啓子進程。

守護進程代碼如下:

var cp = require('child_process');

var worker;

function spawn(server, config) {
    worker = cp.spawn('node', [server, config]);
    worker.on('exit', (code) => {
        console.log("code: " + code)
        if (code != 0) {
            console.log('自動重啓');
            spawn(server, config);
        }
    });
}

function main(argv) {
    spawn('server2.js', argv[0]);

    process.on('SIGTERM', () => {
        worker.kill();
        process.exit(0);
    });
}

main(process.argv.slice(2));

服務器代碼也要在main函數裏做如下調整:

function main(argv) {
    ...

    server = http.createServer((request, response) => {
        var urlInfo = parseURL(root, request.url);

        validateFiles(urlInfo.pathnames, (err, pathnames) => {
    ...
        })
    }).listen(port);

    process.on('SIGTERM', () => {
        server.close(() => {
            process.exit(0);
        });
    });
}

這樣調整後,守護進程會進一步啓動和監控服務器進程。此外,爲了能夠正常終止服務,我們讓守護進程在接收到SIGTERM信號時終止服務器進程。而在服務器進程這一端,同樣在收到SIGTERM信號時先停掉HTTP服務再正常退出。至此,我們的服務器程序就靠譜很多了。

至此,NodeJS合併文件的服務器開發完成,當然還有許多不足之處,比如:提供日誌通知訪問量、充分利用多核CPU等等。如有興趣,可以在此基礎之上,做進一步的開發。


源碼地址

https://github.com/BirdandLion/NodeJSCombineFiles

參考資料

七天學會NodeJS

Node.js官網

發佈了49 篇原創文章 · 獲贊 16 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章