Node|从入门到...好吧只是刚刚入门

941 阅读34分钟

最近看了 2 本 Node 相关的书:《深入浅出 Node.js》(2013)和《Node.js 实战》(2018),将 2 本书中的精华总结为这篇文章,以后用到的时候可以直接看文章 review 了。

一、概述

作者为什么选择 JavaScript?

考虑到高性能(运行时利用 V8 引擎来解析 JavaScript)、符合事件驱动、没有历史包袱这 3 个主要原因,JavaScript 成为了 Node 的实现语言。

为什么叫 Node?

它自身非常简单,通过通信协议来组织许多 Node,非常容易通过扩展来达成构建大型网络应用的目的。每一个 Node 进程都构成这个网络应用中的一个节点,这是它名字所含意义的真谛。

Node 给 JavaScript 带来的意义:

JavaScript 作为一门图灵完备的语言,长久以来却限制在浏览器的沙箱中运行,它的能力取决于浏览器中间层提供的支持有多少。在 Node 中,JavaScript 可以随心所欲地访问本地文件,可以搭建 WebSocket 服务器端,可以连接数据库,可以如 Web Workers 一样玩转多进程。JavaScript 不再继续限制在浏览器中与 CSS 样式表、DOM 树打交道。Node 不处理 UI,但用与浏览器相同的机制和原理运行。Node 打破了过去 JavaScript 只能在浏览器中运行的局面。

image.png

Node 有什么特点?

  • 异步 I/O

JavaScript 是单线程的,我们基于 setTimeout、Promise、async/await 等实现异步操作。在 Node 中,作者在底层构建了很多异步 I/O 的 API,从文件读取到网络请求等,因此我们可以从语言层面很自然地进行并行 I/O 操作。

举个例子🌰:

const fs = require('fs') 
fs.readFile('/path', function (err, file) { 
  console.log('读取文件完成') 
})
console.log('开始读取文件')

这是异步的,会先输出“开始读取文件”,文件读取完成后输出“读取文件完成”。 image.png

  • 事件和回调函数 Node 将前端浏览器中应用广泛且成熟的事件引入后端,配合异步 I/O,将事件点暴露给业务逻辑,轻量级、松耦合。它将所有的任务都当作事件处理。

举个例子:当有 HTTP 请求过来时,HTTP 服务器会发出一个 request 事件。我们可以监听这个 request 事件,并添加一些响应逻辑。

const http = require('http') 
const querystring = require('querystring') 
// 监听服务器的request事件 
http.createServer((req, res) => { 
  let postData = '' 
  req.setEncoding('utf8') 
  // 监听请求的data事件 
  req.on('data', chunk => { 
    postData += chunk 
  }) 
  // 监听请求的end事件 
  req.on('end', () => { 
    res.end(postData) 
  }) 
}).listen(8080) console.log('服务启动完成')
  • 单线程 Node 保持了 JavaScript 在浏览器中单线程的特点,单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

单线程的缺点:无法利用多核 CPU、错误会引起整个应用退出、大量计算占用 CPU 导致无法继续调用异步 I/O。

在浏览器中,JavaScript 与 UI 共用一个线程,在 Node 中,CPU 占用和异步 I/O 也是共用一个线程,这就导致长时间的 CPU 占用会导致后续的异步 I/O 发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。

Node的解决思路:child_process,子进程。将计算发到各个子进程,通过进程之间的事件消息传递结果。

  • 基于 libuv 实现跨平台 image.png

Node的应用场景:

I/O 密集型(Node 利用事件循环处理)、分布式应用、CPU 密集型(需合理调度) image.png Node还能做一些用其他语言很难做到的事情。它是基于 JavaScript 的,所以在 Node 中能运行浏览器中的 JavaScript。复杂的客户端应用可以经过改造在 Node 服务器上运行,让服务器进行预渲染,从而加快页面在浏览器中的渲染速度,也有利于搜索引擎进行索引。

二、模块机制

最初的 JavaScript 先天缺乏【模块】,Java 有类文件,Python有 import 机制,Ruby 有 require, PHP 有 include 和 require。而 JavaScript 通过 <script> 标签引入代码的方式显得杂乱无章,语言自身毫无组织和约束能力。

直到 CommonJS 出现。

2.1、模块定义

CommonJS 对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。

  • 模块引用:require 方法
const math = require('math')
  • 模块定义:exports 对象

在 Node 中,一个文件就是一个模块,将方法挂载在 exports 对象上作为属性

// math.js 
exports.add = function(){}
  • 模块标识:传递给 require() 方法的参数

模块的意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅地连接上下游依赖。每个模块具有独立的空间,它们互不干扰,在引用时也显得干净利落。

image.png

2.2、模块实现

exports、require 和 module 听起来十分简单,那 Node 具体是怎么实现的呢?

在 Node 中引入模块,需要经历3个步骤:路径分析、文件定位、编译执行。

