1 Node简介

1.1 Node 的诞生历程

1.2 Node的命名与起源

1.2.1 为什么是javascript

设计高性能Web服务器的几个要点:事件驱动、非阻塞I/O

1.4 Node的特点

1.1.1 异步IO

1.4.2 事件与回调函数

事件的编程方式具有:轻量级、松耦合、只关注事务点等优势,但是多个异步任务如何协作式一个问题。

1.4.3 单线程

单线程最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。
单线程的弱点:
1.无法利用多核CPU
2.错误会引起整个应用退出,健壮性值得考验
3.大量计算专用CPU导致无法继续调用异步I/O
像浏览器中的js与UI公用一个线程一样,js长时间执行会导致UI的渲染和响应被中断
Node长时间的CPU占用也会导致后续的异步I/O发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。
Node采用了与Web Workers相同的思路来解决单线程中大量计算量的问题:child_process。

1.4.4 跨平台

Node在操作系统与Node上层模块系统之间构建了一层平台层架构,即libuv。

1.5 Node的应用场景

主要探讨I/O密集型CPU密集型
I/O密集型的优势主要在于:Node利用事件循环处理能力,资源占用极少。

1.5.2 是否不擅长CPU密集型业务

V8效率十分高,单以执行效率来评判,毋庸置疑的,性能很好。
但是Node面临的挑战是:由于js单线程的原因,如果有长时间运行的计算(大循环),将会导致CPU时间片不能释放,使得后续I/O无法发起
但是适当调整和分解大型运算任务为多个小任务使得运算能够适时释放,不阻塞I/O调用的发起,这样既可同时享受到并发异步I/O的好处,又能充分利用CPU。

长时间运行的计算比阻塞I/O还影响效率,甚至说就是一个纯计算场景,根本没有I/O
解决方案有两种:

  1. Node可以通过编写C/C++扩展的方式更高效的利用CPU,将一些V8不能做到性能极致的地方通过C/C++来实现,通过测试结果看,速度比java还快!!
  2. 通过子进程来将计算与I/O分离,这样就能充分利用多CPU。

1.5.4 分布式应用

依旧针对单张表进行SQL查询,中间层分解查询SQL,并行地去多台数据库中获取数据并合并。

1.6 Node的使用者

  1. 前后端语言环境统一
  2. 实时应用 //socket.io
  3. 分布式环境 //通过并行I/O的能力
  4. 并行I/O,有效提升Web渲染能力,提升Web渲染速度
  5. 云计算平台提供Node支持。看重的是js低资源占用,高性能的特点。
  6. 游戏开发领域,因为实时性和并发性都很好
  7. 工具类。前端工具

2 模块机制

2.1 CommonJS规范

这个规范涵盖了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、Web服务器网关接口、包管理等。

2.1.2 CommonJS的模块规范

2.1.2.1 模块引用

1
var math = require('math');

2.1.2.2 模块定义

exports用于导出当前模块的方法或者变量,并且他是唯一导出的出口。
module对象,它代表模块自身,而exports是module的属性
导出

1
2
3
4
5
6
7
8
9
10
11
// math.js
exports.add = function () {
var sum = 0,
i = 0,
args = arguments,
l = args.length;
while (i < l) {
sum += args[i++];
}
return sum;
};

引入

1
2
3
4
5
// program.js
var math = require('math'); //引入
exports.increment = function (val) { //导出
return math.add(val, 1);
};

2.1.2.3 模块标识

模块标识其实就是传递给 require () 方法的参数,它必须是符合小驼峰命名的字符串,或者
... 开头的相对路径,或者绝对路径。它可以没有.js后缀。
意义在于:限定在私有的作用域

2.2 Node的模块实现

模块经历步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行

模块分为两类:
核心模块(Node提供):编译过程中,编译进了二进制执行文件。启动时,有部分模块被直接加载进内存,加载速度最快
文件模块(用户编写):执行标准的三个步骤

2.2.1 优先从缓存加载

Node对引入过的模块都会进行缓存,缓存的是编译和执行之后的对象。
不管是核心模块还是文件模块,一律采用缓存优先的方式,这是第一优先级。不同之处在于,核心模块的缓存检查优先于文件模块的缓存检查。

2.2.2 路径分析和文件定位

2.2.2.1 模块标识符分析

  1. 核心模块
  2. ...相对路径
  3. /绝对路径
  4. 非路径形式的文件模块,(node_mondules)

自定义模块:(node_modules)
模块路径是Node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组
手动尝试:
1.创建module_path.js文件,内容为console.log(module.paths); 查找路径的方法
2.将其放到任意一个目录中然后执行 node module_path.js
输出:

1
2
3
4
[ '/home/jackson/research/node_modules',
'/home/jackson/node_modules',
'/home/node_modules',
'/node_modules' ]

沿着当前目录向父级目录查找node_modules目录
路径越深,加载越慢

2.2.2.2 文件定位

缓存加载优化后,就不用二次查找路径了

文件扩展名分析

Node会按.js.json.node次序依次尝试
小技巧:fs模块是同步阻塞式判断文件是否存在的,所以最好填上扩展名
另一个技巧:和缓存一起使用

目录分析和包

如果没有查找到对应的文件,却得到一个目录,此时会将目录当作一个来处理!
首先会在当前目录下查找package.json
通过JSON.parse()解析出包描述对象。
从中取出main属性指定的文件名进行定位。
如果没有找到相应的文件,Node会将index当作默认文件名
如果目录分析中没有定位成功任何文件,
则自定义模块进入下一个模块路径进行查找。 逐级向父级查找
如果模块路径数组都遍历完毕,仍然没有查找到目标文件,则会抛出查找失败的异常!!!

2.2.3 模块编译

不同的文件扩展名,处理的方法也不同
.js文件。通过fs模块同步读取文件后编译执行。
.node文件。这是用C/C++编写的扩展文件,通过dlopen方法加载最后编译生成的文件。
.json文件。通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
其余扩展名文件。它们都被当作.js文件载入

编译成功都会将其文件路径作为索引缓存Module._cache对象上,提高二次引用性能

.json文件的调用如下:

1
2
3
4
5
6
7
8
9
10
// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ': ' + err.message;
throw err;
}
};

Module._extensions会被赋值给require()的extensions属性,
所以通过在代码中在在代码中访问require.extensions可以知道系统中已有的扩展加载方式。

1
2
3
console.log(require.extensions)
//结果
{ '.js': [Function], '.json': [Function], '.node': [Function] }

自定义扩展名加载,通过require.extensions['.ext']的方式实现

2.2.3.1 javascript模块的编译

每个模块中还有__filename__dirname这两个变量的存在
编译过程,对js文件进行头尾包装进行隔离

1
2
3
4
5
6
(function (exports, require, module, __filename, __dirname) {           //头尾包装
var math = require('math');
exports.area = function (radius) {
return Math.PI * radius * radius;
};
});

包装后通过vm原生模块的runInThisContext()方法执行(类似eval,只是具有明确上下文,不污染全局)

2.2.3.2 C/C++模块的编译

通过调用process.dlopen()方法进行加载和执行。
优势:效率高
劣势:编程门槛高

2.2.3.3 JSON文件的编译

JSON文件在做配置项文件时比较有用。
作为配置的话,就可以不用fs模块去异步读取和解析,直接调用require()就可以了

2.3 核心模块

核心模块分为:C/C++编写和js编写

2.3.1 js核心模块的编译过程

2.3.1.1 转存为C/C++代码

采用V8附带的js2c.py工具,将内置的js代码转换成C++里的数组,生成node_natives.h头文件
这个过程,js是以字符串形式存储在node命名空间中,是不可直接执行的。

2.3.1.2 编译js核心模块

与文件模块区别的地方是:获取源代码的方式(内存)以及缓存执行结果的位置。
js模块缓存到NativeModule._cache对象上,文件模块是Module._cache对象上

1
2
3
4
5
6
7
8
function NativeModule(id) {
this.filename = id + '.js';
this.id = id;
this.exports = {};
this.loaded = false;
}
NativeModule._source = process.binding('natives');
NativeModule._cache = {};

2.3.2 C/C++核心模块的编译过程

node_extensions.h文件将这些散列的内建模块统一放进了一个交node_module_list的数组中
Node提供了get_builtin_module()方法从node_module_list数组中取出这些模块

