第一章 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特点
- 异步I/O
Node中的大多数操作都是以异步的方式进行调用的,如Ajax请求、fs文件读取等。
- 事件与回调函数
事件的编程方式具有轻量级、松耦合、只关注事务点等优势,可以通过回调函数接受异步调用返回的数据。
$.ajax({
'url':'/url',
'method':'get',
'data':{},
success:function(data){},
error:function(error){}
})
- 单线程
在Node中,JavaScript是单线程运行的,与其他线程是无法共享任何状态的。
单线程有很多缺点,如无法利用多核CPU、发生错误会使整个程序退出、大量计算占用CPU时无法调用异步I/O。但是单线程也有很明显的优势,即不用像多线程编程那样处理状态同步问题,它既没有死锁的存在,也没有线程上下文交换带来的性能损耗问题。
- 跨平台
Node V0.6.0及之后的版本,在操作系统和Node上层模块之间加入了中间层libuv,使其不仅支持Linux,还支持了Windows。
3. Node与浏览器
Node与浏览器的功能类似,他们都是基于事件驱动的异步架构。浏览器通过事件驱动来服务界面上的交互,Node通过事件驱动来服务I/O。Node可以使JavaScript运行在任何环境中,而并非局限于浏览器。
4. Node应用场景
- I/O密集型
定义:在大部分时间里,任务处于等待 I/O 操作完成的状态,例如等待文件读写、数据库查询、网络通信等
特点:
- CPU 使用率相对较低,因为大部分时间任务在等待外部资源
- 通常涉及异步操作,如使用回调函数或异步编程模型。
Node优化I/O密集型:利用事件循环优化I/O密集型任务的性能,并非为一个请求任务开辟一个线程,所以资源占用极少。
- CPU 密集型
定义:在大部分时间里,任务在进行计算密集型的操作,例如大量的数学运算、图像处理等
特点:
- CPU 使用率相对较高,因为任务主要依赖 CPU 进行计算
- 通常没有太多的 I/O 操作,或者 I/O 操作相对较短
Node优化CPU密集型:利用与Web Workers相同的思路解决大量计算的性能问题,引入了child_process子进程,将大量计算分发到各个子进程,再通过进程之间的事件消息传递结果。
- 区分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部分。
- 模块引用
var math = require('math');
- 模块定义
exports.add = function(){};
exports方法是唯一的出口,负责导出模块的方法或者变量。在Node中,一个文件就是一个模块,将方法挂载到exports对象上作为属性即可定义导出的方式。
- 模块标识
模块标识指的是传递给require方法的参数,它可以是满足小驼峰命名的字符串,也可以是以“.”或者“..”开头的相对路径,还可以是绝对路径。模块标识支持不添加文件后缀名。
3. Node的模块实现
- 模块实现流程
在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方法来协调加载内建模块。
- 模块分类
在Node中,模块大致分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。
核心模块在Node源代码的编译过程中,被编译进了二进制执行文件。Node进程启动后,部分核心模块就被直接加载到了内存中,所以这部分核心模块引入时,文件定位、编译执行两个步骤可以直接省略。而且这部分核心模块的加载速度是最快的。
文件模块是在运行时动态加载的,需要运行完整的3步流程,即路径分析、文件定位、编译执行过程,速度比核心模块慢。
在核心模块中,有些模块全部由C/C++编写,通常不被用户直接调用,所以这部分模块被称为内建模块。
- 模块调用栈
C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用。
JavaScript核心模块主要扮演的职责有两类:作为C/C++内建模块的封装层和桥接层,供文件模块调用;作为纯粹的功能模块。
文件模块由第三方编写,包括普通的JavaScript模块和C/C++扩展模块,主要调用方向为普通JavaScript模块调用扩展模块。
- 优先从缓存加载
与浏览器会缓存静态脚本文件类似,Node对引入的模块也会进行缓存,一减少二次引入时的开销。不同的是浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。
从缓存加载的模块不会执行路径分析、文件定位、编译执行三个过程。
4. 包和NPM
包和NPM将模块联系起来的一种机制。
- 包
包的出现是在模块的基础上进一步组织JavaScript代码。
包描述文件中的关键属性:
- dependencies:使用当前包需要依赖的包列表
- scripts:脚本说明对象,被包管理器用来安装、编译、测试的卸载包
- main:模块引入的检查入口文件
- 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
- 异步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线程均可以并行起来。
- 非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. 函数式编程
- 高阶函数
高阶函数的定义是把函数作为参数或者将函数作为函数返回值。
function foo(x){
return function(){
return x;
}
}
- 偏函数
指定部分参数来产生一个新的定制函数
var isType = function(type){
return function(obj){
return Object.toString.call(obj) === '[object' + type + ']';
}
}
2. 异步编程难点
- 异常问题捕获和处理
- 函数嵌套层级太深
- 阻塞代码
- 多线程编程
- 异步转同步困难
3. 异步编程解决方案
- 事件发布/订阅模式
Node自身提供的event模块,是发布/订阅模式的一个简单实现。
// 订阅
emitter.on('event1',function(message){});
// 发布
emitter.emit('event1','I am message');
-
Promise/Deferred模式
-
流程控制库(async、Step、EventProxy、wind)
第五章 内存控制
1. V8垃圾回收机制
- 内存限制
在Node中运行JavaScript时,只能使用部分内存(64位系统下约为1.4GB,32位系统下约为0.7GB),在这样的内存限制下,导致Node无法直接操作大内存对象。
出现这种现象的原因是“Node基于V8构建而成”,在Node中使用的JavaScript都是通过V8自己的方式来进行内存分配和管理的。V8的管理机制对于浏览器来说足够使用,但是对于Node来说,则会限制其很多操作。
- V8的对象分配
在V8中,所有的JavaScript对象都是通过堆来进行内存分配的,而且V8考虑到运行环境和垃圾回收等原因会限制堆的大小。
- 垃圾回收机制
V8的垃圾回收策略主要是基于分代式垃圾回收机制。将内存分为新生代和老生代,新生代的对象存活时间较短,老生代的对象存活时间较长。
- --max-old-space-size命令行参数可以用于设置老生代内存空间的最大值
- --max-new-space-size命令行参数用于设置新生代内存空间的大小
两个参数需要在项目启动时指定,无法根据具体使用情况作调整。
- 新生代内存的Scavenge算法
Scavenge算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,一个处于使用中的空间称为Form空间,一个处于闲置状态的空间称为To空间。当分配对象时,先使用Form空间进行分配,当进行垃圾回收时,会检查Form空间中的存活对象,将存活对象复制到To空间,而非存活对象占用的空间将会被释放。
当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种生命周期较长的对象会被移动到老生代,这个过程称为晋升。
对象晋升的条件主要有两个:对象是否经历过Scavenge回收及To空间的内存占用比超过限制
- 老生代的Mark-Sweep与Mark-Compact
Mark-Sweep表示标记清除,在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。
Mark-Compact表示标记整理,由于在标记清除阶段获取到的内存空间不连续,所以需要用Mark-Compact在整理过程中,将活着的对象往一端移动,移动完成后,直接清除边界内存。
- 增量标记
垃圾回收算法的执行逻辑是暂停应用,代执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为全停顿。为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,没做完一“步进”,就让JavaScript应用逻辑执行一小会,垃圾回收与逻辑应用交替执行直到标记阶段完成。
2. 高效使用内存
- 作用域
作用域分为全局作用域、函数作用域和with,当JavaScript执行代码时会查找变量定义在哪里,最先查找当前作用域,如果当前作用域没有找到,会去上级作用域查找,直到找到为止。
- 变量的主动释放
全局作用域在进程退出后才能释放全局变量,如果需要释放常驻内存的对象占用的空间,可以通过delete操作来删除引用关系,或者将变量重新赋值,让旧的对象脱离引用关系。
- 闭包
在JavaScript中,实现外部作用域访问内部作用域中变量的方法叫做闭包。闭包是JavaScript中的高级特性,利用闭包可以实现很多巧妙的效果,但同时也会有内存泄漏的风险。
3. 内存指标
- 查看进程的内存占用
process.memoryUsage()可以查看Node进程的内存占用情况
process.memoryUsage();
{
rss:1386...,
heapTotal:613...,
heapUsed:275...
}
- rss:进程的常驻内存
- heapTotal:堆中总共申请的内存量
- heapUsed:目前堆中使用的内存量
- 查看系统的内存占用
os模块的totalmem()和freemen()可以查看操作系统的内存使用情况
totalmem(); // 总内存
freemen(); // 闲置内存
4. 内存泄漏
内存泄漏出现的表现是应当回收的对象出现意外而没有被回收,变成了常驻的老生代对象。
造成内存泄漏的原因有如下几个:
- 内存缓存:老生代对象越多,垃圾回收在进行扫描和整理时,会做很多无用功
- 队列消费不及时:当队列消费速度低于生产速度,将会形成队列堆积
- 作用域未释放
5. 内存泄漏排查
- node-heapdump
// 下载
npm install heapdump
// 引入
var heapdump = require('heapdump');
// 发送快照消息
kill -USR2 <pid>
生成的快照可以通过Chrome浏览器的Profiles打开查看,里面大量重复的字段就有可能是内存泄漏的对象。
- 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服务
- TCP
TCP全名为传输层控制协议,在OSI模型(由七层组成,分别为物理层、数据链路层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。
TCP是面向连接的协议,其显著的特征是在传输之前需要3次握手形成会话,在创建会话的过程,服务器端和客户端分别提供一个套接字,用于两端传输数据。
- 创建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()
})
- TCP服务的事件
服务器事件:
- listening:调用server.listen()绑定端口或者Domain scoket执行
- connection:每个客户端套接字连接到服务器时触发
- close:当服务器关闭时触发
- error:服务器发生异常时触发
连接事件:
- data:某端调用write发送数据时另一端会触发data事件
- end:连接中的某一端发送了FIN数据时,将会触发
- connect:客户端套接字与服务器连接成功时触发
- drain:某一端调用write发送数据时,当前端触发事件
- error:异常发生时,触发事件
- close:套接字完全关闭时,触发该事件
- timeout:一段时间后连接不活跃时触发该事件
2. 构建UDP服务
- UDP
UDP又称为用户数据包协议,与TCP一样同属于网络传输层。UDP与TCP最大的区别是UDP不是面向连接的。
TCP中连接一旦创建,所有的会话都基于连接完成,客户端如果要与另外一个TCP服务通信,需要另创建一个套接字来完成连接。
UDP中一个套接字可以与多个UDP服务通信,但是他提供的服务简单不可靠。
- 创建UDP服务器端
var dgram = require('dgram');
var server = dgram.createScoket('udp4');
server.on('message',function(){});
server.on('listening',function(){});
server.bind(41234);
- UDP套接字事件
- message:接收消息时触发事件
- listening:UDP套接字开始侦听时触发事件
- close:调用close关闭服务器触发
- error:异常时触发
3. HTTP
- 初识HTTP
HTTP的全称是超文本传输协议,英文名为 HyperText Transfer Protocol。HTTP构建在TCP之上,属于应用层协议。
- 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服务
- WebScoket
WebScoket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大量的客户端保持高并发连接。
WebScoket优点:
- 客户端与服务器端只建立一次TCP连接,可以使用更少的连接
- WebScoket服务器端可以推送数据到客户端
- 有更轻量级的协议头,减少数据传送量
- 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编码,最后返回给客户端。
- WebScoket数据传输
在握手顺利完成后,当前连接将不再进行HTTP的交互,而是开始WebScoket的数据帧协议,实现客户端与服务器端的数据交互。
握手完成后,客户端的onopen()将会被触发执行
scoket.onopen = function(){
// TODO:opened()
}
当客户端调用send发送数据时,服务器端触发onmessage();当服务器端调用send发送数据时,客户端的onmessage()触发。当调用send()发送一条数据时,协议可能将这个数据封装为一帧或多帧数据,然后逐帧发送。
为了安全考虑,客户端需要对发送的数据帧进行掩码处理,服务器一旦接收到无掩码帧,连接将关闭。服务器发送给客户端的数据无须做掩码处理,如果客户端接收到带掩码的数据帧时,连接也将关闭。
- 建立连接
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) => {};
};
- 发送心跳包
心跳包必须在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));
};
- 心跳检测机制
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. 网络服务与安全
- TSL/SSL
TSL/SSL是一个公钥/私钥的结构,它是一个非对称的结构,每个服务器端和客户端都有自己的公私钥。公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,所以在建立安全传输之前,客户端和服务器端需要交换公钥。客户端发送数据时需要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密,如此才能完成加密解密的过程。
- 数字证书
公私钥在网络传输中容易遭到窃听,典型的攻击为中间人攻击。为了解决这个问题,TSL/SSL引入了数字证书来进行认证。数字证书中包含了服务器端的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在建立连接前,会通过证书中的签名确认收到的公钥是否来自目标服务器,从而产生信任的关系。
CA数字证书认证中心,其作用是为站点颁发证书,且这个证书中具有CA通过自己的公钥和私钥实现的签名。为了得到签名证书,服务器端需要通过自己的私钥生成CSR(证书签名请求)文件,CA机构将通过这个文件颁发属于该服务器端的签名证书,只要通过CA机构就能验证证书是否合法。
- HTTPS服务
HTTPS服务就是工作在TSL/SSL上的HTTP。
第八章 构建Web应用
1. 基础功能
对于一个Web应用而言,在具体的业务中,一般包括以下需求:
- 请求方法的判断
- URL的路径解析
- URL中查询字符串解析
- Cookie的解析
- Basic认证
- 表单数据的解析
- 任意格式文件的上传处理
- 请求方法
在Web应用中,最常见的方法有GET、POST、PUT、HEAD、DELETE、CONNECT等。其中PUT代表新建一个资源,POST代表更新一个资源,GET代表查看一个资源,DELETE代表删除一个资源。
请求方法存在于报文的第一行的第一个单词,HTTP_Parser在解析请求报文的时候,将报文头抽取出来,设置为req.method。
GET /path?foo = bar HTTP/1.1
- 路径解析
路径部分存在于报文的第一行的第二部分,HTTP_Parser将其解析为req.url。
GET /path?foo = bar HTTP/1.1
客户端浏览器会将这个地址解析为报文,将路径和查询字符串部分放在报文第一行。但是hash部分会被丢弃,不存在于报文的任何位置。
- 查询字符串
查询字符串位于路径之后,在地址栏中?后面部分即为查询字符串。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攻击
- XSS
XSS全称为跨脚本攻击,通常是由网站开发者决定哪些脚本可以执行在浏览器端,不过XSS漏洞会让别的脚本执行。它形成的主要原因多数是用户输入没有被转义,而是被直接执行。
- 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通道属于双向通信,在系统内核中完成了进程间的通信,而不用经过实际的网络层,非常高效。