在 Node 中,模块分为 2 类:Node 提供的模块(核心模块)和用户编写的模块(文件模块):

  • 核心模块:在 Node 源代码编译时就被编译进了二进制执行文件,路径分析时会优先判断,文件定位和编译执行两步可以省略掉;常用的核心模块有:文件系统库(fs、path)、TCP 客户端和服务端库(net)、HTTP库(http 和 https)和域名解析库(dns)、用来写测试的断言库(assert),以及用来查询平台信息的操作系统库(os)等
  • 文件模块:在运行时动态加载

详细的加载过程🔎

1、优先从缓存加载

浏览器会缓存文件,而 Node 会缓存编译和执行后的对象。

2、路径分析和文件定位

模块标识符,也即 require 方法接收的参数,在 Node 中主要有以下几类:

  • 核心模块如 http、fs、path等(加载最快)
  • 以. 或 .. 开头的相对文件路径(次之)
  • 以 / 开头的绝对文件路径(次之)
  • 非路径形式的,如自定义的xxx模块(最慢,会按路径查找知道找到目标文件)

image.png

如果加载的模块不包含文件扩展名,Node 会按照.js --> .json --> .node 的顺序补足扩展名,此时需要调用 fs 模块判断文件是否存在,这里是同步阻塞式的,所以可能导致性能问题。小 tip:如果是.json 和 .node 扩展名,尽量在 require 时带上扩展名,会加快速度。

3、模块编译——以 JavaScript 模块为例

定位到具体的文件后,Node 会新建一个模块对象,根据路径载入并编译。

模块对象:

function Module(id, parent) { 
  this.id = id 
  this.exports = {} 
  this.parent = parent 
  if (parent && parent.children) { 
    parent.children.push(this) 
  } 
  this.filename = null 
  this.loading = false 
  this.children = [] 
}

每一个编译成功的模块都会将其文件路径作为索引缓存在 Module._cache 对象上,以提高二次引入的性能。

在编译过程中,Node 对获取的文件内容进行了头尾包装:

// 头部添加函数头 
(function (exports, require, module, __filename, __dirname) { 
  // ... 
  // 尾部添加
})

以实现作用域隔离。

2.3、包与 NPM 📦

在模块之外,包和 NPM 是将模块关联起来的一种机制。

image.png

符合 CommonJS 规范的包目录应该包含:

  • package.json -- 包描述文件

  • bin -- 存放可执行二进制文件

  • lib -- 存放 JavaScript 代码

  • doc -- 存放文档

  • test -- 存放单元测试用例

2.4、前后端共用模块

前后端 JavaScript 分别搁置在 HTTP 的两端,浏览器端的 JavaScript 需要经历从同一个服务器端分发到多个客户端执行,而服务器端 JavaScript 则是相同的代码需要多次执行。浏览器端的瓶颈在于带宽,服务器端的瓶颈则在于 CPU 和内存等资源。浏览器端需要通过网络加载代码,服务器端从磁盘中加载,两者的加载速度不在一个数量级上。

Node 的模块引入是同步的,因为服务器端从磁盘中加载资源,加载速度很快,同步没啥问题。然而这并不适合于浏览器端,因为同步加载的话用户体验会很差。后来,AMD 规范在前端应用场景中胜出。它的全称是 Asynchronous Module Definition,即是“异步模块定义”。

AMD 是 CommonJS 规范的延伸,它需要用 define 来明确定义一个模块,并通过返回的方式导出。举个例子:

define(
  function(){ 
    let exports = {} 
    exports.sayHello = function() { 
      console.log('Hello World!') 
    } 
    return exports 
  } 
)

区别在于,Node 所使用的 CommonJS 是隐式包装的,在编译的时候把定义的模块通过一个函数头包裹起来,而 AMD 这里是显式定义的。

三、异步IO和异步编程

image.png

我们知道计算设备分为 I/O 设备和计算设备,假设业务场景中有一组互不相关的任务需要完成,有两种方法:

  • 单线程串行依次执行:阻塞,性能不好
  • 多线程并行执行:多线程的代价在于创建线程和执行期间线程上下文切换的开销较大,如果创建多线程的开销小于并行执行的开销,那么【多线程并行执行】是首选的。而且多线程还有锁、状态同步等问题。但是多线程在多核 CPU 上能有效提升 CPU 的利用率。

Node 在两者之间提出了一个折衷方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好的使用 CPU;为了弥补单线程无法利用多核 CPU 的缺点,Node 提供了类似前端浏览器中 Web Workers 的子进程,该子进程可以通过工作进程高效地利用 CPU 和 I/O。Node 不会为每一个请求创建一个线程,而是让一个主线程来处理所有的请求。

异步 I/O 的提出是为了让 I/O 的调用不再阻塞后续运算,将原有等待 I/O 完成的这段时间分配给其余需要的业务去执行。

image.png

操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞。操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件。内核在进行文件 I/O 操作时,通过文件描述符进行管理,而文件描述符类似于应用程序与系统内核之间的凭证。应用程序如果需要进行 I/O 调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。此处非阻塞 I/O 与阻塞I/O 的区别在于阻塞 I/O 完成整个获取数据的过程,而非阻塞 I/O 则不带数据直接返回,要获取数据,还需要通过文件描述符再次读取。