不推荐文件模块直接调用内建模块。
调用核心模块即可!
Node启动时,会生成一个全局变量process,并提供Binding()方法来协助加载内建模块。

在加载内建模块时,先创建一个exports空对象,然后调用get_builtin_module()方法取出内建对象,通过执行register_func()填充exports对象,
最后将exports对象按模块名缓存,并返回给调用方完成导出。
前边的js核心文件被转换为C/C++数组存储后,便是通过process.bingding('natives')取出放置在NativeModule.source中的

1
NativeModule._source = process.binding('natives')

该方法将通过js2c.py工具转换出的字符串数组取出,然后重新转换为普通字符串,以对js核心模块进行编译和执行

2.3.3 核心模块的引入流程

2.3.4 编写核心模块

2.4 C/C++扩展模块

2.5 模块调用栈

2.6 包与NPM

2.6.1 包结构

package.json: 包描述文件
bin:用于存放可执行二进制文件的目录
lib:用于存放js代码的目录
doc:用于存放文档的目录
test:用于存放单元测试用例的代码

2.6.2 包描述文件与NPM

NPM实际需要的字段主要有:
name 、 version 、 description 、 keywords 、repositories 、 author 、 bin 、 main 、 scripts 、 engines 、 dependencies 、 devDependencies 。

2.6.3 NPM常用功能

具体请看npm的文章介绍,这里不做赘述

2.6.4 局域NPM

2.6.5 NPM潜在问题

2.7 前后端共用模块

前端js的瓶颈在于带宽,后端js的瓶颈在于CPU和内存等资源
Node模块引入过程,几乎都是同步的。

2.7.2 AMD规范

AMD是CommonJS模块规范的一个延伸,定义如下:

1
define(id?, dependencies?, factory);        //factory就是实际代码的内容

定义一个简单的

1
2
3
4
5
6
7
define(function() {
var exports = {};
exports.sayHello = function() {
alert('Hello from module: ' + module.id);
};
return exports; //通过返回的方式实现
});

2.7.3 CMD规范

区别在于定义模块和依赖引入的部分。
AMD需要声明模块的时候指定所有的依赖,通过行参传递依赖到模块内容中:

1
2
3
define(['dep1', 'dep2'], function (dep1, dep2) {
return function () {};
});

与AMD相比,CMD模块更接近于Node对CommonJS规范的定义

1
define(factory)

在依赖部分,CMD支持动态引入,示例如下

1
2
3
define(function(require, exports, module) {
// The module code goes here
});

2.7.4 兼容多种模块规范

将类库代码包装在一个闭包内。

3 异步I/O

PHP语言从头到脚都是同步阻塞的方式方式来执行的,复杂的网络应用中,阻塞导致它无法更好的并发。
于Node的事件驱动、异步I/O设计理念比较相近的一个知名产品为Nginx。
Nginx采用纯C编写,性能表现非常优。
区别在于:Nginx具有面向客户端管理连接的强大能力,但是它的背后依然受限于各种同步方式的编程语言
Node却是全方位的,即可以作为服务器端去处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。

3.1.1 用户体验

如果脚本的执行时间超过100ms,用户就会感到页面卡顿。

3.1.2 资源分配

计算机在发展过程中将组件进行了抽象,分为I/O设备和计算设备。
多线程
代价:

  1. 创建线程和执行期线程上下文切换的开销较大。
  2. 多线程编程经常面临锁、状态同步等问题,这是多线程被诟病的主要原因。
    优势:
    在多核CPU上能够有效提升CPU的利用率。
    单线程:
    缺点:性能,任意一个略慢的程序都会导致后续执行代码被阻塞。
    因为无法异步,所以执行时间较长

Node方案:利用单线程,远离死锁,状态同步等问题。利用异步I/O,让单线程远离阻塞,子线程弥补不能利用多核CPU的缺点。

3.2 异步I/O实现现状

3.2.1 异步I/O与非阻塞I/O

异步/同步和阻塞/非阻塞实际上是两回事。
操作系统内核对于I/O只有两种方式:阻塞与非阻塞。

阻塞I/O特点,要等系统内核层面完成所有操作后,调用才结束。
阻塞I/O造成CPU等待I/O。

非阻塞I/O和阻塞I/O区别在于,调用之后会立即返回
但是,由于完整的I/O并没有完成,立即返回的并不是业务层期望的数据,仅仅是当前调用的状态。
为了取得完整的数据,采用轮询的方式重复调用I/O操作来确认。

阻塞I/O造成CPU等待浪费,非阻塞进行处理状态判断,造成CPU资源浪费。

3.2.2 理想的非阻塞异步I/O


其他操作的时候进入下边图的操作

通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O。
都有线程池!
封装在libuv内,保持兼容性

3.3 Node的异步I/O

完成整个异步I/O环节的有事件循环、观察者和请求对象等

厨房每做完一道菜就问前台小妹有没有要做的菜了,没有的话就下班了。
前台小妹是观察者,接到的单就是回调函数。

Node中事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有
文件的I/O观察者、网络I/O观察者等。
时间循环是一个典型的生产者/消费者模型!

3.3.3 请求对象

3.3.4 执行回调

3.4 非I/O的异步API

setTimeout()、setInterval()、setImmediate()、process.nextTick()
这个方法将回调函数放入队列中,在下一轮Tick时去除执行。类似于setTimeout(fn,0)
但是他的复杂度是O(1), 而后者则是O(lg(n))

3.4.3 setImmediate()

和process.nextTick()十分类似,将回调延时执行,但是他的优先级没有process.nextTick()高!!

4 异步编程

4.1 函数式编程

4.1.1 高阶函数

高阶函数则是可以把函数作为参数,或是将函数作为返回值的函数!
数组的sort()就是货真价实的高阶函数

1
2
3
4
5
var points = [40, 100, 1, 5, 25, 10];
points.sort(function(a, b) {
return a - b;
});
// [ 1, 5, 10, 25, 40, 100 ]

4.1.2 偏函数用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var toString = Object.prototype.toString;
var isString = function (obj) {
return toString.call(obj) == '[object String]';
};
var isFunction = function (obj) {
return toString.call(obj) == '[object Function]';
};
//改造成偏函数
var isType = function (type) {
return function (obj) { //一定要返回一个函数,否则的话isType就是一个布尔值而不是一个函数了,可以理解为isType = function(obj){}
return toString.call(obj) == '[object ' + type + ']';
};
};
var isString = isType('String');
var isFunction = isType('Function');

4.2 异步编程的优势与难点

4.2.1 优势

最大特性就是基于事件驱动的非阻塞I/O模型
非阻塞I/O可以使CPU与I/O并不互相依赖等待,让资源更好的利用。
分布式和云方面应用空间很大。

js线程是分配任务和处理结果的大管家,I/O池里的各个I/O线程都是小弟
CPU密集型则取决于管家的能耐如何.
海量请求同时作用在单线程上,就需要防止任何一个计算消耗过多的CPU时间片.
建议对CPU的耗用不要超过10ms

4.2.2 难点

难点1:异常处理

try…catch

Node将异常作为回调函数的第一个实参传回,如果为空,则表明异步调用没有异常!

难点2:函数嵌套过深

难点3:阻塞代码

没有sleep()这样的线程沉睡功能,用于延时操作的只有setTimeout和setInterval(),但是这两个函数并不能阻塞代码运行,所以编出来下列代码,但是会持续占用CPU,而破坏了事件循环的调度!

难点4:多线程编程

通过子进程实现多线程

难点5:异步转同步

借助库来实现。

4.3 异步编程解决方案

有以下三种方式:

  1. 事件发布/订阅模式
  2. Promise/Deferred模式
  3. 流程控制库

4.3.1 事件发布/订阅模式

事件监听模式是一种广泛用于异步编程的模式!!是回调函数的事件化,又称发布/订阅模式。
它具有addListener/on() 、 once() 、 removeListener() 、removeAllListeners()、 emit() 等基本的事件监听模式的方法实现。

1
2
3
4
5
6
// 订阅
emitter.on("event1", function (message) {
console.log(message);
});
// 发布
emitter.emit('event1', "I am message!");

emit多半是伴随事件而异步触发的

继承events模块

1
2
3
4
5
var events = require('events');
function Stream() {
events.EventEmitter.call(this);
}
util.inherits(Stream, events.EventEmitter);

Nodejs实战里并不是这么写的,见3.2.2.3

