Web前端Server框架学习与探索

前言

最近开始接触到公司内部的一个 nodejs 的框架 TSW, 也看了很多内部论坛是上关于 tsw 文章 , 在学习开发使用的过程中, 也对部分技术框架的设计存有疑惑.
但是, 就目前 tsw 在公司内部大范围的使用的情况来说, 接入完善的公司内部业务模块以及与走织云发布流程, 在使用上很好的承担了许多业务.

由于个人的兴趣爱好,在业余的时间也打算研究一下如何去实现一个类似的框架以及开发流程, 本系列文章也算是自我学习的一个记录,加深理解.

此次文章打算分上下两篇:

上篇: 主要介绍如何去实现一个类似 tsw 的集合开发运维一条龙服务的框架.
下篇: 尝试一下开发运维模式–node与服务容器化

友情链接: tswjs.org, tsw 的官网的文档还是写的挺幽默风趣的,就是内容比较少, 需要大家边用边摸索

进程管理:

大部分的 node 程序都是通过 forever 或者 pm2之类的 Node应用的进程管理器来启动并且服务维护,
使用这类工具可以使node服务在后台运行(类似于linux的nohup),另外它们可以在服务因异常或其他原因被杀掉后进行自动重启。 由于Node的单线程特征,自动重启能很大程度上的提高它的健壮性。
NodeJS是单线程的工作方式,如何利用好 cpu 的多核也是开发者刚刚入门会遇到的问题之一。其实,NodeJS很早就支持了Cluster模式,这个就是同时开启多个进程来监听同一个端口,分发http请求处理,这里大家也许有疑问,如何合理的分发请求到给个子进程, 后面会提到…

###最原始的 cgi

每个请求,都需要经过启动进程、处理请求、结束进程三个步骤,对服务器的资源占用较大, 容易导致性能下降系统不稳定

FastCGI 协议

FastCGI 协议让解释程序常驻在内存中, 不需要为每个请求都 fork 一个进程, 极大的提高了系统稳定性和资源的合理利用, 在这个基础上, FastCGI 也被设计为多进程调度的模式

这个过程同样可以描述为三个步骤:

  1. 初始化 FastCGI 进程管理器,并启动多个 CGI 解释器子进程;
  2. 当请求到达 Web 服务器时,进程管理器选择并连接一个子进程,将环境变量和标准输入发送给它
  3. 处理完成后将标准输出和错误信息返还给 Web 服务器
  4. 子进程关闭连接,继续等待下一个请求的到来

关于 cgi 和 fastcgi 之间的技术细节对比可以参考该文章: http://www.awaimai.com/371.html , 不在这里过多的阐述

nodeJs 的child_process 和 cluster

nodeJS 的单线程的模型决定了 node 原生只能利用单核的 cpu , 如果线程崩溃之后, 整个 web 程序都会崩溃, 和传统的原始 cgi 一样面临 稳定性差的问题, 不适合大规模的生产环境的使用
为了解决整个问题, nodejs 自带 child_process 模块和 cluster 模块, 后者是对前者的一个更好的封装

child_process.fork()或者 cluster.fork() , 衍生一个新的 Node.js 进程,并通过建立一个 IPC 通讯通道来调用一个指定的模块,该通道允许父进程与子进程之间相互发送信息。通过 fork 方法可以衍生任意多个同样的的 node.js 进程, 可以充分的利用好 cpu 的多核性能.看到这里, 我们是不是就会想如果通过 fork 功能, 是不是就实现一个类似 FastCGI 的协议了, master 进程负责监听端口, 接收到新的请求之后负责分发给多个工作进程,
这里有两种连接分发的模式:

模式一: 主进程创建监听socket后发送给感兴趣的工作进程,由工作进程负责直接接收连接:

master 进程出创建socket, 绑定到 ip 和端口之后, 本身并不会调用 listen 和 accept 方法去建立连接, 而是将 socket 的 fd(文件描述符)传递到 fork 出来的work子进程中, work 进程负责监听新的连接, 然后执行业务代码.

然而这种模式主要有两个问题,
多个进程之间会 竞争 accept 一个连接, 容易导致 “惊群现象”(linux 内核2.6以上已经不会出现这个情况了) 惊群现象: http://blog.csdn.net/russell_tao/article/details/7204260
不能够确定哪个进程来处理新的连接, 完全是由系统自动分配, 对于开发者来说是不可控的, 容易导致各个 work 进程负载不均衡.

模式二: 循环法。由主进程负责监听端口,接收新连接后再将连接循环分发给工作进程
有一种基于round-robin 算法的模型,
master 进程创建socket, 绑定地址端口, 同时负责监听
当获取到新的连接之后(调用 accept 方法与客户端建立 tcp 连接), 再将这个连接分发到指定的 worker 进程, 这里如何分发到指定的 worker 进程是可控的, 这里使用了 round-robin 算法, 当然还有其他的算法也可以用于请求的分发.

