文章目录
引言
本着对js这门语言的喜爱,我阅读了node.js。希望阅读过程中对其有所收获。以此共勉
Node简介 – 第一章
我惊奇于Node.js竟然是一门这么年轻的语言,2009年就诞生了
选择js的原因
高性能web服务器有两个要点:事件驱动、非阻塞IO
js有到高性能、符合事件驱动、没有历史包袱(学习难度不大,没有很多历史遗留下来的IO阻塞)
Node给JavaScript带来的意义
除了HTML、WebKit和显卡这些UI相关技术没有支持外,Node的结构与Chrome十分相似。
在Node中,js不再局限于浏览器本身,可以访问本地文件,可以搭建WebSocket服务器端,可以连接数据库,可以如Web Workers一样玩转多进程。
这些特性促使了桌面应用程序也可以用html、css、js来实现
异步IO
这个是经典的异步io的请求。
在Node中,绝大多数操作都是异步请求,以节省操作的事件。
事件与回调函数
事件的编程方式具有轻量级、松耦合、只关注事务点等优势,但是在多个异步任务的场景下,事件与事件之间各自独立,如何协作是一个问题。
前面可以看到,回调函数无处不在。这是因为在JavaScript中,我们将函数作为第一等公民来对待,可以将函数作为对象传递给方法作为实参进行调用。(js独特的函数可以作为参数转递)。
回调函数也是最好的接受异步调用返回数据的方式。
我这里补一下回调函数的知识(目前我还是低级开发,用到自定义的回调函数还是比较少的🤦)
回调函数的一下解释
回调函数即别人调用了这个函数,即函数作为参数传入另一个函数。
1. 自己定义的函数
2. 自己没调用
3. 函数最终执行了
单线程
优点:单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。
缺点:
- 无法利用多核CPU。
- 错误会引起整个应用退出,应用的健壮性值得考验。
- 大量计算占用CPU导致无法继续调用异步I/O。
问题
像浏览器中JavaScript与UI共用一个线程一样,JavaScript长时间执行会导致UI的渲染和响应被中断。在Node中,长时间的CPU占用也会导致后续的异步I/O发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。
解决
Web Workers能够创建工作线程来进行计算,以解决JavaScript大计算阻塞UI渲染的问题。工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程中的UI。
所以有些同学在其他地方的开发看到什么渲染进程、其他进程,就会疑惑js不是单线程的吗,为什么会有这么多个线程。这里就可以得到答案啦。
子进程的出现,意味着Node可以从容地应对单线程在健壮性和无法利用多核CPU方面的问题。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果,这可以很好地保持应用模型的简单和低依赖。通过Master-Worker的管理方式,也可以很好地管理各个工作进程,以达到更高的健壮性。
跨平台
我们经常可以看到前端项目的有点就是跨平台,无论是移动端,还是桌面端。
多个平台不同语言重新开发应用确实是挺浪费时间成本和资源的。
node的跨平台得益于操作系统与Node上层模块系统之间构建
了一层平台层架构,即libuv。
libuv已经成为许多系统实现跨平台的基础组件
Node的应用场景
-
适用于IO密集型业务
-
密集CPU使用的情况下
-
面对普通阻塞I/O
可以适当调整和分解大型运算任务为多个小任务,使得运算能够适时释放,
不阻塞I/O调用的发起,这样既可同时享受到并行异步I/O的好处,又能充分利用CPU。(避免单线程引起的弊端) -
对于长时间运行的计算
如果这长期时间的计算比普通io阻塞要花费更多的时间的话,我们可以采用一下措施。(这种情况下一般采用多线程,但是Node没有多线程)- Node可以通过编写C/C++扩展的方式更高效地利用CPU,将一些V8不能做到性能极致的地方通过C/C++来实现。
- 将一部分Node进程当做常驻服务进程用于计算,然后利用进程间的消息来传递结果,将计算与I/O分离,这样还能充分利用多CPU。
可见面对这类问题的情况下,如何合理地调度才是关键。
-
分布式应用
分布式应用往往是高效利用并行I/O的例子。Node高效利用并行I/O的过程,也是高效使用数据库的过程。
模块
前期的js有一下缺陷
- 没有模块系统。
- 标准库较少。ECMAScript仅定义了部分核心库,对于文件系统,I/O流等常见需求却没有标准的API。就HTML5的发展状况而言,W3C标准化在一定意义上是在推进这个过程,但是它仅限于浏览器端。
- 没有标准接口。在JavaScript中,几乎没有定义过如Web服务器或者数据库之类的标准统一接口。
- 缺乏包管理系统。这导致JavaScript应用中基本没有自动加载和安装依赖的能力。
CommonJS的出发点
为了弥补js的缺陷,使其具备具备跨宿主环境执行的能力,开发出一下应用
- 服务器端JavaScript应用程序。
- 命令行工具。
- 桌面图形界面应用程序。
- 混合应用(Titanium和Adobe AIR等形式的应用)。
Node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,NPM对Packages规范的完好支持使得Node应用在开发过程中事半功倍。
CommonJS的模块规范
模块引用
使用require()模块引用模块
var math = require('math');
模块定义
在模块中,上下文提供require()方法来引入外部模块。对应引入的功能,上下文提供了exports对象用于导出当前模块的方法或者变量,并且它是唯一导出的出口。在模块中,还存在一个module对象,它代表模块自身,而exports是module的属性。在Node中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式:
// math.js
exports.add = function () {
var sum = 0,
i = 0,
args = arguments,
l = args.length;
while (i < l) {
sum += args[i++];
}
return sum;
};
// program.js
var math = require('math');
exports.increment = function (val) {
return math.add(val, 1);
};
我来解释这里的逻辑,首先math.js将add()函数导出,这里的传参是通过argument参数数组,将数组里的数全部相加。然后program.js将math.js这个函数导入之后,声明一个两个相加的increment函数,将increment导出
模块标识
模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,或者以.、…开头的相对路径,或者绝对路径。它可以没有文件名后缀.js。
每个模块具有独立的空间,它们互不干扰,在引用时也显得干净利落
模块定义
CommonJS构建的这套模块导出和引入机制使得用户完全不必考虑变量污染,命名空间等方案与之相比相形见绌。
Node的模块实现
引入模块的步骤
- 路径分析
- 文件定位
- 编译执行
在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。
- 核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
- 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。
优先从缓存加载
前端服务器都会对数据进行缓存,防止二次开支造成的浪费。但不同的是,浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。
require()方法对相同模块的二次加载都一律采用缓存优先的方式,所以我们做项目的时候有必要清除缓存。
路径分析和文件定位
模块标识符分析
- 核心模块,如http、fs、path等。
- .或…开始的相对路径文件模块。
- 以/开始的绝对路径文件模块。
- 非路径形式的文件模块,如自定义的connect模块。
我们逐个了解一下几个模块
-
核心模块
核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码,其加载过程最快。如果试图加载一个与核心模块标识符相同的自定义模块,那是不会成功的。如果自己编写了一个http用户模块,想要加载成功,必须选择一个不同的标识符或者换用路径的方式。 -
路径形式的文件模块
以.、…和/开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。 -
自定义模块
自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的
模块路径的生成规则
- 当前文件目录下的node_modules目录。
- 父目录下的node_modules目录。
- 父目录的父目录下的node_modules目录。
- 沿路径向上逐级递归,直到根目录下的node_modules目录。
文件拓展名分析
- 文件扩展名分析
require()分析文件路径会出现标识符中不包含文件扩展名的情况。CommonJS模块规范也允许在标识符中不包含文件扩展名,这种情况下,Node按.js、.json、.node的次序补足扩展名,依次尝试。(这里建议,如果是.node和.json文件,在传递给require()的标识符中带上扩展名,会加快一点速度) - 目录分析和包
如果没有找到对应路径下的文件,却得到一个目录。Node在当前目录下查找
package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。详情
export 和export.module
exports对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但并不能改变作用域外的值。如果要达到require引入一个类的效果,请赋值给module.exports对象。这个迂回的方案不改变形参的引用。
var change = function (a) {
a = 100;
console.log(a); // => 100
};
var a = 10;
change(a);
console.log(a); // => 10
C++插件拓展模块(略)
包与NPM
包组织模块管理图:
包结构
包实际上是一个存档文件,内部结构为
- package.json:包描述文件。
- bin:用于存放可执行二进制文件的目录。
- lib:用于存放JavaScript代码的目录。
- doc:用于存放文档的目录。
- test:用于存放单元测试用例的代码。
package.json 的结构
- name。包名。规范定义它需要由小写的字母和数字组成,可以包含.、_和-,但不允许出现空格。包名必须是唯一的,以免对外公布时产生重名冲突的误解
- description。包简介
- version版本
- keywords。关键词数组,NPM中主要用来做分类搜索。一个好的关键词数组有利于用户快速找到你编写的包。
- maintainers。包维护者列表。每个维护者由name、email和web这3个属性组成。
- repositories。托管源代码的位置列表,表明可以通过哪些方式和地址访问包的源代码。
- dependencies。使用当前包所需要依赖的包列表。这个属性十分重要,NPM会通过这个属性帮助自动加载依赖的包。
- scripts。脚本说明对象。它主要被包管理器用来安装、编译、测试和卸载包。
"scripts": { "install": "install.js", "uninstall": "uninstall.js", "build": "build.js", "doc": "make-doc.js", "test": "test.js" }
在包描述文件的规范中,NPM实际需要的字段主要有name、version、description、keywords、repositories、author、bin、main、scripts、engines、dependencies、devDependencies。
devDependencies。一些模块只在开发时需要依赖。配置这个属性,可以提示包的后续开发者安装依赖包
一个node package有两种依赖,一种是dependencies一种是devDependencies,其中前者依赖的项该是正常运行该包时所需要的依赖项,而后者则是开发的时候需要的依赖项,像一些进行单元测试之类的包。
NPM常用功能
安装依赖包
它的执行语句是npm install express。执行该命令后,NPM会在当前目录下创建node_modules目录,然后在node_modules目录下创建express目录,接着将包解压到这个目录下。
安装好依赖包后,直接在代码中调用require(‘express’);即可引入该包。require()方法在做路径分析的时候会通过模块路径查找到express所在的位置。模块引入和包的安装这两个步骤是相辅相承的。
-
全局安装
npm install express -g命令进行全局模式安装。它根据包描述文件中的bin字段配置,将实际脚本链接到与Node可执行文件相同的路径下。 -
从本地安装
本地安装只需为NPM指明package.json文件所在的位置即可:它可以是一个包含package.json的存档文件,也可以是一个URL地址,也可以是一个目录下有
package.json文件的目录位置。npm install <tarball file>
-
从非官方源安装
如果不能通过官方源安装,可以通过镜像源安装。在执行命令时,添加 –
registry=http://registry.url即可,示例如下:如果不能通过官方源安装,可以通过镜像源安装。在执行命令时,添加 -- registry=http://registry.url即可,示例如下:
如果使用过程中几乎都采用镜像源安装,可以执行以下命令指定默认源:
npm config set registry http://registry.url
NPM钩子命令
"scripts": {
"preinstall": "preinstall.js",
"install": "install.js",
"uninstall": "uninstall.js",
"test": "test.js"
}
在以上字段中执行npm install 时,preinstall指向的脚本将会被加载执行,然后install指向的脚本会被执行。在执行npm uninstall 时,uninstall指向的脚本也许会做一些清理工作等。
异步I/O
假设业务场景中有一组互不相关的任务需要完成,现行的主流方法有以下两种。
- 单线程串行依次执行。
- 多线程并行完成。
如果创建多线程的开销小于并行执行,那么多线程的方式是首选的。多线程的代价在于创建线程和执行期线程上下文切换的开销较大。另外,在复杂的业务中,多线程编程经常面临锁、状态同步等问题,这是多线程被诟病的主要原因。但是多线程在多核CPU上能够有效提升CPU的利用率,这个优势是毋庸置疑的
单线程同步编程模型会因阻塞I/O导致硬件资源得不到更优的使用。多线程编程模型也因为编程中的死锁、状态同步等问题让开发人员头疼
Node在两者之间给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地使用CPU
事件循环
在进程启动时,Node便会创建一个类似于while(true)的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下个循环,如果不再有事件处理,就退出进程。
定时器
setTimeout()和setInterval()与浏览器中的API是一致的,分别用于单次和多次定时执行任务
process.nextTick()
process.nextTick = function(callback) {
// on the way out, don't bother.
// it won't get fired anyway
if (process._exiting) return;
if (tickDepth >= process.maxTickDepth)
maxTickWarn();
var tock = { callback: callback };
if (process.domain) tock.domain = process.domain;
nextTickQueue.push(tock);
if (nextTickQueue.length) {
process._needTickCallback();
}
};
立即异步执行一个任务,可以process.nextTick()方法
每次调用process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行。定时器中采用红黑树的操作时间复杂度为O(lg(n)),nextTick()的时间复杂度为O(1)。相较setTimeout()之下,process.nextTick()更高效
setImmediate()
setImmediate()方法与process.nextTick()方法十分类似,都是将回调函数延迟执行,那时候实现类似的功能主要是通过process.nextTick()来完成
setImmediate(function () {
console.log('延迟执行');
});
console.log('正常执行');
process.nextTick()中的回调函数执行的优先级要高于setImmediate()。这
里的原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者。在每一个轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。
process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是
保存在链表中。
异步编程
函数式编程
高阶函数
高阶函数则是可以把函数作为参数,或是将函数作为返回值的函数
function foo(x) {
return function () {
return x;
};
}
偏函数
偏函数用法是指创建一个调用另外一个部分——参数或变量已经预置的函数——的函数的用法。
我们引入一个新函数,这个新函数可以如工厂一样批量创建一些类似的函数,通过isType()函数预先指定type的值,然后返回一个新的函数:
var isType = function (type) {
return function (obj) {
return toString.call(obj) == '[object ' + type + ']';
};
};
var isString = isType('String');
var isFunction = isType('Function');
异常处理
原则一:必须执行调用者传入的回调函数;
原则二:正确传递回异常供调用者判断。
var async = function (callback) {
process.nextTick(function() {
var results = something;
if (error) {
return callback(error);
}
callback(null, results);
});
};
try {
req.body = JSON.parse(buf, options.reviver);
} catch (err){
err.body = buf;
err.status = 400;
return callback(err);
}
callback();
在编写异步方法时,只要将异常正确地传递给用户的回调方法即可,无须过多处理
事件发布/订阅模式
事件监听器模式是一种广泛用于异步编程的模式,是回调函数的事件化,又称发布/订阅模式。
Node自身提供的events模块是发布/订阅模式的一个简单实现,Node中部分模块都继承自它,它具有addListener/on()、once()、removeListener()、removeAllListeners()和emit()等基本的事件监听模式的方法实现
// 订阅
emitter.on("event1", function (message) {
console.log(message);
});
// 发布
emitter.emit('event1', "I am message!");
事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器。通过emit()发布事件后,消息会立即传递给当前事件的所有侦听器执行。侦听器可以很灵活地添加和删除,使得事件和具体处理逻辑之间可以很轻松地关联和解耦。
数据通过消息的方式可以很灵活地传递。在一些典型场景中,可以通过事
件发布/订阅模式进行组件封装,将不变的部分封装在组件内部,将容易变化、需自定义的部分通过事件暴露给外部处理,这是一种典型的逻辑分离方式。在这种事件发布/订阅式组件中,事件的设计非常重要,因为它关乎外部调用组件时是否优雅,从某种角度来说事件的设计就是组件的接口设计。
-
继承events模块
var events = require('events'); function Stream() { events.EventEmitter.call(this); } util.inherits(Stream, events.EventEmitter);
-
利用事件队列解决雪崩问题
在事件订阅/发布模式中,通常也有一个 once() 方法,通过它添加的侦听器只能执行一次,在执行之后就会将它与事件的关联移除。这个特性常常可以帮助我们过滤一些重复性的事件响应所谓雪崩问题,就是在高访问量、大并发量的情况下缓存失效的情景,此时大量的请求同时涌入数据库中,数据库无法同时承受如此大的查询请求,进而往前影响到网站整体的响应速度。
数据库查询的代码
var select = function (callback) { db.select("SQL", function (results) { callback(results); }); };
如果访问量巨大,同一条SQL会被发送到数据库反复查询,会影响服务性能。但是在这种情景下,连续地多次调用select()时,只有第一次调用是生效的,后续的select()是没有数据服务的,这个时候可以引入事件队列,相关代码如下
var proxy = new events.EventEmitter(); var status = "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"; }); } }
-
多异步之间的协作方案
由于多个异步场景中回调函数的执行并不能保证顺序,且回调函数之间互相没有任何交集,所以需要借助一个第三方函数和第三方变量来处理异步协作的结果。通常,我们把这个用于检测次数的变量叫做哨兵变量。聪明的你也许已经想到利用偏函数来处理哨兵变量和第三方函数的关系了
这里我之前写代码有遇到过,几个异步函数在点击登录按钮执行,首先需要登录函数先执行,得到登录的status后才能让另外两个异步函数执行,请求接口拿到数据。如果没获得登录status就执行另外两个异步函数,就会报错,获取不到数据。这里以渲染页面所需要的模板读取、数据读取和本地化资源读取为例简要介绍一下:
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); });