使用Node.js实现一个简单的FastCGI服务器实例


Posted in Javascript onJune 09, 2014

本文是我最近对Node.js学习过程中产生的一个想法,提出来和大家一起探讨。

Node.js的HTTP服务器

使用Node.js可以非常容易的实现一个http服务,最简的例子如官方网站的示例:

var http = require('http');
http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World\n');
}).listen(1337, '127.0.0.1');

这样就快速的搭建了一个监听在1337端口所有http请求的web服务。
但是,在真正的生产环境中,我们一般很少直接使用Node.js作为面向用户的最前端web服务器,原因主要有以下几种:

1.基于Node.js单线程特性的原因,其健壮性的保证对开发人员要求比较高。
2.服务器上可能已有其他http服务已占用80端口,而非80端口的web服务对用户显然不够友好。
3.Node.js对文件IO处理并没太大优势,如作为常规网站可能需同时响应图片等文件资源。
4.分布式负载场景也是一个挑战。

所以,使用Node.js作为web服务更多可能是作为游戏服务器接口等类似场景,大多是处理不需用户直接访问且仅作数据交换的服务。

基于Nginx作为前端机的Node.js web服务

基于上述原因,如果是使用Node.js搭建的网站形的产品,常规的使用方式是在Node.js的web服务前端放置另一个成熟的http服务器,如最常使用的是Nginx。
然后使用Nginx作为反向代理访问基于Node.js的web服务。如:

server{
    listen 80;
    server_name yekai.me;
    root /home/andy/wwwroot/yekai;
    location / {
        proxy_pass http://127.0.0.1:1337;
    }
    location ~ \.(gif|jpg|png|swf|ico|css|js)$ {
        root /home/andy/wwwroot/yekai/static;
    }
}

这样就比较好的解决了上面提出的几个问题。

使用FastCGI协议通讯

不过,上述代理的方式也有一些不是很好的地方。
一个是有可能的场景是需要控制后面的Node.js的web服务的直接http访问。不过,要解决的话也可以使用自身的服务或者依靠防火墙阻挡。
另外一个是因为代理的方式毕竟是网络应用层上的方案,也不是很方便直接获取和处理与客户端http交互的数据,比如对keep-alive、trunk甚至cookie等的处理。当然这也与代理服务器自身的能力和功能完善程度相关。
所以,我在想尝试另外一种处理方式,首先想到的就是现在在php web应用上普遍使用的FastCGI的方式。

什么是FastCGI

快速通用网关接口(Fast Common Gateway Interface/FastCGI)是一种让交互程序与Web服务器通信的协议。

FastCGI产生的背景是用来作为cgi web应用的替代方案,一个最明显的特点是一个FastCGI服务进程可以用来处理一连串的请求,web服务器会把环境变量和这个页面请求通过一个socket比如FastCGI进程与web服务器连接起来,连接可用Unix Domain Socket或是一个TCP/IP连接。关于更多的背景知识可以参考Wikipedia的词条。

Node.js的FastCGI实现

那么理论上我们只需要使用Node.js创建一个FastCGI进程,再指定Nginx的监听请求发送到这个进程就行了。由于Nginx和Node.js都是基于事件驱动的服务模型,“理论”上应该是天作地合的解决方案。下面我们就亲自实现一下。
在Node.js中net模块刚好可用来建立一个socket服务,为了方便我们就选用unix socket的方式。
在Nginx端的配置稍微修改下:

...
location / {
    fastcgi_pass   unix:/tmp/node_fcgi.sock;
}
...

新建一个文件node_fcgi.js,内容如下:
var net = require('net');
var server = net.createServer();
server.listen('/tmp/node_fcgi.sock');
server.on('connection', function(sock){
    console.log('connection');
    sock.on('data', function(data){
        console.log(data);
    });
});

然后运行(因为权限的原因,请保证Nginx和node脚本使用同一用户或有相互权限的帐号运行,不然读写sock文件会遇到权限问题):