所以我们在一般的生产环境中都是使用模式二来出处理连接的分发

demo1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 以下的例子使用 socket 举例是为让大家更好的了解 连接 是如何分发的
const cluster = require('cluster');
const net = require('net');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
console.log(`主进程 ${process.pid} 正在运行`);

// 衍生工作进程。
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`工作进程 ${worker.process.pid} 已退出`);
});
let handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
var worker = workers.pop();
worker.send({},handle);
workers.unshift(worker);
}
} else {
process.on('message', function (m, handle) {
let buf = 'worker process';
let res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;
console.log('got a connection on worker, pid = %d', process.pid);
let socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
});
}

demo2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
console.log(`主进程 ${process.pid} 正在运行`);
// 衍生工作进程。
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}

} else {
// 工作进程可以共享任何 TCP 连接。
// 在本例子中,虽然表面上创建了多个 http 服务, 但是本质上共享的是一个 HTTP 服务器。
http.createServer((req, res) => {
res.writeHead(200);
res.end('helloworld\n');
}).listen(8000);
}

跑下 demo2, 可以发现
分配给 master 进程和 worker 进程的 device(内核地址)都不一样, 说明各个进程都有自己的 socket, 并且只有一个master 进程在处于 listening 状态, 说明只有 master 进程负责监听8000的端口, worker 进程的 tcp 状态都是 Established, 说明master 把Established状态的连接分配给多个 worker 进程, worker进程不需关心建立 socket 连接, 只需要处理业务和 连接的 io 操作(比如往连接中写数据),通过上述的分析也验证了cluster 确实是使用模式二来进行连接分发的. 而且从结果来看 round-robin 的调度策略还是可以很好的负载均衡的.

特殊情况: socket.io 可以使用 ip hash, master 进程只把同源 ip 的连接分配给同一个 worker 进程就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (cluster.isMaster) {
// ip hash
var worker_index = function(ip, len) {
var s = '';
for (var i = 0, _len = ip.length; i < _len; i++) {
if (!isNaN(ip[i])) {
s += ip[i];
}
}
return Number(s) % len;
};
var server = net.createServer({ pauseOnConnect: true }, function(connection) {
var worker = workers[worker_index(connection.remoteAddress, num_processes)];
worker.send('sticky-session:connection', connection);
}).listen(port);
}

具体的实践有多种方式,可以参考该篇文章 https://segmentfault.com/a/1190000009622158

http://upload-images.jianshu.io/upload_images/332289-af4940f6779a796c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240

进程守护

message 事件是 master 与 worker 之间的通信桥梁, 也是进程守护的一个基础, 当工作子进程因为未处理异常而崩溃时, master 进程监听 exit 和 disconnect 事件 然后重新fork 新的子进程, 同时也可以利用这个机制来进行热重启.

1
2
3
4
5
6
7
8
9
10
11
12
// 监控进程退出事件,  看情况决定是否重新 fork 进程
cluster.on('exit', function (worker, code, signal) {
if (worker.exitedAfterDisconnect === true) {
console.log('Oh, it was just voluntary – no need to worry');
} else {
clsuter.fork();
}
});
// disconnect 事件: worker 进程可以通过调用 process.disconnect() 方法主动断开与 master 的ipc 管道连接, 可以设置 worker.exitedAfterDisconnect 来区分自发退出还是被动退出,主进程可以根据这个值决定是否重新衍生新的工作进程
cluster.on('disconnect', function () {
clsuter.fork();
});

进程间通信

在 nodejs 的 child process 模块中, 运行通过 child.send()方法向子进程发送发送消息, 子进程也可以通过 process.send()向父进程发送消息,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用进程之间的通信很简单, 比如下面
// 父进程:
if (cluster.isMaster) {
cluster.on('message', (worker, message, handle) => {
worker.send(message);
// ...
});
} else if(cluster.isWorker){
// 子进程
process.send({ foo: 'bar' });
process.on('message', (msg) => {
process.send(msg);
});
}

关于 ipc(Inter-Process Communication, 进程间通信) 全双工双向通信的方案:socketpair , 了解更多可以参考如下
https://www.ibm.com/developerworks/cn/linux/l-pipebid/index.html
http://www.cnblogs.com/keepsimple/archive/2013/05/20/3088248.html

关于 node 的进程管理可以暂时告一段落, 看到这里,就是可以自己实现一个简单的版本PM2 的 node 进程管理程序,获取更加激进一点去做一个负载均衡的 node 多进程服务器了

应用层接入

前面一节我们认识到了 node 的进程管理, 也知道怎么样去做一个多进程模型的服务器了, 那么现在tcp 的连接有了, 该上升到 http 的应用层了

1
2
3
4
5
6
7
8
9
let server = http.createServer(app).listen(port);

// app 就是应用层的入口了,
consot app = (request, response) => {
response.writeHead(200, {
"Content-Type": "text/plain"
});
response.end("Hello world!\n");
}