利用事件队列解决雪崩问题

once()方法,只执行一次
雪崩:就是在高访问量、大并发量的情况下缓存失效的情景。
方法一:加状态锁

1
2
3
4
5
6
7
8
9
10
var status = "ready";
var select = function (callback) {
if (status === "ready") {
status = "pending";
db.select("SQL", function (results) {
status = "ready";
callback(results);
});
}
};

缺点:只有第一次生效,后续的select没法再服务

方法二:once()

1
2
3
4
5
6
7
8
9
10
11
12
var proxy = new events.EventEmitter();
var status = "ready"; //这个ready就是状态锁
var select = function (callback) {
proxy.once("selected", callback);
if (status === "ready") {
status = "pending";
db.select("SQL", function (results) {
proxy.emit("selected", results);
status = "ready";
});
}
};

多异步之间的协作方案

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
var count = 0;
var results = {};
var done = function (key, value) {
results[key] = value;
count++;
if (count === 3) {
// 渲染?面
render(results);
}
};
fs.readFile(template_path, "utf8", function (err, template) {
done("template", template);
});
db.query(sql, function (err, data) {
done("data", data);
});
l10n.get(function (err, resources) {
done("resources", resources);
});

//修改
//检测次数的变量成为哨兵变量
//用偏函数处理哨兵变量和第三方函数的关系
var after = function (times, callback) {
var count = 0, results = {};
return function (key, value) {
results[key] = value;
count++;
if (count === times) {
callback(results);
}
};
};
var done = after(times, render);

//调用
var emitter = new events.Emitter();
var done = after(times, render);
emitter.on("done", done);
emitter.on("done", other);
fs.readFile(template_path, "utf8", function (err, template) {
emitter.emit("done", "template", template);
});
db.query(sql, function (err, data) {
emitter.emit("done", "data", data);
});
l10n.get(function (err, resources) {
emitter.emit("done", "resources", resources);
});

作者自己写的模块EventProxy暂时不做分析

4.3.2 Promise/Deferred模式

4.3.2.1 Promiss/A

Promise/Deferred模式其实包含两部分,即Promise和Deferred。
Promise/A提议对单个异步操作做出了这样的抽象定义
Promise三种状态:未完成态、完成态、失败态
只能未完成态向完成态或失败态转化,不能逆反!!
一旦转化,将不能更改

一个Promise对象只要具备then()方法即可,对于then()的要求如下:
接受完成态、错误态的回调函数。
可选地支持progress事件回调作为第三个方法。
then()方法只接受function对象,其余对象将被忽略
then()方法继续返回Promise对象,以实现链式调用。
定义如下:

1
then(fulfilledHandler, errorHandler, progressHandler)

用events模块来实现简单的then()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var Promise = function () {
EventEmitter.call(this);
};
util.inherits(Promise, EventEmitter);
Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) {
if (typeof fulfilledHandler === 'function') {
// ?用once()方法??证成功回调?执行??
this.once('success', fulfilledHandler);
}
if (typeof errorHandler === 'function') {
// ?用once()方法??证异常回调?执行??
this.once('error', errorHandler);
}
if (typeof progressHandler === 'function') {
this.on('progress', progressHandler);
}
return this;
};

其实then()方法所做的事情是将回调函数存放起来,为了完成整个流程,还需要触发执行这些回调函数的地方,实现这些功能的对象通常被称为Deferred,即延时对象

这部分省略····以后回来补吧···

4.3.3 流程控制库

4.3.3.1 尾触发与Next

应用最多的就是Connect的中间件
原始的next()比较复杂,下边是简化后的,
原理:取出队列中的中间件并执行,同时传入当前方法以实现递归调用

1
2
3
4
5
6
function next(err) {
// some code
// next callback
layer = stack[index++];
layer.handle(req, res, next);
}

4.3.3.2 async模块

4.3.3.3 Step模块

4.4 异步并发控制 –过载保护

因为异步I/O很容易实现,如果并发量过大,下层服务器将会吃不消!!这就需要过载保护!
例如:对文件进行大量并发调用,操作系统的文件描述符数量会瞬间用光!抛出Error:EMFILE,too many open files

4.4.1 bagpipe的解决方案

4.4.2 async的解决方案

5 内存控制

Node的内存消耗低的有点,非常适合处理海量的网络请求。
内存控制则是在海量请求和长时间运行的前提下进行探讨的。

之前介绍了CUP和I/O,这张介绍内存

简单说下定义;
CPU是中央处理器,是控制和运算器。
I/O接口是输入/输出的接口。硬盘就是通过I/O接口,把数据送到内存中供CPU处理的。
内存和硬盘都是存储器,受CPU的指挥。一般是从硬盘中读取程序,在内存中处理,然后再写回到硬盘中。

5.1 V8的垃圾回收机制与内存限制

V8只能算是个虚拟机

5.1.2 V8的内存限制

Node只能使用部分内存,64位下约为1.4GB,32位系统下约为0.7GB.
导致无法操作大内存的对象,例如2G的文件
所以不太适合处理静态文件
超过这个限制,进程退出

深层原因:V8的垃圾回收机制的限制!!
以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50ms以上,
做一次非增量式的垃圾回收甚至要1s以上
这是垃圾回收中引起js线程暂停执行的时间,在这样的时间花销下,性能和相应能力就会直线下降
所以才直接限制了堆内存的大小

5.1.3 V8的对象分配

process.memoryUsage() 输出内存信息
得到的数据:
heapTotal:已申请到的堆内存
heapUsed:当前使用量

修改内存大小: 下边是两种方法

1
2
3
node --max-old-space-size=1700 test.js // 单位为MB
// 􀤈者
node --max-new-space-size=1024 test.js // 单位为KB

5.1.4 V8垃圾回收机制

5.1.4.1 V8主要的垃圾回收算法

主要基于分代式垃圾回收机制
现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

V8的内存分代
主要将内存分为新生代和老生代两代。
新生代中的对象为存活时间较短的对象。
老声带中的对象为存活时间较长或常驻内存的对象。

Scavenge算法实现

5.1.5 查看垃圾回收日志

启动时添加--trace_gc参数
执行后,会在gc.log文件中得到所有的垃圾回收信息。

1
node --trace_gc

启动时添加--prof参数
可以得到V8执行时的性能分析数据,其中包含垃圾回收执行时间占用

V8提供了linux-tick-processor工具用于统计日志信息。
该工具可以从Node源码的deps/v8/tools目录下找到。
Windows下的对应命令文件为windows-tick-processor.bat。
将该目录添加到环境变量PATH中,即可以直接调用

1
linux-tick-processor v8.log

5.2 高效使用内存

让垃圾回收机制更高效的工作

5.2.1 作用域

执行函数结束后,该作用域将会销毁。

5.2.1.3 变量的主动释放

全局作用域在老生代中

清理内存:等于null 或者delete删除,只不过delete有可能干扰V8优化,所以还是推荐赋值null

5.2.2 闭包

占用的内存不会被释放。

5.3 内存指标

5.3.1.2 查看系统的内存占用

与process.memoryUsage()不同的是,os模块中的totalmem()和freemem()这两个方法用于查看操作系统的内存使用情况。
分别返回系统的总内存和闲置内存

5.3.2 堆外内存

不是通过V8分配的内存称为堆外内存。
Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制

5.4 内存泄漏

内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。
原因:
缓存
队列消费不及时
作用域未释放(闭包)

5.4.1 慎将内存当作缓存

因为它的访问效率高于I/O,可以节省一次I/O的时间。
对象当缓存来用,意味着常驻在老生代中,所以不要滥用缓存
这是一种以内存换CPU执行时间的做法。

5.4.1.1 缓存限制策略

可以对键值对数量进行限制,超出按先进先出策略进行淘汰。

5.4.1.2 缓存的解决方案

进程之间无法共享内存,进程内使用缓存,不可避免的有重复。
所以还是推荐Redis这种第三方的缓存。
解决了:
将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效
进程之间可以共享缓存

5.4.2 关注队列状态

例如数据库写入效率远远低于文件直接写入,所以会形成数据库写入操作的堆积,js相关作用域得不到释放,内存占用不回落,造成溢出。
解决方案:

  1. 超出报警
  2. 任何异步调用的回调都应该包含超时机制,限定时间内未完成响应