node node_fcgi.js

在浏览器访问,我们看到运行node脚本的终端正常的接收到了数据内容,比如这样:

connection
< Buffer 01 01 00 01 00 08 00 00 00 01 00 00 00 00 00 00 01 04 00 01 01 87 01...>

这就证明我们的理论基础已经实现了第一步,接下来只需要搞清楚这个buffer的内容如何解析就行了。

FastCGI协议基础

FastCGI记录由一个定长前缀后跟可变数量的内容和填充字节组成。记录结构如下:

typedef struct {
    unsigned char version;
    unsigned char type;
    unsigned char requestIdB1;
    unsigned char requestIdB0;
    unsigned char contentLengthB1;
    unsigned char contentLengthB0;
    unsigned char paddingLength;
    unsigned char reserved;
    unsigned char contentData[contentLength];
    unsigned char paddingData[paddingLength];
} FCGI_Record;

version :FastCGI协议版本,现在默认就用1就好
type :记录类型,其实可以当做是不同状态,后面具体说
requestId :请求id,返回时需对应,如果不是多路复用并发的情况,这里直接用1就好
contentLength :内容长度,这里最大长度是65535
paddingLength :填充长度,作用就是长数据填充为满8字节的整数倍,主要是用来更有效地处理保持对齐的数据,主要是性能考虑
reserved :保留字节,为了后续扩展
contentData :真正的内容数据,一会具体说
paddingData :填充数据,反正都是0,直接忽略就好。

