写在前面:
现在博客文章其实不算少了,我发现很多的人都是带着问题来,所以问题解决方案类的文章更受欢迎些。
我也想了想,到底怎么样的文章才会被大家耐心的去阅读?是解决方案?深奥的文章?
解决方案如果能找到特别优秀的,或者说能找到解决我问题的文章我就不会再写,我比较喜欢整理自己学习的过程,学习的路线记录。
我还想要自己写出来的东西都能分享给别人,能把我的想法,总结告诉给别人。
但是很多人都没有耐心看下去。所以我也要做一些改变。
好多人都说,在面试的那几天学到的东西特别多,往往一个简单的问题能牵扯出很多知识点。
所以本篇文章就是从面试问题而来,我花了几天时间搜集关于Node的面试题,并对这些问题进行归类,
再对问题进行排序,由浅入深:由问题1能提到问题2能聊到问题3…
通过这样的方式进行文章整体的梳理,能通过一个问题引出一套概念,知道某些内容是为了解决对应的问题。
在文章的最后会有课后习题,是对前面所有内容的总结提问。
文章内容大部分来自Google或者官方文档,本人英文一般,所以存在理解错误的地方还希望大家能多多指出。
最后,希望这篇文章能对大家有帮助。

《关于Node-你想知道的都在这里》(上)

很早之前我是写java的,写了一年多,但都是一些简单的逻辑编写,并没有深入到底层。一直都觉得特别遗憾,这种基础没打好的感觉如牢笼般困住了我。
尤其是在转到前端之后更是对java生出了一种恐惧,搭环境,jar包,maven….想想都头大。
直到接触到Node.js之后,我终于可以用自己喜欢的语法来写后端代码了。之前没有深入研究的点,也可以有机会去研究。
所以我准备花一段时间来整理下Node.js相关的知识,尽可能通过一些简单的问题剖析到底层。
大纲整体分为两大部分:

1. Node.js 基础知识刨根问底
2. Node.js APIdemo

第一部分偏概念,第二部分偏实践。
这篇文章来介绍第一部分,主要是Node.js的一些基础知识概念。
好了,不多啰嗦,直接开始。

先来看看Node.js定义:

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。
Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。
Node.js 的包管理器 npm,是全球最大的开源库生态系统。

直接从Node的定义中找分析点,第一个关键词是 “V8引擎“。

大家都知道我们写的js是放到浏览器里执行的,java代码需要编译成class文件执行。这是两种程序执行方式。
像C,C++,Java,C#这类编译后才能执行,称之为编译型语言。需要用对应的编译器编译,例如java代码经过jdk编译。
像javascript这类在运行时才解释执行的称为解释型语言。同样它也需要对应的解释器进行解释执行。
由于大部分的js在浏览器中执行,所以javascript解释器一般都嵌入到浏览器内部。根据浏览器厂商的不同,engine也不同。例如:SpiderMonkey,Rhino
我们今天聊的V8 engine最早就是服务于 Google Chrome 浏览器。V8的创始人是Lars Bak。
V8使用C++编写,你可以在GitHub上找到其源代码: https://github.com/v8/v8
V8是一款开源javascript engine,所以它同时也被许多其他项目所使用,比如Couchbase,MongoDB,以及我们今天主要聊的Node.js

接着我们来分析第二句介绍:Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。

这句话两个关键词:”事件驱动” “非阻塞式I/O模型
为了弄清楚它们,我们先来看下它们在Node.js架构中所处的位置:

Node.js有两种类型的组件,顶层的Node.js API使用javascript编写,其他层则使用c/c++编写。
顶层的Node.js API直接暴露给外界作为API调用,方便我们与内部组件沟通,其所有模块都可以在官方文档中找到使用方式:http://nodejs.cn/api/
第二层为Node bindings,也称之为胶水层,负责将更底层的系统api提供给javascript层调用。C / C ++ Addons则是为了方便对Node进行扩展的接口。
第三层为Node运行的核心,分为六大块:

1.V8 engine负责提供javascript运行环境。
2.libuv为Node而设计,提供了事件循环(Event loops),线程池(Thread loops),异步IO(Async I/O)
等核心功能。当V8处理javascript的同时,Libuv处理事件循环和IO操作。他们一起为Node提供强有力的动力支撑。
3.C-ares为Node提供异步处理DNS解析的能力。
4.http_parser负责对HTTP请求与响应进行解析。
5.OpenSSL负责数据传输的安全性,以及提供一些必要的加密算法支持。
6.zlib是通用数据压缩库

我们之前找出的关键字 “事件驱动” “非阻塞式I/O模型” 这些特性就归功于Libuv。所以我们有必要详细的了解下Libuv,先看下Libuv的整体架构:

分析下这个结构,从左至右第一大块是Network I/O,包含了网络层相关的各个模块。
Network I/O与底层通信的处理分为两类,
蓝色部分为windows的IOCP(I/O Completion Port)模型,IOCP是一种异步I/O的API,通过IOCP就可以在window平台下进行正常的操作。
左边绿色部分的epoll是Linux下的I/O通知机制。
kqueue是在OSX和BSD类Unix操作系统下的I/O通知机制。event ports是在Solaris操作系统下的异步I/O机制。
为了对unix以及linux做统一的处理,Libuv在三种方式上方加入了uv__io_t来做统一调度。
右上角的是文件操作,DNS操作,以及用户指定代码,这三种操作类型因为不依赖操作系统原生接口,所以它们的执行交给全局线程池(Thread pool)排队执行。

介绍完整体架构,我们继续进一步学习Libeuv中最重要的事件循环(Event loops)

1.什么是非阻塞式I/O?如何理解同步异步,阻塞I/O 非阻塞I/O?

同步与异步:
当我们发起一个ajax请求时,在请求发起后程序pending,知道请求返回后才继续向下执行,这种方式为同步。
同样一个ajax请求,在请求发起后,程序正常向下执行,当请求返回后通过回调函数进行操作,这种方式称为异步。
阻塞与非阻塞:
阻塞与非阻塞形容的不是执行顺序,而是线程在等待响应时的状态。
阻塞在等待响应时会阻塞当前线程继续执行,导致当前线程被挂起,如果有新的请求则新开线程处理。
非阻塞在等待响应的过程中不会等待,会继续处理其他请求。

2.什么是事件(Event)?什么是消息?

我们做Dom编程时,常常接触到事件绑定,事件处理。
这里面的事件指的是用户的动作,如点击,输入,拖拽等。在Node中各种操作动作,文件读写,请求收发等动作都被抽象为事件。
消息是对发生事件的描述,一般用在跨模块的通知中,通过消息来告知应用程序完成指定的操作。
综上所述,事件是由用户触发,作为一系列操作的开始。消息则是对事件的说明,用来传递事件信号到各个事件处理程序

3.什么是事件驱动(Event-driven )

我们通常会为事件绑定一系列的操作流程,通过用户触发对应的事件来执行不同的流程。
试想一下,一个onClick事件,如何在用户点击的第一时间获取该事件呢?或者说,程序如何知道用户何时进行了点击操作?
最简单的方式是用一个while循环或者是一个线程来不停地检测用户是否执行了操作。由于事件类型越来越多,所以损耗的系统资源也越来越多。
而且在处理某一个事件的同时可能会阻塞后续事件的执行,所以这种方式的性能是非常差的。
我们对单线程的检查做优化,将事件收集与事件处理分别处理。采集线程负责收集用户操作事件,并将用户操作添加到事件队列。
执行线程负责从事件队列中取出事件,根据事件类型执行不同的事件处理函数。当事件队列为空时即可释放一定的cpu资源。
当然这些线程不需要我们维护,我们只需要负责通过指定的接口为事件绑定处理函数即可。
这样的编程方式我们称为事件驱动。

4.什么是线程(thread)?什么是进程(process)?