对于Bagpipe而言,提供了超时模式和拒绝模式。
拒绝模式当队列拥塞时,新到来的调用会直接相应拥塞错误。

5.5 内存泄漏排查

常见工具
v8-profiler、node-heapdump、node-mtrace、dtrace、node-memwatch􀇄􀱎

5.6 大内存应用

Node提供了stream模块用于处理大文件。
不会受到V8内存限制的影响。

6 理解Buffer

Buffer是一个像Array的对象,但他主要用于操作字节。

6.1.1 模块结构

典型的js与C++结合的模块,性能相关部分用C++实现,非性能用js实现。

6.1.2 Buffer对象

它的元素为16进制的两位数,即0到255的数值。
不同编码的字符串占用的元素个数各不相同。

1
2
3
4
5
6
7
8
9
10
var buf = new Buffer(str,'utf-8')
var buf = new Buffer(100); //这个100是定义了length属性的长度
console.log(buf[10]); //因为没有具体定义,所以得到的是一个脚标在10上的一个随机值

buf[20] = -100;
console.log(buf[20]); // -100+256=156
buf[21] = 300;
console.log(buf[21]); // 300-256=44
buf[22] = 3.1415;
console.log(buf[22]); // 3 舍弃小数部分

6.1.3 Buffer内存分配

C++层面申请内存、js分配内存的策略,js只是使用它。

为了高效使用内存,node采用了slab分配机制。
slab是一种动态内存管理机制。
简单而言slab就是一块申请好的固定大小的内存区域。
full完全分配状态
partial部分分配状态
empty没有被分配状态

指定Buffer对象的大小:

1
new Buffer(size);

Node以8KB为界限来区分Buffer是大对象还是小对象

1
Buffer.poolSize = 8 * 1024;

这个8KB的值也就是每个slab的大小值,在js层面,以它作为单位单元进行内存分配

6.1.3.1 分配小Buffer对象

当所有小的Buffer对象都可以回收时,slab的8KB空间才会被回收。

6.1.3.2 分配大Buffer对象

6.2 Buffer的转换

Buffer对象可以与字符串之间相互转换
ASCII
UTF-8
UTF-16LE/UCS-2
Base64
Binary
Hex

6.2.1 字符串转Buffer

主要通过构造函数完成

1
2
3
new Buffer(str, [encoding]);
//不同编码
buf.write(string, [offset], [length], [encoding])

encoding默认是UTF-8

6.2.2 Buffer转字符串

1
buf.toString([encoding], [start], [end])

6.2.3 Buffer不支持的编码类型

1
2
//判断是否支持
Buffer.isEncoding(encoding)

通过第三方模块进行转换;iconv和iconv-lite

6.3 Buffer的拼接

通常是一段一段的方式传输。
data事件获取的chunk对象即是Buffer对象
潜在问题
data += chunk;
这句代码隐藏了toString()操作,它等价于如下代码

1
data = data.toString()+chunk.toString();

如果是英文没有问题,但是对于宽字节的中文,就会形成问题。

1
2
var rs = fs.createReadStream('test.md', {highWaterMark: 11});  //每次读取Buffer长度为11
床前明���光,疑���地上霜,举头���明月,���头思故乡 //结果中有乱码

6.3.1 乱码是如何产生的

读取时是逐个读取Buffer,限制了每次读11个,所以存在截断的情况,所以出现乱码。

6.3.2 setEncoding()与string_decoder()

readable.setEncoding(encoding)让data事件中传递的不再是一个Buffer对象,而是编码后的字符串。

1
2
3
var rs = fs.createReadStream('test.md', { highWaterMark: 11});
rs.setEncoding('utf8');
//重新执行就能得到正确的格式

缺点:只能解决UTF-8、Base64、UCS-2/UTF-16LE这3种编码。

6.3.3 正确拼接Buffer

淘汰掉setEncoding()之后,剩下解决方案只有将多个小Buffer对象拼接为一个Buffer对象,然后通过iconv-lite一类的模块转码这种方式。

1
2
3
4
5
6
7
8
9
10
11
var chunks = [];
var size = 0;
res.on('data', function (chunk) {
chunks.push(chunk);
size += chunk.length;
});
res.on('end', function () {
var buf = Buffer.concat(chunks, size); //这个方法封装了从小Buffer对象向大Buffer对象的复制过程。
var str = iconv.decode(buf, 'utf8');
console.log(str);
});

6.4 Buffer与性能

提高字符串到Buffer的转化效率,可以很大程序的提高网络吞吐量

7 网络编程

Node提供了 net 、 dgram 、 http 、 https 这4个模块,分别用于TCP、UDP、HTTP、HTTPS,适用于服务端和客户端。

7.1 构建TCP服务

目前大多数应用都是基于TCP搭建而成的

7.1.1 TCP

TCP全名为传输控制协议
在OSI模型(由七层组成)中属于传输层协议。

7.1.2 创建TCP服务器端

服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var net = require('net');
var server = net.createServer(function (socket) {
// 新的连接
socket.on('data', function (data) {
socket.write("你好");
});
socket.on('end', function () {
console.log('连接断开');
});
socket.write("欢迎光临:nodejs示例 \n");
});
server.listen(8124, function () {
console.log('server bound');
});

//也可以采用下列方式进行监听
var server = net.createServer();
server.on('connection', function (socket) {
// 新的链接
});
server.listen(8124);

客户端

1
2
3
4
5
6
7
8
9
10
11
12
var net = require('net');
var client = net.connect({port: 8124}, function () { //'connect' listener
console.log('client connected');
client.write('world!\r\n');
});
client.on('data', function (data) {
console.log(data.toString());
client.end();
});
client.on('end', function () {
console.log('client disconnected');
});

结果

1
2
3
4
5
6
$ node client.js
client connected
欢迎光临:nodejs示例

你好
client disconnected

客户端工具有:Telnet和nc

如果是Domain Socket,填写选项时,填写path即可

1
var client = net.connect({path: '/tmp/echo.sock'});

7.1.3 TCP服务的事件

代码分为服务器事件和连接事件。

7.1.3.1 服务器事件

通过net.createServer()创建的服务器而言,它是一个EventEmitter实例。
自定义事件如下几种:
listening
connection
close
error

7.1.3.2 连接事件

data 当一端出发write()事件,另一端会触发data事件
end 任一端发送了FIN数据时,触发
connect 当套接字与服务器连接成功时触发
drain 当一端出发write()事件,当前端会触发这个事件
error
close
timeout

TCP套接字是可写可读的Stream对象,可以利用pipe()方法巧妙的实现管道操作。

1
2
3
4
5
6
var net = require('net');
var server = net.createServer(function (socket) {
socket.write('Echo server\r\n');
socket.pipe(socket);
});
server.listen(1337, '127.0.0.1');

TCP针对网络中的小数据包有一定的优化策略:Nagle算法。

这种算法要求缓冲区的数据达到一定数量或者一定时间后才将其发出。

7.2 构建UDP服务

UDP又称用户数据包协议,与TCP一样同属于网络传输层
UDP与TCP最大的不同是UDP不是面向连接的。
TCP中连接一旦建立,所有的会话都基于连接完成,客户端如果要与另一个TCP服务通信,需要另创建一个套接字来完成链接。
但在UDP中,一个套接字可以与多个UDP服务通信,
它虽然提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是它无需连接,资源消耗低,处理快速且灵活,常常应用在音频视频等。

7.2.1 创建UDP套接字

UDP套接字一旦创建,即可以作为客户端发送数据,也可以作为服务器端接收数据。
下面代码创建了一个UDP套接字

1
2
var dgram = require('dgram');
var socket = dgram.createSocket("udp4");

7.2.2 创建UDP服务器端

1
2
3
4
5
6
7
8
9
10
11
12
var dgram = require("dgram");
var server = dgram.createSocket("udp4");
server.on("message", function (msg, rinfo) {
console.log("server got: " + msg + " from " +
rinfo.address + ":" + rinfo.port);
});
server.on("listening", function () {
var address = server.address();
console.log("server listening " +
address.address + ":" + address.port);
});
server.bind(41234);

7.2.3 创建UDP客户端

1
2
3
4
5
6
var dgram = require('dgram');
var message = new Buffer("?入?出Node.js");
var client = dgram.createSocket("udp4");
client.send(message, 0, message.length, 41234, "localhost", function(err, bytes) {
client.close();
});