现在市面上大部分的框架也所做的工作其实都在扩展这个 app 函数的功能, 主要分为以下几个部分

request response 增强:

这里需要做两件事:

1. 扩展了Node的http.IncomingMessage对象,提供了一个稳健的对象请求。
2. 扩展了Node的http.ServerReponse对象,提供响应对象。

###路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 一个很丑陋但是很基础的路由
consot app = (request, response) => {
if(request.url == '/'){
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Home Page!\n");
} else if(request.url == '/about'){
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("About Page!\n");
} else{
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("404 Not Found!\n");
}
}

显然在实际的生产中是不会这样子写路由的, 常规的做法是把路由抽象出来变成路由的配置, 然后调用对应的处理函数, 当然看完下面中间件模块之后, 我们将会有一个思维上面的飞跃…

###中间件:
中间件描述 https://stephensugden.com/middleware_guide/
下面以 express 框架为例, 实现一个简化版本的中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 中间件本身是一个函数
function middleware(req,res,next){
// do something;
// 做完后调用下一个函数
next();
}
function express() {
var funcs = []; // 待执行的函数数组, 这里可以拓展 funcs 为多层级对象,
//可以借用中间件来实现路由的功能.
var app = function (req, res) {
var i = 0;
function next() {
var task = funcs[i++]; // 取出函数数组里的下一个函数
if (!task) { // 如果函数不存在,return
return;
}
task(req, res, next); // 否则,执行下一个函数
}
next();
}
/**
* use方法就是把函数添加到函数数组中
* @param task
*/
app.use = function (task) {
funcs.push(task);
}
return app; // 返回实例
}

###视图:
关键就在于模板引擎的实现, 接着上个例子-中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
var fs = require('fs'); // 引入 fs 文件模块
var filePath = './views';
app.use(function (req, res, next) {
// 定义一个模板引擎
res.render = (filePath, option) => {
fs.readFile(filePath, function (err, content) {
if (err) return callback(new Error(err));
// 这是一个最简单的模板引擎了...
var rendered = content.toString().replace('#title#', ''+ options.title +'').replace('#message#', ''+ options.message +'');
return callback(null, rendered);
});
}
});

###orm:
对于目前轻量化的框架来说, 加上 orm 的功能貌似有些重, 这里可以考虑使用 MongoDB 的 Mongoose ODM, 作为一个用户可选项

以上的各个应用模块主流的框架都有成熟的可以学习, 相信有时间去阅读一两个框架的源码,估计会功力大增,同时也为以后设计的软件架构打好基础.

兼容其他框架

以上的 路由, 视图,中间件, orm 等四个模块是一个主流的框架基本所具备的, 所以这里需要我们重复造一个轮子么, 答案是不需要, 一方面主流的框架都有技术社区, 如果是模仿他们的话,完全没有必要, 无非是增加开发者的选择困难. 所以这里兼容其他的框架是必不可少的

切入点: 实现一个简单的路由, 将不同域名的请求转发不同的 app 中去,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 服务文件所在目录
var serverPath = '/data/release/node_modules';
var map = {
'a.qq.com': {
'App1': '/App1/app.js',
'App2': '/App2/app.js',
},
'b.qq.com': {
'App1': '/App1/app.js',
'App2': '/App2/app.js',
}
};
/**
* 根据map 加载不同的 app
*/
let findApp = function(req, res) {
var host = req.REQUEST.host || '';
var app = map[host];
if (!app) {
res.end('404');
return;
}
if (typeof app == 'object') {
var pathname = req.REQUEST.pathname || '';
var arr = pathname.split('/');
var firstPath = arr[1] || '';
return require(serverPath + app[firstPath]);
} else if (typeof app == 'string') {
return require(serverPath + app);
}
return require(serverPath + defaultApp);
};

就这样子简单我们的框架只需要负责好转发即可,至于其他的, 扔给 koa express 等框架去处理.

##日志上报,监控告警

一个好的系统框架肯定少不了这些
日志: 现在有很多开源并且成熟的日志库, 可以直接引入使用, 如果开发者需要上报日志,只需要配置一下当前日志的路径即可,

监控告警与日志上报: master 进程在启动的时候, 除了在启动 worker 进程之外, 还需要启动一个监控相关的进程, 主要的作用

  1. 定时查询服务器的运行状态进程详细信息(PID、重启次数、上线时长、内存占用、错误日志),并且上报
  2. 定时获取各个 APP的 log, 上报最新的log
  3. 可以把这个进程的作用当做是一个系统运维功能的大杂烩, 减轻主进程的负担, 尽量就让主进程只处理listening 和连接的分发

小结

上篇的文章大致到这里, 很多技术细节由于时间问题,没有能够深挖, 以后有机会再展开细细的品尝,
当然本文只是一个引子, 主要还是为下文做准备…