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万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章