NodeJS基础

199 阅读33分钟

第一章 Node简介

1. Node定义

Node.js(简称Node)是一个基于Chrome V8 引擎的JavaScript运行时环境,它能够让JavaScript脚本运行在服务端,这使得JavaScript成为与PHP、Python等服务端语言平起平坐的脚本语言。

Node主要由标准库、中间层和底层库3部分组成,其框架如图1.1所示。

  • 标准库:提供了开发人员能够直接进行调用的API,如http模块、stream模块、fs模块等,可以直接使用JavaScript代码调用;
  • 中间层:由于Node底层库采用C/C++实现,标准库中的JavaScript代码无法直接与C/C++进行通信,因此提供了中间层,它在标准库和底层库之间起到桥梁的作用,它封装了底层库中V8引擎和libuv等实现细节,并向标准库提供了基础API服务
  • 底层库:底层库是Node运行的关键,它由C/C++实现,包括V8引擎、libuv、C-ares、OpenSSL、zlib等;

2. Node特点

  1. 异步I/O

Node中的大多数操作都是以异步的方式进行调用的,如Ajax请求、fs文件读取等。

  1. 事件与回调函数

事件的编程方式具有轻量级、松耦合、只关注事务点等优势,可以通过回调函数接受异步调用返回的数据。

$.ajax({
  'url':'/url',
  'method':'get',
  'data':{},
  success:function(data){},
  error:function(error){}
})
  1. 单线程

在Node中,JavaScript是单线程运行的,与其他线程是无法共享任何状态的。

单线程有很多缺点,如无法利用多核CPU、发生错误会使整个程序退出、大量计算占用CPU时无法调用异步I/O。但是单线程也有很明显的优势,即不用像多线程编程那样处理状态同步问题,它既没有死锁的存在,也没有线程上下文交换带来的性能损耗问题。

  1. 跨平台

Node V0.6.0及之后的版本,在操作系统和Node上层模块之间加入了中间层libuv,使其不仅支持Linux,还支持了Windows。

3. Node与浏览器

Node与浏览器的功能类似,他们都是基于事件驱动的异步架构。浏览器通过事件驱动来服务界面上的交互,Node通过事件驱动来服务I/O。Node可以使JavaScript运行在任何环境中,而并非局限于浏览器。

4. Node应用场景

  1. I/O密集型

定义:在大部分时间里,任务处于等待 I/O 操作完成的状态,例如等待文件读写、数据库查询、网络通信等

特点:

  • CPU 使用率相对较低,因为大部分时间任务在等待外部资源
  • 通常涉及异步操作,如使用回调函数或异步编程模型。

Node优化I/O密集型:利用事件循环优化I/O密集型任务的性能,并非为一个请求任务开辟一个线程,所以资源占用极少。

  1. CPU 密集型

定义:在大部分时间里,任务在进行计算密集型的操作,例如大量的数学运算、图像处理等

特点:

  • CPU 使用率相对较高,因为任务主要依赖 CPU 进行计算
  • 通常没有太多的 I/O 操作,或者 I/O 操作相对较短

Node优化CPU密集型:利用与Web Workers相同的思路解决大量计算的性能问题,引入了child_process子进程,将大量计算分发到各个子进程,再通过进程之间的事件消息传递结果。

  1. 区分I/O密集型和CPU密集型

观察任务的行为: 如果任务大部分时间都在等待外部资源(如文件、数据库、网络等),那么它更可能是 I/O 密集型。如果任务主要在进行复杂的计算,那么它更可能是 CPU 密集型。

性能分析工具: 使用性能分析工具来观察任务的 CPU 使用率和 I/O 操作等。例如,操作系统提供的性能监控工具、编程语言自带的性能分析工具,或者第三方工具如 VisualVM、Profiling 工具等。

代码结构: 查看任务的代码结构,如果任务主要是计算和逻辑运算,那么它可能是 CPU 密集型。如果任务主要包含 I/O 操作,那么它可能是 I/O 密集型。

需要注意的是,一些任务可能是同时具有 I/O 密集型和 CPU 密集型的特点,具体取决于任务的具体实现和执行上下文。

第二章 模块机制

1. CommonJS规范

CommonJS规范为JavaScript制定了一个美好的愿景——希望JavaScript能够在任何地方运行。