send()方法发送消息到网络中。
send()方法参数如下

1
socket.send(buf, offset, length, port, address, [callback])

分别发送:Buffer、Buffer的偏移、Buffer的长度、目标端口、目标地址、发送完成后的回调。
与TCP套接字的write()相比,send()方法的参数列表相对复杂。

7.2.4 UDP套接字事件

message 接收到消息事触发
listening
close
error

7.3 构建HTTP服务

TCP和UDP都属于网络传输层,构建高效的网络应用,就应该从传输层进行着手。
经典的应用层(HTTP/SMTP)对于普通应用绰绰有余。

7.3.1 HTTP

7.3.1.1 初识HTTP

HTTP全称是超文本传输协议。
构建在TCP之上,属于应用层协议。

7.3.1.2 HTTP报文

7.3.2 http模块

HTTP服务继承自TCP服务器(net模块),它能够与多个客户端保持链接,由于采用事件驱动,并不为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发。
TCP和HTTP区别在于,在开启keepalive后,一个TCP会话可以用于多次请求和响应。
http模块拿到连接中传来的数据,调用进制模块http_parser进行解析。
http模块将连接所有套接字的读写抽象为 ServerRequest 和ServerResponse 对象

7.3.2.1 HTTP请求

req.method属性:获取GET/POST/DELETE/PUT/CONNECT等几种
req.url属性:获取链接地址
req.httpVersion属性:获取协议版本号

7.3.2.2 HTTP相应

报文头信息API:
res.setHeader()res.writeHead()
例如:

1
res.writeHead(200, {'Content-Type': 'text/plain'});

报文体API:
res.write() 和 res.end()
差别在于:res.end()会先调用write()发送数据,然后发哦是那个信号告知服务器这次响应结束。

延迟发送res.end()的方法实现客户端与服务器端之间的长连接
结束时务必关闭链接。

7.3.2.3 HTTP服务的事件

connection
request
close
checkContinue
connect
upgrade
clientError

7.3.3 HTTP客户端

客户端提供了一个底层API: http.request(options, connect)
用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var options = {
hostname: '127.0.0.1',
port: 1334,
path: '/',
method: 'GET'
};
var req = http.request(options, function(res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
});
});
req.end();

7.3.3.1 HTTP响应

一解析完像迎头就出发response事件

1
2
3
4
5
6
7
8
function(res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
});
}

7.3.3.2 HTTP代理

为了重用TCP连接,http模块包含一个默认的客户端代理对象http.globalAgent
它对服务器端(host+port)创建俺的连接进行了管理。
默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多可以创建5个。
连接池如下图:

对同一域名有下载连接数的限制是相同的。默认是5个。
如果需要改变连接数,可以在options中传递agent选项。
自行构建修改:

1
2
3
4
5
6
7
8
9
10
var agent = new http.Agent({
maxSockets: 10 //最大连接数改为10
});
var options = {
hostname: '127.0.0.1',
port: 1334,
path: '/',
method: 'GET',
agent: agent //将这里改成false的话,将脱离连接池的管理,使得强求不受并发的限制。
};

Agent对象的sockets和requests属性分别表示使用中的连接数和处于等待状态的请求数。

7.3.3.3 HTTP客户端事件

response:得到服务器响应
socket:底层连接池建立的连接分配给请求对象时触发
connect:服务端响应200的时候,客户端触发
upgrade:服务端响应101 Switching Protocols
continue:客户端发起Expect:100-continue头信息,试图发送较大数据量,服务器返回100 Continue状态,客户端触发该事件

7.4 构建WebSocket事件

与Node配合堪称完美,理由两条:

  1. WebSocket客户端也是基于事件编程模型的
  2. 客户端与服务器端之间的长连接,node很擅长这种高并发连接

WebSocket与HTTP的比有如下好处

  1. 只建立一个TCP连接
  2. 可以推送数据到客户端
  3. 更轻量级协议头,减少数据传送量

客户端:

1
2
3
4
5
6
7
8
9
10
var socket = new WebSocket('ws://127.0.0.1:12010/updates');
socket.onopen = function () {
setInterval(function() {
if (socket.bufferedAmount == 0)
socket.send(getUpdateData());
}, 50);
};
socket.onmessage = function (event) {
// TODO:event.data
};

相比HTTP,WebSocket更接近于传输层协议,它并没有在HTTP的基础上模拟服务器端的推送,而是在TCP上定义独立的协议。
但是WebSocket的握手部分是由HTTP完成的,使人觉得他可能是基于HTTP实现的,其实并不是!!
WebSocket协议主要分为两个部分:握手和数据传输。

7.4.1 WebSocket握手

与HTTP协议头区别如下:

1
2
Upgrade: websocket
Connection: Upgrade

这两个字段表明升级为WebSocket

Sec-WebSocket-Key用于校验
这个的值是随机生成的Base64编码的字符串!

服务端接收到之后,生成新字符串,然后通过sha1安全散列算法计算出结果后,再进行Base64编码,最后返回给客户端。

指定子协议和版本号:

1
2
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务器处理后,响应如下报文:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

客户端会校验Sec-WebSocket-Accept的值,如果成功,将开始接下来的数据传输。
一旦握手成功,服务器和客户端将会呈现对等的效果,都能接收和发送消息。

7.4.2 WebSocket数据传输

握手成功后,将不再进行HTTP的交互

握手成功后,客户端的onopen()将会触发执行。

1
2
3
socket.onopen = function () {
// TODO: opened()
};

WebSocket的数据帧协议即是在底层data事件上封装完成的,代码如下:

1
2
3
4
5
6
7
8
WebSocket.prototype.setSocket = function (socket) {
this.socket = socket;
this.socket.on('data', this.receiver);
};
//数组发送时也要做封装操作
WebSocket.prototype.send = function (data) {
this._send(data);
};

当客户端调用send()发送数据时,服务器端触发onmessage();
当服务器调用send()发送数据时,客户端端触发onmessage();
send()发送一条数据时,协议将这个数据封装成一帧或多帧数据,然后逐帧发送。

为了安全考虑,数据帧进行掩码处理。
服务器向客户端发送的数据帧无需掩码处理。

具体如何解析数据帧和触发onmessage(),请参考ws模块的实现。

7.5 网络服务与安全

Node提供3个模块,分别为crypto、tls、https。
crypto主要用于加密解密,SHA1、MD5等加密算法都在其中有体现。
tls模块提供了与net模块类似的功能,区别在于它建立在TLS/SSL加密的TCP连接上。
https完全与http模块接口一致,区别在于它建立于安全的连接之上。

7.5.1 TLS/SSL

7.5.1.1 密钥

TLS/SSL是一个公钥/撕咬的结构,它是一个非对称结构,每个服务器端和客户端都有自己的公司钥。
公钥用来加密要传输的数据,私钥用来解密接收到的数据。

Node在底层采用的是openssl实现TLS/SSL的。
生成私钥:

1
2
3
4
// 生成服务器端私􁈃
$ openssl genrsa -out server.key 1024
// 生成客户端私􁈃
$ openssl genrsa -out client.key 1024

生成对应的公钥:

1
2
$ openssl rsa -in server.key -pubout -out server.pem
$ openssl rsa -in client.key -pubout -out client.pem

问题:中间人可能通过伪装的服务器响应给用户,造成损失。
解决办法:
TLS/SSL引入了数字证书进行认证。
数字证书中包括:服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。
所以建立连接之前都要通过证书验证才能连接成功

7.5.1.2 数字证书

第三方证书机构:CA
CA机构比较费精力和费用,所以很多企业采用自签名证书,就是自己构建安全网络。
生成私钥、生成CSR文件、通过私钥自签名生成证书的过程:

1
2
3
$ openssl genrsa -out ca.key 1024
$ openssl req -new -key ca.key -out ca.csr
$ openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt


服务器先创建自己的CSR文件,然后向CA机构申请签名证书。
生成CSR文件所用的命令

1
$ openssl req -new -key server.key -out server.csr

然后向CA机构申请签名,需要私钥参与。

1
$ openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt

客户端请求服务器的时候先获取服务端的证书,然后通过CA的证书去验证服务器的证书的真伪。

7.5.2 TLS服务

  1. 创建服务器端 略···
  2. TLS客户端 略···

7.5.3 HTTPS服务

其实就是工作在TLS/SSL上的HTTP。