任意技术都并非完美的。阻塞 I/O 造成 CPU 等待浪费,非阻塞带来的麻烦却是需要轮询去确认是否完全完成数据获取,它会让 CPU 处理状态判断,是对 CPU 资源的浪费。

操作系统的 I/O 实现有差异,所以 Node 提供了 libuv 作为抽象封装层做平台兼容性的判断(编译期间)。我们说的 Node 的单线程仅仅指 JavaScript 执行在单线程中,在操作系统中,内部完成 I/O 任务的另有线程池。

image.png

事件循环♻️

在进程启动时,Node 会创建一个类似于 while(true) 的循环,每执行一次循环体的过程称为一个 Tick 。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数执行。然后进入下个循环,如果不再有事件处理,就退出进程。

image.png

在每个 Tick 中,如何判断是否有事件需要处理呢?——观察者,事件循环中有一个或多个观察者,判断是否有事件需要处理就是向这些观察者询问。这个过程如同饭馆的厨房,厨房一轮一轮地制作菜肴,但是要具体制作哪些菜肴取决于收银台收到的客人的下单。厨房每做完一轮菜肴,就去问收银台的小妹,接下来有没有要做的菜,如果没有的话,就下班打烊了。在这个过程中,收银台的小妹就是观察者,她收到的客人点单就是关联的回调函数。当然,如果饭馆经营有方,它可能有多个收银员,就如同事件循环中有多个观察者一样。收到下单就是一个事件,一个观察者里可能有多个事件。

在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。观察者将事件进行了分类。

image.png

Node 异步 I/O 模型的基本要素:事件循环、观察者、请求对象、I/O 线程池。Node 在主线程里维护了一个事件队列, 当接到请求后,就将该请求作为一个事件放入这个队列中,然后继续接收其他请求。当主线程空闲时(没有请求接入时),就开始循环事件队列,检查队列中是否有要处理的事件,这时要分两种情况:如果是非 I/O 任务,就亲自处理,并通过回调函数返回到上层调用;如果是 I/O 任务,就从 线程池 中拿出一个线程来处理这个事件,并指定回调函数,然后继续循环队列中的其他事件。

当线程中的 I/O 任务完成以后,就执行指定的回调函数,并把这个完成的事件放到事件队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用。

JavaScript 是单线程的,所以按常识很容易理解为它不能充分利用多核 CPU。事实上,在 Node 中,除了 JavaScript 是单线程外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的I/O(磁盘I/O和网络I/O等)则是可以并行起来的。无论是 Linux 平台还是 Windows 平台,Node 内部都是通过 线程池 来完成异步 I/O 操作的,而 libuv 针对不同平台的差异性实现了统一调用。

简单模拟一下事件循环的过程:

// 事件队列
const globalEventQueue = []
// 处理请求,放入队列
const processHttpRequest = function(req, res) {
  // 定义事件对象,处理请求
  // ...
  const event = createEvent(req, result, cb)
  // 添加该事件到事件队列的尾部
  globalEventQueue.push(event)
}
// 定义事件循环
const eventLoop = function(){
  // 当事件队列不为空时,循环
  while (globalEventQueue.length > 0) {
    // 从队列头部取出事件
    let curEvent = globalEventQueue.shift()
    // 如果是耗时IO任务
    if (isIOTask(curEvent)) {
      // 从线程池拿出一个线程
      let thread = getThreadFromThreadPool()
      // 将任务交给该线程去处理
      thread.handleIOTask(curEvent)
    } else {
      // 直接处理
      let result = handleEvent(curEvent)
    }
  }
}
// thread 对象的 handleIOTask 方法
const handleIOTask = function(event){
  const curThread = this
  // 执行异步任务 ...
  // 执行完后,当前任务就不再是IO任务
  event.isIOTask = false
  // 将该事件重新添加到队列的尾部
  this.globalEventQueue.push(event)
  // 释放当前线程
  releaseThread(curThread)
}

看到这,得提一下 Node 是否适合 CPU 密集型场景?如果是 I/O 任务,Node 就把任务交给线程池来异步处理,高效简单,因此 Node 适合处理I/O密集型任务。但不是所有的任务都是 I/O 密集型任务,当碰到 CPU 密集型任务时,即只用 CPU 计算的操作,比如要对数据加解密(node.bcrypt.js),数据压缩和解压(node-tar)等,这时 Node 就会亲自处理,一个一个的计算,前面的任务没有执行完,后面的任务就只能干等着。看起来 Node 并不适合 CPU 密集型场景。但得益于 V8,Node 的计算能力还是挺优秀的,如果能够扩展(比如利用子进程)Node 的计算能力,还是可以处理 CPU 密集型场景的。

CPU密集不可怕,如何合理调度是诀窍。

非I/O的异步API♻️