CommonJS规范出现之前,JavaScript在后端运行时存在很多缺陷,主要有以下几点:

  • 没有模块系统
  • 标准库较少
  • 没有标准接口
  • 缺乏包管理系统

为了弥补以上缺陷,CommonJS规范应运而生。

CommonJS规范包含了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、Web服务器网关接口、包管理等。

W3C组织、CommonJS规范、ECMAScript共同组成了JavaScript繁荣的生态系统。

2. CommonJS模块规范

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

  1. 模块引用
var math = require('math');
  1. 模块定义
exports.add = function(){};

exports方法是唯一的出口,负责导出模块的方法或者变量。在Node中,一个文件就是一个模块,将方法挂载到exports对象上作为属性即可定义导出的方式。

  1. 模块标识

模块标识指的是传递给require方法的参数,它可以是满足小驼峰命名的字符串,也可以是以“.”或者“..”开头的相对路径,还可以是绝对路径。模块标识支持不添加文件后缀名。

3. Node的模块实现

  1. 模块实现流程

在Node中引入模块需要经历3个步骤。

第一步:路径分析

第二步:文件定位

第三步:编译执行

1)路径分析和文件定位

根据模块标识符及模块路径定位策略,在系统中定位文件资源。

模块路径是Node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。其生成规则与变量作用域链的查找规则类似,先在当前文件目录下的node_modules目录查找,没有的话就去父目录下的node_modules目录查找,没有的话,就去父目录的父目录下的node_modules目录查找,沿路径向上逐级递归,一直找到根目录的node_modules为止。

2) 编译执行

Node会新建一个模块对象,然后根据路径载入并编译。对不同扩展名的文件,其载入方式也不相同。

  • js文件:通过FS模块同步读取文件后编译执行
  • node文件:通过dlopen加载最后编译生成的文件
  • json文件:通过FS模块同步读取文件,用JSON.parse解析返回结果
  • 其他文件:都被当作js文件载入

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

其中JavaScript模块的编译中,Node对获取的JavaScript文件进行了收尾包装。在头部添加了

(function(exports,require,module,__filename,__dirname){

在尾部添加了

})

使得一个正常的JavaScript文件被包装成了一下样子,每个模块都进行了作用域隔离,Node执行之后,模块的exports属性被返回给了调用方。

(function(exports,require,module,__filename,__dirname){
   var math = require('math');
   exports.add = function(){};
})

Node在启动时,会生成一个全局变量process,并提供Binding方法来协调加载内建模块。

  1. 模块分类

在Node中,模块大致分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

核心模块在Node源代码的编译过程中,被编译进了二进制执行文件。Node进程启动后,部分核心模块就被直接加载到了内存中,所以这部分核心模块引入时,文件定位、编译执行两个步骤可以直接省略。而且这部分核心模块的加载速度是最快的。

文件模块是在运行时动态加载的,需要运行完整的3步流程,即路径分析、文件定位、编译执行过程,速度比核心模块慢。

在核心模块中,有些模块全部由C/C++编写,通常不被用户直接调用,所以这部分模块被称为内建模块。

  1. 模块调用栈

C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用。

JavaScript核心模块主要扮演的职责有两类:作为C/C++内建模块的封装层和桥接层,供文件模块调用;作为纯粹的功能模块。

文件模块由第三方编写,包括普通的JavaScript模块和C/C++扩展模块,主要调用方向为普通JavaScript模块调用扩展模块。

  1. 优先从缓存加载

与浏览器会缓存静态脚本文件类似,Node对引入的模块也会进行缓存,一减少二次引入时的开销。不同的是浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。

从缓存加载的模块不会执行路径分析、文件定位、编译执行三个过程。

4. 包和NPM

包和NPM将模块联系起来的一种机制。

包的出现是在模块的基础上进一步组织JavaScript代码。

包描述文件中的关键属性:

  • dependencies:使用当前包需要依赖的包列表
  • scripts:脚本说明对象,被包管理器用来安装、编译、测试的卸载包
  • main:模块引入的检查入口文件
  1. NPM

借助NPM安装和管理依赖包,使得Node与第三方模块之间形成了很好的一个生态系统。