前面我们提到了线程,我们花一点点功夫介绍下线程与进程的关系,以方便我们后续内容的理解。
简单来说,一个应用程序至少有一个进程,一个进程至少拥有一个线程。
阮一峰老师的一篇文章介绍了操作系统-进程-线程以及内存之间的关系。《进程与线程的一个简单解释》
里面提到了进程的内存空间对所有内部线程共享,共享内存涉及到的争夺问题使用互斥锁(Mutual exclusion,缩写 Mutex)解决,
当有多个线程进行争夺共享内存时,采用信号量(Semaphore)来避免冲突。

5.什么是事件循环(Event loops)

聊了进程线程以及事件驱动,下面我们认识下事件循环。
在遇到高并发的场景时,可以采用多线程执行的方式来提高处理效率。
但我们都知道javascript是单线程,这就意味着它无法利用多线程来高效的处理任务。
起点低不代表我们追求少,javascript也希望有套高效的任务处理器。
因为是单线程,所以所有任务都是串联执行,只有当前任务执行完毕才会执行下一个任务。
但是很多任务都是需要很长时间的等待,等待的过程中cpu资源就被闲置了。
一个现实的例子,你去5家银行开户办卡,因为你只有一个人,所以你必须顺序的去办每一家的银行卡,
但是每家银行需要你做的只有填表和窗口办理这么两件事情,更多的时间浪费在了排队上。
这就导致办卡5分钟,排队1小时。依然是你一个人的情况下,怎么才能提高自己的效率呢?
你肯定能想到在排队的过程中去其他家银行拿好材料,一边排队,一边填写资料。
其实Event loops也是这么做的。Event loops把所有任务分为两种:同步任务(synchronous)和异步任务(asynchronous。
同步任务指的是那些立即可以执行,不需要等待的任务。
像前面例子里的填写资料,窗口办理这类立即可以执行的任务称之为同步任务。
异步任务指的是那些需要耗时的任务,他们被单独管理,当异步任务可以执行时,它会被重新划分到同步任务立即执行。
我们把同步任务放到执行栈(execution context stack)中来顺序执行,把异步任务放到一个任务队列(task queue)中处理。
当执行栈中的任务执行完毕后,会扫描任务队列中处理完成的任务,将异步任务交给同步任务队列继续执行。
由此往复的循环便形成了事件循环。

6.Node下的工作流程

前面讲的事件循环只是一种程序结构,是实现异步的一种机制,并不是Node专属,我们来看下Node中如何利用Event loops来处理事件。
看下Node的工作流程:

首先用户的js代码会交给V8 engine解释执行,在执行的过程中需要调用底层API,例如Network I/O,File I/O等。
I/O操作经由Bindings发送到Libuv(此处 OS OPERATION 为根据操作系统指定不同行为,其实应该放在最后)
Libuv将所有I/O操作添加到事件队列(Event queue)
启动Event loop对事件队列顺序执行,根据事件类型从工作线程中取出对应的线程执行该事件。
当工作线程处理完毕后取出对应的回调函数进行执行
Libuv将执行结果返回V8,V8返回到用户。

说实话,上面的内容我存在一些疑惑,我们之前讲事件循环时,说了事件循环其实由两个关键部分组成,一个是执行栈,一个是任务队列。
我一直在尝试从这张图中找到这两部分,我开始认为Event queue就是执行栈,但是它其实只是接受事件的队列。
上面的流程图中,其实更多的是介绍了任务队列。关于异步任务的callback何时执行,执行顺序,以及执行栈的位置等都没有表达出来。
所以我找了很多资料,看了很多流程图,终于找到了执行栈。看下面这张图:

这张图里的就是Node的执行栈,执行栈的执行过程分为图中的6个阶段
每个阶段都是一个回调FIFO(先入先出)队列.
每次执行栈都会顺序执行每个阶段,并执行当前阶段队列中所有的回调。
当队列中回调执行完毕或达到最大回调限制时,执行栈将移动到下一阶段继续执行。
我们来看下各个阶段都负责管理哪些回调:

1. timers: 这一阶段执行到期的setTimeout()和setInterval()绑定的callback回调
2. I/O callbacks: 这一阶段执行所有I/O操作的callback回调
3. idle, prepare: 此处执行一些内部回调,用来回收与分配handle。
4. poll: 所有的I/O操作都交给了I/O callback来执行,如果遇到需要延迟执行的callback,就会被放到poll里等待执行
5. check: 通过setImmediate()设置的回调在这个阶段执行
6. close callbacks: 任务的close回调在此阶段执行。例如:socket.on(‘close’, …)

每个阶段都有自己的详细说明,可以参考:
Node官方文档的说明:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

Libuv文档说明:http://docs.libuv.org/en/v1.x/design.html#the-i-o-loop

7.nextTick与setImmediate

nextTick与setImmediate简单来说就是在执行栈的不同时机插入回调函数。
我们前面讲到setImmediate方法有自己专属的执行阶段”check“,通过setImmediate绑定的回调函数会在这里执行。
nextTick绑定的回调函数保存在nextTickQueue队列中,nextTickQueue不属于上面任何一个处理阶段。nextTickQueue的执行时机是任意一个阶段的所有任务执行完毕后,
会立即检查nextTickQueue队列中是否有需要执行的回调,如果有会优先执行,然后进行下一阶段的处理。

8.Node下的定时器

我们看了Node的工作流程,也分析了Node中执行栈的执行顺序。
那我们试着问一下,在Node中是怎么实现setTimeout和setInterval这两个定时器的呢?
分析这个问题之前我们再看一张图,是Libuv的执行顺序图:

整体的结构和前面Node的一致,执行顺序有一点变化,在最顶层有一个Update loop time。
也就是说在全局维护了一个time变量,也就是当前的系统时间
每一次循环执行完成后都会更新time的值
而在执行栈的第一步就是执行到期的setTimeout()和setInterval()绑定的callback回调
怎么判断是否到期呢?就是通过与time的比较来判断是否需要执行此callback。
这就会导致一个情况的发生,如果执行栈的其他阶段运行了特别耗时的操作,就会导致time变量的值远大于setTimeout和setInterval传递的时间。
现象就是定时器会延迟执行,不会在指定的时间内执行,其原因就是因为执行栈内存在耗时操作。而执行栈必须等待耗时操作完成后才会执行定时器。

9.Node style Callback(error-first)

我们对Node下的工作流程已经掌握,在最后我们需要再对其中一个规范做说明。
Node的成功离不开内部高效的事件循环以及异步I/O,整个Node的设计从上到下都遵循着异步的概念。
异步操作会有一个必然的产物就是callback,对于异步任务,你必须要指定任务完成后需要执行的回调函数,才能准确的接收到异步任务的执行结果。
对于越来越多的callback回调函数,以及callback中各式各样的参数,Node觉得有必要对callback指定一个准则,那就是error-first(错误优先):

1.callback函数的第一个参数为error对象保留。如果发生异常,异常信息会被放在第一个err参数返回
2.callback函数的第二个参数保留给成功的响应数据。如果没发生异常,err参数会传递null,第二个参数为成功后的返回数据。

具体的表现:

fs.readFile('/foo.txt', function(err, data) {
    // TODO: Error Handling Still Needed!
    console.log(data);
});

我们在编写模块时,要注意这个规范,以便我们程序的异常可以正确的接收到。

经过对Node深入的了解。我们已经分析了Node整体架构,Libuv整体架构,V8 engine,事件循环,Node事件处理流程,定时器等关键内容。
相信大家也不会再被Node相关的基础问题难倒。

我们看下Node的最后一句介绍:Node.js 的包管理器 npm,是全球最大的开源库生态系统。

1.什么是npm?

我们做dom编程的时候,经常需要从互联网上找一些脚本来放到本地,每次创建新项目都要从老的项目把js包copy到新项目中。
这样的场景下第一不方便我们做版本管理,第二容易引起文件的混乱,第三自己写的脚本无法与他人共享。
其他平台都会对此类问题有自己的解决方案,例如:apt-get,yum,maven等包管理工具。
npm(Node Package Manager.)就是javascript的一个包管理工具,每个软件包内部是一个可独立执行的js模块以及一个package.json文件。
npm负责统一管理这些模块,你可以通过npm来安装,升级,卸载指定的软件包。package.json文件内部定义了软件包的依赖以及基础信息,
包括包名称,版本,依赖,license,以及开发者信息等内容。详细的配置项可以参考官方文档:package.json配置

2.npm如何使用?

Node从v0.6.x版本之后默认安装了npm,不需要单独安装。

$ npm install packageName  //安装指定包
$ npm uninstall packageName   //卸载指定包
$ npm update packageName   //更新软件包

更多详细的npm命令可以参考npm官方文档:https://docs.npmjs.com/cli/start

我们分析了这么多Node相关的细节知识,最后再以第三人称角度分析一下Node

1.为什么要使用Node?

我们前面有介绍过Node是异步单线程,通过Event loops来实现非阻塞I/O。那么问题来了,Apache支持多线程,多线程处理任务效率更高,
为什么还要使用一个单线程的运行环境呢?
我们讲一个进程下回存在多个线程,而多个线程是共享进程的内存空间的。所以当线程数越来越多时,内存空间并没有变化,
这就会导致线程之间会存在内存争夺的问题,就会有线程排队的问题,互斥锁,信号量等方案就是线程排队的解决策略。
了解了多线程存在的问题后,我们就能看到Node的优点,Node通过单线程来异步执行所有事件,通过Event loops来提高执行效率。
反观Node就没有一点问题了吗?它的问题也出在单线程上,I/O事件需要消耗的cpu运算其实并不多,
只是比较耗时而已,所以Node把这些耗时不耗力的任务交给事件循环处理。
一旦遇上费力的操作,需要耗费CPU运算能力的操作,就全部都落在了单线程的肩上。
你I/O处理的再快,奈何任务根本到不了后方,单线程全部在处理CPU运算的任务。

2.Node的使用场景?

介绍了Node对比多线程的优缺点,其实能发挥其优点的场景就是适用场景,反之则是不适用的场景。
Node处理I/O占优,适用于一些I/O密集的场景,例如实时聊天室,RESTFUL API接口层。
在视频编解码,数据分析,AI等CPU密集场景时避免适用Node。
Apache多线程CPU运算占优,适用于并发较少,计算量大,业务逻辑复杂的场景。
最后附上Node核心贡献者Felix Geisendörfer写的《教你如何说服老板指南》
里面介绍了Node的使用场景和需要避免的场景。

3.Node的进步

我们讲Node是运行在单线程上的,Node也做过一些尝试,使Node也可以支持多线程,让Node在CPU密集的场景下也能有好的表现。
接下来我们分别介绍下Node的Workers与cluster(集群)

1. html5 Web Worker

当我们在浏览器中执行脚本时,会因为计算量大导致页面停止渲染,失去响应直到脚本执行完成。
Web Worker就是为了解决这些场景,在不中断用户界面的情况下执行大计算量的任务。
Web Worker允许主线程将一些任务分配给子线程,在主线程运行的同时,子线程在后台执行,两者互不影响。
Web Worker也有一些限制条件:

1.同域限制:子线程的使用必须与主线程脚本文件在同一域。
2.DOM限制:子线程无法读取网页的DOM对象。
3.脚本限制:子线程无法读取网页的全局变量和函数,也不能执行alert和confirm方法。
4.文件限制:子线程无法读取本地文件。

Web Worker的使用:

// file index.js
var w = new Worker('demo_workers.js'); //新建一个线程,参数是线程要执行的脚本文件。
w.postMessage('hello worker');	//主线程通过调用postMessage来启动子线程。
w.addEventListener('message', function(e) {  //通过监听子线程的message事件接受来自子线程的消息。
    console.log(e.data);
}, false);
w.onerror(function(e) { 	//通过绑定异常处理函数来处理子线程异常
    console.log(e);
});
w.terminate();	//关闭子线程



// file demo_workers.js
self.addEventListener('message', function(e) {
	console.log(e.data);
	self.postMessage('hello index');
}, false);
//子线程通过监听message事件接收来自主线程的启动信号
//子线程也可以通过postMessage来向主线程发送消息
self.close();	//关闭自身

了解详细可参考:W3C Workers

2.Node webworker-threads

web worker是html5的新特性。在Node中是不存在的。但是有人参照Web Worker的标准在Node中实现了一套Worker,并打包发布到了npm上。
它就是:webworker-threads 你可以前往GitHub仓库查看它的具体信息。https://github.com/audreyt/node-webworker-threads
因为其遵守了Web Worker的标准,所以你完全可以按照使用Web Worker的方式使用它。

3.Node cluster(集群)

每一个Node实例都在单线程中运行,为了利用多核系统,我们可以启动多个Node实例来提供相同的服务,利用cluster来协调各个实例,实现负载均衡。
通过cluster可以轻松的将端口共享给所有实例。下面我们看看如何使用cluster,看一段代码:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;  //获取cpu数量

if (cluster.isMaster) {
  console.log(`Master is running`);

  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else if(cluster.isWorker) {
  // Workers can share any TCP connection
  // In this case it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

$ node server.js
Master is running
Worker 1880 started
Worker 1292 started
Worker 2368 started
Worker 1652 started

上面的代码根据cpu数量创建了四个Worker,通过cluster.isMaster来判断当前进程是否是主进程,通过cluster.isWorker判断是否是Worker进程。
每个Worker都会启动一个服务来监听8000端口

主进程中:
var worker = cluster.fork(); 只有主进程才可以调用此方法。
通过fork方法复制主进程的上下文环境,新建一个worker,并返回worker对象。
worker.id 属性取出当前worker在cluster.workers中的唯一id。
worker.process 属性可以获取worker所在的进程对象。
worker.send() 方法可以由主进程向子进程发送消息
woeker.kill(‘kill’); 用于终止worker进程,参数为系统信号。
你也可以通过listening对主域名的二级域名绑定单独的Worker进程:

cluster.on('listening', function (worker, address) {
  console.log("A worker is now connected to " + address.address + ":" + address.port);
});

listening回调函数接收两个参数,一个是当前的Worker对象,另一个是地址对象,包含网址端口等信息。
这对于处理服务于多个网址的Node应用非常有用

在Worker中:
通过process来获取当前进程对象,相当于Web Worker中的self。
通过: process.on(‘message’, function(msg) {}); 来接受主进程的消息。
通过: process.send(msg); 向主进程发送消息
通过: process.exit(0);退出当前进程

详细的使用大家可以参考Node官方文档:https://nodejs.org/api/cluster.html
以及阮一峰老师的Cluster模块介绍:http://javascript.ruanyifeng.com/nodejs/cluster.html

最后附上一篇随堂测试,看下大家的掌握程度:

Q: Node v8 engine是什么?
Q: Node 组成结构?
Q: libuv 是什么? 如何工作?
Q: Libuv 组成结构?
Q: 如何区分阻塞与非阻塞?
Q: 进程与线程的区别?
Q: 什么是事件驱动模型?
Q: 什么是事件循环?
Q: Node中处理任务的流程?
Q: Libuv的事件执行分为几个阶段?分别处理哪些任务?
Q: nextTick, setTimeout 以及 setImmediate 三者有什么区别?
Q: node是单线程还是多线程?node下的定时器如何实现?
Q: 什么是error-first回调方式?
Q: 你为什么使用Node?
Q: Node 如何利用多个cpu?
Q: Node 如何实现分布式?
Q: Node 如何不中断地重启服务?