深入浅出 nodeJs-构建Web应用和多进程
构建Web应用
Node 的出现,前端将被重新定义
基础功能
在具体业务开始前,需要为业务预处理一些细节,这些细节将会挂载在 req 或 res 对象上,供业务代码使用
-
请求方法。 在 web 应用中,还有 HEAD、DELETE、PUT、CONNECT 方法。可以通过请求方法决定性响应行为
-
路径解析。可以把路径解析为 url
-
查询字符串。查询字符串位于路径之后
-
Cookie。设置 header 里的 set-header 字段
-
Sesssion。Session 的数据只保留在服务器端,可以和 cookie 一起来进行数据的存储和访问
- 基于 Cookie 实现用户和数据的映射。一旦服务器启用了 Session,它会约定一个键值作为 Session 的口令,这个值可以随意约定。比如 Connect 默认采用 connect_uid,Tomcat 会采用 jsessionid 等,以下为生成 session 的代码:
var sessions = {};
var key = 'session_id';
var EXPIRES = 20 * 60 * 1000;
var generate = function() {
var session = {};
session.id = (new Date()).getTime() + Math.random();
session.cookie = {
expire: (new Date()).getTime() + EXPIRES
};
sessions[session.id] = session;
return session;
};
每个请求到来时,检查 cookie 中的口令与服务器端的数据,如果过期,重新生成
- 通过查询字符串来实现浏览器端与服务器端数据的对应。原理是检查请求的查询字符串,如果没有值,会先生成新的带值的 URL,然后形成跳转。这种方案的风险比较大,只要复制地址栏地址就能拥有相同的身份
- session 与内存。如果 session 直接存在变量中,会容易接触到内存限制的上限,并且会引起垃圾回收的频繁扫描。可以使用 redis 来把 session 集中化,将原本分散在多个进程的数据,同意转移到集中度数据存储中。
第三方缓存带来的一个问题是会引起网络访问。但是其影响少之又少。采用它的理由有以下几条
- Node 与缓存服务保存长连接,而非频繁的短连接,握手延迟只影响初始化
- 高速缓存直接在内存中进行数据存储和访问
- 缓存服务通常与 node 在同一机房,网络速度受到的影响较少
- session 与安全。Session 的口令会保存在客户端,会存在口令盗用的情况,而 session 的安全,就是这个口令的安全。有一种做法是吧这个口令通过私钥进行签名。客户端由于不知道私钥值,签名很难伪造,如果签名非法,将服务端的数据立即过期即可
缓存 缓存规则有几条
- 添加 Expires 或 Cache-Control 到报文头中
- 配置 ETags
- 让 Ajax 缓存
使用Cache-Control 的 Etag 或是 lastModified 字段
当服务器意外更新内容时,无法通知客户端更新。此时我们在使用缓存时要设置版本号,当内容有更新时,我们就让浏览器发起新的 url 请求。
Basic 认证 Basic 认证是当客户端与服务端进行请求时,允许通过用户名和密码实现的一种身份认证方式
如果一个页面需要 Basic 认证,会检查请求报文头中的 Authorization 字段的内容,该字段的值由认证方式和加密值构成
这种方式有太多缺点,一般只有 HTTPS 的情况下使用,不过所有浏览器都支持
数据上传
通过 http 的 Transfer-Encoding 或 Content-Length 即可判断请求中是否带有内容
varvar hasBody = function(req) {
return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};
在 HTTP_Parser 解析报头结束后,报文内容部分通过 data 事件触发,只需以流的方式处理
function(req, res) {
if (hasBody(req)) {
var buffers = [];
req.on('data', function(chunk) {
buffers.push(chunk);
});
req.on('end', function() {
req.rawBody = Buffer.concat(buffers).toString();
handle(req, res);
});
} else {
handle(req, res);
}
}
表单数据
通过 Content-Type: application/x-www-form-urlencoded
进行解析,它的报问体和 query 相同 foo=bar&baz=val
其他格式
通过 Content-Type: application/json
解析 json 格式内容
附件上传
提交文件的这种特殊表单与普通表单的差异在表单可以含有 file 类型的控件,以及需要指定表单属性 enctype 为 multipart/form-data
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="username">Username:</label> <input type="text" name="username" id="username" />
<label for="file">Filename:</label> <input type="file" name="file" id="file" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>
当浏览器遇到 multipart/form-data
表单时,构造的请求报文与普通表单完全不同
Content-Type: multipart/form-data; boundary=AaB03x
Content-Length: 18231
boundary
指定的是每部分内容的分界符,值是随机生成的一端字符串,报文体的内容通过在它前面添加 - -
进行分割,报文结束在它前后都加上 - -
表示结束。 Content-Length
的值是报文体的长度
可以通过 formidable
模块基于流式解析报文,将接收到的文件写入到系统的临时文件夹中,并返回对应的路径
var formidable = require('formidable');
function(req, res) {
if (hasBody(req)) {
if (mime(req) === 'multipart/form-data') {
var form = new formidable.IncomingForm();
form.parse(req, function(err, fields, files) {
req.body = fields;
req.files = files;
handle(req, res);
});
}
} else {
handle(req, res);
}
}
数据上传与安全
- 内存限制。在解析表单、JSON、和 XML 部分,我们是先保存用户提交的所有数据,在解析处理,最后才传递给业务逻辑。但这种策略的问题是仅仅适合数据量小的提交请求,一旦数据量过大,将发生内存被占光的情况。攻击者只需要模拟伪造大量数据就能吃光内存
有两个方案可以解决:
- 限制上传内容的大小,一旦超过限制,停止接收数据,并响应400状态码
- 通过流式解析,将数据流导向到磁盘中,Node 只保留文件路径等小数据
- CSRF。可以为每个请求的用户,在 Session 中赋予一个随机值
var generateRandom = function(len) {
return crypto.randomBytes(Math.ceil(len * 3 / 4))
.toString('base64')
.slice(0, len);
};
var token = req.session._csrf || (req.session._csrf = generateRandom(24));
<form id="test" method="POST" action="http://domain_a.com/guestbook">
<input type="hidden" name="content" value="vim 是最好的编辑器" />
<input type="hidden" name="_csrf" value="<%=_csrf%>" />
</form>
路由解析
对于不同的业务,我们希望有不同的处理方式
文件路径型
- 静态文件。这种方式无需转换,url 的路径与网站目录的路径一致,无需转换,非常直观。处理方式也十分简单
- 动态文件。更具文件路径执行动态脚本是基本的路由方式,它的处理原理是 Web 服务器根据 URL 路径找到对应的文件,再根据文件名后缀寻找脚本的解析器,并传入 HTTP 请求的上下文
MVC MVC 模型的主要思想是将业务逻辑按职责分离,分为以下几种
- 控制器(Controller),一组行为的集合
- 模型(Model),数据相关的操作和封装
- 视图(View),视图的渲染
中间件
中间件的行为类似 Java 中过滤器的工作原理,对于 Web 应用的各种基础功能,通过中间件来完成,每个中间件处理掉相对进的逻辑,最终汇成强大的基础框架
一个基本的中间件会是如下形式:
var middleware = function(req, res, next) {
// TODO
next();
}
// 中间件串联
app.use(querystring);
app.use(cookie);
app.use(session);
app.get('/user/:username', getUser);
app.put('/user/:username', authorize, updateUser);
app.use('/user/:username', querystring, cookie, session, function(req, res) {
// TODO
});
// 比如中间的 querystring 中间件
// querystring解析中间件
var querystring = function(req, res, next) {
req.query = url.parse(req.url, true).query;
next();
};
异常处理
为 next()
方法添加 err 参数,并捕获中间件直接抛出的同步异常
var handle = function(req, res, stack) {
var next = function(err) {
if (err) {
return handle500(err, req, res, stack);
}
// 从stack数组中出中间件执行
var middleware = stack.shift();
if (middleware) {
// 传入next()函数自使中间件能执行结递
try {
middleware(req, res, next);
} catch (ex) {
next(err);
}
}
};
// 启动执行
next();
};
由于异步时需要自己抛出异常,所以异常处理的中间件有四个参数
app.use(function(err, req, res, next) {
// TODO
})
中间件与性能 有两个主要的提升性能的点
- 编写高效的中间件。其实就是提升单个处理单元的处理速度,以尽早调用
next()
执行后续逻辑 - 合理利用路由,避免不必要的中间件执行
页面渲染
Node 并没有提供页面渲染的内置功能
内容响应
内容响应的过程中,响应报头的 Content-*
十分重要,比如下面的 js 文件
Content-Encoding: gzip
Content-Length: 21170
Content-Type: text/javascript; charset=utf-8
- MIME(Multipurpose Internet Mail Extensions)。Content-Type 值决定采用不同的渲染方式,这个值我们简称为 MIME 值。社区有专门的mime模块可以文件路径来判断文件类型
- 附件下载。在一些场景下,可能需求是只要求客户端去下载文件而不是打开它,此时可以看 Content-Disposition 字段。值为 inline 时查看,为 attachment 时存为附件
- 响应JSON。
res.json = function(json) {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify(json));
};
- 响应跳转。当 URL 因为某些问题跳转到别的 URL 时,我们可以封装出一个快捷的方法实现跳转
res.redirect = function(url) {
res.setHeader('Location', url);
res.writeHead(302);
res.end('Redirect to ' + url);
};
视图渲染 模板是带有特殊标签的 HTML 片段,通过与数据的渲染,将数据填充到这些特殊标签中,最后生成普通的带数据的 HTML 片段。通常我们将渲染方法设计为 render()
玩转进程
Node 由于使用的是 V8 引擎,所以它的模型与浏览器类似,但是单进程单线程并非完美的结构,而且单线程上如果异常没有被捕获,将会引起整个进程的崩溃
多进程架构
针对单进程单线程对多核使用不足的问题,前人的经验是启动多进程即可。理想状态下是每个进程各自利用一个 CPU,以此实现多核CPU 的利用。Node 提供了 child_process 模块,并且提供了 child_press.fork()
函数供我们实现进程的复制
var http = require('http');
http.createServer(function(req, res) {
res.writeHead(200, {
'Content-Type': 'text/plain'
});
res.end('Hello World\n');
}).listen(Math.round((1 + Math.random()) * 1000), '127.0.0.1');
// 将以下代码存为 master.js 并通过 node master 启动它
var fork = require('child_process').fork;
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}
上面就是著名的 Master-Worker
模式,又称为主从模式,进程分为两种:主进程和工作进程。这是典型的分布式架构中用于并行处理业务的模式,具备较好的可伸缩性和稳定性。主进程负责调度和管理工作进程,工作进程负责具体的业务处理
fork()
进程是昂贵的,启动多个进程只是为了充分将 CPU 资源利用起来,而不是为了解决并发问题
创建子进程 child_process 模块可以给予 Node 可以随意创建子进程的嗯呢管理,它提供了四个方法用于创建子进程。
- spawn(): 启动一个子进程来执行命令
- exec(): 启动一个子进程来执行命令,但它有一个回调函数获知子进程的状况。
- execFile(): 启动一个子进程来执行可执行文件
- fork(): 与 spawn() 类似,不同点在于它创建 Node 的子进程只需指定要执行的 js 文件模块即可
spawn() 与 exec(), execFile() 不同的是,后两者创建时可以指定 timeout 属性设置超时时间,一旦创建的进程运行时间超过设定的时间将会被杀死
尽管有四种方法,但其实后3种方法都是 spawn() 的延伸应用
进程间通信 HTML5 提出了 WebWorker API。WebWorker 允许创建工作线程并在后台运行,使得一些阻塞较为严重的计算不影响主线程上的 UI 渲染。主线程和工作线程之间通过 onmessage() 和 postMessage() 进行通信,子进程对象则由 send() 方法实现主进程向子进程发送数据,message 事件实现收听子进程发来的数据
// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');
n.on('message', function(m) {
console.log('PARENT got message:', m);
});
n.send({
hello: 'world'
});
// sub.js
process.on('message', function(m) {
console.log('CHILD got message:', m);
});
process.send({
foo: 'bar'
});
创建子进程之后,父进程和子进程之间将会创建 IPC 通道。通过 IPC 通道,父子进程之间才能通过 message 和 send() 传递消息
- 进程间通信原理。IPC(Inter-Process Communication)即进程间通信。是为了让不同的进程能够互相访问资源并进行协调工作。Node 中实现 IPC 通道的是管道(pipe)技术,具体实现细节由 libuv 提供
句柄传递 所有进程不能都监听同一个端口,会抛出端口被占用的情况,要解决这个问题,可以主进程对外接收所有网络请求,再将这些请求分别代理到不同的端口进程上,但这样会因为客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。
为了解决浪费文件描述符的问题,Node 引入了进程间发送句柄的功能,send() 方法的第二个可选参数就是句柄
child.send(message, [sendHandle]);
句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如服务端 socket 对象,客户端 socket 对象,UDP 套接字、管道等
有了句柄,我们可以去掉代理这种方案,是主进程收到 socket 请求后,将这个 socket 直接发送给工作进程,而不是重新与工作进程之间建立新的 socket 连接来转发数据。
var child = require('child_process').fork('child.js');
// Open up the server object and send the handle
var server = require('net').createServer();
server.on('connection', function(socket) {
socket.end('handled by parent\n');
});
server.listen(1337, function() {
child.send('server', server);
});
// 子进程代码
process.on('message', function(m, server) {
if (m === 'server') {
server.on('connection', function(socket) {
socket.end('handled by child\n');
});
}
});
- 句柄发送与还原。目前子进程对象 send() 方法可以发送的句柄类型包括以下几种
- net. Socket。TCP 套接字
- net. Server。TCP 服务器,任意建立在 TCP 服务上的应用层服务都可以享受到它带来的好处
- net. Native。C++ 层面的 TCP 套接字或 IPC 管道
- dgram. Socket。UDP 套接字
- dgram. Native。C++ 层面的 UDP 套接字
send() 方法在在将消息发送到 IPC 管道迁,将消息组装成两个对象,一个参数是 handle,另一个参数是 message。message 参数如下所示:
{
cmd: 'NODE_HANDLE',
type: 'net.Server',
msg: message
}
发送到 IPC 管道中的实际上是我们要发送的句柄描述符,文件描述符实际上是一个整数值。这个 message 对象在写入到 IPC 管到时也会通过 JSON.stringify() 进行序列化。所以最终发送到 IPC 通道中的信息都是字符串
连接了 IPC 通道的子进程可以读取到父进程发来的消息,将字符串通过 JSON.parse() 解析还原为对象后,才出发 message 事件将消息体传递给应用层使用。
在这个过程中,消息对象还要被进行过滤处理,message.cmd 的值如果以 NODE_
为前缀,它将响应一个内部事件 internalMessage
。如果 message.cmd 的值为 NODE_HANDLE
,它将取出 message.type
值和得到的文件描述符一起还原出一个对应的对象
- 端口共同监听。发送句柄后,由于并不是相同的文件描述符,所以监听相同端口不会引起异常。多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用,换言之就是网络请求向服务端发送时,只有一个幸运的进程能够抢到连接
集群稳定之路
搭建好了集群,我们还有一些细节需要考虑
进程事件 子进程除了 message 事件外,Node 还有如下事件
- error: 当子进程无法被复制创建、无法被杀死,无法发送消息时会触发该事件
- exit: 当子进程退出时触发该事件,子进程如果是正常退出,这个事件的第一个参数为退出码,否则为 null。如果进程是通过
kill()
方法被杀死的,会得到第二个参数,他表示杀死进程时的信号 - close: 在子进程的标准输入输出流中止时触发该事件,参数与 exit 相同
- disconnect: 在父进程或子进程中调用
disconnect()
方法时触发该事件,在调用该方法时将关闭监听 IPC 通道
上述这些事件是父进程能监听到的与子进程相关的时间。除了 send()
外,还能通过 kill()
方法给子进程发送消息。它并不是傻子子进程,它只是发送了一个系统信号,执行 kill -l
可以看到详细的信号列表
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGEMT 8) SIGFPE
9) SIGKILL 10) SIGBUS 11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU 23) SIGIO 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1 31) SIGUSR2 $ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
进程在收到响应信号时,应当做出约定的行为
自动重启
我们通过监听子进程的 exit
事件来获知其退出的记录,此时需要启动一个工作进程来继续服务
// master.js
var fork = require('child_process').fork;
var cpus = require('os').cpus();
var server = require('net').createServer();
server.listen(1337);
var workers = {};
var createWorker = function() {
var worker = fork(__dirname + '/worker.js');
//退出时重新启动新的进程
worker.on('exit', function() {
console.log('Worker ' + worker.pid + ' exited.');
delete workers[worker.pid];
createWorker();
});
// 句柄转发
worker.send('server', server);
workers[worker.pid] = worker;
console.log('Create worker. pid: ' + worker.pid);
};
for (var i = 0; i < cpus.length; i++) {
createWorker();
}
// 进程自己退出时,让所有工作进程退出
process.on('exit', function() {
for (var pid in workers) {
workers[pid].kill();
}
});
process.on('uncaughtException', function() {
// 停止接收新的连接
worker.close(function() {
// 所有已有连接断开后,退出进程
process.exit(1);
});
});
- 自杀信号。上述代码存在的问题是要等到已有的所有连接断开后进程才推出。极端情况下,所有工作进程都停止接收新的连接,全处在等待退出的状态,这回丢掉大部分请求。
所以我们不能大等到工作进程退出后才重启新的工作进程,同时也不能暴力退出进程,这会导致已连接的用户直接断开。于是我们可以在退出的流程中增加一个自杀 suicide
信号。
// worker.js
process.on('uncaughtException', function(err) {
process.send({
act: 'suicide'
});
//停止接收新的连接
worker.close(function() {
// 所有已有连接断开后,退出进程
process.exit(1);
});
// 5秒后退出进程,防止长连接
setTimeout(function() {
process.exit(1);
}, 5000);
});
// 从 exit 事件的处理函数中转移到 message 事件的处理函数中
var createWorker = function() {
var worker = fork(__dirname + '/worker.js');
// 启动新的进程
worker.on('message', function(message) {
if (message.act === 'suicide') {
createWorker();
}
});
worker.on('exit', function() {
console.log('Worker ' + worker.pid + ' exited.');
delete workers[worker.pid];
});
worker.send('server', server);
workers[worker.pid] = worker;
console.log('Create worker. pid: ' + worker.pid);
};
- 限量重启。通过自杀信号告知主进程可以使得新连接总是有进程服务,但工作进程不能无限制地被重启,我们应该在单位时间内规定只能重启多少次,超过限制就触发
giveup
事件,告知放弃重启工作进程这个重要事件
负载均衡 在多进程之间监听相同的端口,使得用户请求能够分散到多个进程上进行处理,好处是可以将 CPU 资源都调用起来。
Node 默认提供的机制是采用操作系统的抢占式策略。谁抢到谁服务
Node 提供了一种新的策略使负载均衡更合理,这种策略叫 Round-Robin
,又叫轮叫调度。它的工作方式是由主进程接受连接,将依次分发给工作进程。分发的策略是在 N 个工作进程中,每次选择 i=(i+1) mod n
个进程来发送连接。在 cluster 模块中启用它的方式如下:
// 启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_RR
// 不启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_NONE
// 或者在环境变量中设置 NODE_CLUSTER_SCHED_POLICY的值
export NODE_CLUSTER_SCHED_POLICY = rr
export NODE_CLUSTER_SCHED_POLICY = none
状态共享 在不允许共享数据的情况下,我们需要一种方案和机制来实现数据在多个进程之间的共享
- 第三方数据存储。解决数据共享最直接、简单的方式是通过第三方来进行数据存储,但这种方式是如果数据发生改变,还需要一种机制通知到各个子进程,使它们的内容状态得到更新
实现状态同步的机制有两种,一种是各个子进程去向第三方进行定时轮询,定时轮询带来的问题是轮询时间不能过密
- 主动通知。即当数据发生更新时,主动通知子进程。这个过程仍然不能脱离轮询,我们可以及减少轮询的进程数量,我们将这种用来发送通知和查询状态是否更改的进程叫做通知进程。
这种推送机制如果按进程间信号传递,在跨多台服务器时会无效,故可以考虑采用 TCP 或 UDP 的方案,一旦通过轮询发现有数据更新后,将更新后的数据发送给工作进程,而这种方式可以使状态响应处的压力不至于太大
Cluster 模块
cluster 模块可以解决多核CPU的利用率问题,也会提供较完善的 API,用以处理进程健壮性问题
cluster 可以创建 Node 进程集群
// cluster.js
var cluster = require('cluster');
var http = require('http');
var numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
// Fork workers
for (var i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', function(worker, code, signal) {
console.log('worker ' + worker.process.pid + ' died');
});
} else {
// Workers can share any TCP connectionvar cluster = require('cluster');
cluster.setupMaster({
exec: "worker.js"
});
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
cluster.fork();
}
// In this case its a HTTP server
http.createServer(function(req, res) {
res.writeHead(200);
res.end("hello world\n");
}).listen(8000);
}
通过环境变量中的 NODE_UNIQUE_ID
来判断主进程和工作进程
cluster.isWorker = ('NODE_UNIQUE_ID' in process.env);
cluster.isMaster = (cluster.isWorker === false);
但是用 isWorker 和 isMaster 的代码可读性比较差,可以直接使用 cluster.setupMaster()
这个 API,将主进程和工作进程从代码上完全剥离
通过 cluster.setupMaster()
创建子进程而不是使用 cluster.fork()
,程序结构不再凌乱。
Cluster 工作原理
cluster 模块就是 child_process 和 net 模块的组合应用。cluster 启动时,它会在内部启动 TCP 服务器,在 cluster.fork()
子进程时,将这个 TCP 服务端 socket 的文件描述符发送给工作进程。
如果进程是通过 cluster.fork()
复制出来的,那么它的环境变量就存在 NODE_UNIQUE_ID
,如果工作进程中存在 listen() 侦听网络端口的调用,它将拿到该文件描述符,通过 SO_REUSEADDR
端口重用,从而实现多个子进程共享端口
在 cluster 模块应用中,一个主进程只能管理一组工作进程,而通过 child_process 操作子进程时,可以隐式地创建多个 TCP 服务器,使得子进程可以共享多个服务器端 socket
Cluster 事件
- fork: 复制一个工作进程后触发
- online: 复制好一个工作近臭,工作进程主动发送一条 online 消息给主进程,主进程收到消息后,触发该事件
- listening: 工作进程中调用 listen() 后,发送一条 listening 消息给主进程,主进程收到消息后,触发该事件
- disconnect: 主进程和工作进程之间 IPC 通道断开后会触发
- exit: 有工作进程退出时触发该事件
- setup:
cluster.setupMaster()
执行后触发