nodejs的架构分为三个部分,Natives modules, Builtin modules, 底层。
Natives modules 是由js实现的内容,提供应用程序可直接调用的库,例如fs,path,http等,一般我们称之为内置模块。
Builtin modules: 我们都知道js编写的代码是无法直接操作硬件的,要与硬件通信需要一个桥梁,Builtin modules就是这个桥梁,通过这个桥梁就可以让node的核心模块获得硬件的支持做一些更底层的操作,比如文件的读写行为。在这个过程中就是用node c或者c++来表示。在这里处理内置的模块还有一些第三方模块来充当桥梁的,这里面是c++的代码来编写成。例如socket, http, etc这样的一些基础功能。像socket,http,etc他们并不是真正代码级别上的实现,他们更像是一个功能调用的对照表,这就好像我们要在js中去调用一个a功能,但是a功能的实现是通过c或者c++实现的,而且实现之后的内容又被放在另外的地方,所以这个时候就需要找到这个a功能的实现,builtin modules就起到调用c++函数的功能。
最下一层就是v8, libuv, c-ares, http等具体的功能模块,v8的功能有两个,一个是执行js代码另一个是提供桥梁接口。开发者在使用nodejs的时候,表面上是调用了node的某个函数,不过真正起作用的是c或者c++编写的函数,这个转换的具体实现就是v8引擎赋能完成的,说白了就是js与c之间的转换功能。
v8为node提供了初始化操作,创建了执行上下文环境和作用域等内容,有了v8之后node就具备了执行和调用的大前提,node在执行和调用的过程中还存在很多的细节,比如事件循环,事件驱动,异步IO等这些就是libuv参与的内容。有了v8和libuv,node的功能就非常的强大了。余下的就是一些第三方功能模块。这些模块用来完成相应的功能。
node最初是为了实现高性能的前端服务器,后来慢慢演化成了一门服务器端“语言”。
异步IO
异步IO并非是nodejs的原创但是他在node中确有这广泛的运用。假设有两个任务需要执行,如果是同步执行那么总时间是大于两个任务执行的总和的,如果是异步执行总时间就会小于两个任务执行的总和或者干脆等于较慢的那个任务执行的时间。
对于操作系统来说IO只有阻塞和非阻塞这样两种,也就是说当前是否可以立即获取到调用之后返回的结果。当采用非阻塞的方式CPU的时间片就可以被拿出来去处理其他事物,这个时候对于性能是提升的。只是这种操作同样存在一些问题。因为立即返回的并不是业务层真正期望得到的实际数据。仅仅是当前的调用状态。操作系统为了获取完整的数据就会让应用程序重复的调用IO操作,判断IO是否完成,一般将重复判断IO是否完成的技术叫做轮询。
常见的轮询技术有很多,比如read,select,poll,kqueue,event ports。
虽然轮询技术能确定IO是否完成然后将轮询之后产生的数据返回回去,但是对于代码而言还是同步的效果,因为轮询过程中程序还是在等在IO的结果。期望的IO应该是代码发起非阻塞的调用。无需通过遍历或者唤醒的方式来轮询的判断当前的IO是否结束,而是可以在调用发起之后直接进行下一个任务的处理。等到IO的结果处理完成之后再通过某种信号或者回调将数据传回给当前的代码。
Node当中的libuv可以理解为接种不同的异步IO操作的抽象封装层。当运行一段nodejs编写的代码之后最终是会走到libuv环境中,他会对当前的平台进行判断,依据平台调用相应的异步IO处理方法。
Node中实现异步IO的过程是离不开事件循环的,不过事件循环这里不过多介绍,后面有时间单独开篇章来说明。
这里从执行的周期来介入说明,使用Node运行一段js脚本,如果代码中存在异步请求,libuv就会工作,他的内部存在一个事件循环机制,会对相应的异步请求处理程序进行管理。如果处理的是网络IO,则会调用操作系统底层的IO接口来进行处理。如果是一个文件IO就会放入到Node自行实现的一个线程池当中进行处理。无论是哪一种处理方式,最终都会有一个返回的结果,这个结果在出来之后就会通过event loop再去把他对应的处理程序加入到事件队列中,等待js的主线程进行执行。
这里的循环也不是一直运转不停的,当他发现队列中完全没有了需要等待执行的任务时也就会去退出循环,当前程序的执行也就结束了。对于node来说异步IO操作也就算是实现和完成了。
IO可以看做是应用程序的瓶颈所在,他的处理一定是需要消耗时间的,这个时间是和设备环境相关的。采用异步IO是可以提高性能的,IO操作的本身属于系统级别,平台都有对应的实现,linuv就是对这些方法的封装,实现了跨平台。node单线程配合事件驱动及libuv实现了异步IO。
事件驱动
事件驱动的架构就是建立在软件开发中的通用模式,为了便于理解,这里将它和发布订阅、观察者模式进行类比,但是这三者并不是一回事,只是他们在使用的时候有些相似的地方,比如说发布者广播消息,订阅者可以监听到订阅的消息。
NodeJS诞生初期是为了实现高性能的web服务,他实现高性能的主要表现就是拥有了一套单线程下的异步非阻塞的IO机制,但是也正是异步非阻塞的IO实现让我们编写NodeJS代码时会编写很多的异步代码,由于非阻塞所以程序代码在执行的过程中业务层拿到的并不是最终的目标数据。
等到同步代码执行完毕之后底层的libuv就开始工作,可以认为在libuv的里面有两个非常重要的内容一个是event loop一个是event queue。
当libuv接收到异步请求之后多路分解器进行工作,首先会找到当前平台下可用的IO处理接口,在等待着IO处理结束之后将相应的事件通过事件循环或者其他方式添加到事件队列中,在这个过程中事件循环是一直工作的,最后按照一定的顺讯从事件队列中取出相应的事件交给主线程进行执行。
在这个过程中事件驱动的体现就是有人发布了事件,订阅了这个事件的人在将来接收到具体事件消息,就会执行所注册的处理程序,这样的架构就很好的解决了NodeJS当中异步非阻塞操作所带来的数据最终获取的问题。
具体的代码实现就是在NodeJS中内置了event模块,而且事件操作本身也是NodeJS很重要的一个组成部分。
const Event = require('events');
const myEvent = new Event();
// 订阅事件event
myEvent.on('event', () => {
console.log('event执行了')
})
// 触发事件 event
myEvent.emit('event')
复制代码
单线程
NodeJS是使用JS实现高效可伸缩的高性能Web服务,在我们的认知中常见的web服务都是由多线程或者多进程实现的。单线程如何支持高并发以及NodeJS存在的一些缺点是什么。
在NodeJS的底层通过异步IO来实现非阻塞并非操作,具体表现就是多个请求无需阻塞会由上到下的执行。
NodeJS的单线程是指的主线程是单线程的,他并不是只有单线程,V8的主线程是单线程,在libuv中是存在线程池的,默认会有四个线程,会将Node请求分为网络IO和非网络IO,以及非IO的异步操作。
针对网络IO来说会调用当前平台对应方法进行处理,另外的两种会通过线程池来处理。如果四个线程不够用还可以通过修改配置的方式增加线程,不过一般不需要这样做而已。
单线程增加了线程的安全,也减少了CPU的开销,解决了内存同步开销等问题。
但是当处理CPU密集型的任务时会过多的占用CPU,这样一来后面的逻辑就会等待,而且单线程无法体现多核CPU的优势,当然这些问题在Node中都给出了解决方案,比如cluster集群等。
const http = require('http');
// 定义一个耗时任务
function sleep(time) {
const long = Date.now() + time * 1000;
while(Date.now() < sleep ) {}
return;
}
sleep(4); //消耗4s
const server = http.createServer((req, res) => {
res.end('server start');
})
server.listen(8080, () => {
console.log('running');
})
复制代码
这里就会等待4s才会启动服务器。
NodeJS虽然是单线程的机制但是他配合异步IO和事件循环可以实现高并发请求,NodeJS单线程指的是运行主线程是单线程的,也就是V8里面执行js代码的那个部分是单线程的,但是libuv中是存放多个线程的线程池的。
NodeJS的单线程也确实不太适合处理CPU密集型任务的,比如说计算,循环等。
BFF
非阻塞性IO操作可以让Node胜任IO密集型高并发请求,因此很多企业会选择在前端和大后端之间利用NodeJS搭建一个BFF(Backend For Frontend)层。
NodeJS在这种场景下不仅可以提高吞吐量,而且还可以很方便的处理数据,如果将Node当做后端语言看待的话肯定是可以处理数据的。
再不去关注大量业务逻辑的情况下,还可以使用NodeJS直接操作数据库,这样可以很容易搭建出高效轻量的API服务。NodeJS很适合开发实时聊天程序,当然还有Ajax丰富的单页应用。
再不过分涉及业务的情况下Node可以做的事情有很多,他比较适合IO密集型请求,不适合大量的业务逻辑,因为他的计算能力不是他的特色。
目前为止Node的主要战场还是在前端工程化这一块,后端服务并不是他的强项。
全局对象
NodeJS中的全局对象和浏览器中的window是不完全相同的,同时在他的全局对象上也挂载了很多有用的方法和属性。
全局对象可以看做是JavaScript中的特殊对象,他可以在任何地方被访问到的,而且不需要特别的声明,在NodeJS中全局对象就是Global,在Node中Global的根本作用就是作为宿主。
浏览器中的window就是一个全局对象,它里面保存了很多的数据,比如alert就是他的数据。Node中也是这样设计的,global就是一个全局对象,在他的身上有很多的全局变量。
__filename: 返回正在执行脚本文件的绝对路径
__dirname: 返回正在执行脚本所在的目录
timer类函数:执行顺序与事件循环间的关系,就是一些定时器函数,setTimeout, clearTimeout等。
process: 提供与当前进程互动的接口,指向了内置的process模块
require: 实现模块的加载
module、exports: 处理模块的导出
在Node中默认情况this是空对象{},不是global对象,这和浏览器有些区别。
console.log(this); // {}
复制代码
但是如果在node中包了一层自执行函数,这时候的this和global就相同的。
(function() {
console.log(this == global); // true
})()
复制代码
这和node中的模块化实现有一定的关系,因为在Node中每一个js文件都是一个独立的模块,模块与模块之间都是一个独立的空间,可以认为的就是js模块在指定的时候在外层都被包裹了一层自执行函数。常用的一些__firname, __dirname, require就是以参数传入进去的。这也就是为什么我们可以去使用这些全局变量。
(function(__filename, __dirname, module, exports, require) {
console.log(this == global); // true
})(__filename, __dirname, module, exports, require)
复制代码