2. NODE模块端实现
2.2 node模块的实现
引入模块:
- 路径分析
- 文件定位
- 编译执行
2.2.1 优先从缓存加载
所有模块二次加载都会采用缓存优先加载方式。
2.2.2 路径分析文件定位
- 核心模块
- . 或者 ..开头的相对路径模块:指明了路径将查找结果存在缓存中,第二次加载更快,加载速度慢于核心模块。
- /绝对路径文件
- 自定义模块:查找最慢。
- 模块路径:当前目录node_modules,父级nm,父亲的父亲,,,,一直到根目录。(所以很慢),第二次引入时不需要路径分析,文件定位和编译执行的过程,大大提高了再次加载模块时的效率,
- 文件定位:
- 文件扩展名:按js json node的次序不足扩展名。所以如果是引用json或者node文件会加快速度。
- 目录分析和包:如果是一个文件夹,那么去找他packagejson下的main字段,如果没有packagejson或者没有main字段或者有但错误,那么依次查找index.js index.json index.node。如果最后都没有找到,那么报错。
2.2.3 模块编译:
- js:fs模块同步读取文件编译执行
- node: 由c或者c++编译的模块 dlopen()方法加载最后编译生成的文件。
- json:jsonparse解析后返回结构
- 其余 按js处理。
- require.extensions可以知道系统中已有的扩展加载方式。
js模块的编译
头尾包装
(function(exports, require, module, __filename, __dirname)
{\n ---- \n});
之后的代码会通过vm原生模块runInThisContext()方法执行,返回一个具体的function对象。最后将当前模块的各个参数传给这个function执行。
可以给exports赋值,但是由于它是形参并不能改变作用域外的值。
c/c++ 模块的编译
其实不需要编译,模块的exports对象和node模块产生联系返回给调用者。
json模块
直接变成对象赋值给模块对象的exports,以供外部调用。
2.3 核心模块
c/c++编写的模块存储在node项目src下,js文件在lib下。
2.3.1 js核心模块编译过程
- 转存为c/c++代码
- 编译js核心模块:和文件模块区别: 从内存中加载,以及缓存执行结果的位置。
2.3.2 c/c++核心模块的编译过程
c++主内,js主外,开发速度和性能的平衡点。
- 内建模块的组织形式:一旦node开始执行,它们被直接加载进内存中,无须再次做路径定位,文件分析,编译执行。
- 内建模块的导出:文件模块 -> 核心模块 -> 内建模块 (层级依赖关系)Binding方法协助加载内建模块。加载模块时,先创建一个exports 然后取出,填充到exports对象,最后将exports对象按模块名字缓存并返回给调用方。
2.4 c/c++ 扩展模块
- 位运算性能极低:可以编写c/c++扩展模块来提升性能。
- js:require -> process.dlopen -> libuv(uv_dlopen()/uv.dlsym()) -> nix / windows
2.5 模块调用栈
文件模块(js -> c/c++)
|
核心模块(js -> c/c++)
2.6 包与npm
2.6.1 包结构:
- package.json:描述文件
- bin:用于存放可执行二进制文件目录
- doc:存放文档
- test:测试用例的代码
2.6.2 描述文件和npm:
存放在根目录下。npm行为与包描述文件息息相关。
CommonJs中的package.json:
- name:包名。全局唯一。
- description:包简介。
- version:版本号。
- keywords:关键词数组,npm中用来做分类搜索。
- maintainers:包维护者列表,对象数组包含名字 email web等信息。(NPM通过该属性进行权限认证)
- contributors:贡献者列表,格式和maintainers一样。
- bugs:bug反馈网址。
- licenses:当前包所使用的许可证列表。type url。
- repositoried:源码托管位置。
- dependencies:依赖列表,NPM会通过这个属性帮助自动加载依赖的包。
- homepage:当前包网站地址。
- os:支持操作系统列表,没有代表不支持假设。
- cpu:同os
- engine:支持js引擎的列表(ejs flusspferd gpsee jsc spidermonkey node v8 narhal)
- builtin:是否是底层系统标准组件。
- directories:包目录说明
- script:脚本说明对象
npm实现规范中的包:
- author:作者
- bin:一些作者希望包可以作为命令行工具使用。配置好了bin字段后通过npm install package_name -g命令可以将脚步添加到执行路径中,之后可以在命令行中直接使用。
- main:模块引入require包时会先检查这个字段。如果不存在依次检测index.js index.node index.json。
- devDependencies:只在开发需要的包放在这个属性中。
2.6.3 NPM包常用功能
- 查看帮助:
- -v 版本号
- npm help + command 查看命令帮助
- 安装依赖包
- 全局安装包:-g,并非安装之后在任何地方都可以require到,而是表达:将一个包安装为全局可用的可执行命令。 它根据bin字段进行配置。事实上全局的包都被安装到了某一个统一目录下,可以通过:
path.resolve(process.execPath, '..', '..', 'lib', 'node_modules');
推算出来。
- 从本地安装:可以将包下载到本地,需要为npm指明package.json文件路径。可以是存档,可以是url,可以是个目录。
- i tarball file
- i tarball url
- i folder
- 从非官方源安装
npm install underscore --registry=http://registry.url
npm config set registry http://registry.url
- NPM勾子命令:
"scripts": {
"preinstall":
"install":
"uninstall"
"test":
}
- 包发布:
- 编写模块
- 初始化描述文件:init
- 注册包仓库账号:adduser
- 上传包:publish
- 安装包:install
- 包管理权限:npm owner ls (add rm)
- 包分析:npm ls 包依赖树
Kwalitee(quality):
- 良好测试
- 良好文档
- 测试覆盖率
- 编码规范
- 其他
2.7 前后端公用模块
2.7.1 模块的侧重点
- 前端瓶颈在于带宽网络速度。
- 后端瓶颈在于cpu和内存资源。
- AMD在前端场景胜出。
异步IO
I/O调用交给操作系统,继续其他调用,等io结束执行回调。
操作系统内核对于io只有两种方式阻塞和非阻塞。在调用阻塞io时应用程序需要等待io完成返回结果。非阻塞,调用后立即返回但是不返回结果。需要文件描述符再次读取。非阻塞io返回之后cpu时间段可以用来处理其他事物。
非阻塞io:因为仅仅返回了调用状态所以并不是业务希望获取到的结果,需要程序不断重复调用io确认是否完成,这样的过程叫做轮询。
轮询演进:
- read:重复检查io来获取数据,在获取最终数据前cpu一直耗在等待上。
- select:通过文件描述符上的事件状态进行判断。限制:它最多可以同时检查1024个文件描述符。
- poll:用链表避免长度限制,但是如果文件描述符太差性能还是低下。
- epoll:进入轮询如果没有检查到io事件将会进行休眠,直到事件发生将它唤醒,不是遍历查询所以不会浪费cpu。
- kqueue类似,但是仅在freebsd系统上。
cpu没有得到有效利用,不够好。
3.2.2 理想的非阻塞异步io
发出请求后 cpu继续自己的事情,然后等待返回数据执行回调。linux:aio:缺点 无法利用系统缓存
3.2.3 现实的异步io
js执行在单线程里罢了,再node中无论nix或者windows平台里,内部完成io的还是线程池。
3.3 node的异步io
3.3.1 时间循环
进程启动时,node便会启动一个类似于while true的循环,每执行一次循环体的过程我们称为Tick。每个tick的过程就是查看是否有事件等待处理,如果有,就取出事件及相关的回调函数,如果存在关联的回调函数,就执行他们然后进入下一个循环,如果不再有事件处理,3就退出进程。
3.3.2 观察者
每个事件循环都有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。这如同点单小妹,厨师就是事件执行者询问小妹是否有餐需要去做。
浏览器采用了类似的机制。
3.3.3 请求对象
js发起调用到内核执行完io操作过渡过程中,存在一种中间产物 它叫做请求对象。例如 open打开一个文件。
调用node核心模块 - 调用c++内建模块 - 内建模块通过libuv进行系统调用。 libuv判断平台分别执行不同的执行方法。实际上调用了uv_fs_open()。 在调用该函数过程中,我们创建了一个FSReqWrap请求对象。从js层传入的参数和当前方法都被封装在这个请求对象中,回调被绑定在oncomplete_sym上。然后将这个对象推到线程池中等待。
然后js返回,都不会影响到js线程的后续执行。
3.3.4 执行回调
等线程池中io调用完毕之后,会将获取到的结果存储在req-result上,然后调用某函数通知IOCP。我们可以通过get函数提取。这个时候就用到时间循环的tick,每次循环都会通过get提取结果,如果存在就会当作请求处理。
事件循环 观察者 请求对象 io线程池四者共同构成了node异步io的主要模型。
3.4 非IO的异步APi
setTimeout setInterval setImmediate process.nextTick()
3.4.1 定时器
setTimeout 和 setInterval 与浏览器一致,他们不需要io线程池的参与。每次调用都会插入到定时器观察者内部的一个红黑树里,每次tick执行会检查是否超过定时时间,如果超过就形成一个事件。setInterval是重复性检测。
定时器时间不一定准,尽管一次tick很快,但是如果一个1ms定时器 上次tick执行了5ms 那就超时了4ms。
3.4.2 process.nextTick()
在未了解process.nextTick之前,很多人也许会为了立即异步执行一个任务,用setTimeout设置0ms。但是实际上这个步骤需要动用红黑树,创建定时器对象和迭代等操作,浪费性能,时间上使用nextTick()这样的方法更为轻量。它简单的将回调放入队列中 下次tick执行。
3.4.3 setImmediate
process.nextTick采用的idle观察者,而setImmediate采用check观察者,idle观察者优先级高于check观察者。
process.nextTick会在每轮循环中将数组中的回调全部执行完,而setImmediate的结果保存在链表中,在每轮循环执行链表中的一个一个回调。
3.5 事件驱动与高性能服务器
网络io也采用事件驱动,无须为每一个请求创建对应的线程。因为线程少,上线文切换代价很低,所以即使有大量链接也不受线程切换上线文开销的影响。
传统:同步,每进程每请求,每线程每请求。
4. 异步编程
4.2 异步编程的优缺点
4.2.1 优点
高效率解决io密集型的
4.2.2 难点
- 异常处理:传统try catch没有作用,一般异常作为回调函数第一个参数,如果为空则表示没有异常。
- 函数嵌套过深
回调执行 回调执行 回调执行。。。。 - 阻塞代码 while 时间判断是错误的,用setTimeout效果更好。
- 多线程编程 web workers
- 异步转同步:async
4.3 异步编程解决方案
4.3.1 发布订阅者模式
- 如果对一个事件添加了超过10个监听器,会得到警告,一是为了减少cpu占用,二是可能导致内存泄露。
- 为了异常处理,EventEmitter对error事件进行了特殊处理,如果运行期间错误触发了error 会检测对error是否添加了监听器,如果没有就抛出异常,如果没有捕捉,则会推出程序。
- 继承events模块 util.inherits(Stream, EventEmitter);
- once()解决雪崩:没有缓存时,大批量数据同时访问数据库。
var proxy = new events.EventEmitter();
var status = "ready";
var select = function(cb) {
proxy.once("selected", callback);
if(status === "ready") {
status = "pending";
db.select("SQL", function(r){
proxy.emit("selected", r);
status = "ready";
})
}
}
- 异步之间的写作方案
// 解决多异步提供条件:
// 原本:
fs.readdir(xxx, function (err, files) {
files.forEach(function (item) {
fs.readfile(item, function (err, res) {
// 一系列操作.....
})
})
});
// 现在我们以ui渲染为例,我们需要拿到模板,数据,本地化资源才能进行渲染:
// 我们首先引入哨兵函数:
// 我们每拿到一样东西计数加一,等拿到所有东西执行回调,该函数为哨兵函数工厂可以自己定义回调和需要几样结果
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 event.Emitter();
emitter.on("done", done);
fs.readFile(template_path, "utf-8", function (err, template) {
emitter.emit("done", "template", template);
})
db.query(sql, function (err, data) {
emitter.emit("done", "data", data);
})
l10n.get(template_path, function (err, resources) {
emitter.emit("done", "resources", resources);
})
// 如此就解决了回调地狱的问题。
// 看似完美,但是也有不足在于每次我们都需要提取结果进行emit,每次都需要准备done函数,如果这些都抽象化?
// 采用EventProxy模块
var proxy = new EventProxy();
proxy.all("template", "data", "resources", function (template, data, resources) {
// TODO
})
fs.readFile(template_path, "utf-8", function (err, template) {
proxy.emit("done", "template", template);
})
db.query(sql, function (err, data) {
proxy.emit("done", "data", data);
})
l10n.get(template_path, function (err, resources) {
proxy.emit("done", "resources", resources);
})
// 用all订阅多个事件,当每个事件都触发all回调才触发
// 除了all 还有 tail区别在于 all只会执行一次, tail在于如果执行一次后,某一次事件再次触发将会采用最新的数据继续执行回调。
// 订阅事件列表和参数列表一致。
// 除此之外还提供after函数
proxy.after("data", 10, function (datas) {
// TODO datas是十次数据的数组
});
// 执行十次data事件后执行回调
- EventProxy的异常处理
// 曾经的处理方式
var proxy = new EventProxy();
proxy.all("template", "data", "resources", function (template, data, resources) {
// TODO
})
proxy.bind('err', function(err) {
// TODO
})
fs.readFile(template_path, "utf-8", function (err, template) {
if(err) {
proxy.emit('err', err);
}
proxy.emit("done", "template", template);
})
db.query(sql, function (err, data) {
if(err) {
proxy.emit('err', err);
}
proxy.emit("done", "data", data);
})
l10n.get(template_path, function (err, resources) {
if(err) {
proxy.emit('err', err);
}
proxy.emit("done", "resources", resources);
})
// 因为异常处理的原因代码量一下子就对了起来,而在ep实践过程中改善了这个问题
// 将emit done 和 err都绑定到了一个done方法上
proxy.fail(callback);
fs.readFile(template_path, "utf-8", proxy.done('template'));
db.query(sql, proxy.done('data'));
l10n.get(template_path, proxy.done('resources'));
// 我们只需要关注业务逻辑,不需要关注错误处理
proxy.fail(callback)
// 等价于
proxy.bind('err', function(err){
// 解绑所有函数
proxy.unbind();
callback(err);
})
proxy.done('resources');
// 等价于
let anonymous = function (err, resources) {
if(err) {
proxy.emit('err', err);
}
proxy.emit("done", "resources", resources);
}
// 如果只有一个回调函数传给ep 那么无须考虑异常,done会为你自己处理,我们还可以自定义一些数据处理而非默认的
// 默认的
let anonymous = function (err, resources) {
if(err) {
proxy.emit('err', err);
}
proxy.emit("done", "resources", resources);
}
// 数据处理
proxy.done('tpl', function(content) {
content.replace('s', 'S');
return content;
});
// 这里会帮你自己做异常处理,除此之外回调的数据是处理过的数据。
4.3.2 Promise/Deferred模式
- Promises/A
- 只会处于一态: 未完成、完成、失败
- 只能从未完成向其他两态转化,不可逆转
- 一旦转化不能被更改
API定义上 - 接受完成态和错误态的回调
- 可选支持progress方法
- then只支持function对象
- 继续返回Promise对象
// Promise实现
var Promise = function () {
EventEmitter.call(this);
}
util.inherits(Promise, EventEmitter);
Promise.prototype.then = function (fullfilledHandler, errorHandler, progressHandler) {
if (typeof fulfilledHandler === 'function') {
this.once('success', fullfilledHandler);
}
if (typeof errorHandler === 'function') {
this.once('erroe', errorHandler);
}
if (typeof progressHandler === 'function') {
this.once('progress', progressHandler);
}
return this;
}
// then只是将方法存放了起来,还需要一个地方触发执行这些回调
var Deferred = function () {
this.state = 'unfulfilled';
this.promise = new Promise();
}
Deferred.prototype.resolve = function (obj) {
this.state = 'fulfilled';
this.promise.emit('success', obj);
}
Deferred.prototype.reject = function (obj) {
this.state = 'failed';
this.promise.emit('error', obj);
}
Deferred.prototype.progress = function (obj) {
this.promise.emit('progress', obj);
}
// 最终实现api
var promisify = function (res) {
var deferred = new Deferred();
var result = '';
res.on('data', function (chunk) {
result += chunk;
deferred.progress(chunk);
});
res.on('end', function () {
deferred.resolve(result);
});
res.on('error', function (err) {
deferred.reject(err);
});
return deferred.promise;
}
// 这样我们的promise就封装好了
// 原来的调用
res.setEncoding('utf8');
res.on('data', function (c) {
console.log('BODY: ', c);
})
res.on('end', function (c) {
// DONE
})
res.on('error', function (c) {
// ERROR
})
// 现在变为
promisify(res).then(function () {
// DONE
}, function () {
// ERROR
}, function (chunk) {
// progress
console.log('BODY: ', chunk)
})
// Q模块的实现
defer.prototype.makeNodeResolver = function() {
var that = this;
return function(error , value) {
if(error) {
self.reject(error);
} else if(arguments.length > 2) {
self.resolve(array_slice(arguments, 1));
} else {
self.resolve(value);
}
};
};
var readFile = function(file, encoding) {
var deferred = Q.defer();
fs.readFile(file, encoding, deferred.makeNodeResolver());
return deferred.promise;
}
- Promise中的多异步协作
// 类似于EventProxy
Deferred.prototype.all = function(promises) {
var count = promises.length;
var that = this;
var results = [];
promises.forEach(function(promise, i) {
promise.then(function(data) {
count--;
results[i] = data;
if(count === 0) {
that.resolve(results);
}
}, function(err) {
that.reject(err);
});
});
return this.promise;
}
// 应用场景
var promise1 = readFile('foo.txt', "utf-8");
var promise2 = readFile('bar.txt', "utf-8");
deferred.all([promise1, promise2]).then(function(results) {
// TODO
}, function(error) {
// TODO
});
- Promise的进阶知识
// 回调地狱:每一异步依赖于上一次异步结果
obj.api1(function (value1) {
obj.api2(value1, function (value2) {
obj.api3(value2, function (value3) {
obj.api4(value3, function (value4) {
cb(value4);
})
})
})
})
// 采用event.emitter
var emitter = new event.emitter();
emitter.on("step1", function () {
obj.api1(function (value1) {
emitter.emit("step2", value1);
})
})
emitter.on("step2", function () {
obj.api1(function (value2) {
emitter.emit("step3", value2);
})
})
emitter.on("step3", function () {
obj.api1(function (value3) {
emitter.emit("step4", value3);
})
})
emitter.on("step4", function () {
obj.api1(function (value4) {
cb(value4);
})
})
emitter.emit("step1");
// 这确实揭开了回调,但是明显更复杂,代码量更多了。
- 支持序列执行Promise
// 理想情况
Promise().then(obj.api1).then(obj.api2).then(obj.api3).then(obj.api4).then(function(value4) {
// Do something with value4
}, function(error) {
// 1-4 's error
}).done();
// 改造一下Deferred
var Deffered = function() {
this.promise = new Promise();
}
// 完成态
Deffered.prototype.resolve = function(obj) {
var promise = this.promise;
var handler;
while((handler = promise.queue.shift())) {
if(handler && handler.fulfilled) {
var ret = handler.fulfilled(obj);
if(ret && ret.isPromise) {
ret.queue = promise.queue;
this.promise = ret;
return;
}
}
}
}
// 失败态
Deffered.prototype.reject = function(obj) {
var promise = this.promise;
var handler;
while((handler = promise.queue.shift())) {
if(handler && handler.error) {
var ret = handler.error(obj);
if(ret && ret.isPromise) {
ret.queue = promise.queue;
this.promise = ret;
return;
}
}
}
}
// 生成回调函数
Deferred.prototype.callback = function() {
var that = this;
return function(err, file) {
if(err) {
return that.reject(err)
}
that.resolve(file);
}
}
var Promise = function() {
this.queue = [];
this.isPromise = true;
}
Promise.prototype.then = function (fullfilledHandler, errorHandler) {
var handler = {};
if (typeof fulfilledHandler === 'function') {
handler.fulfilled = fulfilledHandler;
}
if (typeof errorHandler === 'function') {
handler.error = errorHandler;
}
this.queue.push(handler);
return this;
}
// 实际运用
var readFile1 = function(file, encoding) {
var deferred = new Deffered();
fs.readFile(file, encoding, defferred.callback());
return deferred.promise;
}
var readFile2= function(file, encoding) {
var deferred = new Deffered();
fs.readFile(file, encoding, defferred.callback());
return deferred.promise;
}
readFile1('1.txt', 'utf8').then(function(file1) {
return readFile2(file1.trim(), 'utf8');
}).then(function(file2){
console.log(file2);
});
主要有两个主要步骤:
- 将所有回调都存到队列中
- 一旦监测到返回了新的Promise对象停止执行,然后将当前Deferred对象的promise引用改为新的Promise对象,并将队列中余下的回调转交给他。
- 将API Promise化
var smooth = function(method) {
return function() {
var deferred = new Deffered();
var args = Array.prototype.slice.call(arguments, 0);
args.push(deferred.callback());
method.apply(null, args);
return deferred.promise;
}
}
var readFile = smooth(fs.readFile);
4.3.3 流程控制库
尾触发:常见关键词next,执行该函数后执行下一个流程
function xxx (req,res,next) {
// TODO
next();
}
app.use(xxx)
// =>
app.use = function(route, fn) {
this.stack.push({route: route, fandle: fn});
return this;
}
function next() {
// some code
layer = stack[index++];
layer.handle(req, res, next);
}
所有嫌异步编程开发复杂开发者都可以参考该流程的处理,对于业务逻辑,逐步处理均有效。
- 异步的串行执行
// 异步串行 (前者执行完执行后者再执行cb,没有依赖)
async.series([
function(cb) {
fs.readFile('file1.txt', 'utf-8',cb);
},
function(cb){
fs.readFile('file2.txt', 'utf-8',cb);
}
], function(err, results) {
// results -> [file1.txt, file2.txt]
});
// 异步并行 (两个同时执行完再执行cb,最终cd依赖两个值)
async.parallel([
function(cb) {
fs.readFile('file1.txt', 'utf-8',cb);
},
function(cb){
fs.readFile('file2.txt', 'utf-8',cb);
}
], function(err, results) {
// results -> [file1.txt, file2.txt]
});
// 后者依赖前者数据
async.waterfall([
function(cb) {
fs.readFile('file1.txt', 'utf-8',cb);
},
function(arg1, cb){
fs.readFile('arg1', 'utf-8',cb);
}
], function(err, result) {
// result -> file2.txt
});
// 声明依赖自动处理
var deps = {};
async.auto(deps);
- step (自行了解)
- wind
4.4异步并发控制
同步的每个io都是阻塞的,虽然慢但是总是一个接着一个调用,不会初夏耗用文件描述符太多的情况,而异步不同,对于异步io虽然并发容易实现,但是由于太容易实现,依然需要控制,尽管是压榨底层系统的性能,但是给予一定的过载保护,以防止过犹不及。
for (var i=0; i<100; i++){
async();
}
4.4.1 bagpipe的解决方案
- 用一个队列控制并发量
- 如果当前活跃值小于限定值,从队列中取出执行
- 如果活跃调用达到限定值,调用存放在队列中
- 每个异步调用结束时,从队列中取出新的异步调用执行
var Bagpipe = require('bagpipe');
var bagpipe = new Bagpipe(10);
for (var i = 0; i < 100; i++) {
bagpipe.push(async, function () {
// xxxx
});
}
bagpipe.on('full', function (length) {
console.log('XXXXX');
});
- 拒绝模式:快速失败,让调用今早返回。refuse true
- 超时控制:timeout
4.4.2 async的解决方案
- parallelLimit()方法,缺点在于不能动态添加并行任务。
- queue解决了动态添加 但是接收参数固定不能多样化
5.内存控制
5.1 V8垃圾回收机制和内存限制
5.1.2 Node与V8
V8的内存管理机制导致,node使用js操作内存(64位约为1.4GB,32位约为0.7GB)
5.1.3 V8的对象分配
为什么是1.5g源于垃圾回收机制,以1.5g的垃圾回收为例,v8做一次小的垃圾回收需要50ms而做一次非增量的垃圾回收需要1000ms以上,前端和服务器端这些事件都无法响应,应用的性能和响应能力都会降低,所以在当时的考虑下直接限制了内存。限制并非不能打开:
node --max-old-space-size=1700
node --max-new-space-size=1024
上述参数在v8初始化时生效,一旦生效无法动态改变。
5.1.4 V8的垃圾回收机制
- V8主要的垃圾回收算法:
按对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同分代进行不同高效算法。
- V8的内存分代:
主要分为新生代和老生代,他们的内存空间加起来就是整体大小,之前的old和new分别就是针对设置新生代和老生代内存空间的最大值。新生代对于不同位操作系统分别由两个size(reserved_semispace_size)构成,分别为16MB和8MB(一个size)。所以最大为,32和16。 - scavenge算法:
新生代所采用算法,scaveng主要采用cheney算法。- 将新生代的内存一分为二,处于使用的叫做from处于限制的叫做to。
- 当开始进行垃圾回收时,检查from中的存活对象然后移到to中,非存活的对象占用空间将被释放,完成复制后,from和to的对象进行对换。
典型的牺牲空间换取时间的算法,但是对于新生代非常合适,因为新生代的生命周期一般都比较短。
-
当一个对象多次复制依然存活,它将会被认为是生命周期较长的对象。之后将被移动到老生代中,采用新的算法进行管理。这个过程成为晋升。
- 是否经历过scavenge回收,在进行复制到to空间的过程中,如果经历过了一次scavenge回收,那么将会将对象从from空间直接移动到老生代空间中。
- 是否超过To空间占比,当从From空间复制一个对象到to空间时,如果to空间已经使用超过25,那么这个对象直接晋升到老生代空间中。
-
Mark-Sweep & Mark-Compact
老生代存活时间比较才,并且需要空间比较大,再使用scavenge会显得效率低下,并且空间浪费。- Mark-Sweep:标记清理两个阶段,标记或者的对象,随后的清理阶段清理死亡的对象。(scavenge只复制活的对象,因为活的在新生代中占比比较小,而mark-sweep只清理死亡的,因为死亡对象在老生代中占比比较少),所以这是两种回收方式能高效处理的原因。
-
Mark-Compact:如果只使用sweep会造成空间碎片,导致之后分配大对象的时候可能所有的碎片都不够进行分配。于是在sweep基础上,将活着的对象往一端移动,移动完成后直接清理边界死亡对象的内存。
-
Incremental Marking
为了避免出现js逻辑和垃圾回收器情况不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑停下来,待执行完垃圾回收后再恢复应用逻辑,这种行为被称为全停顿。新生代回收影响不大,全栈垃圾回收造成的停顿就会比较可怕。所以就出现了增量标记,标记一会逻辑执行一会,一直交替到完成标记阶段。
完成这步最大停顿时间节约为原来时间的1/6。还有延迟清理,和增量清理。
小结:提高性能的方式之一是让垃圾回收尽少进行。
5.1.5 查看垃圾回收日志
- 在启动时加上 --trace_gc参数:找出哪些阶段比较耗时。
- --prof参数,得到v8执行的性能分析数据。(需要执行linux-tick-processor v8.log)查看日志结果。
5.2 高效使用内存
5.2.1 作用域
定义一个函数,每次调用的时候会创建一个作用域,然后作用域中生命的局部变量都会分配在该作用域上,随着作用域的销毁而销毁。
var foo = function() {
local = {};
}
local很小会分配在from空间中,下一次垃圾回收时被释放。
- 标识符查找:当前作用域查找local找不到就继续向上作用域查找。
- 作用域:b调用a函数, c调用b函数就会产生一条作用域链,当使用一个变量找不到时就会一层一层向上查找,最后找不到抛错。(不清楚去看js高级程序设计)
- 变量主动释放:定义在global上的变量,知道进程退出才会被销毁,因此导致引用的对象常驻在老生代中。可以使用delete来删除引用,或者重新赋值,让旧的对象脱离引用关系,在接下来的老生代清楚和整理的过程中,会被释放。delete会影响v8自动优化,所以使用赋值方式最后。
5.2.2 闭包:可以导致内存不被释放。(不清楚去看js高级程序设计)
5.3 内存指标
- 查看进程内存占用
process.memoryUsage() 可以看到内存使用情况,
{rss:13853672, heapTotal:6131200, heapUsed: 2757120}
rss是进程常驻内存,进程的内存一共分为几部分,一部分是rss其余在swap和filesystem中。
- 查看系统内存占用
os.totalmem() os.freemem()
5.3.3 堆外内存
用buffer申请对象的时候内存分配并非通过V8分配详见第6章。
5.4 内存泄漏
一个字节的泄漏导致堆积,导致花费更长时间进行对象扫描,应用响应缓慢直到崩溃。
- 缓存
- 队列消费不及时
- 作用域未释放
5.4.1 谨慎将内存当作缓存
var cache = {};
var get = function(key) {
if(cache[key]) {
return cache[key];
} else {
// get from ...
}
}
var set = function(key, value) {
cache[key] = value;
}
cache会无限堆积造成泄漏。
- 缓存限制策略
- 到达限制后先入先出策略
- LRU算法缓存
- 缓存的解决方案
局限:不同进程中无法共享
方案:使用node之外的进程进行缓存 不影响node性能:redis。
5.4.2 关注队列状态
- 数据库写入堆积导致作用域得不到释法
- 监控队列长度、超时机制、拒绝模式。
5.5 内存泄漏排查
v8-profiler
-
node-heapdump
- npm install heapdump
- 编写node代码:
var heapdump = require('heapdump'); var http = require('http'); var leakArray = []; var leak = function() { leakArray.push("leak" + Math.random()); } http.createServer(function(req, res) { leak(); res.writeHead(200,{'Content-type': 'text/plain'}); res.end('Hello World\n'); }).listen(1337); console.log('Start 1337 ...');
- 访问 127.0.0.1:1337 多次
-
sudo lsof -i :1337
得到pid -
kill -USR2 <pid>
向服务器发送sigusr2信号,这个时候会在文件目录下生成heapdump-<sec>.<usec>.heapsnapshot格式的内存快照
6.chrome浏览器 打开 memory 选择文件目录 加载生成的内存快照 分析
node-memwatch:多年未维护
5.6 大内存应用:stream
6.理解Buffer
6.1 Buffer结构
Buffer是一个Array的对象,主要用于操作字节。
6.1.1 结构模块
Buffer是一个典型的js和c++结合的模块,性能部分由c++实现,非性能部分由js实现。
6.1.2 Buffer对象
Buffer类似于数组,元素为16进制的两位数,0到255的数值。
Buffer受Array类型的影响很大,可以访问length属性得到长度,也可以通过下标访问元素。
var buf = new Buffer(100);
console.log(buf.length); // 100
console.log(buf[10]);
通过下标访问刚初始化的Buffer元素得到的是0~255间的一个随机值。
同样我们可以通过下标给Buffer元素进行赋值。
buf[20] = -100; // 156
buf[21] = 300; // 44
buf[22] = 3.1415 // 3
如果我们给buffer元素赋不在0-255范围的值:
- 如果小于0,逐次加256 只到值在0-255区间。
- 大于255,逐次减256 只到值在0-255区间。
- 小数:舍去小数部分然后按以上处理。
6.1.3 Buffer 内存分配
slab分配机制:slab(一块申请好的固定大小内存区域):
- full:完全分配
- partial:部分分配
- empty:没有分配
Node以8kb (8 * 1024)来区分Buffer是大对象还是小对象。
- 分配小Buffer对象:小于8kb。
使用一个局部变量pool作为中间处理,处于分配状态的slab单元指向他:
var pool;
function allocPool() {
pool = new SlowBuffer(Buffer.poolSize);
pool.used = 0;
}
目前为一个新构造的slab单元,出去empty状态。
当我们new Buffer(1024); 这次构造将会去检查pool对象,如果pool没有创建。将会创建一个新的slab单元指向他。
if(!pool || pool.length - pool.used < this.length) allocPool();
同时当前Buffer对象的parent属性指向该slab,并记录下是从这个slab的哪个位置开始使用,slab对象自身也记录使用了多少字节:
this.parent = pool;
this.offset = pool.used;
pool.used += this.length;
if(pool.used & 7) pool.used = (pool.used + 8) & ~7;
这个时候slab的状态为partial。
再次创建Buffer的时候,会判断这个slab剩余空间是否足够。如果足够使用剩余,否则更新slab分配状态。不够将会创造一个新的slab,原slab剩余空间将会造成浪费,比如 1 和 8192字节。第二次分配时不够使用所以创建新的slab,第一个slab将会被1字节的buffer对象独占。 由于一个slab会被多个Buffer使用,所以只有这些小的buffer对象在作用域被释放被回收这个slab的8kb空间才会被回收。
- 分配大的Buffer对象:
this.parent = new SlowBuffer(this.length);
this.offset = 0;
直接分配一个SlowBuffer对象作为slab单元,这个单元将会被这个大的Buffer对象独占。该类在C++中定义,引用Buffer模块可以访问到它但是不建议直接操作。都能够被v8标记回收,但是由于SlowBuffer是C++中定义的,所以内存不在V8的堆中。
- 小结:小的Buffer进行,先分配再使用,使得js到操作系统间不必有过多Buffer操作,对于大的Buffer而言,使用C++层面提供的内存。
6.2 Buffer的转换
目前支持的类型:
- ASCII
- UTF-8
- UTF-16LE/UCS-2
- Base64
- Binary
- Hex
6.2.1 字符串转Buffer
new Buffer(str, [encoding])
,不传默认utf-8;
一个Buffer可以存储不同编码类型的字符串转码值buf.write(string, [offset],[length],[encoding])
;
由于可以不断写入内容到Buffer对象中,并且可以制定每次写入的编码,所以Buffer可以存在多种编码转化后的结果。
6.2.2 Buffer转字符串
buf.toString([encoding], [strat], [end])
如果类型不同就需要在局部指定不同编码。
6.2.3 Buffer不支持的编码类型:
Buffer.isEncoding(encoding)
判断是否支持某种编码。
不支持的编码:
- iconv-lite:纯js实现,性能好 (无法转换,多字节� 单字节?)
- iconv:c++实现,性能不如以上(三级策略//IGNORE //TRANSLIT //TRANSLIT//INGORE)
var iconv = require('iconv-lite');
var str = iconv.decode(buf, 'win1252');
var buf = iconv.encode('sssss', 'win1252');
6.3 Buffer的拼接
var fs = require('fs');
var rs = fs.createReadStream('test.md');
var data = '';
rs.on('data', function (chunk) {
data += chunk;
});
rs.on('end', function () {
console.log(data);
})
我们在国外网站上经常看见类似这样的示例,新人通常带着字符串的思想来看待,所以不会觉得有任何异常,但事实上读取的chunk是buffer类型。
在执行:data += chunk;
实际上是执行:data = data + chunk.toString();
而我们知道当buf.toString不带参数默认是 utf-8进行编码,这对于英文(单字节)来说没有任何问题,但是对于汉字这样的多字节编码就会有问题,只是chunk的长度越大出现错误的概率越小,但实际上这样但处理还是有点问题的,比如:
var fs = require('fs');
var rs = fs.createReadStream('test.md', {highWaterMark: 11});
var data = '';
rs.on('data', function (chunk) {
data += chunk;
});
rs.on('end', function () {
console.log(data);
})
每次Buffer只读11个字节。我们知道一个汉字由3个字节构成。如果test.md的内容为:
床前明月光,疑似地上霜;举头望明月,低头思故乡。
那么读取的内容为:
床前明��光,疑���地上霜;举头��明月,���头思故乡。
原因在于一次性读11个字节,那么能够成功解码3个汉字,剩下还有两个字节无法被解析就变成了�。
所以在处理多字节编码的时候,需要细心处理。
6.3.2 setEncoding() 与 string_decoder()
在createReadStream的rs对象中还有一个setEncoding() 方法。
var fs = require('fs');
var rs = fs.createReadStream('test.md', {highWaterMark: 11});
rs.setEncoding('utf8');
var data = '';
rs.on('data', function (chunk) {
data += chunk;
});
rs.on('end', function () {
console.log(data);
})
这代表data事件中传递的不再是一个Buffer对象,而是编码后的字符串。为此再次执行得到结果:床前明月光,疑似地上霜;举头望明月,低头思故乡。
。
这样输出就不再受Buffer大小的影响了。
不管设置否编码触发data事件的次数依然相同,这意味着设置编码并没有改变按段读取的基本方式,但是为什么乱码问题被解决了,在于内置的decoder对象。
var StringDecoder = require('string_decoder').StringDecoder;
var decoder = new StringDecoder('utf8');
当设置了编码之后,decoder知道utf-8编码下是以3个字节的方式存储的,所以第一次读取了11个字节他会先解决3(11/3 = 3.xxx) *3个字节,还剩2个字节它会自动保存下实例内部,等到下一次11个字节进入执行时,再将剩余2个字节拼接到11个字节上继续采用同样的解析方式,所以乱码问题就得到了解决。
但是它目前只能处理UTF-8、BASE64、UCS-2/UTF-16LE三种编码。虽然能解决大部分问题,但不能从根本解决该问题。
6.3.3 正确拼接Buffer
var fs = require('fs');
var iconv = require('iconv-lite');
var chunks = [];
var size = 0;
var rs = fs.createReadStream('test.md', {highWaterMark: 11});
rs.on('data', function (chunk) {
chunks.push(chunk);
size += chunk.length;
});
rs.on('end', function () {
var buf = Buffer.concat(chunks, size);
var str = iconv.decode(buf, 'utf8');
console.log(str);
})
正确的拼接方法是用一个数组存储收到的所有Buffer碎片,然后调用Buffer.concat方法生成一个合并的Buffer对象,读取所有Buffer后一次性使用iconv-lite解析。
Buffer.concat的实现,Array的每一个元素可能由n个字节组成所以在拼接成Buffer需要记录字节长度。
Buffer.concat = function(list, length) {
if(!Array.isArray(list)) {
throw new Error('Usage: xxxx');
}
if(list.length === 0) {
return new Buffer(0);
} else if(list.length === 1) {
return list[0];
}
if(typeof length !== 'number') {
length = 0;
for(var i=0; i<list.length; i++) {
var buf = list[i];
length += buf.length;
}
}
var buffer = new Buffer(length);
var pos = 0;
for(var i=0; i<list.length; i++) {
var buf = list[i];
buf.copy(buffer, pos);
pos += buf.length;
}
return buffer;
}
6.4 Buffer与性能
var http = require('http');
var haloworld = '';
// haloworld = new Buffer(haloworld);
for (var i = 0; i < 1024 * 10; i++) {
haloworld += 'a';
}
http.createServer(function (req, res) {
res.writeHead(200);
res.end(haloworld);
}).listen(8001);
我们使用字符串进行网络传输:发起100个并发客户端:
ab -c 100 -t 50 http://127.0.0.1:8001/
得到结果:
Transfer rate: 4671.68 [Kbytes/sec] received
然后我们取消注释,使用Buffer进行网络传输得到结果:
Transfer rate: 4903.68 [Kbytes/sec] received
(原作中是 -c 200 -t 100,性能几乎提高一倍,我的mac做不到,但也能看到有性能提升。)
换成Buffer之后,性能提高了许多,原因在于预先转换静态内容为Buffer,可以有效减少CPU重复使用,节省服务器资源,通过预先换为Buffer的方式,使性能得到提升。因为文件自身使二进制数据。
- 文件读取
在文件读取的时候设置一个highWaterMark对行性能影响至关重要,在fs.createReadStream(path, opts)传入一些参数:
{highWaterMark: 64 * 1024}
完成一次读取时,从这个Buffer中通过slice()方法读取部分数据作为一个小的Buffer对象,然后通过data事件传递给调用方,如果Buffer用完,则重新分配,如果有剩余继续使用。
如果highWaterMark设置过小,可能分配过多或者导致系统调用次数过多。理想状态下,每次读取的长度就是用户指定的highWaterMark,但是有可能读到了结尾或者本身就没有highWaterMark这么大。pool是常驻内存,当pool小于128字节,才会重新分配一个新的Buffer对象,这与Buffer的内存分配类似。
由于fs.createReadStream内部采用fs.read实现,将会引起对磁盘的系统调用,如果highWaterMark过小,调用次数越多,系统调用次数越多,性能越差。
7.网络编程
7.1 TCP:
- 7层模型:应用 表示 会话 传输 网络 链路 物理
- 3次握手形成对话
7.2 创建TCP服务器端:
server:
var net = require('net');
var server = net.createServer(function(socket) {
// 新的链接
socket.on('data', function(data) {
socket.write('你好');
});
socket.on('end', function(){
socket.write('断开链接');
});
socket.write('欢迎光临');
});
server.listen(8124, function() {
console.log('server bound');
});
client:
var net = require('net');
var client = net.connect({port: 8124}, function() {
console.log('connect OK!');
client.write('world!\r\n');
});
client.on('data', function(data) {
console.log(data.toString());
client.end();
});
client.on('end', function() {
console.log('client disconnected!');
});
客户端得到console:
connect OK!
欢迎光临
你好
client disconnected!
7.1.3 TCP服务的事件:
1.服务器事件:
- listening:调用server.listen()或者绑定Domain Socket后触发。server.listen(port, listeningEvent);
- connection:每个客户端套接词连接到服务端时触发,简介为createServer()最后参数。
- close:所有链接都断开,触发。
- error 异常触发。
- 链接事件:
- data:当从一端向另一端发送数据,另一端触发。
- end:任意一端发送了FIN数据会触发。
- connect:链接成功时。
- drain:任意一端调用write时,当前一端触发。
- error
- close:关闭时
- timeout:当一定事件不再活跃,通知用户当前链接闲置。
值得注意,如果每次发送一个字节,网络上只有极少数有效数据包,针对网络优化有Nagle算法,缓冲区数据达到一定或者一定时间后才发送 故有延迟,可以使用socket.setNoDelay(true) 去掉该算法。关掉后另一端可能将会接收到多个小数据包合并。
7.2 构建UDP
客户端想要和另一个tcp服务通信,需要穿件一个套接字来完成连接。在udp中,一个套接词可以与多个udp服务通信,提高简单面向事务不可靠信息传输服务,在网络差存在严重丢包,但是由于它无须链接,资源消耗低,处理迅速灵活,所有常常应用在偶尔丢一两个包也不会产生影响的场景,如视频音频。
7.2.1 创建UDP套接字:
udp-server:
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 address ' + address.address + ' port ' + address.port)
});
server.bind(41234);
udp-client:
var dgram = require('dgram');
var message = new Buffer('射弩前地');
var client = dgram.createSocket('udp4');
client.send(message, 0, message.length, 41234, '0.0.0.0', function(err, bytes){
client.close();
});
服务端打印的信息:
server address 0.0.0.0 port 41234
server got:射弩前地 from 127.0.0.1:58172
send方法可以发送信息到网络中,虽然参数列表相对复杂,但是更为灵活,可以随意发送数据到网络中的服务器,而tcp需要套接词构建。
7.2.4 UDP套接词事件
- message:网卡端口收到信息时
- listening:开始监听
- close:close()事件触发,并不再触发message事件。
- error:如果不监听直接抛出退出进程。
7.3 构建HTTP服务
tcp于udp都属于网络传输层协议,如果构建高效网络应用,应该从传输层入手。但是对于经典场景我们无须从传输层入手构造应用,对于普通应用直接使用http或者smtp等经典应用层协议绰绰有余。
7.3.1 HTTP
var http = require('http');
http.createServer(function(req, res) {
res.writeHead(200,{'Content-type': 'text/plain'});
res.end('Hello World\n');
}).listen(1337);
console.log('Start 1337 ...');
调用curl -v http://127.0.0.1:1337
查看所有报文信息:
// 第一部分:三次握手
* Rebuilt URL to: http://127.0.0.1:1337/
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 1337 (#0)
// 第二部分:发送请求
> GET / HTTP/1.1
> Host: 127.0.0.1:1337
> User-Agent: curl/7.51.0
> Accept: */*
>
// 第三部分:处理后返回请求
< HTTP/1.1 200 OK
< Content-type: text/plain
< Date: Wed, 30 May 2018 09:32:37 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello World
// 最后部分:结束对话
* Curl_http_done: called premature == 0
* Connection #0 to host 127.0.0.1 left intact
7.3.2 http模块:
异步事件循环支持高并发。
TCP服务以connection为单位进行服务,HTTP服务以request为单位进行服务,对connection进行了封装。
http将套接字的读写抽象为ServerRequest 和 ServerResponse对象。
- HTTP请求:
> GET / HTTP/1.1
> Host: 127.0.0.1:1337
> User-Agent: curl/7.51.0
> Accept: */*
>
- req.method 表示请求方法 GET
- req.url 值为/
- req.httpVersion属性:值为1.1.
其余都为key-value格式,被解析后放置在req.headers上
报文体对象抽象为一个只读流对象,如果业务逻辑需要读取报文中的数据,需要在这个流结束后才能进行操作。
HTTP响应
res.setHeader()
res.writeHead()
两个步骤,set可以多次,但是只有调用write之后才会写入连接中,http模块会自动帮你设置一些头信息。实体部分则是调用res.write()和res.end()实现,区别在于end会先调用write发送数据,然后发送信号告诉这次响应结束。一旦发送数据setHeader和writeHead不再生效。
务必在结束时候调用end,也可以延迟end实现客户端与服务器之间长链接。HTTP服务的事件:
- connection:客户端和服务端建立底层TCP时。
- request:建立TCP链接后,解析出http请求头会触发。
- close:与tcp一致。
- checkContinue:客户端发送大数据时,不会将数据直接发送,而是先发一个头部待用Expect: 100-continue的请求到服务器,服务器将会触发该事件。如果没有监听这个事件,服务器将会自动响应客户端100 Continue状态表示接受数据上传。 如果不接受则响应400。触发改事件不触发request事件。触发后重新连接才会触发request事件。
- connect事件:客户端发起connect触发,通常在http代理出现。
- upgrade:客户端要求升级时,头部带上Upgrade字段。
- clientError事件:客户端的异常会传到这里。
7.3.3 HTTP客户端:
http.request 用于构造HTTP客户端,与curl命令相同。
var http = require('http');
var options = {
hostname: '127.0.0.1',
port: 1337,
path: '/',
method: 'GET'
}
var req = http.request(options, function (res) {
console.log(res.statusCode);
console.log(JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
})
});
- host: 服务器域名或者IP地址
- hostname: 服务器名称
- port:端口号 默认80
- localAddress:建立网络链接的本地网卡
- socketPath:Domain套接字路径
- method:默认GET
- path:默认/
- headers:头对象
- auth:Basic认证
- HTTP响应
解析完响应头触发response事件,同时传递一个响应对象以操作ClientResponse。后续响应报文体以只读流方式提供。 - HTTP代理
为了重用TCP连接,http模块包含一个代理对象http.globalAgent。他对每个服务器端创建的连接进行管理,默认情况下,通过ClientRequest对象对同一个服务端发起HTTP最多可以创建5个连接。
如果发送10次,得到处理的只有5个,后续请求需要等到某个请求结束才会真正发出,一旦请求量过大会影响性能。如果改变可以在options传递agent选项,默认为5。也可以agent为false;是请求不受并发限制。
var agent = new http.Agent({
maxSockets: 10
});
var options = {
agent: agent
}
agent对象的sockets和requests两个值表示使用中连接数,和处于等待状态的请求数。
- HTTP客户端事件:
- response
- socket:底层连接池中建立的连接分配给当前请求对象时,触发
- connect:响应200 触发
- upgrade:发送upgrade字段,并且得到101响应时。
- continue:得到100响应时。
7.4 构建WebSocket服务:
- WebSocket客户端基于事件的编程模型与node自定义事件相差无几
- WebSocket实现了客户端与服务器之间的长连接,而Node事件驱动的方式擅长大量客户端保持高并发连接。
其他优点: - 只需要建立一个TCP连接
- 服务器可以推送数据到客户端
- 更轻量的协议头
分为两个部分:握手和传输数据。
7.4.1
建立连接时通过HTTP发起请求报文:
GET /chat HTTP/1.1
Host: xxx.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xxxx
Sec-WebSocket-Protocal:chat,superchat //子协议
Sec-WebSocket-Version: 13 // 版本号
与普通的HTTP请求协议区别在于:
Upgrade 和 Connection,上述字段表示将请求服务器升级协议为WebSocket。其中Sec-WebSocket-Key用于安全校验,随机生成的base64编码字符串服务端收到后与字符串xxx相连,形成后的字符串通过sha1安全散列算法计算出结果后,进行Base64编码返回给客户端。
服务端处理请求完成后响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: xxxx
Sec-WebSocket-Protocal:chat,superchat
上面报文告之客户端正在更换协议,更新为WebSocket协议,并在当前套接字上应用新的协议,客户端校验Sec-WebSocket-Accept值如果成功,将开始数据传输。
7.4.2 WebSocket数据传输
握手完毕触发socket.onopen事件。
服务端没有onopen事件可言。
客户端send()数据,服务器onmessage()数据反之亦然。 为了安全考虑客户端需要对发送的数据帧进行掩码处理,服务器如果收到无掩码帧将关闭连接,而服务器端无须掩码,同样如果客户端收到带掩码的数据帧连接也会关闭。
。
7.5 网络服务与安全
- 密钥
TLS/SSL是一个公钥私钥的结构,它是一个非对称的结构。客户端使用服务端公钥加密消息发送,服务端收到后私钥解密,然后使用客户端公钥加密,客户端接收到私钥解密。
问题在于在通信前客户端和服务器需要交换公钥,然后这一步很容易受到中间人攻击(在客户端和服务器之间扮演中间人,获取双方的公钥伪造双方的交流,所以我们需要认证这个公钥是来自服务器而不是中间人)。
不过如果公钥和密钥如果都可以安全交换,那么数据为什么不行?然后我们采取的方法就是密钥交换采取第三方认证(数字签名),然后数据再按照之前描述的方式交换。
生成私钥:
// 服务器私钥
openssl genrsa -out server.key 1024
// 客户端私钥
openssl genrsa -out client.key 1024
上述命令生成了两个1024位长的RSA私钥文件,我们继续生成公钥:
// 服务器公钥
openssl rsa -in server.key -pubout -out server.pem
// 客户端公钥
openssl rsa -in client.key -pubout -out client.pem
- 数字签名
我们上述的第三方就是CA(Certificate Authority,数字证书认证中心)。CA作用是颁发证书,这个证书中具有CA通过自己公钥私钥实现的签名。
为了得到签名证书,服务器需要通过自己的私钥生成CRS(Certificate Signing Request,证书签名请求),然后ca会为服务器颁发属于该服务器的签名证书,通过CA机构就能验证证书是否合法。
但是证书颁发是一个繁琐的过程,很多中小企业采用自签名证书来构建安全网络。
以下是生成私钥生成CSR通过私钥自签名生成证书的过程:
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
意思步骤完成了扮演CA所需要的文件。接下来回到服务器向CA申请签名:
openssl req -new -key server.key -out server.csr
openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt
生成好证书后,客户端发起安全连接前回去获取服务器端的证书,并通过CA验证证书真伪:
客户端需要验证CA证书真伪,如果知名CA他们证书被预装在浏览器中。
7.5.2 TLS服务
- 服务器
var tls = require('tls');
var fs = require('fs');
var options = {
key: fs.readFileSync('./server.key'),
cert: fs.readFileSync('./server.crt'),
requestCert: true,
ca: [ fs.readFileSync('./ca.crt')]
};
var server = tls.createServer(options, function(stream) {
console.log('server connected', stream.authorized ? 'authorized' : 'unauthorized');
stream.write('welcome!\n');
stream.setEncoding('utf8');
stream.pipe(stream);
});
server.listen(8000, function() {
console.log('bound!');
})
openssl s_client -connect 127.0.0.1:8000
可以测试证书是否正常。
- 客户端
生成必要信息:
openssl req -new -key client.key -out client.csr
openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in client.csr -out client.crt
创建客户端:
var tls = require('tls');
var fs = require('fs');
var options = {
key: fs.readFileSync('./client.key'),
cert: fs.readFileSync('./client.crt'),
ca: [fs.readFileSync('./ca.crt')]
};
var stream = tls.connect(8000, options, function () {
console.log('client connected', stream.authorized ? 'ok' : 'no');
process.stdin.pipe(stream);
});
stream.setEncoding('utf8');
stream.on('data', function (data) {
console.log(data);
})
stream.on('end', function() {
console.log(11);
});
与普通tcp创建连接相比只是多了一个传入证书的过程,其他没有啥差别。
7.5.3 HTTPS服务
HTTPS就是工作在TLS/SSL上的HTTP。
var https = require('https');
var fs = require('fs');
var options = {
key: fs.readFileSync('./server.key'),
cert: fs.readFileSync('./server.crt')
};
https.createServer(options, function (req, res) {
res.writeHead(200, { 'Content-type': 'text/plain' });
res.end('Hello World\n');
}).listen(1337);
console.log('Start 1337 ...');
curl -k https://localhost:1337/
忽略证书验证得到结果。
curl --cacert keys/ca.crt https://localhost:1337/
安全获得到结果。
var https = require('https');
var fs = require('fs');
var options = {
hostname: '127.0.0.1',
port: 1337,
path: '/',
method: 'GET',
key: fs.readFileSync('./client.key'),
cert: fs.readFileSync('./client.crt'),
ca: [fs.readFileSync('./ca.crt')],
}
options.agent = new https.Agent(options);
var req = https.request(options, function (res) {
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log(chunk);
})
});
req.end();
req.on('error', function (e) {
console.log(e);
})
8.构建Web应用
8.1 基础功能
http.createServer(function (req, res) {
res.writeHead(200, { 'Content-type': 'text/plain' });
res.end('Hello World\n');
}).listen(1337);
8.1.1 请求方法
根据req.method做不同处理 请求方法的处理
8.1.2 请求路径
根据req.url 获取到路径,进行路径解析url.parse
一般有采用 /controller/action/a/b/c处理方法
8.1.3 查询字符串
var query = querystring.parse(url.parse(req.url).query);
// foo=bar&foo=baz foo将会是个数组
{
foo: ['bar', 'baz'];
}
8.1.4 Cookie
- 服务器向客户端发送cookie
- 浏览器保存cookie
- 之后每次浏览器都会将cookie发送给服务器
值的形式Cookie: key=value; key2=value2
我们可以在执行逻辑前将它挂载到req上
req.cookie = parseCookie(req.headers.cookie)
服务器写入cookie:Set-Cookie: name=value; Path=/; Expires=xxx; Domian=.domain.com;
- path: Cookie影响到的路径
- Expires和Max-age:告诉Cookie什么时候国旗,如果不设置,那么关闭浏览器就失效,Expires是一个UTC格式的时间字符串。Max-Age告诉Cookie多久后过期。
- HttpOnly:告诉浏览器不允许通过脚本document.cookie去改变这个Cookie值,但实际上还会发送给服务器。
- Secure:true HTTP中无效 HTTPS中有效。
YSLOW: - 为静态组件使用不同域名:因为静态资源几乎不需要cookie所以不需要加上cookie
- 减少dns查询:浏览器缓存可以消除这个副作用影响。
8.1.5 Session
Cookie弊端:cookie可以被前端串改,而且数据量不能太大,并且是明文传输,对于敏感数据gg。
Session数据只存在于客户端,客户端无法修改,且解决数据敏感问题。
但是如何将每个客户和服务器中的数据一一对应?
- 第一种Cookie实现用户和数据映射:
cookie存一个口令,如果被串改也无法修改服务器里的数据,由于数据过期时间较短,并且存放在服务器安全性较高。如何产生口令?
一个用户来访问,如果cookie里没有sessionId那么就为他分配一个session:
var sessions = {};
var key = 'session_id';
var EXPIPERS = 20 * 60 * 1000;
var generate = function () {
var session = {};
session.id = (new Date()).getTime() + Math.random();
session.cookie = {
expire: (new Date()).getTime() + EXPIPERS
};
sessions[session.id] = session;
return session;
};
var app = 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() + EXPIPERS;
req.session = session;
} else {
delete sessions[id];
req.session = generate()
}
} else {
req.session = generate();
}
}
handle(req, res);
}
// 响应给客户端
res.writeHead = function() {
var cookies = res.getHeader('Set-Cookie');
// 写入cookies
var session = serilaize(key, req.session.id);
cookies = Array.isArray(cookies) ? cookies.concat(sessions) : [cookies,session];
res.setHeader('Set-Cookie', cookies);
return writeHead.apply(this, arguments);
}
- 第二种把口令写入查询字符串中
如果没有sessionId就生成一个并且跳转到带有sessionId的请求。过期也会重新跳转。
- Session与内存:
我们如果按照以上的那些写法,session是写在内存中的,但是我们第五章讲过,如果session太多会引起垃圾回收的频繁扫描。并且我们可能为了利用多核cpu启动多个进程,内存无法共享。
我们选择采用高速缓存,例如Redis。利用第三方缓存的问题在于引起网络访问,数据要比本地磁盘访问要慢。但是我们仍然采用高速缓存的原因在于:
- node与缓存服务保持长连接,而非频繁短连接,握手延迟只影响初始化。
- 高速缓存直接在内存中进行数据访问和存储。
- 缓存服务通常与node运行在相同机房或者机器上,网络影响小。
为此,session需要换成异步进行获取。
- Session与安全:
口令可以被伪造,所以有安全隐患。
一个做法将口令通过私钥加密:
// 将值通过私钥签名,由.分割原值和签名
var sign = function(val, secret) {
return (val + '.' + crypto).creatHmac('sha256', secret).update(val).digest('base64').replace(/\=+$/, '');
}
// 响应时
var val = sign(req.sessionID, secret);
res.setHeader('Set-Cookie', cookie.serialize(key, val));
// 取出口令检查签名
var unsign = function(val, secret) {
var str = val.slice(0, val.lastIndexOf('.'));
return sign(str, secret) == val ? str : false;
}
这样以来只有sessionId是无法得到信息的。但是如果攻击者能拿到私钥就无法防御,一种解决方案是除了验证这些信息意外还会验证用户的独一无二信息 比如手机号?ua或者ip。
- XSS漏洞(见图解HTTP)
8.1.6 缓存
如果一个站点不怎么更新,每次用户打开页面都要去请求相同的东西,会造成不必要的浪费,因此节约不必要的传输,对用户和服务器提供者来说都有好处。
- 添加Expires或Cache-Control到报文头中
- 配置ETag
- 让Ajax可以缓存
通常只有get请求缓存。
请求 - 是否有本地文件 - 是否可用 - 采用 - 结束
不然都去发生请求,采用最新的文件。 - HTTP 1.0:If-Modified-Since/Last-Modified
var handle = function(res, req) {
fs.stat(filename, function(err, stat){
var lastModified = stat.mtime.toUTCString();
if(lastModified == req.headers['if-modified-since']) {
res.writeHead('304', "Not Modified");
res.end();
} else {
fs.readFile(filename, function(err, file) {
var lastModified = stat.mtime.toUTCString();
res.setHeader("Last-Modified", lastModified);
res.writeHead(200, "OK");
res.end(file);
})
}
})
}
这里有些缺陷:
- 文件的时间戳改动文件不一定改动
- 时间戳只能精确到秒级别,更新频繁的内容无法生效。
为此HTTP1.1引入了ETag来解决这个问题。有服务器生成,可以自定义生成规则。
// If-None-Match/ETag
var getHash = function(str) {
var shasum = crypto.createHash('sha1');
return shasum.update(err).digest('base64');
}
var handle = function(res, req) {
fs.readFile(filename, function(err, file){
var hash = getHash(file);
var noneMatch = req.headers['if-none-match'];
if(hash === noneMatch) {
res.writeHead('304', "Not Modified");
res.end();
} else {
fs.readFile(filename, function(err, file) {
res.setHeader("ETag", hash);
res.writeHead(200, "OK");
res.end(file);
})
}
})
}
浏览器收到了ETag 之后,会放在请求头If-None-Match中。
尽管条件请求可以在文件内容没有修改的情况下节省带宽,但是它依然会发起一个HTTP请求,最好的方式是请求都不用发,这个时候就可以设置Expire和Cache-Control头。 浏览器根据该值进行缓存。
HTTP 1.0 使用Expire是一个GMT格式的时间字符串,浏览器接收到这个过期值之后,只要本地还存在这个缓存文件,在到期时间之前都不会再发起请求。 不过缺陷在于服务器和客户端的时间可能不一致,可能提前或者过期删除,HTTP1.1引入了Cache-Control设置了max-age值。优点在于更精确,解决一致性问题,并且可以精确控制,如果max-age和Expire同时存在,将进行Expire覆盖。
- 缓存清除:
- 每次发布,路径跟随版本号
- 每次发布跟随hash(一般采用)
8.1.7 Basic认证
如果一个页面需要Basic认证,它会检查请求报文头中的Authorization字段的内容,由认证方式加上加密值组成:
Authorization Basic dxNlcdnuewew
如果用户首次访问该页面,URL地址中没有携带认证内容,那么浏览器会响应一个401未授权的状态码。
响应头中WWW-Authorization字段告诉浏览器采用什么样认证和加密,然后浏览器会弹出一个对话框进行交互式提交认证信息。
Basic有太多缺点,base64传输几乎等同于明文,一般在HTTPS情况下才使用,不过Basic认证支持范围十分广泛,几乎所有浏览器都支持。
8.2 数据上传
我们常用的GET请求可以让服务器进行大多数业务逻辑操作了,但是单纯的头部报文无法携带大量的数据,在业务中往往需要接收一些数据,比如表单提交,文件提交,JSON上传等。
http模块只对HTTP报文头部进行了解析,然后触发request事件,如果请求还有内容部分需要用户自行接收和解析,通过Transfer-Encoding和Content-Length即可判断。
var hasBody = function (req) {
return 'transfer-encodeing' in req.headers || 'content-length' in req.headers;
}
function app(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处。