7.5.3.1 准备证书

用上文生成的私钥和证书就可以

7.5.3.2 创建HTTPS服务

只比HTTP服务多一个选项配置,其余几乎相同

1
2
3
4
5
6
7
8
9
10
var https = require('https');
var fs = require('fs');
var options = { //多了这个配置
key: fs.readFileSync('./keys/server.key'),
cert: fs.readFileSync('./keys/server.crt')
};
https.createServer(options, function (req, res) { //记得在这里传入配置
res.writeHead(200);
res.end("hello world\n");
}).listen(8000);

7.5.3.3 HTTPS客户端

实现HTTPS的客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var https = require('https');
var fs = require('fs');
var options = {
hostname: 'localhost',
port: 8000,
path: '/',
method: 'GET',
key: fs.readFileSync('./keys/client.key'),
cert: fs.readFileSync('./keys/client.crt'),
ca: [fs.readFileSync('./keys/ca.crt')]
};
options.agent = new https.Agent(options);
var req = https.request(options, function(res) {
res.setEncoding('utf-8');
res.on('data', function(d) {
console.log(d);
});
});
req.end();
req.on('error', function(e) {
console.log(e);
});

8 构建Web应用

8.1 基础功能

ServerRequest和ServerResponse对象提供对请求和响应报文的操作。
具体业务中,可能有如下要求:
请求方法的判断 req.method
URL的路径解析
URL中查询字符串解析
Cookie的解析
Basic认证
表单数据的解析
任意格式文件的上传处理
Session会话的需求。

实现这些功能都从如下这个函数展开

1
2
3
4
function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end();
}

8.1.1 请求方法

HTTP_Parser解析请求报文,设置为req.method
PUT 新建一个资源
POST 更新
GET 查看一个资源
DELETE 删除一个资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function(req, res) {
switch (req.method) {
case 'POST':
update(req, res);
break;
case 'DELETE':
remove(req, res);
break;
case 'PUT':
create(req, res);
break;
case 'GET':
default:
get(req, res);
}
}

8.1.2 路径解析

HTTP_Parser将其解析为req.url
注意:hash部分会被丢弃,不会存在于报文的任何地方。

8.1.3 查询字符串

例如: /path?foo=bar&baz=val
querystring模块用于处理这部分数据

1
2
3
var url = require('url');
var querystring = require('querystring');
var query = querystring.parse(url.parse(req.url).query);

更简洁的写法

1
var query = url.parse(req.url, true).query;         //传第二个参数

它会将foo=bar&baz=val解析为一个JSON对象

1
2
3
4
{
foo: 'bar',
baz: 'val'
}

注意:键出现多次,值会变成数组

1
2
3
4
5
// foo=bar&foo=baz
var query = url.parse(req.url, true).query;
// {
// foo: ['bar', 'baz'] //这里是一个数组
// }

标准的Cookie如下

1
Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

8.1.4 初识Cookie

HTTP无状态协议,所以无法区分用户之间的身份。
识别身份,最早的方案就是Cookie

处理分为以下几步:
服务器向客户端发送Cookie → 浏览器保存Cookie → 每次会话浏览器都会将Cookie发向恶恶其

Cookie保存在请求报文的Cookie字段中

curl工具模拟构造:

1
curl -v -H "Cookie: foo=bar; baz=val" "http://127.0.0.1:1337/path?foo=bar&foo=baz"

cookie报文字段在req.headers.cookie
原理很简单:

1
2
3
4
5
6
7
8
9
10
11
12
var parseCookie = function (cookie) {     //这个cookie传入的是req.headers.cookie
var cookies = {};
if (!cookie) {
return cookies;
}
var list = cookie.split(';');
for (var i = 0; i < list.length; i++) {
var pair = list[i].split('=');
cookies[pair[0].trim()] = pair[1];
}
return cookies;
};

挂载到req

1
2
3
4
function (req, res) {
req.cookies = parseCookie(req.headers.cookie); //这个后边那个其实和 url.parse(req.url, true).query;是一样的
handle(req, res);
}

之后处理业务

1
2
3
4
5
6
7
8
var handle = function (req, res) {
res.writeHead(200);
if (!req.cookies.isVisit) {
res.end('???????动??');
} else {
// TODO
}
};

Set-Cookie的个数是有限制的。所以可以用数组,解决这个问题,存储更多的数据

1
2
3
4
res.setHeader('Set-Cookie', [serialize('foo', 'bar'), serialize('baz', 'val')]);
//形成两条Cookie
Set-Cookie: foo=bar; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
Set-Cookie: baz=val; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

8.1.4.2 Cookie的性能影响

每次传送都会带有cookie
cookie过多,导致报头较大
优化:
减小cookie:限定cookie域,不要放到根节点上,只有域名相同才会发送。
为静态组件使用不同的域名:可以将静态文件放在其他域名上
减少DNS查询:看起来和上一条相驳,但是现在浏览器都能缓存DNS,所以可以忽略了

前端也可以通过document.cookie来修改cookie。

广告和统计用cookie最多。

缺点:

  1. 体积大
  2. 前后端都可以修改,不安全

8.1.5 Session

写在前边–客户端请求服务器→服务器检查没有session→生成一个值并设定时间,通过头信息响应给客户端一个新的值,以后每次传来都做对比

Session只保留在服务器端
客户和服务器端一一对应的两种方法:
第一种:基于Cookie来实现用户和数据的映射
可以将口令放到Cookie中
Session有效期比较短,通常为20分钟

一旦检查有用户请求Cookie中没有携带该值,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间。
生成session的代码

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
35
36
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;
};
//检查口令
function (req, res) {
var id = req.cookies[key];
if (!id) {
req.session = generate();
} else {
var session = sessions[id];
if (session) {
if (session.cookie.expire > (new Date()).getTime()) {
// 更新超时时间
session.cookie.expire = (new Date()).getTime() + EXPIRES;
req.session = session;
} else {
// 超时删除旧的数据,并重新生成
delete sessions[id];
req.session = generate();
}
} else {
// session过期或口令不对,重新生成session
req.session = generate();
}
}
handle(req, res);
}

响应客户端的时候设置新的值

1
2
3
4
5
6
7
8
var writeHead = res.writeHead;
res.writeHead = function () {
var cookies = res.getHeader('Set-Cookie');
var session = serialize('Set-Cookie', req.session.id);
cookies = Array.isArray(cookies) ? cookies.concat(session) : [cookies, session];
res.setHeader('Set-Cookie', cookies);
return writeHead.apply(this, arguments);
};

通过下列逻辑可以判断和设置session

1
2
3
4
5
6
7
8
9
10
var handle = function (req, res) {
if (!req.session.isVisit) {
res.session.isVisit = true;
res.writeHead(200);
res.end('欢迎第一次来到动物园');
} else {
res.writeHead(200);
res.end('动物园欢迎您');
}
};

第二种:通过查询字符串来实现浏览器端和服务器端数据的对应
检查请求的查询字符串,如果没有值,会生成新的带值的URL

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
35
36
var getURL = function (_url, key, value) {
var obj = url.parse(_url, true);
obj.query[key] = value;
return url.format(obj);
};
function (req, res) {
var redirect = function (url) {
res.setHeader('Location', url);
res.writeHead(302);
res.end();
};
var id = req.query[key];
if (!id) {
var session = generate();
redirect(getURL(req.url, key, session.id));
} else {
var session = sessions[id];
if (session) {
if (session.cookie.expire > (new Date()).getTime()) {
// ???时时间
session.cookie.expire = (new Date()).getTime() + EXPIRES;
req.session = session;
handle(req, res);
} else {
// ?时?????的数据??重?生成
delete sessions[id];
var session = generate();
redirect(getURL(req.url, key, session.id));
}
} else {
// 如?session过????不对?重?生成session
var session = generate();
redirect(getURL(req.url, key, session.id));
}
}
}

风险大,地址发给别人,别人就拥有了跟你相同的身份。
Cookie换了浏览器或电脑将无法访问。ETag也可以实现。

8.1.5.1 Session与内存

Session直接存在变量sessions内,但Node会有内存限制,所以风险很大。
Node的进程与进程之间是不能直接共享内存的,所以会产生混乱
可以通过Redis、Memcached解决

第三方缓存通过网络访问,所以速度会慢,但是依然采用,理由有:
Node和缓存服务是长连接,所以握手延迟只影响初始化
直接在内存中进行存储和访问
同机器或机房,网络速度影响很小