NPM常用命令整理:

  • npm -v:查看NPM版本
  • npm install:安装依赖包
  • npm install -g:全局模式安装
  • npm install <tarball file>:从本地安装
  • npm install underscore --registry=http://registry.url:从非官方源安装,例如淘宝镜像
  • npm publish <folder>:发布包
  • npm ls:分析包,如包的具体路径

NMP钩子命令:package.json中的script字段中的属性是为了让包在安装或者卸载过程中执行钩子函数

第三章 异步I/O

1. 为什么用异步I/O

从“用户体验”角度出发,JavaScript与UI渲染共用一个单线程,JavaScript在执行时UI渲染和交互响应处于停滞状态,用户感知到的是页面卡顿。如果采用异步I/O,在下载资源期间,JavaScript与UI交互行为都不会处于等待状态,用户可以继续操作页面。

从“资源分配”角度出发,在计算机资源中,通常I/O与CPU计算之间是可以并行进行的,但是同步的编程模式导致I/O进行时,后续任务会等待I/O运行,造成资源浪费。采用异步I/O,可以使单线程远离阻塞,更好的利用CPU。

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

2. 异步I/O与非阻塞I/O

阻塞I/O:阻塞I/O调用之后,应用程序需要等待I/O完成才能返回结果。其特点是调用之后一定要等到系统内核层面完成所有操作后,调用才会结束。CPU会等待I/O操作。

非阻塞I/O:非阻塞I/O调用之后,立即返回,但是不会返回具体数据结果,要想获取数据,需要通过文件描述符再次读取。CPU不会等到I/O操作。

在非阻塞I/O中获取完整数据,可以通过轮询实现:

  • read:重复调用检查I/O状态完成完整数据的读取
  • select:判断文件描述符上的事件状态,获取完整数据,对多只能检查1024个文件描述符
  • poll:采用链表方式取代数组1024长度限制
  • epoll:采用事件通知、执行回调方式获取完整数据

3. Node的异步I/O

  1. 异步I/O流程

第一环:事件循环

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

第二环:观察者

事件循环中每一个事件都会有一个对应的观察者,负责判断是否还有关联的事件需要处理。在每个事件循环中可以有一个或多个观察者。

第三环:请求对象

请求对象是异步I/O过程中的重要中间产物,所有的状态都会保存在这个对象中,包括送入线程池等待执行以及I/O操作完成后的回调函数。

第四环:执行回调

组装好请求对象、送入I/O线程池,实际上完成了异步I/O的第一部分,回调通知是第二部分。

线程池中的I/O操作完成后,会将获取的结果存储在req->result属性上,然后调用PostQueuedCompletionStatus通知IOCP,告知当前对象已经完成。

在Node中除了JavaScript是单线程外,其实还有很多多线程,只是I/O线程使用的CPU较少。

除了用户代码不能并行外,所有的I/O线程均可以并行起来。

  1. 非I/O的异步API
  • 定时器setTimeout、setInterval:定时器会被插入到定时器观察者内部的红黑树中,每次Tick执行时,会从红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数会被立刻执行
  • process.nextTick():可以实现立即异步执行一个任务。每次调用只会将回调函数放入队列中,在一轮Tick时取出执行,优先级高于setImmediate
  • setImmediate():延迟执行一个回调函数,优先级低于process.nextTick

process.nextTick在每一轮循环中会将数组中的回调函数全部执行完,setImmediate()在每一轮循环中执行链表中的一个回调函数。

4. Node的事件驱动

Node中的执行顺序为微任务 > I/O观察者 > 宏任务,以此来保证每轮循环能够快速执行完毕,防止CPU占用过多阻塞后续I/O调用。

Node的这种执行方式被称为事件驱动,其内部将I/O操作作为事件响应,而不是阻塞操作,从而实现了事件函数的快速执行与错误处理。由于Node能够采用异步非阻塞的方式访问文件系统、数据库、网络等外部资源,因此,它能够高效的处理海量并发请求,极大的提高了应用程序的吞吐量。

事件驱动的本质是“主循环+事件触发” 。

第四章 异步编程

1. 函数式编程

  1. 高阶函数

高阶函数的定义是把函数作为参数或者将函数作为函数返回值。

function foo(x){
   return function(){
      return x;
   }
}
  1. 偏函数

指定部分参数来产生一个新的定制函数