具体的结构和说明请参考官网文档(http://www.fastcgi.com/devkit/doc/fcgi-spec.html#S3.3)。

请求部分

似乎好像很简单,就是这样解析一次拿到数据就行了。不过,这里有一个坑,那就是这里定义的是数据单元(记录)的结构,并不是整个buffer的结构,整个buffer由一个记录一个记录这样的组成。一开始可能对于我们习惯了前端开发的同学不大好理解,但是这是理解FastCGI协议的基础,后面还会看到更多例子。
所以,我们需要将一个记录一个记录单独解析出来,根据前面拿到的type来区分记录。这里是一个简单的获取所有记录的函数:

function getRcds(data, cb){
    var rcds = [],
        start = 0,
        length = data.length;
    return function (){
        if(start >= length){
            cb && cb(rcds);
            rcds = null;
            return;
        }
        var end = start + 8,
            header = data.slice(start, end),
            version = header[0],
            type    = header[1],
            requestId = (header[2] << 8) + header[3],
            contentLength = (header[4] << 8) + header[5],
            paddingLength = header[6];
        start = end + contentLength + paddingLength;
        var body = contentLength ? data.slice(end, contentLength) : null;
        rcds.push([type, body, requestId]);
        return arguments.callee();
    }
}
//使用
sock.on('data', function(data){
    getRcds(data, function(rcds){
    })();
}

注意这里只是简单处理,如果有上传文件等复杂情况这个函数不适应,为了最简演示就先简便处理了。同时,也忽略了requestId参数,如果是多路复用的情况下不能忽略,并且处理会需要复杂得多。
接下来就可以根据type来对不同的记录进行处理了。type的定义如下:

#define FCGI_BEGIN_REQUEST       1
#define FCGI_ABORT_REQUEST       2
#define FCGI_END_REQUEST         3
#define FCGI_PARAMS              4
#define FCGI_STDIN               5
#define FCGI_STDOUT              6
#define FCGI_STDERR              7
#define FCGI_DATA                8
#define FCGI_GET_VALUES          9
#define FCGI_GET_VALUES_RESULT  10
#define FCGI_UNKNOWN_TYPE       11
#define FCGI_MAXTYPE (FCGI_UNKNOWN_TYPE)

接下来就可以根据记录的type来解析拿到真正的数据,下面我只拿最常用的FCGI_PARAMS、FCGI_GET_VALUES、FCGI_GET_VALUES_RESULT来说明,好在他们的解析方式是一致的。其他type记录的解析有自己不同的规则,可以参考规范的定义实现,我这里就不细说了。
FCGI_PARAMS、FCGI_GET_VALUES、FCGI_GET_VALUES_RESULT都是“编码名-值”类型数据,标准格式为:以名字长度,后跟值的长度,后跟名字,后跟值的形式传送,其中127字节或更少的长度能在一字节中编码,而更长的长度总是在四字节中编码。长度的第一字节的高位指示长度的编码方式。高位为0意味着一个字节的编码方式,1意味着四字节的编码方式。看个综合的例子,比如长名短值的情况:

typedef struct {
    unsigned char nameLengthB3;  /* nameLengthB3  >> 7 == 1 */
    unsigned char nameLengthB2;
    unsigned char nameLengthB1;
    unsigned char nameLengthB0;
    unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
    unsigned char nameData[nameLength
            ((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
    unsigned char valueData[valueLength];
} FCGI_NameValuePair41;

对应的实现js方法示例:

function parseParams(body){
    var j = 0,
        params = {},
        length = body.length;
    while(j < length){
        var name,
            value,
            nameLength,
            valueLength;
        if(body[j] >> 7 == 1){
            nameLength = ((body[j++] & 0x7f) << 24) + (body[j++] << 16) + (body[j++] << 8) + body[j++];
        } else {
            nameLength = body[j++];
        }
        if(body[j] >> 7 == 1){
            valueLength = ((body[j++] & 0x7f) << 24) + (body[j++] << 16) + (body[j++] << 8) + body[j++];
        } else {
            valueLength = body[j++];
        }
        var ret = body.asciiSlice(j, j + nameLength + valueLength);
        name = ret.substring(0, nameLength);
        value = ret.substring(nameLength);
        params[name] = value;
        j += (nameLength + valueLength);
    }
    return params;
}

这样就实现了一个简单可获取各种参数和环境变量的方法。完善前面的代码,演示我们如何获取客户端ip:

sock.on('data', function(data){
    getRcds(data, function(rcds){
        for(var i = 0, l = rcds.length; i < l; i++){
            var bodyData = rcds[i],
                type = bodyData[0],
                body = bodyData[1];
            if(body && (type === TYPES.FCGI_PARAMS || type === TYPES.FCGI_GET_VALUES || type === TYPES.FCGI_GET_VALUES_RESULT)){
                    var params = parseParams(body);
                    console.log(params.REMOTE_ADDR);
                }
        }
    })();
}

到现在我们已经了解了FastCGI请求部分的基础,下面接着将响应部分的实现,并最终完成一个简单的echo应答服务。

响应部分

响应部分相对比较简单,最简单的情况只需要发送两个记录就行了,那就是FCGI_STDOUT和FCGI_END_REQUEST。
具体记录实体的内容就不冗述了,直接看代码吧:

var res = (function(){
    var MaxLength = Math.pow(2, 16);
    function buffer0(len){
        return new Buffer((new Array(len + 1)).join('\u0000'));
    };
    function writeStdout(data){
        var rcdStdoutHd = new Buffer(8),
            contendLength = data.length,
            paddingLength = 8 - contendLength % 8;
        rcdStdoutHd[0] = 1;
        rcdStdoutHd[1] = TYPES.FCGI_STDOUT;
        rcdStdoutHd[2] = 0;
        rcdStdoutHd[3] = 1;
        rcdStdoutHd[4] = contendLength >> 8;
        rcdStdoutHd[5] = contendLength;
        rcdStdoutHd[6] = paddingLength;
        rcdStdoutHd[7] = 0;
        return Buffer.concat([rcdStdoutHd, data, buffer0(paddingLength)]);
    };
    function writeHttpHead(){
        return writeStdout(new Buffer("HTTP/1.1 200 OK\r\nContent-Type:text/html; charset=utf-8\r\nConnection: close\r\n\r\n"));
    }
    function writeHttpBody(bodyStr){
        var bodyBuffer = [],
            body = new Buffer(bodyStr);
        for(var i = 0, l = body.length; i < l; i += MaxLength + 1){
            bodyBuffer.push(writeStdout(body.slice(i, i + MaxLength)));
        }
        return Buffer.concat(bodyBuffer);
    }
    function writeEnd(){
        var rcdEndHd = new Buffer(8);
        rcdEndHd[0] = 1;
        rcdEndHd[1] = TYPES.FCGI_END_REQUEST;
        rcdEndHd[2] = 0;
        rcdEndHd[3] = 1;
        rcdEndHd[4] = 0;
        rcdEndHd[5] = 8;
        rcdEndHd[6] = 0;
        rcdEndHd[7] = 0;
        return Buffer.concat([rcdEndHd, buffer0(8)]);
    }
    return function(data){
        return Buffer.concat([writeHttpHead(), writeHttpBody(data), writeEnd()]);
    };
})();

在最简单的情况下,这样就可以发送一个完整的响应了。把我们最终的代码修改一下:

var visitors = 0;
server.on('connection', function(sock){
    visitors++;
    sock.on('data', function(data){
        ...
        var querys = querystring.parse(params.QUERY_STRING);
            var ret = res('欢迎你,' + (querys.name || '亲爱的朋友') + '!你是本站第' + visitors + '位用户哦~');
            sock.write(ret);
            ret = null;
            sock.end();
        ...
    });

打开浏览器访问:http://domain/?name=yekai,可看到类似“欢迎你,yekai!你是本站第7位用户哦~”。
至此,我们就成功的使用Node.js实现了一个最简单的FastCGI服务。如果需要作为真正的服务使用,接下来只需要对照协议规范完善我们的逻辑就行了。

对比测试

最后,我们需要考虑的问题是这个方案具体是否具有可行性?可能已经有同学看出了问题,我先把简单的压测结果放上来:

//FastCGI方式:
500 clients, running 10 sec.
Speed=27678 pages/min, 63277 bytes/sec.
Requests: 3295 susceed, 1318 failed.
500 clients, running 20 sec.
Speed=22131 pages/min, 63359 bytes/sec.
Requests: 6523 susceed, 854 failed.
//proxy方式:
500 clients, running 10 sec.
Speed=28752 pages/min, 73191 bytes/sec.
Requests: 3724 susceed, 1068 failed.
500 clients, running 20 sec.
Speed=26508 pages/min, 66267 bytes/sec.
Requests: 6716 susceed, 2120 failed.
//直接访问Node.js服务方式:
500 clients, running 10 sec.
Speed=101154 pages/min, 264247 bytes/sec.
Requests: 15729 susceed, 1130 failed.
500 clients, running 20 sec.
Speed=43791 pages/min, 115962 bytes/sec.
Requests: 13898 susceed, 699 failed.

为什么proxy方式反而会优于FastCGI方式呢?那是因为在proxy方案下后端服务是直接由Node.js原生模块跑的,而FastCGI方案是我们自己使用JavaScrip实现的。不过,也可以看出两者方案效率上并没有很大的差距(当然,这里对比的只是简单的情况,如果在真正的业务场景下,差距应该会更大),并且如果Node.js原生支持FastCGI服务,那么效率上应该会更优。

后记

如果有兴趣继续玩的同学可以查看我本文实现的例子源码,这两天研究下了协议规范,其实不难。
同时,回头准备再玩玩uWSGI,不过官方说v8已经在准备直接支持了。
玩得很浅,如有错误欢迎指正交流。

Javascript 相关文章推荐
js 中 document.createEvent的用法
Aug 29 Javascript
Ajax异步提交表单数据的说明及方法实例
Jun 22 Javascript
JavaScript使用Replace进行字符串替换的方法
Apr 14 Javascript
javascript实现一个数值加法函数
Jun 26 Javascript
js手机号4位显示空格,银行卡每4位显示空格效果
Mar 23 Javascript
基于Vue2的独立构建与运行时构建的差别(详解)
Dec 06 Javascript
form表单数据封装成json格式并提交给服务器的实现方法
Dec 14 Javascript
微信小程序使用swiper组件实现层叠轮播图
Nov 04 Javascript
一个手写的vue放大镜效果
Aug 09 Javascript
jQuery实现简单弹幕制作
Dec 10 jQuery
vue使用element-ui实现表单验证
Dec 13 Vue.js
vite2.0+vue3移动端项目实战详解
Mar 03 Vue.js
初识SmartJS - AOP三剑客
Jun 08 #Javascript
javascript实例分享---具有立体效果的图片特效
Jun 08 #Javascript
js 中将多个逗号替换为一个逗号的代码
Jun 07 #Javascript
js 去除字符串第一位逗号的方法
Jun 07 #Javascript
javascript去除字符串中所有标点符号和提取纯文本的正则
Jun 07 #Javascript
用js替换除数字与逗号以外的所有字符的代码
Jun 07 #Javascript
javascript写的异步加载js文件函数(支持数组传参)
Jun 07 #Javascript
You might like
一些操作和快捷键的理解和讨论
2020/03/04 星际争霸
php使用正则表达式提取字符串中尖括号、小括号、中括号、大括号中的字符串
2020/04/05 PHP
PHP Cookie学习笔记
2016/08/23 PHP
PHP 7.1中AES加解密方法mcrypt_module_open()的替换方案
2017/10/17 PHP
浅析PHP中json_encode与json_decode的区别
2020/07/15 PHP
Aster vs Newbee BO5 第三场2.19
2021/03/10 DOTA
JavaScript DOM进阶方法
2015/04/13 Javascript
js显示文本框提示文字的方法
2015/05/07 Javascript
jQuery+HTML5实现手机摇一摇换衣特效
2015/06/05 Javascript
jQuery的事件委托实例分析
2015/07/15 Javascript
深入分析jsonp协议原理
2015/09/26 Javascript
angularjs中ng-attr的用法详解
2016/12/31 Javascript
angularjs 实现带查找筛选功能的select下拉框实例
2017/01/11 Javascript
JS中SetTimeout和SetInterval使用初探
2017/03/23 Javascript
为什么我们要做三份 Webpack 配置文件
2017/09/18 Javascript
vue2 前端搜索实现示例
2018/02/26 Javascript
JavaScript实现邮箱后缀提示功能的示例代码
2018/12/13 Javascript
JavaScript简单实现动态改变HTML内容的方法示例
2018/12/25 Javascript
使用nodejs分离html文件里的js和css详解
2019/04/12 NodeJs
基于Python实现文件大小输出
2016/01/11 Python
基于Python实现通过微信搜索功能查看谁把你删除了
2016/01/27 Python
python爬虫之BeautifulSoup 使用select方法详解
2017/10/23 Python
python3.6+django2.0+mysql搭建网站过程详解
2019/07/24 Python
解决django服务器重启端口被占用的问题
2019/07/26 Python
解决Pytorch 加载训练好的模型 遇到的error问题
2020/01/10 Python
Python基于DB-API操作MySQL数据库过程解析
2020/04/23 Python
python调用API接口实现登陆短信验证
2020/05/10 Python
Python selenium使用autoIT上传附件过程详解
2020/05/26 Python
英国鲜花速递:Serenata Flowers
2018/04/03 全球购物
马来西亚最大的在线隐形眼镜商店:MrLens
2019/03/27 全球购物
瑞典耳机品牌:URBANISTA
2019/12/03 全球购物
机票销售员态度不好检讨书
2014/09/27 职场文书
收银员岗位职责范本
2015/04/07 职场文书
刑事案件上诉状
2015/05/23 职场文书
2016银行求职自荐信
2016/01/28 职场文书
2016年圣诞节活动总结范文
2016/04/01 职场文书