8.1.5.2 Session与安全

随机算法有可能命中口令。
口令通过私钥加密签名。

XSS漏洞

跨站脚本攻击:用户输入未转义

8.1.6 缓存

软件架构 C/S模式到B/S
缓存规则,提高性能
Expires或Cache-Control
配置Etags
让Ajax可缓存

8.1.7 Bsasic认证

允许通过用户名和密码实现的一种身份认证方式。
检查报文头的Authorization字段的内容,由认证方式和加密值构成。
缺点太多,近似于明文,所以推荐https

8.2 数据上传

http模块只对报文头进行解析,然后触发request事件。
报文体需要用户自行接收和解耦。
通过报头的Transfer-Encoding或Content-length来判断是否有内容
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var hasBody = function(req) {
return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};
//报文内容通过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);
}
}

挂载到req.rawBody处

8.2.1 表单数据

1
2
3
4
5
<form action="/upload" method="post">
<label for="username">Username:</label> <input type="text" name="username" id="username" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>

获取请求的报文体

1
2
3
4
5
6
var handle = function (req, res) {
if (req.headers['content-type'] === 'application/x-www-form-urlencoded') { //判断是否有上传内容
req.body = querystring.parse(req.rawBody); //解析上传的内容 类似foo=bar&baz=val
}
todo(req, res);
};

8.2.2 其他格式

8.2.2.1 JSON文件

从客户端提取json文件

1
2
3
4
5
6
7
8
9
10
11
12
13
var handle = function (req, res) {
if (mime(req) === 'application/json') {
try {
req.body = JSON.parse(req.rawBody);
} catch (e) {
// 异常内容􀇈响应Bad request
res.writeHead(400);
res.end('Invalid JSON');
return;
}
}
todo(req, res);
};

8.2.2.2 XML文件

8.2.3 附件上传

属性enctypemultipart/form-datafile上传控件

1
2
3
4
5
6
<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>

与其他表单差异在于:

1
2
Content-Type: multipart/form-data; boundary=AaB03x
Content-Length: 18231

boundary=AaB03x指的是每部分的分节符,后边的那个是随机数
报文体内容前面添加–进行分割,结束在他前后都加–表示结束

1
2
3
4
5
6
--AaB03x\r\n     //开始
Content-Disposition: form-data; name="file"; filename="diveintonode.js"\r\n
Content-Type: application/javascript\r\n
\r\n
... contents of diveintonode.js ...
--AaB03x-- //结束

上传文件和普通的表单、json先接收后解析不同,接收大小为止的数量时要十分小心。
处理方法:自行处理上传的内容,接收并保存在内存中,或流式处理掉。
模块formidable就是基于流式处理解析报文!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}
}

8.2.4 数据上传与安全

8.2.4.1 内存限制

解析表单、JSON等数据采用先保存后解析,最后传递给业务逻辑。
上边这种策略只适合数据量小的提交请求。
数据量大,内存很快被吃光,解决办法

  1. 限制上传内容的大小,超过限制,停止接收数据,返回400状态码
  2. 通过流式解析

Content是判断Content-Length来进行限制的!

8.2.4.2 CSRF

跨站请求伪造。
CSRF不需要知道Session ID就能让用户中招
举个留言的例子:
就是伪造者伪造了一个用户提交网站所提交的表单内容,让用户直接通过伪造者的网站去提交表单
客户端请求服务器→服务器检查没有session→生成一个值并设定时间,通过头信息响应给客户端一个新的值,以后每次传来都做对比

8.3 路由解析

8.3.1 文件路径型

8.3.1.1 静态文件

路径解析部分已经介绍过了

8.3.1.2 动态文件

通过URL找到对应的文件
Node的文件主要都是js,所以分不清是前端脚本还是后端脚本,所以没法像apache那样根据后缀同时服务动态和静态文件

8.3.2 MVC

根据URL做路由映射,有两个分支实现。

  1. 通过手工关联映射
  2. 通过自然关联映射

8.3.2.1 手工映射

需要手工配置,比较原始,但没有格式上的限制,很灵活
难点:根据不同的用户显示不同的内容

1
2
/user/setting
/setting/user

通过正则匹配方法匹配到任意用户,例如通过如下方式匹配到任意用户

1
2
3
use('/profile/:username', function (req, res) {         //注意:username
// TODO
});

通过一个很nb的正则,最后达到下边这样的效果:

1
2
/profile/:username => /profile/jacksontian, /profile/hoover
/user.:ext => /user.xml, /user.json

重新改进注册部分

1
2
3
4
var routes = [];    
var use = function (path, action) {
routes.push([pathRegexp(path), action]);
};

以及匹配部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function (req, res) {
var pathname = url.parse(req.url).pathname;
for (var i = 0; i < routes.length; i++) {
var route = routes[i];
// 正则匹配
if (route[0].exec(pathname)) {
var action = route[1];
action(req, res);
return;
}
}
// 处理404请求
handle404(req, res);
}

参数解析

太复杂了 不看了·····尼玛

8.3.2.2 自然映射

尽路由不如无路由。
实际上并非没有路由,而是路由按一定约定的方式自然而然地实现了路由,而无需去维护路由映射。

以/user/setting/12/1987为例:它会按约定去找controllers目录下的user文件,将其require出来后,调用这个文件模块的setting()方法,而其余的值作为参数直接传递给这个方法。

8.3.3 RESTful

8.4 中间件

8.4.1 异常处理

8.4.2 中间件与性能

主要提升点:

  1. 编写高效的中间件
  2. 合理利用路由,避免不必要的中间件执行

8.4.2.1 编写高效的中间件

1.使用高效的方法
2.缓存需要重复计算的结果
3.避免不必要的计算。比如HTTP报文体的解析,对于GET方法完全不需要

8.4.2.2 合理使用路由

8.5 页面渲染

8.5.1 内容响应

内容响应过程中,响应报头中的Content-*字段十分重要。
服务端告知客户端内容是以gzip编码,内容长度为21170个字节,内容类型为js,字符集为UTF-8

1
2
3
Content-Encoding: gzip
Content-Length: 21170
Content-Type: text/javascript; charset=utf-8

8.5.1.1 MIME

如果想让客户端用正确方式来处理响应内容,了解MIME必不可少。

模块mime可以判断文件类型

8.5.1.2 附件下载

Content-Disposition字段,客户端会根据它的值判断应该将报文数据当作浏览内容还是下载的附件。
inline:即时查看
attachment:附件
例如

1
Content-Disposition: attachment; filename="filename.ext"

8.5.1.3 响应JSON

1
2
3
4
5
res.json = function (json) {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify(json));
};

8.5.1.4响应跳转

1
2
3
4
5
res.redirect = function (url) {
res.setHeader('Location', url);
res.writeHead(302);
res.end('Redirect to ' + url);
};

8.5.2 视图渲染

渲染方法设计为render(),参数就是模板路径和数据

1
2
3
4
5
6
7
res.render = function (view, data) {
res.setHeader('Content-Type', 'text/html');
res.writeHead(200);
// 实际渲染
var html = render(view, data);
res.end(html);
};

8.5.3 模板

8.5.4 Bigpipe

9 玩转进程

9.1 服务模型变迁

9.1.1 石器时代:同步

9.1.2 青铜时代:复制进程

100个连接需要100个进程

9.1.3 白银时代:多线程

线程开销小,可共享数据,通过线程池也可以减少创建和销毁线程的开销。
操作系统只能通过将CPU切分为时间片的方法,让线程较为均匀的使用CPU资源。
但是操作系统内核在切换线程的同时也要切换线程上下文,当线程数量过多时,时间会被耗用在上下文切换中。
所以大量并发,还是无法做到强大的伸缩性

9.1.4 黄金时代:事件驱动

Node与Nginx采用单线程避免了不必要的内存开销和上下文切换开销。
两个问题:CPU的利用率和进程的健壮性

9.2 多进程架构

Node提供了child_process模块,并且提供了child_process.fork()函数供我们实现进程的复制。
worker.js

1
2
3
4
5
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

1
2
3
4
5
var fork = require('child_process').fork;
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) { //cpu的个数
fork('./worker.js'); 发放给不同的子进程
}

fork至少需要30毫秒的启动时间和至少10MB的内存,所以fork()进程是昂贵的。