var isType = function(type){
   return function(obj){
      return Object.toString.call(obj) === '[object' + type + ']';
   }
}

2. 异步编程难点

  1. 异常问题捕获和处理
  2. 函数嵌套层级太深
  3. 阻塞代码
  4. 多线程编程
  5. 异步转同步困难

3. 异步编程解决方案

  1. 事件发布/订阅模式

Node自身提供的event模块,是发布/订阅模式的一个简单实现。

// 订阅
emitter.on('event1',function(message){});

// 发布
emitter.emit('event1','I am message');
  1. Promise/Deferred模式

  2. 流程控制库(async、Step、EventProxy、wind)

第五章 内存控制

1. V8垃圾回收机制

  1. 内存限制

在Node中运行JavaScript时,只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB),在这样的内存限制下,导致Node无法直接操作大内存对象。

出现这种现象的原因是“Node基于V8构建而成”,在Node中使用的JavaScript都是通过V8自己的方式来进行内存分配和管理的。V8的管理机制对于浏览器来说足够使用,但是对于Node来说,则会限制其很多操作。

  1. V8的对象分配

在V8中,所有的JavaScript对象都是通过堆来进行内存分配的,而且V8考虑到运行环境和垃圾回收等原因会限制堆的大小。

  1. 垃圾回收机制

V8的垃圾回收策略主要是基于分代式垃圾回收机制。将内存分为新生代和老生代,新生代的对象存活时间较短,老生代的对象存活时间较长。

  • --max-old-space-size命令行参数可以用于设置老生代内存空间的最大值
  • --max-new-space-size命令行参数用于设置新生代内存空间的大小

两个参数需要在项目启动时指定,无法根据具体使用情况作调整。

  1. 新生代内存的Scavenge算法

Scavenge算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,一个处于使用中的空间称为Form空间,一个处于闲置状态的空间称为To空间。当分配对象时,先使用Form空间进行分配,当进行垃圾回收时,会检查Form空间中的存活对象,将存活对象复制到To空间,而非存活对象占用的空间将会被释放。

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种生命周期较长的对象会被移动到老生代,这个过程称为晋升。

对象晋升的条件主要有两个:对象是否经历过Scavenge回收及To空间的内存占用比超过限制

  1. 老生代的Mark-Sweep与Mark-Compact

Mark-Sweep表示标记清除,在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。

Mark-Compact表示标记整理,由于在标记清除阶段获取到的内存空间不连续,所以需要用Mark-Compact在整理过程中,将活着的对象往一端移动,移动完成后,直接清除边界内存。

  1. 增量标记

垃圾回收算法的执行逻辑是暂停应用,代执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为全停顿。为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,没做完一“步进”,就让JavaScript应用逻辑执行一小会,垃圾回收与逻辑应用交替执行直到标记阶段完成。

2. 高效使用内存

  1. 作用域

作用域分为全局作用域、函数作用域和with,当JavaScript执行代码时会查找变量定义在哪里,最先查找当前作用域,如果当前作用域没有找到,会去上级作用域查找,直到找到为止。

  1. 变量的主动释放

全局作用域在进程退出后才能释放全局变量,如果需要释放常驻内存的对象占用的空间,可以通过delete操作来删除引用关系,或者将变量重新赋值,让旧的对象脱离引用关系。

  1. 闭包

在JavaScript中,实现外部作用域访问内部作用域中变量的方法叫做闭包。闭包是JavaScript中的高级特性,利用闭包可以实现很多巧妙的效果,但同时也会有内存泄漏的风险。

3. 内存指标

  1. 查看进程的内存占用

process.memoryUsage()可以查看Node进程的内存占用情况

process.memoryUsage();
{
  rss:1386...,
  heapTotal:613...,
  heapUsed:275...
}
  • rss:进程的常驻内存
  • heapTotal:堆中总共申请的内存量
  • heapUsed:目前堆中使用的内存量
  1. 查看系统的内存占用

os模块的totalmem()和freemen()可以查看操作系统的内存使用情况

totalmem(); // 总内存
freemen(); // 闲置内存

4. 内存泄漏

内存泄漏出现的表现是应当回收的对象出现意外而没有被回收,变成了常驻的老生代对象。