setTimeout()、setInterval()、setImmediate()和process.nextTick()

调用setTimeout()setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中,每次 Tick 执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,执行回调。

image.png

想要立即执行一个异步任务,我们也许会这么做:

setTimeout(function(){ 
  // TODO 
}, 0)

这样存在的问题有:定时器的精确度不够,不一定能在我们期望的时间执行;采用定时器会要动用红黑树,创建定时器对象和迭代等操作;所以较为浪费性能;

一种更好的方式是:process.nextTick(),该方法会将回调放入队列中,在下一轮 Tick 时取出执行,时间复杂度为O(1),而采用定时器红黑树,时间复杂度为O(log(n)),相比之下process.nextTick()更轻量更高效。

process.nextTick(function(){ 
  // TODO 
})

process.nextTick()作用相同的还有setImmediate():

setImmediate(function(){ 
  // TODO 
})

那它和process.nextTick()共存时会怎么样呢?

process.nextTick(function(){
  console.log('nextTick')
})
setImmediate(function(){
  console.log('setImmediate')
})
console.log('正常执行')
// 会输出 正常执行 、 nextTick 、setImmediate

会先执行process.nextTickprocess.nextTick属于 idle 观察者,setImmediate 属于 check 观察者,每轮循环检查中,执行优先级为: idle 观察者 --> I/O 观察者 --> check 观察者。

具体实现上,process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中。在行为上,process.nextTick()在每轮循环中会将数组中的回调函数全部执行,setImmediate()在每轮循环中执行链表中的一个回调。

事件驱动和高性能服务器🤖️

Node 通过事件驱动的方式处理请求,无须为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这是 Node 高性能的一个原因。

Nginx 也摒弃了多线程方式,采用了和 Node 相同的事件驱动。Node 和 Nginx 具有相同的特性,不同之处在于,Nginx 采用纯 C 写成,性能较高,用于反向代理和负载均衡等业务,对于处理具体业务方面较为欠缺。Node 则是一个高性能的平台,它没有 Nginx 在 Web 服务器方面那么专业,但场景更大,自身性能也不错。所以应该选择适合 Node 的场景以发挥它的性能优势。

四、内存控制与理解 Buffer

在服务端,资源寸土寸金,要为海量用户服务,就得使一切资源都要高效循环利用。

V8的垃圾回收(GC)机制和内存限制🗑

Node 基于 V8 构建,所以 Node 中使用的 JavaScript 对象基本上都是通过 V8 自己的方式来进行分配和管理的。V8 的内存管理机制在浏览器端用起来绰绰有余,但是 Node 中却限制了开发者。

V8中 JavaScript 对象都是通过【堆】进行分配的:

image.png

为何 V8 要限制堆的大小?表层原因是 V8 最初为浏览器而设计,不太可能遇到大量内存的场景。深层原因是垃圾回收机制的限制,因为垃圾回收也是耗时的,会引起 JavaScript 线程暂停执行,导致应用性能和响应能力下降,所以当时直接限制了堆内存。

Node中查看进程的内存占用: process.memoryUsage()

Buffer结构🔵

Buffer 是类似于 Array 的对象,但它主要用于操作字节(专门存放二进制数据的缓存区),是 JS 和 C++ 的结合,性能相关部分用 C++ 实现,非性能相关部分用 JavaScript 实现。Buffer 不等于 字符串,他们之间存在编码关系。

image.png

Buffer 在 Node 启动时就已经加载并放在了全局对象 global 上,无须 require 引入。

let buf = new Buffer(100) 
console.log(buf.length) // 100 
buf[10] = 100 
console.log(buf[10]) // 100

Buffer 对象的内存是对应于 V8 堆内存之外的一块原始内存,是在 Node 的 C++ 层面申请的,在 JS 中分配的。

五、网络编程

在 Web 领域,大多数的编程语言需要专门的 Web 服务器作为容器,如 ASP、ASP.NET 需要 IIS 作为服务器,PHP 需要搭载 Apache 或 Nginx 环境等,JSP 需要 Tomcat 服务器等。

但对于 Node 而言,只需要几行代码即可构建服务器,无需额外的容器。Node 提供了 net、dgram、http、https 这4个模块,分别用于处理 TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。

image.png

5.1、TCP

传输层协议,面向连接,需要三次握手四次挥手,在创建会话的过程中,服务器端和客户端分别提供一个套接字,这两个套接字共同形成一个连接。服务器端与客户端则通过套接字实现两者之间连接的操作。

创建 TCP 服务器端:

const net = require('net')
// 创建TCP服务器
const server = net.createServer(socket => {
  // 新的连接
  socket.on('data', data => {
    socket.write('Hello')
  })
  socket.on('end', () => {
    console.log('连接断开')
  })
 socket.write('Hello')
})
// 监听
server.listen(8124, () => {
  console.log('server bound')
})

通过 net.createServer 创建的服务器是一个 EventEmitter 实例,它的自定义服务器事件有如下几种:

  • listen
  • connection
  • close
  • error

