这篇文章主要介绍了使用Node.js实现一个简单的FastCGI服务器实例,也可以作为一个比较详细的Node.js服务器创建教程,需要的朋友可以参考下

本文是我最近对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 Worldn');
}).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 OKrnContent-Type:text/html; charset=utf-8rnConnection: closernrn"));
  }

  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已经在准备直接支持了。
玩得很浅,如有错误欢迎指正交流。

最新资讯
欧洲专利局研究:三星电池专利申请量为全球第一

欧洲专利局研究:三星电

欧洲专利局(EPO)与国际能源署(IEA)22日发布一项共同研究结
滴滴橙心优选与中粮福临门达成合作 涉及商品供应等方面

滴滴橙心优选与中粮福

滴滴旗下社区电商平台橙心优选与中粮福临门食品营销有
Facebook内容监管委员会大选前运作 可推翻扎克伯格决定

Facebook内容监管委员

该监督委员会被一些人士称为Facebook的“最高法(tian)院(ping)”,有
特斯拉新电池计划发布 对谁的威胁最大?

特斯拉新电池计划发布

虽然现在特斯拉的电池计划并未成为现实,但随着特斯拉从
拼多多低市价1000元卖茅台,茅台称没供货

拼多多低市价1000元卖

拼多多上第三方卖家销售的53度飞天茅台价格远低于市场
美团配送魏巍:开放平台业务规模已增长175%

美团配送魏巍:开放平台

美团配送总经理魏巍透露,自去年宣布对外开放即时配送能
最新文章
详解Vue的ref特性的使用

详解Vue的ref特性的使

这篇文章主要介绍了详解Vue的ref特性的使用,文中通过
vue学习笔记之slot插槽基本用法实例分析

vue学习笔记之slot插

这篇文章主要介绍了vue学习笔记之slot插槽基本用法,结
vue跳转方式(打开新页面)及传参操作示例

vue跳转方式(打开新页

这篇文章主要介绍了vue跳转方式(打开新页面)及传参操作,
vue学习笔记之过滤器的基本使用方法实例分析

vue学习笔记之过滤器

这篇文章主要介绍了vue学习笔记之过滤器的基本使用方
js获取本日、本周、本月的时间代码

js获取本日、本周、本

本篇文章给大家分享的内容是利用js如何获取本日、本周
node crawler如何添加promise支持

node crawler如何添加

这篇文章主要介绍了node crawler如何添加promise支持,