造成内存泄漏的原因有如下几个:

  • 内存缓存:老生代对象越多,垃圾回收在进行扫描和整理时,会做很多无用功
  • 队列消费不及时:当队列消费速度低于生产速度,将会形成队列堆积
  • 作用域未释放

5. 内存泄漏排查

  1. node-heapdump
// 下载
npm install heapdump
// 引入
var heapdump = require('heapdump');
// 发送快照消息
kill -USR2 <pid>

生成的快照可以通过Chrome浏览器的Profiles打开查看,里面大量重复的字段就有可能是内存泄漏的对象。

  1. node-memwatch
// 引入
var memwatch = require('memwatch');
// 使用
memwatch.on('leak',function(info){
   ... ...
})
memwatch.on('stats',function(stats){
   ... ...
})
  • stats:每次进行全堆垃圾回收时,将会触发一次stats事件
  • leak:如果发生内存泄漏就会发生一个leak事件

第六章 理解Buffer

1. Buffer结构

Buffer是一个像Array的对象,其中非性能部分由JavaScript实现,与性能相关的部分由C/C++实现。Buffer元素为16进制的两位数,即0-255的数值。Buffer和Array一样,可以访问length属性达到长度,也可以通过下标访问元素。

var buf = new Buffer(100);

console.log(buf.length);
console.loh(buf[10]);
buf[10] = 156;

给Buffer元素赋值时,如果小于0,就将该值逐次加到256,直到得到一个0到255之间的整数。如果得到的数值大于255,就逐次减256,直到得到0-255区间内的数值,如果是小数,舍弃小数部分,只保留整数部分。

2. Buffer内存分配

Node在内存的使用上应用的是在C++层面申请内存,在JavaScript中分配内存的策略。Node以8KB为界限来区分Buffer是大对象和小对象,然后采用slab的机制进行预先申请和事后分配。

slab指的是一块申请好的固定大小的内存区域,其状态有三种:

  • full:完全分配状态
  • partial:部分分配状态
  • empty:没有被分配状态

3. Buffer拼接

用一个数组来存储接收到的所有Buffer片段并记录下所有片段的总长度,然后调用Buffer.concat方法生成一个合并的Buffer对象。

var chunks = [];
var size = 0;

res.on('data',function(chunk){
   chunks.push(chunk);
   size += chunk.length;
});

res.on('end',function(){
   var buf = Buffer.concat(chunks,size);
   var str = iconv.decode(buf,'utf8');
   console.log(str);
})

第七章 网络编程

1. 构建TCP服务

  1. TCP