连接事件有:

  • data
  • end
  • connect
  • error
  • close
  • timeout

5.2、UDP

用户数据包协议,不是面向连接的,无需三次握手四次挥手,一个套接字可以和多个 UDP 服务通信,网络差时会丢包严重。DNS 服务是基于 UDP 实现的。

创建 UDP 套接字:

const dgram = require('dgram')
const socket = dgram.createSocket('udp4')

创建 UDP 服务器端:

const dgram = require('dgram')
const server = dgram.createSocket('udp4')
server.on('message', (msg, rinfo) => {
  console.log('server got' + msg + 'from' + rinfo.address)
})
server.on('listening', () => {// TODO})
// 绑定端口
server.bind(41234)

5.3、HTTP

Node 的 http 模块包含对 http 处理的封装。http 模块将连接所用套接字的读写抽象为 ServerRequest 和 ServerResponse 对象,它们分别对应请求和响应操作。在请求产生的过程中,http 模块拿到连接中传来的数据,调用二进制模块 http_parser 进行解析,在解析完请求报文的报头后,触发 request 事件,调用用户的业务逻辑。

image.png

image.png

5.4、WebSocket

WebSocket 实现了客户端与服务器端之间的长连接,且只建立一个 TCP 连接,WebSocket 服务器端可以推送数据到客户端,这远比 HTTP 请求响应模式更灵活、更高效。有更轻量级的协议头,减少数据传送量。

比如在客户端:

const socket = new WebSocket('wss://127.0.0.1/login')
socket.onopen = () => {
    // 每50毫秒向服务器端发送一次数据
    setInterval(func, 50)
}
//接收服务器传来的参数
socket.onmessage = event => {
    console.log(event.data)
}

具体项目中的例子:

// websocket连接
initWebSocket() {
    if (typeof WebSocket === 'undefined') {
      this.$message({ message: '您的浏览器不支持WebSocket' })
      return false
    }
    this.aliveTime = new Date().getTime()
    const token = this.token.split('Bearer ')[1]
    const wsurl = `wss://${process.env.VUE_APP_DOMAIN}/best?token=` + token
    this.socket = new WebSocket(wsurl)
    this.socket.onmessage = this.websocketonmessage
    this.socket.onerror = this.websocketonerror
    this.socket.onclose = this.websocketonclose
    // 检测webSocket连接是否断开
    this.checkTimer = setInterval(this.checkWebsocketAlive, 5 * 1000)
}

WebSocket 是在 TCP 上定义独立的协议,并没有在 HTTP 的基础上模拟服务器端的推送。WebSocket 协议主要分为两个部分:

  • 握手🤝:通过 HTTP 发起请求报文,主要有个【upgrade: websocket;Connection: Upgrade】字段,表示将协议升级为 WebSocket;一旦 WebSocket 握手成功,服务器端与客户端将会呈现对等的效果,都能接收和发送消息。
  • 数据传输🚛:握手完成后,当前连接将不再进行 HTTP 的交互,而是开始 WebSocket 的数据帧协议,进行数据交换。触发执行opopen()。

image.png

5.5、网络服务与安全🔐

Node 在网络安全上提供了3个模块,分别为 crypto、tls、https。其中 crypto 主要用于加密解密,SHA1、MD5 等加密算法都在其中有体现。真正用于网络的是另外两个模块,tls 模块提供了与 net 模块类似的功能,区别在于它建立在 TLS/SSL 加密的 TCP 连接上。对于 https 而言,它完全与 http 模块接口一致,区别也仅在于它建立于安全的连接之上,HTTPS是工作在TLS/SSL上的HTTP。

TLS/SSL 是非对称的公钥/私钥结构:

image.png

为了避免中间人攻击,TLS/SSL引入了数字证书认证密钥,数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系。数字证书由第三方CA颁发。

六、构建Web应用

从 http 模块中服务器端的 request 事件开始分析。

request 事件发生于【网络连接建立,客户端向服务器端发送报文,服务器端解析报文,发现 HTTP 请求的报头】时。在已触发 reqeust 事件前,它已准备好 ServerRequest 和 ServerResponse 对象以供对请求和响应报文的操作。

const http = require('http')
http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'})
  res.end('Hello World \n')
}).listen(1337, '127.0.0.1')
console.log('Server is running at http://127.0.0.1:1337/')

在具体的业务中,可能还会有进一步的需求:

  • 请求方法的判断
  • URL 的路径解析
  • URL 中查询字符串解析
  • cookie 的解析
  • Basic 认证
  • 表单数据解析
  • 任意格式文件的处理

这一切的一切,都要从这里展开:

function handleRequest(req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'})
  res.end('')
}

6.1、请求方法

HTTP_Parser 在解析请求报文时,会将报文头抽取出来,设置为 req.method 。PUT 代表新建一个资源,POST 表示要更新一个资源,GET 表示查看一个资源,而 DELETE 表示删除一个资源。

可以根据请求方法来决定如何响应:

function handleReqMethod(req, res) {
    switch (req.method) {
        case 'GET':
            get(req, res)
            break
        case 'POST':
            update(req, res)
            break
        case 'PUT':
            create(req, res)   
            break
        case 'DELETE':
           delete(req, res)    
           break
        default:
           get(rea, res)   
    }
}

6.2、路径解析

HTTP_Parser 在解析请求报文时,会将路径抽取出来,设置为 req.url 。常见的根据路径进行业务处理的应用时静态文件服务器,它会根据路径查找磁盘中的文件,然后响应给客户端。

function parsePath(req, res) {
    const pathname = url.parse(req.url).pathname
    fs.readFile(path.join(ROOT, pathname), (err, file) => {
      if (err) {
        res.writeHead(404)
        res.end('找不到文件-_-')
        return   
      }
      res.writeHead(200)
     res.end(file)
    })
}

6.3、查询字符串

查询字符串位于路径之后,在地址栏路径的 ? 之后,query 的部分。Node提供了 querystring 模块用于处理这部分。

const url = require('url') 
const querystring = require('querystring') 
const query = querystring.parse(url.parse(req.url).query)

6.4、Cookie

HTTP 是无状态的,Cookie 是最早用于标识和认证用于的方案,Cookie 的处理分为以下几步:

  1. 服务器端向客户端发送 cookie
  2. 浏览器将 cookie 保存
  3. 之后浏览器发送请求都会带上 cookie 一起发送给服务器端

Http_Parser 会将所有的报文字端解析到 req.headers 上,cookie 就在req.headers.cookie。cookie 是 key=value;key2=value2 形式的,解析起来也比较容易:

const parseCookie = cookie => {
    let cookies = {}
    if(!cookie) return cookies
    let list = cookie.split(';')
    list.forEach(item => {
      let pair = item.split('=')
      cookies[pair[0].trim()] = pair[1]
    })
    return cookies
}

如果 cookie 设置的过多,会导致浏览器每次请求携带的报头较大,如果 cookie 不是每次都需要,那就造成带宽的浪费了。而且 cookie 可以在前后端修改(如果设置 HttpOnly 的话前端无法修改),有安全隐患。

为了解决 cookie 数据敏感的问题,Session 应运而生。Session 的数据只保留在服务器端,客户端无法修改,这样数据的安全性得到一定的保障,数据也无须在协议中每次都被传递。

怎么实现呢?

方法一:基于 cookie 实现用户和数据的映射,在 cookie 中存一个【口令】,请求到来时,根据 req.cookies 生成 req.session;禁用 cookie 的话这种方法就不行了。

其余的可以再去了解。

为了解决性能问题(Session 都存在内存中)和 Session 数据无法跨进程共享的问题,常用的方案是将 Session 集中化,将原本可能分散在多个进程里的数据,统一转移到集中的数据存储中。目前常用的第三方缓存工具是 Redis、Memcached 等,通过这些高效的缓存,Node 进程无须在内部维护数据对象,垃圾回收问题和内存限制问题都可以迎刃而解,并且这些高速缓存设计的缓存过期策略更合理更高效,比在 Node 中自行设计缓存策略更好。

尽管采用专门的缓存服务会比直接在内存中访问慢(因为需要请求去获取),但其影响小之又小,带来的好处却远远大于直接在 Node 中保存数据。

缓存:

  • 强缓存:HTTP1.0: Expires (服务器端设置)HTTP1.1: Cache-Control
'Cache-Control' : max-age = 1000 * 60 
// 为Cache-Control设置了max-age值 
// 它比Expires优秀的地方在于 
// Cache-Control能够避免浏览器端与服务器端时间不同步带来的不一致性问题 
// 只要进行类似倒计时的方式计算过期时间即可 
// 除此之外,Cache-Control的值还能设置public、private、no-cache、no-store等能够更精细地控制缓存的选项。
  • 协商缓存:HTTP1.0: If-Modified-Since/Last-Modified HTTP1.1: If-None-Match/ETag

第一次请求服务器生成并返回一个 Etag,后续请求客户端带上 If-None-Match 头,具体的值就是服务器返回的 Etag 的值。HTTP1.0 的同。

缓存更新:根据文件内容的 hash 值进行缓存淘汰会更加高效,因为文件内容不一定随着 Web 应用的版本而更新,而内容没有更新时,版本号的改动导致的更新毫无意义,因此以文件内容形成的 hash 值更精准。

6.5、Node web 实例

文件夹📁:

❑ package.json—— 一个包含依赖项列表和运行这个程序的命令的文件;

❑ public/—— 静态资源文件夹,CSS和客户端JavaScript都放在这里;

❑ node_modules/——项目的依赖项都会装到这里;

❑ 放程序代码的一个或多个JavaScript文件。

程序代码一般又会分成下面几块:

❑ app.js或index.js——设置程序的代码;

❑ models/——数据库模型;

❑ views/——用来渲染页面的模板;

❑ controllers/ 或routes/——HTTP请求处理器;

❑ middleware/——中间件组件。

