Node. js被广大开发者所青睐,主要是因为Node.js包含了以下特点。
异步是相对于同步而言的。同步和异步描述的是用户线程与内核的交互方式。同步是指用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行;异步是指用户线程发起I/O请求后仍继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。图1-2展示了异步I/O模型。
图1-2 异步I/O模型
举个通俗的例子,你打电话问书店老板有没有《Node.js企业级应用开发实战》这本书卖。如果是同步通信机制,书店老板会说“你稍等,不要挂电话我查一下”。然后书店老板就跑过去书架上查,而你自己则在电话这边干等。等到书店老板查好了(可能是5秒,也可能是一天)在电话里告诉你查询的结果。而如果是异步通信机制,书店老板直接告诉你“我查一下,查好了打电话给你”,然后直接挂电话。等查好了,他会主动打电话给你。而等回电的这段时间内,你可以去干其他事情。在这里老板通过“回电”这种方式来回调。
通过上面的例子可以看到,异步的好处是显而易见的,它可以不必等待I/O操作完成,就可以去干其他的事,极大地提升了系统的效率。
读者欲了解更多有关同步、异步方面的内容,可以参阅笔者所著的《分布式系统常用技术及案例分析》。
对于JavaScript开发者而言,大家对于事件一词应该都不会陌生。用户在界面上单击一个按钮,就会触发一个“单击”事件。在Node.js中,事件的应用也是无处不在。
在传统的高并发场景中,其解决方案往往是使用多线程模型,也就是为每个业务逻辑提供一个系统线程,通过系统线程切换来弥补同步I/O调用时的时间开销。
而在Node.js中使用的是单线程模型,对于所有I/O都采用异步式的请求方式,避免了频繁的上下文切换。Node.js在执行的过程中会维护一个事件队列,程序在执行时进入事件循环(Event Loop)等待下一个事件到来,每个异步式I/O请求完成后会被推送到事件队列,等待程序进程进行处理。
Node. js的异步机制是基于事件的,所有的磁盘I/O、网络通信、数据库查询都以非阻塞的方式请求,返回的结果由事件循环来处理。Node.js进程在同一时刻只会处理一个事件,完成后立即进入事件循环检查并处理后面的事件,其运行原理如图1-3所示。
图1-3 运行原理
图1-3是整个Node.js的运行原理,从左到右,从上到下,Node.js被分为了四层,分别是应用层、V8引擎层、Node API层和LIBUV层。
·应用层:JavaScript交互层,常见的就是Node.js的模块,如http、fs等。
·V8引擎层:利用V8引擎来解析JavaScript语法,进而和下层API交互。
·Node API层:为上层模块提供系统调用,一般是由C语言来实现,和操作系统进行交互。
·LIBUV层:跨平台的底层封装,实现了事件循环、文件操作等,是Node.js实现异步的核心。
这样做的好处是CPU和内存在同一时间集中处理一件事,同时尽可能让耗时的I/O操作并行执行。对于低速连接攻击,Node.js只是在事件队列中增加请求,等待操作系统的回应,因而不会有任何多线程开销,很大程度上可以提高Web应用的健壮性,防止恶意攻击。
注意,事件驱动也并非是Node.js的专利,例如,在Java编程语言中,Netty也是采用了事件驱动的机制来提供系统的并发量。有关Netty的内容,可以参阅笔者所著的开源书《Netty 4.x用户指南》和《Netty实战(精髓)》(https://waylau.com/books/)。
从上面所介绍的事件驱动的机制可以了解到,Node.js只用了一个主线程来接收请求,但它接收请求以后并没有直接做处理,而是放到了事件队列中,然后又去接收其他请求了,空闲的时候,再通过事件循环来处理这些事件,从而实现了异步效果。当然对于I/O类任务还需要依赖于系统层面的线程池来处理。因此,可以简单地理解为,Node.js本身是一个多线程平台,而它对JavaScript层面的任务处理是单线程的。
无论是Linux平台还是Windows平台,Node.js内部都是通过线程池来完成异步I/O操作的,而LIBUV针对不同平台的差异性实现了统一调用。因此,Node.js的单线程仅仅是指JavaScript运行在单线程中,而并非Node.js平台是单线程。
上面提到,如果是I/O任务,Node.js就把任务交给线程池来异步处理,因此Node.js适合处理I/O密集型任务。但不是所有的任务都是I/O密集型任务,当碰到CPU密集型任务时,即只用CPU计算的操作,如要对数据加解密、数据压缩和解压等,这时Node.js就会亲自处理,一个一个地计算,前面的任务没有执行完,后面的任务就只能等着,导致后面的任务被阻塞。即便是多CPU的主机,对于Node.js而言也只有一个事件循环,也就是只占用一个CPU内核,当Node.js被CPU密集型任务占用,导致其他任务被阻塞时,却还有CPU内核处于闲置状态,造成资源浪费。
因此,Node.js并不适合CPU密集型任务。
微服务(Microservices)架构风格就像是把小的服务开发成单一应用的形式,运行在其自己的进程中,并采用轻量级的机制进行通信(一般是HTTP资源API)。这些服务都是围绕业务能力来构建,通过全自动部署工具来实现独立部署。这些服务,可以使用不同的编程语言和不同的数据存储技术,并保持最小化集中管理。
Node. js非常适合构建微服务。
首先,Node.js本身提供了跨平台的能力,可以运行在自己的进程中。
其次,Node.js易于构建Web服务,并支持HTTP的通信。
最后,Node.js支持从前端到后端再到数据库全栈开发能力。
开发人员可以通过Node.js内嵌的库来快速启动一个微服务应用。业界也提供了成熟的微服务解决方案来打造大型微服务架构系统,如Tars.js、Seneca等。
读者欲了解更多微服务方面的内容,可以参阅笔者所著的《Spring Cloud微服务架构开发实战》。
通过构建基于微服务的Node.js,可以轻松实现应用的可用性和扩展性。特别是在当今Cloud Native盛行的年代,云环境都是基于“即用即付”的模式,并且往往提供自动扩展的能力。这种能力通常被称为弹性,也被称为动态资源提供和取消。自动扩展是一种有效的方法,专门针对具有不同流量模式的微服务。例如,购物网站通常会在“双11”的时候,迎来服务的最高流量,服务实例当然也是最多的。如果平时也配置那么多的服务实例,显然就是浪费。Amazon就是这样一个很好的实例,Amazon总是会在某个时间段迎来流量的高峰,此时,就会配置比较多的服务实例来应对高访问量。而在平时,流量比较小的情况下,Amazon就会将闲置的主机出租,来收回成本。正是拥有这种强大的自动扩展的实践能力,造就了Amazon从一个网上书店,摇身一变成为世界云计算巨头。自动扩展是一种基于资源使用情况自动扩展实例的方法,通过复制要缩放的服务来满足SLA(Service-Level Agreement,服务等级协议)。
具备自动扩展能力的系统,会自动检测到流量的增加或减少。如果是流量增加,则会增加服务实例,从而能够使其可用于流量处理。同样的,当流量下降时,系统通过从服务中取回活动实例从而减少服务实例的数量。如图1-4所示,通常会使用一组备用机器完成自动扩展。
图1-4 自动扩展
与Java一样,Node.js是跨平台的,这意味着开发的应用能够运行在Windows、macOS和Linux等平台上,实现了“一次编写,到处运行”。很多Node.js开发者都是在Windows上做开发的,然后再将代码部署到Linux服务器上。
特别是在Cloud Native应用中,容器技术常常作为微服务的宿主,而Node.js是支持Docker部署的。
有关Cloud Native方面的内容,可以参阅笔者所著的《Cloud Native分布式架构原理与实践》。