TCP全名为传输层控制协议,在OSI模型(由七层组成,分别为物理层、数据链路层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。

TCP是面向连接的协议,其显著的特征是在传输之前需要3次握手形成会话,在创建会话的过程,服务器端和客户端分别提供一个套接字,用于两端传输数据。

  1. 创建TCP服务
var net = require('net');

var server = net.createServer(function(scoket){
   // 新的连接
   scoket.on('data',function(data){
      scoket.write();
   });

   scoket.on('end',function(){
      console.log('断开连接')
   });
})

server.listen(8124,function(){
   console.log()
})
  1. TCP服务的事件

服务器事件:

  • listening:调用server.listen()绑定端口或者Domain scoket执行
  • connection:每个客户端套接字连接到服务器时触发
  • close:当服务器关闭时触发
  • error:服务器发生异常时触发

连接事件:

  • data:某端调用write发送数据时另一端会触发data事件
  • end:连接中的某一端发送了FIN数据时,将会触发
  • connect:客户端套接字与服务器连接成功时触发
  • drain:某一端调用write发送数据时,当前端触发事件
  • error:异常发生时,触发事件
  • close:套接字完全关闭时,触发该事件
  • timeout:一段时间后连接不活跃时触发该事件

2. 构建UDP服务

  1. UDP

UDP又称为用户数据包协议,与TCP一样同属于网络传输层。UDP与TCP最大的区别是UDP不是面向连接的。

TCP中连接一旦创建,所有的会话都基于连接完成,客户端如果要与另外一个TCP服务通信,需要另创建一个套接字来完成连接。

UDP中一个套接字可以与多个UDP服务通信,但是他提供的服务简单不可靠。

  1. 创建UDP服务器端
var dgram = require('dgram');
var server = dgram.createScoket('udp4');

server.on('message',function(){});
server.on('listening',function(){});

server.bind(41234);
  1. UDP套接字事件
  • message:接收消息时触发事件
  • listening:UDP套接字开始侦听时触发事件
  • close:调用close关闭服务器触发
  • error:异常时触发

3. HTTP

  1. 初识HTTP

HTTP的全称是超文本传输协议,英文名为 HyperText Transfer Protocol。HTTP构建在TCP之上,属于应用层协议。

  1. HTTP服务的事件
  • connection事件:在开始HTTP请求和响应前,客户端与服务器端需要建立底层的TCP连接,这个连接可能因为开启了keep-alive,可以在多次请求响应之间使用,当这个连接建立时,服务器触发一次connection事件;
  • request事件:建立TCP连接后,http模块底层将在数据流中抽象出HTTP请求和HTTP响应,当请求数据发送到服务器端,在解析出HTTP请求头后,将会触发该事件,在res.end()后,TCP连接可能用于下一次请求响应;
  • close事件:当已有的连接都断开时,触发该事件
  • checkContinue事件:某些客户端在发送较大的数据时,并不会将数据直接发送,而是先发送一个头部带Expect:100-continue的请求到服务器,服务器将会触发checkContinue事件;
  • connect事件:当客户端发起CONNECT请求时触发;
  • upgrade事件:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上Upgrade字段,服务器端会在接收到这样的请求时触发该事件;
  • clientError事件:连接的客户端触发error事件时,这个错误会传递到服务器端,此时触发该事件;

4. 构建WebScoket服务

  1. WebScoket

WebScoket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大量的客户端保持高并发连接。

WebScoket优点:

  • 客户端与服务器端只建立一次TCP连接,可以使用更少的连接
  • WebScoket服务器端可以推送数据到客户端
  • 有更轻量级的协议头,减少数据传送量
  1. WebScoket握手
GET / chat HTTP/1.1
Host: server.example.com
Upgrade: webscoket
Connection: Upgrade
Sec-WebScoket-key: dGhILHNbXBsZSBub25JZQ==
Sec-WebScoket-Ptotocol: chat,superchat
Sec-WebScoket-Version: 13

客户端建立连接时,通过HTTP发起请求报文,会添加Upgrade和Connection两个请求头部字段,表示请求服务器端升级协议为WebScoket。其中Sec-WebScoket-key用于安全校验。

Sec-WebScoket-key: dGhILHNbXBsZSBub25JZQ==

Sec-WebScoket-key的值为随机生成的Base64编码的字符串,服务器端接收到之后将其与字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11相连,形成字符串“dGhILHNbXBsZSBub25JZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后通过sha1安全散列算法计算出结果后,再进行Base64编码,最后返回给客户端。

  1. WebScoket数据传输

在握手顺利完成后,当前连接将不再进行HTTP的交互,而是开始WebScoket的数据帧协议,实现客户端与服务器端的数据交互。

握手完成后,客户端的onopen()将会被触发执行

scoket.onopen = function(){
   // TODO:opened()
}

当客户端调用send发送数据时,服务器端触发onmessage();当服务器端调用send发送数据时,客户端的onmessage()触发。当调用send()发送一条数据时,协议可能将这个数据封装为一帧或多帧数据,然后逐帧发送。

为了安全考虑,客户端需要对发送的数据帧进行掩码处理,服务器一旦接收到无掩码帧,连接将关闭。服务器发送给客户端的数据无须做掩码处理,如果客户端接收到带掩码的数据帧时,连接也将关闭。

  1. 建立连接
let socket = null; 

const connectWebSocket = () => {
  const socketUrl = config?.baseWsUrl + "/ws"; // 服务端地址
  socket = new WebSocket(socketUrl);
  // 监听 WebSocket 连接成功事件
  socket.onopen = () => {};
  // 监听数据获取事件
  socket.onmessage = async (event) => {};
  // 错误数据传输错误事件
  socket.onerror = (msg) => {};
  // 监听ws关闭事件
  socket.onclose = (msg) => {};
};
  1. 发送心跳包

心跳包必须在ws连接建立后发送,客户端向服务器发送的心跳包是type: ping

let timer = null

socket.onopen = () => {
  console.log('WebSocket连接成功!')
  connected.value = true;
  retry = 0
  clearInterval(timer);
  timer = setInterval(() => {
    sendMessage("ping");
  }, 1000 * 6);
  sendMessage(true);
};

const sendMessage = (type = "") => {
  if (!socket) return;
  const state = {}; 
  const messageObj = { // 消息对象
    ...state,
    type
  };
  socket.send(JSON.stringify(messageObj));
};
  1. 心跳检测机制
socket.onmessage = () => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'ping') {
      if( msg.status !== 'pong') { // 断线重连
        // Message.loading('断线重连中...')
        reConnectWebSocket();
      }
      console.log('心跳检测中状态:', msg.status)
  }
};