1. 创建项目,在终端输入:
mkdir node-project-01 
cd node-project-01 
npm init -y 
// -y 表示 yes
2. 使用更为便捷的express框架,安装:
npm install express --save

安装成功后,package.json 中会看到:

"dependencies": { 
  "express": "^4.17.1" 
}
3. 启动一个简单的服务器:
// index.js
const express = require('express')
const app = express()
const port = process.env.PORT || 3000
app.get('/', (req, res) => {
    res.send('Hello World!!!')
})
app.listen(port, () => {
    console.log('express服务启动起来了!')
})

终端执行:

node index.js

image.png

此时访问 http://localhost:3000 👇

image.png

OK!DONE!

我们把启动服务的命令写在script脚本里:

"scripts": { 
  "start": "node index.js" 
}

以后直接 npm start 就好了。

4. RESTful API

比如我们想搞个文章管理的功能,可能会用到以下接口:

❑ POST /createArticles——创建新文章;

❑ GET /getArticles/:id——获取指定文章;

❑ GET /getArticles——获取所有文章;

❑ DELETE /articles/:id——删除指定文章;

const express = require('express')
const app = express()
const port = process.env.PORT || 3000
app.get('/', (req, res) => {
    res.send('Hello World!!!')
})

const articles = [{ title: 'example001'}]
// 获取文章
app.get('/getArticles', (req, res, next) => {
    // 发送响应数据
    res.send(articles)
})

// 创建文章
app.post('/createArticles', (req, res, next) => {
    // 发送响应数据
    res.send('创建成功')
})
// 获取指定文章
app.get('/getArticles/:id', (req, res, next) => {
    const id = req.params.id
    res.send(articles[id])
})
// 删除指定文章
app.delete('/getArticles/:id', (req, res, next) => {
    const id = req.params.id
    delete articles[id]
    res.send('删除成功')
})

app.listen(port, () => {
    console.log('express服务启动起来了!')
})

此时访问http://localhost:3000/getArticles 👇

image.png

API 已经初步实现了,但此时还不能实现创建文章,因为POST请求需要解析请求体,先安装一下:

npm install --save body-parser

使用:

const bodyParser = require('body-parser')
// 支持编码为JSON的请求消息体
app.use(bodyParser.json())
// 支持编码为表单的请求消息体
app.use(bodyParser.urlencoded({extended: true}))

// 创建文章
app.post('/createArticles', (req, res, next) => {
    const article = { title: req.body.title}
    articles.push(article)
    res.send(articles)
})

这样就能实现创建文章了,试一下:

curl --data "title=createTest" http://localhost:3000/createArticles

image.png 创建成功!

PS:curl 是客户端(client)的 URL 工具,用来请求 Web 服务器的命令行工具。

接下来还可以创建数据库存储数据,都是实战型的,这里就过了。

七、玩转进程

Web 服务器架构的变迁:

同步(一次只为一个请求服务)--> 复制进程(每个连接需要一个进程,100个连接需要启动100个进程) --> 多线程(一个线程服务一个请求,线程相对进程的开销要小很多,且线程之间可以共享数据;但大并发时性能不行)--> 事件驱动(Node 与 Nginx)

一个 Node 进程只能利用一个核,那如何充分利用多核 CPU 服务器呢?Node 执行在单线程上,一旦单线程上的某个错误没有被捕获,将引起整个进程的崩溃,如何保证进程的健壮性与稳定性?

多进程架构

Node 提供了 child_process 模块,child_process.fork() 函数供我们实现进程的复制。

image.png

通过 fork 复制的进程都是独立的。对于 child_process 模块,创建好了子进程,然后父子进程之间通信是很容易的。

// parent.js
const cp = require('child_process')
// fork子进程,父子进程之间将会创建IPC通道,可以传递消息
const n = cp.fork(__dirname + '/xxx.js')
n.on('message', m => {
  console.log('parent got message', m)
})
n.send({hello: 'world'})

//xxx.js
process.on('message', m => {
  console.log('child got message', m)
})
process.send({xxx: 'yyy'})

IPC通信(进程间通信)

使不同进程能够互相访问资源,协调工作。实现进程间通信的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket 等。

Node 中实现 IPC 通道的是管道(pipe)技术。具体细节由 libuv 提供。

image.png

父进程在实际创建子进程之前,会创建 IPC 通道并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FD)告诉子进程这个 IPC 通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的 IPC 通道,从而完成父子进程之间的连接。

image.png

IPC 通道是用命名管道或 Domain Socket 创建的,它们与网络 socket 的行为比较类似,属于双向通信。不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。在 Node 中,IPC 通道被抽象为 Stream 对象,在调用 send() 时发送数据(类似于write()),接收到的消息会通过 message 事件(类似于 data)触发给应用层。

注意:只有启动的子进程是 Node 进程时,子进程才会根据环境变量去连接 IPC 通道,对于其他类型的子进程则无法实现进程间通信,除非其他进程也按约定去连接这个已经创建好的 IPC 通道。