9.2.1 创建子程序

4个方法:
spawn()
exec()
execFile()
fork()
后边三种都是spawn的延伸应用。

9.2.2 进程间通信

浏览器线程通信:WebWorker
主线程和工作线程之间通过onmessage()和postMessage()进行通信。
子进程对象则由send()方法实现主进程向子进程发送数据
message是收听子进程发来的数据。
实际上是通过消息传递内容,而不是共享和直接操作相关资源

示例:

1
2
3
4
5
6
7
8
9
10
11
12
// 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的全称,即进程通信。
实现进程通信技术有很多:命名管道,匿名管道,socket,信号量,共享内存,消息列队,Domain Socket等
Node中实现IPC通道是管道(pipe)技术
Node管道是抽象层面的称呼。
细节实现由libuv提供。
表现在应用层上的通信只有简单的message事件和send()方法。
父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量告诉子进程这个IPC通道的文件描述符。
子进程在启动过程中,根据文件描述符趋连接这个已存在的IPC通道,从而完成父子进程之间的连接。

IPC通道是用命名管道或Domain Socket创建的,与网络socket的行为很类似,属于双向通信
不同之处是他们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。
IPC通道被抽象为Stream对象,在调用send()时发送数据,接收到的消息会通过message事件触发给应用层
只有Node的子进程,才会根据环境变量去链接IPC通道,其他类型的子进程除非按约定去连接这个已创建好的IPC通道。

9.2.3 句柄传递

每接收到一个连接,将会用掉一个文件描述符,代理进程(通过代理可以避免端口不能重复监听的问题)的话需要两个文件描述符。
为了解决上述代理问题,引入句柄功能,send()方法除了能通过IPC发送数据外,还能发送句柄,第二个可选参数就是句柄。
child.send(message, [sendHandle])

句柄是用来标识资源的引用,内部包含了指向对象的文件描述符。
比如句柄可以标识一个服务器端socket对象、一个客户端socket对象、一个UDP套接字、一个管道等
解决掉了代理这种方案。

9.3 集群稳定之路

搭建好了集群,充分利用了多核CPU资源。
问题:
1.性能问题
2.多工作进程的存活状态管理
3.工作进程的平滑重启
4.配置或者静态数据的动态重新载入
5.其他细节

9.3.1 进程事件

Node事件:
message
error
exit
close
disconnect

除了send()还能通过kill()方法给紫禁城发送消息。
kill()并不是正杀死,只是发送了一个系统信号。
父进程将通过kill()方法给子进程发送一个SIGTERM信号。
他与进程默认的kill()方法类似,

1
2
3
4
// 子进程
child.kill([signal]);
// 当前进程
process.kill(pid, [signal]);

他们一个发送子进程,一个发送目标进程。

9.3.2 自动重启

9.3.2.1 自杀信号
9.3.2.2 限量重启

9.3.3 负载均衡

子进程是集群
每个厨师工作量是一门学问,既不能让一个厨师忙不过来,也不能让一些厨师闲着,这种保证多个处理单元工作量公平的策略叫负载均衡。
Node默认采用操作系统的抢占式策略。
但是对不同的业务,可能存在I/O繁忙,而CPU较为空闲的情况,这可能造成某个进程能够抢到较多请求,形成负载不均衡的情况。
新策略:轮叫调度。
工作方式:由主进程接受连接,将其一次分发给工作进程。
分发的策略是在N个工作进程中,每次选择第i=(i+1)mod n个进程来发送连接。

9.3.4 状态共享

9.3.4.1 第三方数据存储

Redis
存在问题:如果数据发生改变,还需要一种机制通知到各个子进程,使得他们的内部状态也得到更新。
实现状态同步机制的两种方案:
1.各个子进程去向第三方进行定时轮询
2.主动通知

9.4 Cluster模块

这个模块构建强大的单机集群。
v0.8版本之前,实现多进程架构必须通过child_process来实现,因为比较难,所以直接引入了cluster模块,用于解决多核CPU的利用率问题。

9.4.1 Cluster工作原理

9.4.2 Cluster事件

fork
online
listening
disconnect
exit
setup

10 测试

10.1 单元测试

10.1.1 单元测试的意义

10.1.2 单元测试介绍

10.1.2.1 断言

10.1.2.2 测试框架

10.1.2.3 测试代码和文件组织

10.1.2.4 测试用例

10.1.2.5 测试覆盖率

如何判断单元测试对代码的覆盖情况,,我们需要直观的工具来体现。
jscover模块

10.1.2.6 mock

10.1.2.7 私有方法的测试

10.1.3 工程化与自动化

10.1.3.1 工程化

Makefile

10.1.3.2 持续集成

travis-ci/Strider/Jenkins

10.2 性能测试

负载测试、压力测试、基准测试

10.2.1 基准测试

基准测试要统计的就是在多少时间内执行了多少次某个方法

10.2.2 压力测试

对网络接口进行压力测试以判断网络接口的性能
考察几个指标:吞吐率、响应时间和并发数,这些指标反应了服务器的并发处理能力
常用工具:ab、siege、http_load

10.2.3 基准测试驱动开发

基准测试驱动开发,分为如下几部流程图

  1. 写基准测试
  2. 写/改代码
    3.收集数据
    4.找出问题
    5.回到第二步

10.2.4 测试数据与业务数据的转换

某个页面每天的访问量为100万。根据实际业务情况,主要访问量大致集中在10个小时内,那么换算公式就是:
QPS=PV/10h

11 产品化

项目工程化过程中,最基本的几部是目录结构、构建工具、编码规范、代码审查

11.1.1 目录结构

11.1.2 构建工具

11.1.2.1 Makefile

Makefile文件是*nix系统下经典的构建工具。

11.1.2.2 Grunt

11.1.3 编码规范

11.1.4 代码审查

11.2 流程部署

11.2.1 部署环境

测试→生产

11.2.2 部署操作

用nohub和&以不挂断进程的方式执行

11.3 性能

动静分离、多进程架构、分布式

11.3.1 动静分离

静态文件用Nginx或者CDN来处理。

11.3.2 启动缓存

两种途径:提升缓存,避免不必要的计算。

11.3.3 多进程框架

多进程管理模块:
官方:cluster模块
三方:pm、forever、pm2

pm2基础用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 安装
npm install -g pm2
$ pm2 start app.js -i 4 # 后台运行pm2,启动4个app.js
$ pm2 start app.js -i max # 也可以把'max' 参数传递给 start
# 正确的进程数目依赖于Cpu的核心数目
$ pm2 start app.js --name my-api # 命名进程
$ pm2 list # 显示所有进程状态
$ pm2 monit # 监视所有进程
$ pm2 logs # 显示所有进程日志
$ pm2 stop all # 停止所有进程
$ pm2 restart all # 重启所有进程
$ pm2 reload all # 0秒停机重载进程 (用于 NETWORKED 进程)
$ pm2 stop 0 # 停止指定的进程
$ pm2 restart 0 # 重启指定的进程
$ pm2 startup # 产生 init 脚本 保持进程活着
$ pm2 web # 运行健壮的 computer API endpoint (http://localhost:9615)
$ pm2 delete 0 # 杀死指定的进程
$ pm2 delete all # 杀死全部进程

11.3.4 读写分离

针对数据库而言
读取速度远远高于写入速度
读写分离,将数据库进行主从设计,这样读数据操作不再受到写入的影响。

11.4 日志

11.4.2 异常日志

console.log􀇖
console.info
console.warn
console.error

11.4.3 日志与数据库

11.4.4 分割日志

11.5 监控报警

11.5.1 监控

11.5.1.2 日志监控

11.5.1.3 响应时间

11.5.1.4 磁盘监控

11.5.1.5 内存监控

11.5.1.6 CPU占用监控

11.5.1.7 CPU load监控

11.5.1.8 I/O负载

11.5.1.9 网络监控

11.5.1.10 应用状态监控

11.5.1.11 DNS监控

11.5.2 报警的实现

邮件的报警
短信或电话报警

11.5.3 监控系统的稳定性

11.6 稳定性

为了更好的稳定性,典型的水平扩展方式就是多进程、多机器、多机房,这样是分布式设计。

  1. 多机器
    一旦出现分布式,就需要考虑负载均衡、状态共享和数据一致性等问题

11.7 异构共存

← Prev Next →