const reConnectWebSocket = (isAutomatic=false, retryCunt=10) => { 
  if (!automatic) { // automatic 是否手动重连
    console.log(retry, "自动重连次数");
    retry++;
    if (retry >= retryCunt) { 
      clearInterval(timer); 
      connected.value = false; 
      socket = null; 
      return;
    } 
  } else { 
    console.log('手动重连')
  } 
  connectWebSocket(); 
};

5. 网络服务与安全

  1. TSL/SSL

TSL/SSL是一个公钥/私钥的结构,它是一个非对称的结构,每个服务器端和客户端都有自己的公私钥。公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,所以在建立安全传输之前,客户端和服务器端需要交换公钥。客户端发送数据时需要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密,如此才能完成加密解密的过程。

  1. 数字证书

公私钥在网络传输中容易遭到窃听,典型的攻击为中间人攻击。为了解决这个问题,TSL/SSL引入了数字证书来进行认证。数字证书中包含了服务器端的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在建立连接前,会通过证书中的签名确认收到的公钥是否来自目标服务器,从而产生信任的关系。

CA数字证书认证中心,其作用是为站点颁发证书,且这个证书中具有CA通过自己的公钥和私钥实现的签名。为了得到签名证书,服务器端需要通过自己的私钥生成CSR(证书签名请求)文件,CA机构将通过这个文件颁发属于该服务器端的签名证书,只要通过CA机构就能验证证书是否合法。

  1. HTTPS服务

HTTPS服务就是工作在TSL/SSL上的HTTP。

第八章 构建Web应用

1. 基础功能

对于一个Web应用而言,在具体的业务中,一般包括以下需求:

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

在Web应用中,最常见的方法有GET、POST、PUT、HEAD、DELETE、CONNECT等。其中PUT代表新建一个资源,POST代表更新一个资源,GET代表查看一个资源,DELETE代表删除一个资源。

请求方法存在于报文的第一行的第一个单词,HTTP_Parser在解析请求报文的时候,将报文头抽取出来,设置为req.method。

GET /path?foo = bar HTTP/1.1
  1. 路径解析

路径部分存在于报文的第一行的第二部分,HTTP_Parser将其解析为req.url。

GET /path?foo = bar HTTP/1.1

客户端浏览器会将这个地址解析为报文,将路径和查询字符串部分放在报文第一行。但是hash部分会被丢弃,不存在于报文的任何位置。

  1. 查询字符串

查询字符串位于路径之后,在地址栏中?后面部分即为查询字符串。Node提供了querystrying模块用于处理这部分数据。

2. Cookie

Cookie用于记录服务器端与客户端之间的状态,最早用于判断判断用户是否第一次访问网站。

Cookie处理分为以下几步:

  • 服务器端向客户端发送Cookie
  • 浏览器将Cookie保存
  • 之后浏览器每次请求都会携带Cookie

HTTP_Parser会将所有的报文字段解析到req.headers上,Cookie则等于req.headers.cookie。Cookie的格式为key=value;key2=value2形式的。

Set-Cookie:name=value;Path=/;Expires=Sun,23-Apr-23 09:01:35 GMT;Domain=.domain.com;
  • name=value:必须包含部分
  • path:Cookie影响的路径,当前访问的路径不满足该匹配时,浏览器不能发送Cookie
  • Expires:告知浏览器Cookie的过期时间,省略的话,在关闭浏览器时会丢失掉Cookie
  • HttpOnly:不允许通过脚本document.cookie更改Cookie
  • Secure:设置为true时,在HTTP中时无效的,在HTTPS中才有效