句柄传递

——WHY?

一个端口只能被一个进程监听,其余进程也想监听同一端口时会报错。通常的解决方法是让主进程监听主端口,主进程对外接受所有的网络请求,再将这些请求分别代理到不同端口的进程上,通过代理,可以避免端口不能重复监听的问题,也可以做负载均衡。

由于进程每接收一个连接都会用掉一个文件描述符,因此上述代理方案中客户端连接到代理进程、代理进程连接到工作进程会用掉两个文件描述符。OS 中文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。

为了解决此问题,Node 引入了进程间发送句柄的功能。

——WHAT?

什么是句柄?句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务器端 socket 对象、一个客户端 socket 对象、一个 UDP 套接字、一个管道等。

child.send(message, [sendHandle]) // 第二个可选参数就是句柄

发送句柄意味着什么?在主进程接收到请求之后,将请求直接发送给工作进程,而不用重新与工作进程之间建立新的连接来转发数据。

——HOW?

举个例子:

// 主进程文件
const child = require('child_process').fork('child.js')
// 建立server对象并发送句柄
const server = require('net').createServer()
server.on('connection', socker => {
  socket.end('主进程')
})
// 这里句柄就是server对象
server.listen(1337, () => {
  // 将请求交给子进程处理
  child.send('server', server)
  // 发送给子进程之后关闭server,不然server还在监听1337端口
  server.close()
})

// 子进程文件
process.on('message', (m, server) => {
  server.on('connection', socket => {
    socket.end('子进程')
  })
})

子进程对象 send 方法中可以发送的句柄类型包括:

  • net.Socket TCP套接字
  • net.Server TCP服务器
  • net.Native C++层面的TCP套接字或IPC管道
  • dgram.Socket UDP套接字
  • dGram.Native C++层面的UDP套接字

send()方法能发送消息和句柄并不意味着它能发送任意对象。

image.png

Node进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。

为何发送句柄后多个进程可以监听同一端口?独立启动的进程中,TCP 服务器端 socket 套接字的文件描述符并不相同,导致监听到相同的端口时会抛出异常。使用句柄后,其实是复用了服务器端的套接字

多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务。这些进程服务是抢占式的。

集群稳定之路

有了多进程架构,我们就可以搭建集群,充分利用多核 CPU 资源了。

主进程管理子进程:

// master.js
const fork = require('child_process').fork
// Node内置模块os,cpus可以查看cpu信息
const cpus = require('os').cpus()
const server = require('net').createServer()
server.listen(1337)
const workers = {}
const createWorker = () => {
    let worker = fork(__dirname + '/worker.js')
    // 监听worker的退出事件
    worker.on('exit', () => {
      console.log('worker' + wprker.pid + '退出了')
      // 重新启动新的进程
      createWorker()
    })
    //  句柄转发
    worker.send('server', server)
    workers[worker.pid] = worker
}
for (let i=0; i<cpus.length; i++) {
    createWorker()
}
// 如果主进程退出,让所有工作进程退出
process.on('exit', () => {
  for (let pid in workers) {
      // kill 杀死(退出)进程
      workers[pid].kill()
  }
})

负载均衡

在多进程之间监听相同的端口,使得用户请求能够分散到多个进程上进行处理,这带来的好处是可以将 CPU 资源都调用起来。这犹如饭店将客人的点单分发给多个厨师进行餐点制作。既然涉及多个厨师共同处理所有菜单,那么保证每个厨师的工作量是一门学问,既不能让一些厨师忙不过来,也不能让一些厨师闲着,这种保证多个处理单元工作量公平的策略叫负载均衡。

Node 默认的策略是采用操作系统(OS)的抢占式策略,空闲的进程抢请求,谁抢到谁服务。对 Node 而言,这个【空闲】得分 CPU 和 I/O 两部分,对不同的业务,可能存在 I/O 繁忙,而 CPU 较为空闲的情况,这可能造成某个进程能够抢到较多请求,形成负载不均衡的情况。

为此 Node 提供了一种新的负载均衡策略——轮叫调度:由主进程接受连接,将其依次分发给工作进程。分发的策略是在 N 个工作进程中,每次选择第 i = (i + 1) mod n 个进程来发送连接。

Cluster模块

Node 创建单机集群必须通过 child_process 实现,实现起来有很多细节需要处理,于是 v0.8 引入了 Cluster 模块,以解决多核 CPU 的利用率问题。

基于 Cluster 创建 Node 进程集群:

// cluster.js
const cluster = require('cluster')
// 创建子进程
cluster.setupMaster({
  exec: 'worker.js'
})
const cpus = require('os').cpus()
for (let i=0; i<cpus.length; i++) {
    cluster.fork()
}

判断是主进程还是工作进程:

// 工作进程 
cluster.isWorker = ('NODE_UNIQUE_ID' in process.env) 
// 主进程 
cluster.isMaster = (cluster.isWorker === false)

实际上 Cluster 模块就是 child_process 和 net 模块的组合应用。