Node.js学习笔记
Node.js是一个基于Chrome V8 JavaScript引擎的脚本运行环境,它提供了一个事件驱动的、非阻塞的I/O模型,适合服务器端编程。
JavaScript语言标准的API在Node.js中均可用,此外后者提供了大量的其它API,这些API提供了服务器端编程的支持。目前版本的Node.js已经原生支持大量ES6特性。
使用Node.js可以很方便的开发Web应用的服务器端,比起其它动态语言的Web框架,Node.js的最大优势是前后端语言统一。
Node.js的包管理系统——npm,已经成为世界上最大的开源项目生态系统,其中包含了大量适用于前、后端的优秀的库。
- 异步I/O:网络通信、本地文件系统读写,都采用异步方式,不会导致阻塞
- 单线程:和浏览器中运行的JavaScript一样,Node.js中的你编写的代码也是在单线程中执行的。这意味着你不需要考虑状态共享与同步的问题。如果遇到计算密集型任务,你需要使用子进程的方式来处理,而不能让当前Node.js实例阻塞
- 跨平台:Node.js可以在主流的操作系统上运行
- 支持C/C++扩展。与Python、Perl之类的脚本语言类似,Node.js也支持Native扩展
由于Node.js提供了大量的异步API,因此它非常使用I/O密集型的应用,典型的例子是Web服务器。
Node.js内置了CommonJS规范的实现,作为自己的模块管理系统。Node.js对CommonJS规范进行了一些扩展。
要让一个模块在运行时可用,需要进行:路径分析、文件定位、编译执行这三个步骤。Node.js把模块分为两类:
- 核心模块:随Node运行时一起分发的内置模块。这种模块已经内置在Node.js的二进制文件中,因此不需要路径分析和文件定位。Node.js实例启动时,部分核心模块会被直接加载
- 文件模块:用户编写的第三方模块。需要完整的分析、定位、编译执行步骤,速度相对慢
依据CommonJS规范,要导入一个模块给当前代码使用,需要调用 require() 函数。
就像浏览器缓存那样,Node.js对所有导入过的模块都会进行缓存,但是缓存的是编译、执行过后的对象而不是源码。不管是核心、文件模块,第二导入时,都会优先使用缓存。
模块标识符可以分为几种不同类型:
- 核心模块标识符:无前缀,例如http 、 fs 、 path
- 基于相对路径的模块标识符: . 或者 .. 开头。相对路径会被转换为绝对路径并定位到文件
- 基于绝对路径的模块标识符: / 开头
- 非路径形式的模块标识符,例如自定义的connect模块
模块标识符可以附带扩展名以体现其类型,如果不包含扩展每,Node.js会依次查找.js ⇨ .json ⇨ .node扩展名。在查找过程中,需要阻塞的调用fs模块来判断文件是否存在,这是潜在的性能风险点。
所谓模块路径,是Node.js用来定位文件模块的路径列表,其作用类似于PATH环境变量。这些路径的列表由一系列 node_modules 目录构成,第一条为当前目录下的node_modules子目录,第二条为当前目录父目录下的node_modules子目录……类推。执行下面的代码可以清楚的看到:
1 2 3 4 5 6 7 8 9 |
console.log( module.paths ); // [ '/home/alex/JavaScript/projects/webstorm/NodeStudy/node_modules', // '/home/alex/JavaScript/projects/webstorm/node_modules', // '/home/alex/JavaScript/projects/node_modules', // '/home/alex/JavaScript/node_modules', // '/home/alex/node_modules', // '/home/node_modules', // '/node_modules' ] |
越是靠前的条目优先级越高,查找模块时越优先使用。如果最终找不到想require的模块,一个异常被抛出。
Node.js中每个模块都是一个对象,其构造函数类似于:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function Module( id, parent ) { // 模块标识符 this.id = id; // 导出的API this.exports = {}; // 父模块对象 this.parent = parent; if ( parent && parent.children ) { parent.children.push( this ); } this.filename = null; this.loaded = false; // 子模块列表 this.children = []; } |
在定位到模块后,Node.js在内存中创建对应的模块对象,并编译、执行模块的代码。不同类型模块的执行方式不同:
- 对于.js,通过fs模块同步读取、编译执行
- 对.node这类C/C++扩展,通过dlopen()加载已经编译好的文件
- 对于.json,通过fs模块同步读取,然后调用JSON.parse()解码为JavaScript对象
Node.js对ES6特性的支持被分为三组:
Shipping | 这些特性被V8引擎看做是稳定的,并且在Node.js中自动开启 |
Staged | 这些特性基本完成,但是上不稳定,需要通过运行时标记 --harmony 启用 |
In progress | 这些特性可以被各自的harmony标记启用,不推荐在生产环境下使用 |
查询node.green可以了解Node.js各版本对ES6的支持情况。
执行下面的命令,可以查看哪些特性属于In progress分组:
1 |
node --V8-options | grep "in progress" |
从14版本开始,可以原生支持ES6的import语句,需要配置
1 |
"type": "module" |
1 2 3 |
import { sep } from 'path' console.log('print: ', sep); // / |
低版本则需要使用Babel。
Node.js提供了一个命令行工具node,用于执行Node.js应用程序。该命令行的调用格式如下:
1 |
node [options] [V8 options] [script.js | -e "script"] [arguments] |
下面这段Node.js代码启动一个HTTP服务器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const http = require('http'); const hostname = '127.0.0.1'; const port = 3000; // 以一个回调函数来注册HTTP服务器,当请求到达时,自动调用该回调处理 const server = http.createServer((req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('Hello Node!'); }); // 在指定的地址和端口上监听请求 server.listen(port, hostname, () => { console.log(`Server running at http://${hostname}:${port}/`); }); |
要调用这段代码,只需要:
1 |
node index.js |
下表列出node命令的常用选项:
选项 | 说明 |
-e, --eval | 将参数作为JavaScript进行估算 |
-p, --print | 与上面类似,并且打印估算结果 |
-c, --check | 检查脚本的语法但不运行 |
-r, --require | 在运行时启动时,预加载指定的模块 |
--no-deprecation | 禁止废弃(deprecation)用法的警告 |
--trace-deprecation | 打印废弃用法的调用栈 |
--throw-deprecation | 废弃用法将导致错误抛出 |
--no-warnings | 禁止警告信息 |
--trace-sync-io | 在第一个事件循环之后,一旦出现同步I/O操作,即打印其调用栈 |
--zero-fill-buffers | 自动以\0填充Buffer、SlowBuffer实例 |
--track-heap-objects | 跟踪堆中的对象分配,用于堆快照分析 |
--prof-process | 处理基于V8选项 --prof生成的剖析结果 |
--V8-options | 打印可用的V8命令行选项。注意V8选项中的 _ 和 - 可以替换 |
--V8-pool-size | 指定用于执行后台任务的V8线程池的大小,设置为0则自动依据当前处理器数量设置 |
--tls-cipher-list=list | 指定备选的TLS密码算法 |
--openssl-config=file | 在启动时加载OpenSSL配置 |
下表列出node命令读取的环境变量:
环境变量 | 说明 |
NODE_DEBUG=module[,…] | 需要打印调试信息的核心模块列表 |
NODE_PATH=path[:…] | 从中寻找模块的目录前缀 |
NODE_DISABLE_COLORS=1 | 是否在REPL环境中使用语法高亮 |
Node.js官方提供的API,包括以下大类:
API类别 | 简介 |
Assert | 该模块提供一些断言类功能,断言失败会导致AssertionError被抛出 |
Buffer | 读写二进制数据流的容器 |
Child Processes | 创建和管理子进程 |
Process |
操控进程,支持的操作包括:
注意:当前的全局对象——process,代表当前Node.js进程 |
Cluster | 一个Node.js实例仅仅运行在单个线程中,要想利用多核系统的优势,可以使用Cluster来启动多个Node.js进程 |
Console | 提供一个简单的调试控制台程序 |
Crypto | 一个密码算法库,提供对OpenSSL Hash、HMAC、加解密、签名/验证等功能的封装接口 |
DNS | 调用操作系统进行DNS查找,或者直接联系DNS服务器进行查找 |
Errors | 异常/错误类型层次 |
Events | 内置的观察者模式 |
File System | 文件系统API |
HTTP | 提供对HTTP客户端、服务器的支持 |
HTTPS | Node.js为HTTPS支持提供的单独模块 |
Modules | 内置的、基于CommonJS规范的模块加载系统 |
net | 异步网络包装器,同时包括创建服务器/客户端(所谓Streams)的功能 |
OS | 包含一系列操作系统相关的实用方法。例如获取体系结构、内存容量、临时目录、主机名、负载、网络接口……等信息 |
Path | 处理目录、文件的路径 |
Querystring | 解析URL查询串 |
URL | 支持URL的解析 |
Readline | 支持从Readable的流中读取数据 |
REPL | 提供一个REPL(Read-Eval-Print-Loop)实现,可以嵌入到其它程序中使用 |
Stream | 提供操控流式数据的抽象接口 |
String Decoder | 把Buffer解码为字符串 |
Timers | 定时器API支持 |
TLS | 基于OpenSSL构建的TLS、SSL层 |
TTY | 支持读写TTY |
UDP | 支持UDP数据报的收发 |
VM | 在一个V8虚拟机上下文中编译、执行JavaScript代码 |
V8 | 获得V8引擎的堆统计信息,或者编程式的设置V8引擎的标记 |
Zlib | 支持基于Gzip的压缩/解压缩 |
全局对象 |
Node.js提供了少量在任何模块中可用的全局对象:
|
Web开发人员一直在使用异步I/O,这是因为浏览器中UI渲染、JavaScript执行互斥——它们不能同时进行,它们可能由单一线程负责完成。这意味着JavaScript执行稍微耗时(比如100ms+)就会让用户觉得卡顿。网络操作通常都比较耗时 ,因此不适合同步的完成。同步操作的劣势在同时执行多个操作的场景下更加明显,用户等待时间可能成倍的增加,而多个异步操作消耗的总时间仅仅和最耗时的那个操作消耗的时间接近。
尽管Ajax支持同步方式,但是基本没人使用,因为等待响应到达的过程中,同步Ajax会导致页面失去响应。
除了用户体验,异步I/O在资源占用方面也具有优势。它避免了不必要的线程,从而减少了上下文切换的开销。
从底层实现的角度来说,Node.js的异步I/O与很多主流框架无异:专门的线程负责select/poll之类的系统调用来发现I/O事件,并在事件发生时由其它线程执行回调函数。Node.js中的“其它”线程就是执行用户代码的JavaScript线程。
Node.js的执行模型与浏览器环境下的事件循环差不多。在启动后,一个无限循环被创建。每一次处理循环体的过程称为一个Tick。一个Tick的流程如下:
- 取出下一个事件,如果没有更多的事件,退出本次循环
- 判断该事件是否关联了回调,如果是,执行回调
- 跳转到第1步
其中回调是通过Node.js提供的API注册的,回调相当于观察者模式中的观察者。
Node.js提供了一些与I/O编程无直接关系的API:
API | 说明 |
setTimeout() |
与浏览器中同名API语义一致。 调用这些API后,一个定时器对象被放入一棵红黑树中,每当Tick开始执行时,Node.js检查红黑树中已经超时的定时器,并调用其回调函数 需要注意的是,这些函数无法在时间精度上完全满足需求。如果某次事件循环消耗的时间过多,则回调可能被延迟执行 |
setInterval() | |
process.nextTick() | 安排一个任务,在下一个Tick取出执行 |
setImmediate() | 与nextTick()类似,但是nextTick在下一个事件循环中的执行优先级比setImmediate高 |
几种典型的服务器模型比较如下:
同步式 | 同时仅处理一个请求,其它请求必须排队等候。这种模型仅仅用于测试目的 |
每进程/每请求 | 为每个新到达的请求创建/使用一个进程。但是进程的创建、维护开销是比较大的,因此Scalability很差 |
每线程/每请求 |
为每个新到达的请求创建/使用一个线程。Apache服务器仍然在使用这种模型 不管是每进程、每线程,都具有如下缺点:
|
事件驱动 |
避免了进程、线程的开销,可以应对高并发场景。 Nginx是一个例子,它基于C编写,性能优异,除了用作Web服务器以外,还可以用于负载均衡、反向代理 基于Node.js的服务器也是这种类型,在Web服务器领域没有Nginx性能好,但是用途更广 |
尽管该模式可以用于同步编程,在异步编程领域其价值更大。Node.js模块events提供完整的观察者模式支持:
1 2 3 4 5 6 7 8 9 10 |
const EventEmitter = require( 'events' ); let ee = new EventEmitter(); const EVENT_GREETING = 'greeting'; // 订阅事件 ee.on( EVENT_GREETING, ( who ) => { console.log( `Hello ${who}!` ); // 打印 Hello Alex! } ); // 发布事件 ee.emit( EVENT_GREETING, 'Alex' ); |
和Web环境下的的DOM事件系统比起来,该模块提供的API要简单的多:
方法 | 说明 |
addListener/on() | 注册一个监听器 |
once() | 注册一个一次性监听器,该监听器在第一次调用后自动移除 |
removeListener() | 移除一个监听器 |
removeAllListeners() | 移除所有监听器 |
emit() | 发布一个事件 |
events模块提供了一些额外的逻辑:
- 如果为一个EventEmitter添加超过10个监听器,你会得到一个警告,这是因为Node.js的单线程特征。设计者认为过多的监听器可能存在潜在的内存泄漏、CPU占有。调用 emitter.setMaxListeners(0) 可以去除该警告
- 为了异常处理的考虑, error 事件被特殊对待。如果没有针对这种事件的监听器,将会抛出异常
所谓雪崩问题,是指高并发访问情况下,缓存失效导致大量请求需要进入下层处理,从而拖慢应用整体速度的情况。这种情况下,可以使用事件队列和once()来处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var proxy = new events.EventEmitter(); var status = "ready"; // callback为请求处理函数 var select = function ( callback ) { // 使用一次性监听器,加入队列 proxy.once( "selected", callback ); // 只有第一个请求才会触发下层处理 —— 数据库I/O if ( status === "ready" ) { status = "pending"; db.select( "SQL", function ( results ) { // 唤醒所有队列中的请求 proxy.emit( "selected", results ); status = "ready"; } ); } }; |
观察者模式的缺点是,你必须预先把处理函数注册上去,在emit调用之后注册的监听函数不会被调用。例如下面这个Ajax调用:
1 2 3 4 5 |
$.get( '/api', { success: onSuccess, error: onError, complete: onComplete } ); |
Deferred模式则允许你先执行异步调用,随后延迟的传递处理函数。这种模式首先由Dojo引入,在jQuery 1.5中被广为人知:
1 2 |
// 处理函数被后续传入 $.get( '/api' ).success( onSuccess ).error( onError ).complete( onComplete ); |
这种方式看起来更加舒服,还可以避免嵌套的Callback地狱。
与Deferred类似的Promise模式已经成为ES6的一部分,因此推荐使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
async.map( [ 'file1', 'file2', 'file3' ], fs.stat, function ( err, results ) { // 对三个文件分别调用fs.stat方法,结果以数组形式存放到results } ); // 类似的parallelLimit可以限制并发数量 async.parallel( [ function ( callback ) { }, function ( callback ) { } ], function ( err, results ) { // 并行执行,结果以数组形式存放到results } ); async.series( [ function ( callback ) { }, function ( callback ) { } ], function ( err, results ) { // 串行执行,结果以数组形式存放到results } ); |
Node.js主要用于服务器端,需要面对大量并发的场景,因此高效、合理使用内存资源非常重要。
JavaScript属于自动内存管理类的编程语言,开发人员不能严格控制内存的分配和释放。内存管理工作由垃圾回收器负责,对于Node.js来说,垃圾回收器由V8引擎提供。
V8在堆内存用量上有限制,在64bit系统中最大上限约为1.7GB左右,32bit系统则为1GB左右,默认上限为1.0GB / 0.5GB。使用V8V8选项可以微调,但是不能超过最大上限:
1 |
node --max-old-space-size=1024 --max-new-space-size=1024 |
V8采用分代垃圾回收算法,与JVM经典垃圾回收算法类似,V8将堆内存划分为新生代、年老代两部分,不过V8不支持动态调整两代的比例。
对于新生代对象的垃圾回收,主要采用Scavenge算法轻量,此算法效率较高但是内存浪费较大。对于年老代,则结合使用标记-清除、标记-压缩算法。 这三种算法在标记阶段都需要Stop the world以确保数据一致性。为了避免Stop the world引入过长停顿,V8使用了增量标记的方法——让标记与JavaScript逻辑交替执行,最大停顿时间可以减少为1/6。后续版本的V8对清理、压缩算法也进行了优化,都是为了缩短停顿时间。
JavaScript中的变量可以定义在全局作用域、函数作用域、块作用域(let/const)中。你应该尽可能缩小作用域范围,便于垃圾回收器回收。
全局变量会常驻内存,知道Node.js实例退出。你可以显式的 delete global.varname 删除之,或者重新赋值。
闭包会导致外层作用域中的变量驻留内存,要特别小心。
调用 process.memoryUsage() 可以查看Node.js实例的内存占用情况:
1 2 3 4 5 6 7 8 9 |
// 单位都是字节 { // 驻留集尺寸(resident set size),即进程的位于物理内存中的页的总大小 rss: 16580608, // 堆总大小 heapTotal: 8425472, // 堆已使用大小 heapUsed: 4030376 } |
调用os模块的 totalmem() 和 freemem() 则可以看到操作系统的总内存、空闲内存数量。
你可能注意到rss总是大于heapTotal,这说明V8并不总是在堆中分配内存的。Buffer对象在堆外内存中分配,因而也就可以突破堆的大小限制。
由于V8的内存限制,你不能调用 fs.readFile() 、 fs.writeFile() 直接读写超大文件,而应该可以使用Node.js内置的stream模块。类似于Java的输入/输出流,strem也分为可读、可写、双向流。
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var reader = fs.createReadStream( 'in.txt' ); var writer = fs.createWriteStream( 'out.txt' ); // stream继承了EventEmitter reader.on( 'data', function ( chunk ) { // 当读取到数据块后 writer.write( chunk ); } ); reader.on( 'end', function () { // 当读取完毕后 writer.end(); } ); // 对于这种读出再写入的场景,可以使用管道,更加简化 var reader = fs.createReadStream('in.txt'); var writer = fs.createWriteStream('out.txt'); reader.pipe(writer); |
由于JavaScript最初主要用于前端开发,很少需要操纵二进制数据,因此也缺乏对应的API。Node.js在这方面做了很大改进,提供了高性能的Buffer接口来处理图片、上传文件之类的二进制文件。
Buffer类似于数组,但是主要用来操作二进制数据——字节。Buffer的性能关键部分基于C++实现,它的内存在V8的堆外分配。
由于Buffer太常用,因此Node.js将其作为Global对象的属性,可以直接使用,不需要require。
下面的代码创建一个Buffer对象:
1 2 3 4 5 6 7 8 9 |
let greeting = '你好'; let buf = new Buffer( greeting ,'UTF-8'); console.log( buf ); // <Buffer e4 bd a0 e5 a5 bd> let len = buf.length; // 访问长度 buf[0]; // 下标访问元素 buf[len-1] = 0; // 分配Buffer时可以显式指定长度 new Buffer(size); |
可以看到Buffer的接口类似于数组,但是Buffer的元素是一个字节大小的数字,打印控制台的时候每个元素表示为两位十六进制数。
Node.js直接向操作系统申请Buffer所需的内存。为了提高使用效率,Node.js使用Slab分配机制。Slab最初在Solairs中使用,后来被Linux系统引入。Slab代表一块申请好的、固定大小的内存,处于三种状态之一:full,完全分配;partial,部分分配;empty,没有被分配。
Node.js以8KB为界限,来区分Buffer是大对象还是小对象,8KB也是单个Slab的大小。这两种对象的分配方式有所不同。
Buffer可以和字符串相互转换,转换为字符串时,可以使用ASCII、UTF-8、UTF-16LE/UCS-2、Base64、Binary、HEX等编码方式。转换示例:
1 2 3 4 5 6 |
// 字符串转换为Buffer new Buffer(str, [encoding]); // Buffer可以后续的写入字符串 buf.write(string, [offset], [length], [encoding]); // Buffer转换为字符串 buf.toString([encoding], [start], [end]) |
Node.js官方不支持中国常用的GB2312、GBK编码方式,可以通过第三方库解决:
1 2 3 4 5 |
var iconv = require('iconv-lite'); // 转换为字符串 var str = iconv.decode(buf, 'win1251'); // 转换为Buffer var buf = iconv.encode("Sample input string", 'win1251'); |
通常的I/O应用场景中,Buffer是逐段传输给回调函数的。下面的代码展示了如何拼接出完整的内容:
1 2 3 4 5 6 7 8 9 10 |
var fs = require('fs'); var rs = fs.createReadStream('Readme.md'); var data = ''; // chunk是一个Buffer对象 rs.on("data", function (chunk) { data += chunk; // 实际上是调用Buffer.toString()完成操作 }); rs.on("end", function () { console.log(data); }); |
上面的代码把Buffer当做字符串处理,这在西方单字节编码方案里没有问题,但是遇到宽字符就会出现乱码。可以通过设置编码方式来解决:
1 2 |
var rs = fs.createReadStream('test.md', { highWaterMark: 11}); rs.setEncoding('utf-8'); |
这样设置后,回调函数接收到的就是解码后的字符串了。但是这种设置只能支持UTF-8等几种内置的编码方式。
要支持任意方式编码的字符串,还是要使用iconv-lite之类的第三方库:
1 2 3 4 5 6 7 8 9 10 11 12 |
var chunks = []; var size = 0; res.on('data', function (chunk) { chunks.push(chunk); size += chunk.length; }); res.on('end', function () { // 调用concat把Buffer的数组连接为一个完整的新的Buffer var buf = Buffer.concat(chunks, size); var str = iconv.decode(buf, 'utf-8'); console.log(str); }); |
Buffer在文件、网络I/O中被广泛使用,其性能很重要。我们常常需要把字符串转换为Buffer,以二进制的方式在网络上传输,在Web应用中这种转换发生的非常频繁,因此提高转换效率很重要。
可以考虑把静态内容预先转换为Buffer对象,以减少反复的转换带来的CPU消耗,提高性能。
读取文件系统时,选项highWaterMark对性能有很大影响:
1 2 3 4 5 6 7 8 |
fs.createReadStream(path, { flags: 'r', encoding: null, fd: null, mode: 0666, start: 0, end: 10000, // 文件读取的起止位置 highWaterMark: 64 * 1024 // 高水位设置 }); |
highWaterMark如果设置的过小,则可能导致系统调用次数过多。highWaterMark设置的越大,读取大文件的速度也就越大,但是设置的过大则是浪费。
Node.js是一个为网络而生的平台,利用它搭建网络服务非常的简单,只需要几行代码即可开启简单的网络服务。Node.js提供了net、dgram、http、https这几个模块,分别用于处理TCP、UDP、HTTP、HTTPS协议。
一个简单的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
let net = require('net'); let server = net.createServer( // 当一个新的套接字连接建立后,执行的回调 function (socket) { // 当对方发来数据后,执行的回调 socket.on('data', function (data) { socket.write(`Hello ${data} !`); }); // 当FIN时,执行的回调 socket.on('end', function () { console.log('Good bye !'); }); // 立即向对方发送的内容 socket.write("Who are you ?"); } ); server.listen(8088, function () { // 服务器启动后执行的回调 console.log('Server started.'); }); // 也可以这样编写: let server = net.createServer(); server.on('connection', function (socket) { // 新连接 }); server.listen(8088); |
监听UNIX Domain Socket也是支持的:
1 |
server.listen('/tmp/echo.sock'); |
由于Socket是一个可读写的Stream对象,因此可以使用管道,实现Echo服务:
1 2 3 4 |
let server = net.createServer(function (socket) { // 从此Socket读到的内容,原样写回去 socket.pipe(socket); }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let net = require('net'); let client = net.connect({host: '127.0.0.1', port: 8088}, function () { console.log('Connection opened.'); // 立即向对方发送的内容 client.write('Alex'); }); client.on('data', function (data) { // 接收到对方发送来的数据时 console.log(data.toString()); client.end(); }); client.on('end', function () { // FIN时 console.log('Connection closed.'); }); |
类型 | 说明 |
服务器事件 | |
listening | 服务器绑定端口或者UNIX Domain Socket并开始监听连接时,触发此事件。可以通过 server.listen(port,listeningListener) 的第二参数传入事件处理函数 |
connection | 单新的客户端套接字连接到服务器时触发。可以通过 net.createServer() 的入参传入事件处理函数 |
close | 当服务器关闭后触发。调用 server.close() 后服务器不再接受新连接,现有连接处理完毕后,该事件触发 |
error | 当服务器遇到错误,例如尝试监听已经被占用的端口时触发。如果不侦听此事件,异常被抛出 |
连接事件 由代表一个连接的Socket对象发布 |
|
data |
当一端调用 write() 发送数据时,另外一端会触发该事件,事件的参数就是write的数据 注意:TCP针对网络中的小报文就有优化算法Nagle,仅当缓冲区数据到达一定量、或者超过某个时间阈值,才会发送TCP报文。该算法默认启用以提高性能,要关闭可以: 用socket.setNoDelay(true) |
end | 连接中的任意一端发送FIN报文时,触发该事件 |
connect | 仅用于客户端,当成功连接到服务器时触发该事件 |
error | 当发生错误时,触发该事件 |
close | 当套接字完全关闭时,触发该事件 |
timeout | 当连接不活动超过一定时间后触发该事件,通知连接被闲置 |
1 2 3 4 5 6 7 8 9 10 11 12 |
var dgram = require( "dgram" ); var server = dgram.createSocket( "udp4" ); // 接收到客户端消息时触发 server.on( "message", function ( msg, rinfo ) { console.log( "Server got: " + msg + " from " + rinfo.address + ":" + rinfo.port ); } ); // 开始监听时触发 server.on( "listening", function () { var address = server.address(); console.log( "Server listening " + address.address + ":" + address.port ); } ); server.bind( 8088 ); |
1 2 3 4 5 6 |
var dgram = require( 'dgram' ); var message = new Buffer( "Hello Node!" ); var client = dgram.createSocket( "udp4" ); client.send( message, 0, message.length, 8088, "127.0.0.1", function ( err, bytes ) { client.close(); } ); |
类型 | 说明 |
message | 接收到UDP数据报后触发该事件,第一个参数是Buffer,第二个参数是发送者的信息 |
listening | 套接字开始监听时触发该事件 |
close | 调用close()方法时触发该事件 |
error | 发生错误的时触发该事件,如果不监听该事件,异常被抛出 |
Node.js提供的http模块,继承自net模块,由于支持HTTP协议的处理。http将对Stream的读写操作封装到ImcomeMessage、ServerResponse对象中,这两个对象分别代表请求、应答。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let http = require( 'http' ); let srv = http.createServer( function ( req, resp ) { // req为IncomeMessage,resp为ServerResponse console.log( req.method ); // 请求方法 console.log( req.url ); // 请求的URL,相对于服务器。访问http://localhost:8088/时值为 / console.log( req.headers ); // 请求头 // 写入响应头 resp.writeHead( 200, { 'Content-Type': 'text/plain' } ); // end与write的区别是,后者在写入数据的同时,还结束本次请求处理 resp.write( 'Hello ' ) resp.end( 'Node!' ); } ); srv.listen( 8088, '127.0.0.1' ); srv.on( 'connection', function ( socket ) { console.log(socket.remoteAddress); // 127.0.0.1 } ); |
注意:
- 响应头的设置必须在发送响应体之前进行
- 确保调用end()来结束请求处理,否则客户端一直处于等待
- end()之后,底层连接可能被关闭,也可能用于处理下一个请求
事件 | 说明 |
connection | 当底层TCP连接建立时触发该事件。注意连接可能开启了keep-alive,因而可能被多个请求使用 |
request | http模块从底层数据流中抽取出请求、响应。解析完请求头后,触发该事件。调用resp.end()后,当前连接可以用于下一个请求 |
close | 调用server.close()后,新连接不再被接收,现有请求处理完毕后触发该事件 |
checkContinue |
客户端上传大块数据时,不会直接发送。客户端会先发送一个带有Expect: 100-continue头的请求到服务器,此时该事件被触发。如果不监听该事件,服务器自动发送100 Continue状态码,客户端开始上传数据。你可以响应400 Bad Request拒绝上传 同一个请求中,request与该事件不会同时触发。客户端真正上传数据时,发送的是另外一个请求 |
connect | 客户端发起连接请求时触发 |
upgrade |
客户端需要升级所使用的协议时,需要与服务器协商。此时客户端发送带有Upgrade头的请求,服务器在接收到这种请求后触发该事件。与WebSocket有关 如果不监听该事件,发起请求的连接被关闭 |
clientError | 连接的客户端触发error事件时,该错误会传播到服务器,并触发该事件 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
var http = require( "http" ); var options = { // 服务器主机和端口 hostname: '127.0.0.1', port: 8088, // 请求的URL path: '/', // 请求方法 method: 'GET', // 使用哪个本地网络接口 localAddress: '127.0.0.1', // 使用UNIX Domain Socket时指定: socketPath: undefined, // 指定请求头 headers: undefined, // HTTP基本认证,自动作为Authorization请求头 auth: undefined }; // 创建一个ClientRequest对象 var req = http.request( options, function ( resp ) { // resp为IncomeMessage // 响应状态码 console.log( resp.statusCode ); // 响应头 console.log( JSON.stringify( resp.headers ) ); resp.setEncoding( 'utf8' ); // 接收到数据后执行的回调 resp.on( 'data', function ( chunk ) { console.log( chunk ); } ); } ); req.end(); |
客户端默认使用 http.globalAgent 作为代理对象,此代理为每个服务器维护最多5个TCP连接池。池中连接可以用于重复的请求发送。你可以自己指定连接池大小:
1 2 3 4 5 6 7 |
var agent = new http.Agent( { maxSockets: 10 } ); var options = { method: 'GET', agent: agent }; |
Agent对象的sockets、requests分别表示连接池中的连接数量、等待处理的请求数量。
可以直接设置 agent:fasle ,这样,客户端的并发连接数不受限制。
事件 | 说明 |
response | 对应服务器端的request事件。当服务器响应到达时,触发该事件 |
socket | 当连接池中的TCP连接分配给当前请求时,触发该事件 |
connect | 客户端发起CONNECT请求时,如果服务器响应了200状态码,触发该事件 |
upgrade | 客户端发起Upgrade请求时,如果服务器响应了101 Switching Protocols状态码,触发该事件 |
continue | 客户的发起Expect: 100-continue请求时,如果服务器响应了100 Continue,触发该事件 |
WebSocket是HTML5的重要特性,Node.js可以完美支持WebSocket。两者的编程模型几乎一致,而Node.js的事件驱动机制特别适合高并发的长连接。
WebSocket不是堆HTTP协议的再次封装,它是独立的协议。它主要由两部分组成:握手、数据传输。
握手是基于HTTP协议进行的,客户端发出具有特殊头的请求:
1 2 3 4 5 6 7 8 |
# 下面两个头表示将连接升级为WebSocket协议 Upgrade: websocket Connection: Upgrade # 这个Key用于安全性校验 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== # 子协议和版本号 Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13 |
服务器收到上述请求后,如果同意切换为WebSocket协议,则作出如下应答:
1 2 3 4 5 6 7 8 |
# 101响应,表示同意协议切换 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade # 用于安全性校验 Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= # 使用的子协议 Sec-WebSocket-Protocol: chat |
一旦握手成功,服务器 - 客户端将成为对等实体,双方都可以主动发送数据。发送的数据变为WebSocket数据帧,而不再是HTTP报文。
Node.js没有内置WebSocket库,可以使用社区提供的ws库。框架Socket.io就是基于ws库实现的。
为了应对HTTP、FTP等协议明文传输导致的安全性问题,网景公司引入了SSL(安全套接字层,Secure Sockets Layer)协议。SSL在传输层上方完成加密、解密工作,对于应用层是透明的。后来IETF将SSL标准化,成为TLS(Transport Layer Security,传输层安全)协议。
Node.js在网络安全方面提供了三个模块:crypto 、 tls 、 https。其中crypto用于加解密,SHA1、MD5等经典算法都包含在其中;tls模块类似于net,可以建立基于TLS/SSL加密的TCP协议;https模块则提供https协议的支持。
Node.js基于OpenSSL实现TLS/SSL,你可以利用openssl命令来生成通信需要的密钥对:
1 2 3 4 |
// 生成服务器密钥对 openssl genrsa -out server.key 1024 // 客户端密钥对类似 openssl genrsa -out client.key 1024 |
为了防止中间人攻击,公钥需要被CA签名,形成证书。我们可以使用自签名证书,即自己扮演CA角色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 生成CA密钥对 openssl genrsa -out ca.key 1024 # 生成CA根证书的请求,需要填写一系列表明身份的字段,Common Name必须和服务器域名匹配,否则无法正常使用 openssl req -new -key ca.key -out ca.csr # 使用CA私钥对CA公钥进行自签名,得到CA根证书 openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt # 生成服务器证书请求 openssl req -new -key server.key -out server.csr # 生成服务器证书 openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in server.csr -out server.crt # 客户端证书类似 openssl req -new -key client.key -out client.csr openssl x509 -req -CA ca.crt -CAkey ca.key -CAcreateserial -in client.csr -out client.crt |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var tls = require( 'tls' ); var fs = require( 'fs' ); var options = { // 服务器私钥、服务器证书 key: fs.readFileSync( './keys/server.key' ), cert: fs.readFileSync( './keys/server.crt' ), // 是否要求客户端提供证书,注意客户端总是要求服务器提供证书 requestCert: true, // CA的根证书,用于验证客户端证书的合法性 ca: [ fs.readFileSync( './keys/ca.crt' ) ] }; var server = tls.createServer( options, function ( stream ) { // 如果客户端证书被CA签名,则stream.authorized为true console.log( 'Client connected ', stream.authorized ? 'authorized' : 'unauthorized' ); stream.write( "Welcome!" ); stream.setEncoding( 'utf-8' ); stream.pipe( stream ); } ); server.listen( 8088, function () { console.log( 'Server bound' ); } ); |
可以使用下面的命令验证上述服务器能否正常工作:
1 |
openssl s_client -connect 127.0.0.1:8088 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var tls = require( 'tls' ); var fs = require( 'fs' ); var options = { key: fs.readFileSync( './keys/client.key' ), cert: fs.readFileSync( './keys/client.crt' ), ca: [ fs.readFileSync( './keys/ca.crt' ) ] }; var stream = tls.connect( 8088, options, function () { process.stdin.pipe( stream ); } ); stream.setEncoding( 'utf8' ); stream.on( 'data', function ( data ) { console.log( data ); } ); stream.on( 'end', function () { stream.close(); } ); |
1 2 3 4 5 6 7 8 9 10 |
var https = require( 'https' ); var fs = require( 'fs' ); var options = { key: fs.readFileSync( './keys/server.key' ), cert: fs.readFileSync( './keys/server.crt' ) }; https.createServer( options, function ( req, res ) { res.writeHead( 200 ); res.end( "Hello Node!" ); } ).listen( 8088 ); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var https = require( 'https' ); var fs = require( 'fs' ); var options = { hostname: '127.0.0.1', port: 8088, path: '/', method: 'GET', key: fs.readFileSync( './keys/client.key' ), cert: fs.readFileSync( './keys/client.crt' ), ca: [ fs.readFileSync( './keys/ca.crt' ) ] }; options.agent = new https.Agent( options ); var req = https.request( options, function ( res ) { res.setEncoding( 'utf-8' ); res.on( 'data', function ( d ) { console.log( d ); } ); } ); req.end(); req.on( 'error', function ( e ) { console.log( e ); } ); |
Node.js是在单个线程中执行JavaScript的,要想利用多核CPU的优势,必须创建额外的进程。
Node.js中未捕获的异常会导致进程直接退出,这影响了其健壮性,应该使用额外的进程防止应用程序崩溃。
利用child_process模块,利用创建子进程,fork()函数可以复制当前进程:
1 2 3 4 5 6 7 |
#!/usr/bin/env node var http = require( 'http' ); http.createServer( function ( req, res ) { res.writeHead( 200, { 'Content-Type': 'text/plain' } ); res.end( 'Hello World\n' ); } ).listen( Math.round( (1 + Math.random()) * 10000 ), '127.0.0.1' ); |
1 2 3 4 5 |
var fork = require('child_process').fork; var cpus = require('os').cpus(); for (var i = 0; i < cpus.length; i++) { fork('./worker.js'); // 复制当前进程,然后执行指定的脚本 } |
执行 node master.js 后,会创建额外的子进程,其数量等于CPU核心数。
这种模式被称为主从模式,主进程不负责具体的业务逻辑,仅仅完成工作进程的调度和管理,它的功能单一,容易达到稳定状态,一般不会崩溃。从进程负责各种业务的处理,可能因为意外崩溃,但是不会导致整个应用程序宕机。
除了增强健壮性之外,主进程还可以根据需要增加子进程的数量,因而具有可伸缩性。
child_process提供多种创建子进程的方法。它们均返回子进程对象:
方法 | 说明 |
spawn() |
启动一个子进程来执行命令 与后两者不同的是:exec、execFile可以指定timeout,超时后子进程被杀死 |
exec() | 启动一个子进程来执行命令,可以通过回调函数获得子进程的状态 |
execFile() | 启动一个子进程来执行可执行文件 |
fork() | 类似于spawn(),但是执行的目标是JavaScript模块 |
父进程可以在子进程对象上监听以下事件:
事件 | 说明 |
message | 进程间通信时发布的事件 |
error | 如果子进程无法被创建、杀死、发送消息时触发该事件 |
exit |
子进程退出时触发该事件。监听器接受两个参数:
|
close | 子进程的标准输入/输出流关闭时触发,参数与exit同 |
disconnect | 在父进程或者子进程中调用disconnect()时触发,导致IPC通道关闭 |
父进程调用子进程的 kill([signal]) 方法时,子进程将收到以POSIX信号为名的事件:
1 2 3 4 5 |
// 父进程不指定具体信号时,默认发送SIGTERM信号,子进程的正常行为应该是退出 process.on( 'SIGTERM', function () { console.log( 'Got a SIGTERM, exiting...' ); process.exit( 1 ); } ); |
Node.js中父子进程之间的通信方式,类似于HTML5中Web Worker与JavaScript主线程之间的通信方式:
1 2 3 4 5 6 7 |
var cp = require( 'child_process' ); var cp = cp.fork( __dirname + '/worker.js' ); // 在子进程上注册监听器,以获得子进程发来的消息 cp.on( 'message', function ( m ) { console.log( 'Message from child: ', m ); } ); cp.send( { hello: 'world' } ); |
1 2 3 4 5 |
// 在当前进程对象上注册监听器,以获得父进程发来的消息 process.on( 'message', function ( m ) { console.log( 'Message from parent:', m ); } ); process.send( { foo: 'bar' } ); |
上面的例子中我们让多个子进程分别监听不同的端口,这不符合Web服务器应用场景。我们需要的是,多个子进程都能处理同一端口监听到的新请求。
解决此问题的一种方式是,添加一个前端代理,把监听到的请求转发给某一个子进程处理。
Node.js从v0.5.9版本开始,支持通过进程间通信发送句柄(Handle)。句柄是执行某种资源的引用,这些资源包括:Socket套接字、管道等。考虑下面的代码:
1 2 3 4 5 6 7 8 9 |
var child = require( 'child_process' ).fork( 'child.js' ); var server = require( 'net' ).createServer(); server.on( 'connection', function ( socket ) { socket.end( 'Handled by parent\n' ); } ); server.listen( 8088, function () { // 向子进程发送句柄 child.send( 'server', server ); } ); |
1 2 3 4 5 6 7 |
process.on( 'message', function ( type, server ) { if ( type === 'server' ) { server.on( 'connection', function ( socket ) { socket.end( 'Handled by child' ); } ); } } ); |
启动服务后,多次发起连接,可以看到,请求随机的被父、子进程处理。由于主进程不负责业务处理,我们可以在发送句柄后,关闭主进程中的套接字:
1 2 3 4 5 6 7 |
server.listen( 8088, function () { // 向子进程发送句柄 child1.send( 'server', server ); child2.send( 'server', server ); // 关闭主进程中的套接字 server.close(); } ); |
为何套接字可以在多个进程中共享,甚至在父进程中关闭套接字后,子进程居然不受影响呢?
实际上,Node.js在底层使用了prefork技术。当你调用send()发送句柄时,Node.js自动通过IPC通道发送服务器套接字的文件描述符。此描述符只是一个整数值,但是不能直接传送,必须基于特殊的IPC通道(Linux下是UNIX domain sockets)才能有效的传递给其它进程。
只要得到代表服务器套接字的文件描述符,任何进程都可以针对它调用 accept() 来等待连接请求。至于连接到底发送给哪个进程处理,取决于操作系统的调度,每个进程都有抢占文件描述符的机会。
我们利用进程相关的事件机制,可以实现:
- worker退出后,由master自动重新创建新的worker代替它
- worker遇到未捕获异常时,自动退出,因为数据状态可能已经被破坏
实现这两点,可以提高健壮性。示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var fork = require( 'child_process' ).fork; var cpus = require( 'os' ).cpus(); var server = require( 'net' ).createServer(); server.listen( 8088 ); // 持有子进程的集合 var workers = {}; var createWorker = function () { var worker = fork( __dirname + '/worker.js' ); // 监听子进程的退出事件 worker.on( 'exit', function () { // 重新创建一个子进程 delete workers[ worker.pid ]; createWorker(); } ); // 发送句柄 worker.send( 'server', server ); workers[ worker.pid ] = worker; }; for ( var i = 0; i < cpus.length; i++ ) { createWorker(); } |
1 2 3 4 5 6 7 8 9 10 11 |
var http = require( 'http' ); var server = http.createServer( function ( req, res ) { /* Web服务器逻辑 */ } ); // 捕获未被处理的异常 process.on( 'uncaughtException', function () { // 执行资源清理 // 如果资源清理比较耗时,可以提前通知master当前进程即将自杀,让父进程尽快创建新的worker // 然后,自杀 process.exit( 1 ); } ); |
使用操作系统默认调度机制,让多个worker去抢占请求,具有缺陷。因为抢占是和CPU相关的,缺乏计算压力的worker会更容易抢占到请求,但是这种worker在I/O方便压力可能反而很大。这导致了负载不均衡。
Node引入了一种新的负载均衡机制——循环调度(Round-Robin)。其工作方式是,让master接受连接,然后依次分发给子进程。
在cluster模块中,启用此负载均衡机制的方法是:
1 2 3 4 |
// 启用Round-Robin cluster.schedulingPolicy = cluster.SCHED_RR; // 不启用Round-Robin cluster.schedulingPolicy = cluster.SCHED_NONE; |
你也可以设置环境变量:
1 2 |
export NODE_CLUSTER_SCHED_POLICY=rr export NODE_CLUSTER_SCHED_POLICY=none |
RR策略也可以通过前置的代理服务器实现,缺点是消耗两倍的文件描述符。操作系统支持的最大打开文件总数量、进程打开文件总数量是有限制的。
Node.js中不适合存放太多数据,因为V8引擎本身不是为服务器场景设计的,它不适合管理太大内存。
我们可以使用第三方数据存储方案,例如RDBMS、文件系统、缓存服务(Redis等)。
Node.js从v.08版本开始引入Cluster模块,内置了master/worker模式的支持。你不再需要通过child_process自己实现集群了。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var cluster = require( 'cluster' ); var http = require( 'http' ); var numCPUs = require( 'os' ).cpus().length; if ( cluster.isMaster ) { // master进程的逻辑 for ( var i = 0; i < numCPUs; i++ ) { cluster.fork(); } cluster.on( 'exit', function ( worker, code, signal ) { // worker进程退出时的回调 } ); } else { // 子进程可以共享任何TCP监听 http.createServer( function ( req, res ) { } ).listen( 8088 ); } |
判断一个进程是master还是worker,主要依赖于环境变量:
1 2 3 |
// 判断当前进程 cluster.isWorker = ('NODE_UNIQUE_ID' in process.env); cluster.isMaster = (cluster.isWorker === false); |
可以使用 cluster.setupMaster() 调用,将master/worker的代码分离:
1 2 3 4 5 6 7 8 |
var cluster = require( 'cluster' ); cluster.setupMaster( { exec: "worker.js" // worker的代码分离出去 } ); var cpus = require( 'os' ).cpus(); for ( var i = 0; i < cpus.length; i++ ) { cluster.fork(); } |
事件 | 说明 |
fork | 复制一个worker进程后触发 |
online | 复制好worker后,worker主动发一条online消息给master,master接收到该消息后触发online事件 |
listening | worker中调用listen()对共享的服务器套接字进行监听后,发送一条listening消息给master,master接收到该消息后触发listening事件 |
disconnect | master - worker之间的IPC通道断开后触发 |
exit | worker退出后触发 |
setup | 调用cluster.setupMaster()后触发该事件 |
Node.js提供了一个assert模块,可以进行基本的断言测试,如果测试失败会导致异常抛出。示例代码:
1 2 |
var assert = require( 'assert' ); assert.equal( Math.max( 1, 100 ), 100 ); |
常用的断言方法包括:
方法 | 说明 |
ok() | 断言结果为真 |
equal() | 断言实际指等于期望值 |
notEqual() | 断言实际指不等于期望值 |
deepEqual() | 深度相等性比较,会比较对象的属性、数组的元素 |
notDeepEqual() | |
strictEqual() | 使用 === 而不是 == 比较 |
notStrictEqual() | |
throws() | 断言代码块抛出异常 |
doesNotThrow() | 断言代码块不抛出异常 |
ifError() | 断言结果为假—— null 、 undefined 、 0 、 '' 、 false |
第三方的断言框架包括:should.js。
参考:mocha学习笔记
Node.js在处理静态文件方面没有优势,可以把图片、样式表、多媒体文件都交由静态文件服务器处理,Node.js仅负责处理动态请求。可以利用Nginx来搭建静态文件服务器,或者使用CDN。
如果Node.js需要反复发送某些静态的部分,可以将其存放在Buffer中,不要重复字符串与Buffer之间的转换。
考虑使用Redis、Memcached等缓存框架,避免不必要的底层数据访问。
利用官方的cluster模块,或者第三方的pm、forever、pm2等模块,可以利用多核CPU的优势、增强应用健壮性。