由于Cookie的实现机制,一旦服务器向客户端发送了设置Cookie的意图,除非Cookie过期,否则客户端每次请求都会发送Cookie到服务器端,一旦设置的Cookie过多,将会导致报头较大。所以在使用时应当满足以下规则:

  • 减少Cookie大小
  • 为静态组件使用不同的域名
  • 减少DNS查询

3. Session

由于Cookie可以在前后端进行修改,所以存储的数据是不安全的,对于敏感数据的保护是无效的。为了解决这个问题,Session应运而生。Session只保留在服务器端,服务器端启用Session后,它将约定一个键值作为Session的口令,这个值可以随意约定,一旦服务器检查到用户请求Cookie中没有携带该值,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间(一般为20分钟)。

var sessions = {};
var key = 'session_id';
var EXPIRES = 20 * 60 * 1000;

var generate = function(){
   var session = {};
   session.id = (new Date()).getTime() + Math.random();
   session.cookie = {
      expire:(new Date()).getTime() + EXPIRES 
   };
   sessions[session.id] = session;
   return session;
}

每个请求到来时,检查Cookie中的口令与服务器端的数据,如果过期,就重新生成。

function( req, res){
   var id = req.cookies[key];
   if(!id){
      req.session = generate();
   }else{
      var session = sessions[id];
      if(session){
         if(session.cookie.expire > (new Date()).getTime()){
            // 更新超时时间
            session.cookie.expire = (new Date()).getTime() + EXPIRES;
            req.session = session;
         }else{
            // 超时了,删除旧的数据,并重新生成
            delete sessions[id];
            req.session = generate();
         }
      }else{
         // 如果session过期或口令不对,重新生成session
         req.session = generate();
      }
   }
   handle(req,res);
}

4. 缓存

可以通过以下规则设置缓存:

  • 添加Expires或者Cache-control到报头中
  • 配置Etag
  • 让Ajax可缓存

可以通过以下方式清除缓存:

  • 每次发布,路径中跟随Web应用的版本号:http://url.com/?v=20130501
  • 每次发布,路径中跟随该文件内容的hash值:<http://url.com/?hash>=afadafadwe

5. Web攻击

  1. XSS

XSS全称为跨脚本攻击,通常是由网站开发者决定哪些脚本可以执行在浏览器端,不过XSS漏洞会让别的脚本执行。它形成的主要原因多数是用户输入没有被转义,而是被直接执行。

  1. CSRF

CSRF全称为跨站请求伪造。服务器端与客户端通过Cookie来标识和认证用户,通常用户通过浏览器访问服务器的SessionID是无法被第三方知道的,但是CSRF的攻击者并不需要知道SessionID就能让用户中招。攻击者通过诱导登陆认证后的用户访问第三方网站,在第三方网站向服务器发送攻击请求。

第九章 多进程架构

1. Master-Worker模式

Master-Worker模式又称为主从模式,Node中的进程被分为主进程和工作进程,是典型的分布式架构中用于并行处理业务的模式,具备良好的可伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度和管理工作进程,它是趋于稳定的。工作进程负责具体的业务处理。

2. 创建子进程

child_process模块给予Node可以随意创建子进程的能力。它提供了四个方法用于创建子进程。

  • spawn():启动一个子进程来执行命令
  • exec():启动一个子进程来执行命令,与spawn()不同的是,它具有一个回调函数货值子进程的状况
  • exexFile():启动一个子进程来执行可执行文件
  • fork():与spawn()类似,不同的是它创建的Node子进程只需要指定要执行的JavaScript文件模块即可

3. 进程间通信

子进程对象通过send()方法实现主进程向子进程发送数据,message事件实现收听子进程发送来的数据。

// parent.js
var cp = requier('child_process');
var n = cp.fork(__dirname + '/sub.js');

n.on('message',function(m){
   console.log('PARENT got message:' + m);
});
m.send({hellow:'word'});

// sub.js
process.on('message',function(m){});
process.send({});

通过fork或者其他方式创建子进程后,父子进程会通过IPC通道进行通信,IPC全称为进程间通信,其目的是让不同进程能够互相访问资源并进行协调工作。

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

IPC通道属于双向通信,在系统内核中完成了进程间的通信,而不用经过实际的网络层,非常高效。