Stream 流

Stream 是一个抽象接口,Node 中有很多对象实现了这个接口。例如,对 http 服务器发起请求的 request 对象就是一个 Stream,还有 stdout(标准输出)

Node.js Stream 有四种流类型:

  • Readable - 可读操作
  • Writable - 可写操作
  • Duplex - 可读可写操作
  • Transform - 操作被写入数据,然后读出结果

所有的 Stream 对象都是 EventEmitter 的实例。常用的事件有:

  • data - 当有数据可读时触发
  • end - 没有更多的数据可读时触发
  • error - 在接收和写入过程中发生错误时触发
  • finish - 所有数据已被写入到底层系统时触发

模块机制

Node 的模块实现

在 Node 中引入模块,需要经历一下 3 个步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行

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

  • 核心模块部分在 Node 源代码的编译过程中,编译进了二进制执行文件。在 Node 进程启动时,部分核心模块就被直接加载进内存中,这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的
  • 文件模块是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢

不论是核心模块还是文件模块,require() 方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。不同之处在于核心模块的缓存检查优先于文件模块的缓存检查

如果标识符不包含文件扩展名,Node 会按 .js、.json、.node 的次序补足扩展名,调用 fs 模块同步阻塞式地判断文件是否存在

require() 分析文件扩展名后,如果没有找到对应的文件,但得到了一个目录,Node 会在当前目录下查找 package.json,通过 JSON.parse() 解析出描述对象,从中取出 main 属性指定的文件名进行定位,如果 main 属性指定的文件名错误,或者没有 package.json 文件,Node 会将 index 当作默认文件名,然后依次查找 index.js、index.json、index.node,如果还是没有,会进入下一个模块路径进行查找,如果模块路径数组都被遍历完毕依然没有,就抛出查找失败的异常

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

在编译的过程中,Node 对获取的 JavaScript 文件内容进行了头尾包装,一个正常的 JavaScript 文件会被包装成如下的样子:

1
2
3
4
5
6
(function(exports, require, module, __filename, __dirname){
var math = require('math')
exports.area = function(radius){
return Math.PI * radius * radius
}
})

这样每个模块文件之间都进行了作用域隔离。包装之后的代码通过 vm 原生模块的 runInThisContext() 方法执行,返回一个具体的 function 对象。最后,将当前模块对象的 exports 属性、require() 方法、module(模块对象自身)、以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个 function() 执行。执行后,模块的 exports 属性被返回给了调用方。exports 属性上的任何方法和属性都可以被外部调用到

exports 和 module.exports 的区别
exports 对象是通过形参的方式传入的,直接赋值会改变形参的引用,但是并不能改变作用域外的值。如果要达到引入一个类的效果,就赋值给 module.exports 对象(这个迂回的方案不改变形参的引用)

包与 NPM

包结构

包是一个存档文件,即一个目录直接打包为 .zip 或者 tar.gz 格式的文件,安装后解压还原为目录。完全符合 CommonJS 规范的包目录应该包含如下这些文件:

  • package.json 包描述文件
  • bin 用于存放可执行二进制文件的目录
  • lib 用于存放 JavaScript 代码的目录
  • doc 用于存放文档的目录
  • test 用于存放单元测试用例的代码

包描述文件与 NPM

包描述文件,即 package.json,用于表达非代码相关的信息
CommonJS 为 package.json 文件定义了如下必须的字段:

  • name 包名。由小写字母和数字组成,可以包含.、-和_。包名是唯一的

  • description 包简介

  • version 版本号。通常为 major.minor.revision 格式。详见→点我查看

  • keywords 关键词数组。用来做分类搜索

  • maintainers 包维护者列表。每个维护者由 name、email 和 web 3个属性组成。NPM 通过该属性进行权限认证

  • contributors 贡献者列表。格式与维护者列表相同

  • bugs 一个可以反馈 bug 的网页地址或邮件地址

  • licenses 当前包使用的许可证列表。格式如下:

    “licenses”: [{“type”:”GPLv2”, “url”:”http://www.example.com/licenses/gpl.html"}]

  • repositories 托管源代码的位置列表。表明可以通过哪些方式和地址访问包的源代码

  • dependencies 使用当前包所需要依赖的包列表

除了必选字段外,还定义了一部分可选字段:

  • homepage 当前包的网站地址

  • os 操作系统支持列表。如果设置了列表为空,则不做任何假设

  • cpu CPU架构的支持列表。如果设置了列表为空,则不做任何假设

  • engine 支持的 JavaScript 引擎列表

  • builtin 标志当前包是否是内建在底层系统的标准组件

  • derectories 包目录说明

  • implements 实现规范的列表。标志当前包实现了 CommonJS 的哪些规范

  • scripts 脚本说明对象。它主要被包管理器用来安装、编译、测试和卸载包。示例如下:

    1
    2
    3
    4
    5
    6
    7
    "scripts": {
    "install": "install.js",
    "uninstall": "uninstall.js",
    "build": "build.js",
    "doc": "make-doc.js",
    "test": "test.js"
    }

NPM 实际需要的字段主要有 name、version、description、keywords、repositories、author、bin、main、scripts、engines、dependencies、devDependencies,其中:

  • author 包作者
  • bin 用于存放可执行二进制文件的目录,使包可以作为命令行工具使用
  • main 模块入口。如果不存在,require()方法会查找目录下的 index.js、index.json、index.node 文件作为默认入口
  • devDependencies 只在开发时使用的包

NPM 常用功能

CommonJS 包规范是理论,NPM 是其中的一种实践

全局模式安装

全局模式并不是将一个模块包安装为一个全局包的意思,它并不意味着可以在任何地方通过 require() 来引用到它

实际上,-g是将一个包安装为全局可用的可执行命令。它根据包描述文件中的 bin 字段配置,将实际脚本链接到与 Node 可执行文件相同的路径下

软链接:
也叫符号链接(Symbolic Link)。软链接文件有类似于 Windows 的快捷方式。它实际上是一个特殊的文件,在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息

本地安装

本地安装只需为 NPM 指明 package.json 文件所在的位置即可,它可以是:

  • 一个包含 package.json 的存档文件
  • 一个 URL 地址
  • 一个目录下有 package.json 文件的目录位置

示例

1
2
3
npm install <tarball file>
npm install <tarball url>
npm install <folder>

从非官方源安装

  • 安装时指定包的源地址

    1
    npm install underscore --registry=http://registry.url
  • 修改默认源

    1
    npm config set registry http://registry.url

局域NPM

局域NPM仓库的搭建方法与搭建镜像站的方式几乎一样。不同的地方在于,企业局域NPM可以选择不同步官方源仓库中的包

异步I/O

Node 的异步 I/O

完成整个异步I/O环节的有事件循环、观察者和请求对象等

事件循环

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

Tick流程图

观察者

每个事件循环中有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事情

这个过程好比饭馆的厨房,厨房一轮一轮的制作菜肴,每做完一轮就去问收银小妹接下来还有没有要做的菜,没有的话,就下班。收银小妹就是观察者,她收到的客人点单就是关联的回调函数

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

在 Windows 下,这个循环基于 IOCP 创建,而在 *nix 下则基于多线程创建

请求对象

从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,叫做请求对象

fs.open()方法调用示意图

从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里经典的调用方式

整个异步i/o的流程

事件循环、观察者、请求对象、I/O线程池这四者共同构成了 Node 异步 I/O 模型的基本要素

非 I/O 的异步 API

定时器

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

setTimeout()的行为

process.nextTick()

每次调用 process.next() 方法,会将回调函数放入队列中,在下一轮 Tick 时取出执行。定时器中采用红黑树的操作时间复杂度为 0(lg(n)),nextTick()的时间复杂度为 0(1)。相较之下,process.nextTick()更高效

setImmediate()

setImmediate()方法与 process.nextTick()方法十分类似,都是将回调函数延迟执行

process.nextTick()中的回调函数执行的优先级要高于 setImmediate()。原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于 idle 观察者,setImmediate()属于 check 观察者。在每一个轮循环检查中,idle 观察者优于 I/O 观察者,I/O 观察者先于 check 观察者

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
process.nextTick(function(){
console.log('nextTick延迟执行1')
})
process.nextTick(function(){
console.log('nextTick延迟执行2')
})
setImmediate(function(){
console.log('setImmediate延迟执行1')
process.nextTick(function(){
console.log('强势插入')
})
})
setImmediate(function(){
console.log('setImmediate延迟执行2')
})
console.log('正常执行')

// 正常执行
// nextTick延迟执行1
// nextTick延迟执行2
// setImmediate延迟执行1
// 强势插入
// setImmediate延迟执行2

事件驱动与高性能服务器

事件驱动,即通过主循环加事件触发的方式来运行程序

利用Node构建Web服务器的流程图

几种经典的服务器模型:

  • 同步式 同步式服务一次只能处理一个请求,并且其余请求都处于等待状态
  • 每进程/每请求 为每个请求启动一个进程。由于系统资源是固定的,因此该方法不具备扩展性
  • 每线程/每请求 为每个请求启动一个线程来处理。尽管线程比进程轻量,但由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢

Node 通过事件驱动的方式处理请求,无须为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低,这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受线程上下文切换开销的影响,这是 Node 高性能的一个原因

异步编程

函数式编程

高阶函数

在通常的语言中,函数的参数只接受基本的数据类型或是对象引用,返回值也只是基本数据类型和对象引用。高阶函数则是可以把函数作为参数,或是将函数作为返回值的函数

异步编程的优势与难点

优势

处理 I/O 密集问题

异步I/O调用的示意图

难点

1、异常处理

异步 I/O 的实现主要包含两个阶段:提交请求和处理结果。这两个阶段中间有事件循环的调度,两者彼此不关联

Node 在处理异常上形成了一种约定,将异常作为回调函数的第一个实参传回,如果为空值,则表明异步调用没有异常抛出(错误优先原则)

我们在自行编写异步方法时,也需要去遵循一些原则:

  • 原则一:必须执行调用者传入的回调函数
  • 原则二:正确传递回异常供调用者判断

2、函数嵌套过深

3、阻塞代码

4、多线程编程
web workers的工作示意图

JavaScript 的单线程在浏览器中指的是 JavaScript 执行线程与 UI 渲染共用的一个线程

5、异步转同步

异步编程解决方案

事件发布/订阅模式

1
2
3
4
5
// 示例代码
emitter.on('event1', function(message){
console.log(message)
})
emitter.emit('event1, 'I am message')

可以看到,订阅事件就是一个高阶函数的应用。事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器

Node 对事件发布/订阅的机制做了一些额外的处理,这大多是基于健壮性而考虑的。下面为两个具体的细节点:

  • 如果对一个事件添加了超过10个侦听器,将会得到一条警告。Node设计者认为侦听器太多可能导致内存泄漏,所以存在这样一条警告。调用 emitter.setMaxListeners(0); 可以将这个限制去掉
  • 为了处理异常,EventEmitter 对象对 error 事件进行了特殊对待。如果运行期间的错误出发了 error 事件,EventEmitter 会检查是否有对 error 事件添加过侦听器。如果添加了,这个错误将会交由该侦听器处理,否则将会作为异常抛出。如果外部没有捕获这个异常,将会引起线程退出

雪崩问题:在高访问量、大并发量的情况下缓存失效的情景,此时大量的请求同时涌入数据库中,数据库无法同时承受如此大的查询请求,进而往前影响到网站整体的响应速度

由于多个异步场景中回调函数的执行并不能保证顺序,且回调函数之间互相没有任何交集,所以需要借助一个第三方函数和第三方变量来处理异步写作的结果。通常,我们把这个用于检测次数的变量叫做哨兵变量

1
2
3
4
5
6
7
8
9
10
11
12
// 利用偏函数来处理哨兵变量和第三方函数的关系
var after = function (times, callback){
var count = 0, results = {}
return function (key, value){
results[key] = value
count++
if(count === times){
callback(results)
}
}
}
var done = after(times, render)

Promise/Deferred模式

流程控制库

1、尾触发与 Next

尾触发:需要手工调用才能持续执行后续调用。常见的关键词是 next。尾触发目前应用最多的地方是 Connect 的中间件

中间件通过队列形成一个处理流

内存控制

V8 的垃圾回收机制与内存限制

V8 的内存限制

64位系统下约为1.4GB,32位系统下约为0.7GB

V8 的对象分配

在 V8 中,所有的 JavaScript 对象都是通过堆来进行分配的。Node 提供了 V8 中内存使用量的查看方式,代码如下:

1
2
3
4
5
node
process.memoryUsage()
rss: 14958592
heapTotal: 7195904 // 堆内存使用情况 - 已申请到的堆内存
heapUsed: 2821496 // 堆内存使用情况 - 当前使用的量

V8 的垃圾回收机制

V8 主要的垃圾回收算法:

在 V8 中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象

  • Scavenge 算法

    在分代的基础上,新生代中的对象主要通过 Scavenge 算法进行垃圾回收。在 Scavenge 的具体实现中,主要采用了 Cheney 算法

    Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,一个处于使用中,称为 From 空间;另一个处于闲置状态,称为 To 空间。对象先在 From 空间中进行分配,当开始进行垃圾回收时,会检查 From 空间中的存活对象,将存活对象复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色互换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制

    Scavenge 的缺点是只能使用堆内存的一半,但由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异得表现。因此,Scavenge 非常适合应用在新生代中

    V8 的堆内存示意图如下所示:

    v8的堆内存示意图

    当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象,这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理,对象从新生代中移动到老生代中的过程称为晋升

    对象晋升的条件主要有两个,一个是对象是否经历过 Scavenge 回收,一个是 To 空间的内存占用比是否超过限制(25%)

    晋升流程
    晋升的判断示意图

  • Mark-Sweep & Mark-Compact

    Mark-Sweep 分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没被标记的对象。活对象在新生代中只占较小部分,所以 Scavenge 中只复制活着的对象,而死对象在老生代中占较小部分,所以 Mark-Sweep 只清理死亡对象,这是两种回收方式能高效处理的原因。下图为 Mark-Sweep 在老生代空间中标记后的示意图,黑色部分标记为死亡对象

    mark-sweep在老生代空间中标记后的示意图

    Mark-Sweep 在进行一次标记清除回收后,内存空间会出现不连续的状态,为解决内存碎片问题,Mark-Compact 在 Mark-Sweep 的基础上演变而来。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。如下图所示,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞

    mark-compact完成标记并移动存活对象后的示意图

    3种垃圾回收算法的简单对比
    从上表可以看出,在 Mark-Sweep 和 Mark-Compact 之间,由于 Mark-Compact 需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用 Mark-Compact

  • Incremental Marking

    为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)

    为了降低全堆垃圾回收带来的停顿时间,V8 从标记阶段下手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让 JavaScript 应用逻辑执行一会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。如下图所示

    增量标记示意图

V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction)等技术

高效使用内存

在 V8 面前,开发者所要具备的责任是如何让垃圾回收机制更高效地工作

作用域

由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。如果需要释放常驻内存的对象,可以通过 delete 操作来删除引用关系,或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清除和整理的过程中,会被回收释放

同样,如果在非全局作用域中,想主动释放变量引用的对象,也可以通过这样的方式。在 V8 中通过 delete 删除对象的属性有可能干扰 V8 的优化,所以通过赋值方式解除引用更好

闭包

1
2
3
4
5
6
7
8
9
10
var foo = function(){
var bar = function(){
var local = '局部变量'
return function(){
return local
}
}
var baz = bar()
console.log(baz())
}

一般而言,在 baz() 函数执行完成后,局部变量 local 将会随着作用域的销毁而被回收。但是注意这里的特点在于返回值是一个匿名函数,且这个函数中具备了访问 local 的条件。

闭包是 JavaScript 的高级特性,利用它可以产生很多巧妙的效果。它的问题在于:一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放,除非不再有引用

内存指标

进程的内存总共有几部分:
1、常驻内存(rss:resident set size)
2、交换器(swap)
3、文件系统(filesystem)

1
2
3
process.memoryUsage() // 查看 node 进程的内存占用情况
os.totalmem() // 查看系统总内存
os.freemem() // 查看系统的闲置内存

不是通过 V8 分配的内存称为堆外内存(Buffer)

内存泄漏

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

  • 缓存
  • 队列消费不及时
  • 作用域未释放

大内存应用

由于 V8 的内存限制,我们无法通过 fs.readFile() 和 fs.writeFile() 直接进行大文件的操作,而改用 fs.createReadStream() 和 fs.createWriteStream() 方法通过流的方式实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入到另一个文件的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
var reader = fs.createReadStream('in.txt')
var writer = fs.createWriteStream('out.txt')
reader.on('data', function(chunk){
writer.write(chunk)
})
reader.on('end', function(){
writer.end()
})

// 由于读写模型固定,上述方法有更简洁的方式
var reader = fs.createReadStream('in.txt')
var write = fs.createWriteStream('out.txt')
reader.pipe(writer)

通过流的方式,上述代码不会受到 V8 内存限制的影响。但是这种大片使用内存的情况依然要小心,即使 V8 不限制堆内存的大小,物理内存依然有限制

理解 Buffer

Buffer 结构

模块结构

Buffer 是一个典型的 JavaScript 与 C++ 结合的模块,它将性能相关部分用 C++ 实现,将非性能相关的部分用 JavaScript 实现,如图所示:

buffer的分工

Node 在进程启动时就已经加载了它,并将其放在全局对象(global)上,所以在使用 Buffer 时,无须通过 require() 即可直接使用

Buffer 对象

Buffer 对象类似于数组,它的元素为16进制的两位数,即0到255的数值

1
2
3
4
var str = "深入浅出node.js"
var buf = new Buffer(str, 'utf-8')
console.log(buf)
// <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 6e 6f 64 65 2e 6a 73>

由上面的示例可见,不同编码的字符串占用的元素个数各不相同,上面代码中的中文在 UTF-8 编码下占用3个元素,字母和半角标点符号占用1个元素

Buffer 受 Array 类型的影响很大,可以访问 length 属性得到长度,也可以通过下标访问元素,在构造对象时也十分相似

1
2
3
4
5
6
7
8
9
10
11
12
13
var buf = new Buffer(100)
console.log(buf.length) // 100
// 上述代码分配了一个长 100 字节的 buffer 对象,可以通过下标访问刚初始化的 Buffer 的元素
console.log(buf[10]) // 这里的结果时一个0到255之间的随机值
// 通过下标进行赋值
buf[10] = 100
console.log(buf[10]) // 100
buf[20] = -100
console.log(buf[20]) // 156
buf[21] = 300
console.log(buf[21]) // 44
buf[22] = 3.1415
console.log(buf[22]) // 3

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

Buffer 内存分配

Buffer 对象内存分配不是在 V8 堆内存中,而是在 Node 的 C++ 层面实现内存的申请的。为了高效地使用申请来的内存,Node 采用了 slab 分配机制

简而言之,slab 就是一块申请好的固定大小的内存区域,具有如下3种状态:

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

可以通过以下方式分配指定大小的 Buffer 对象:

1
new Buffer(size)

Node 以 8KB 为界限来区分 Buffer 是大对象还是小对象,这个 8KB 的值也就是每个 slab 的大小值,在 JavaScript 层面,以它为单位单元进行内存的分配

Buffer 的转换

Buffer 对象可以与字符串之间相互转换。目前支持的字符串编码类型有如下几种:

  • ASCII
  • UTF-8
  • UTF-16LE/UCS-2
  • Base64
  • Binary
  • Hex

字符串转 Buffer

字符串转 Buffer 对象主要是通过构造函数完成的

1
new Buffer(str, [encoding])

通过构造函数转换的 Buffer 对象,存储的只能是一种编码类型。encoding 参数不传时,默认按 UTF-8 编码进行转换和存储

也可以使用 write() 方法存储不同编码类型的字符串转码的值

1
buf.write(string, [offset], [length], [encoding])

Buffer 转字符串

Buffer 对象的 toString() 可以将 Buffer 对象转换为字符串

1
buf.toString([encoding], [start], [end])

可以设置 encoding(默认为 UTF-8),start、end 这3个参数实现整体或局部的转换。如果 Buffer 对象由多种编码写入,就需要在局部指定不同的编码,才能转换回正常的编码

Buffer 不支持的编码类型

由于 Node 的 Buffer 对象支持的编码类型有限,为此,Buffer 提供了一个 isEncoding() 函数来判断编码是否支持转换

1
Buffer.isEncoding(encoding)

将编码类型作为参数传入上面的函数,如果支持转换返回值为 true,否则为 false

对于不支持的编码类型,可以借助 Node 生态圈中的模块完成转换

Buffer 拼接

Buffer 在使用场景中,通常是以一段一段的方式传输。以下是常见的从输入流中读取内容的示例代码:

1
2
3
4
5
6
7
8
9
10
var fs = require('fs)

var rs = fs.createReadStream('test.md')
var data = ''
rs.on('data, function(chunk){
data += chunk
})
rs.on('end', function(){
console.log(data)
})

上述代码一旦输入流中有宽字节编码时,就会出现问题,即出现�乱码符号

问题在于这面这段代码

1
data += chunk

这段代码里隐藏了 toString() 操作,它等价于如下的代码

1
data = data.toString() + chunk.toString()

英文环境下,这个 toString() 不会造成任何问题,但对于宽字节的中文,却会形成问题。点我查看示例代码

乱码是如何产生的

上面的例子中,原始 Buffer 的长度为72。由于我们限定了 Buffer 对象的长度为11,因此只读流需要读取7次才能完成完整的读取。上面提到,默认编码为 UTF-8,中文字在 UTF-8 下占3个字节,所以第一个 Buffer 对象在输出时,只能显示3个字符,剩下的两个字节以乱码的形式显示,第二个 Buffer 对象的第一个字节也不能形成文字,只能显示乱码。于是形成一些文字无法正常显示的问题(不知道电子书上的乱码会不会是这个原因)

setEncoding() 与 string_decoder()

setEncoding() 为可读流设置编码。让 data 事件中传递的不再是一个 Buffer 对象,而是编码后的字符串

点我查看示例代码

在调用 setEncoding() 时,可读流对象在内部设置了一个 decoder 对象,每次 data 事件都通过该 decoder 对象进行 Buffer 到字符串的解码,然后传递给调用者。decoder 对象来自于 string_decoder 模块 StringDecoder 的实例对象,StringDecoder 在得到编码后,直到宽字节字符串在 UTF-8 编码下是以3个字节的方式存储的,所以第一次 write() 时,只输出前9个字节转码形成的字符,后两个字节被保留到 StringDecoder 实例的内部,第二次 write() 时,会将这2个剩余字节和后续11个字节组合在一起,再次用3的整数倍字节进行转码,于是乱码问题通过这种中间形式被解决了

string_decoder 目前只能处理 UTF-8、Base64 和 UCS-2/UTF-16LE 这3种编码

正确拼接 Buffer

1
2
3
4
5
6
7
8
9
10
11
12
var chunks = []
var size = 0
res.on('data', (chunk)=>{
chunks.push(chunk)
size+=chunk.length
})
res.on('end', ()=>{
var buf = Buffer.concat(chunks, size)
// 使用 iconv-lite 模块进行解码
var str = iconv.decode(buf, 'utf-8')
console.log(str)
})

Buffer 与性能

通过预先转换静态内容为 Buffer 对象,可以有效地减少 CPU 的重复使用,节省服务器资源。在 Node 构建的 Web 应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为 Buffer 的方式,使性能得到提升。由于文件自身是二进制数据,所以在不需要改变内容的场景下,尽量只读取 Buffer,然后直接传输,不做额外的转换,避免损耗

fs.createReadStream() 的工作方式是在内存中准备一段 Buffer,然后在 fs.read() 读取时逐步从磁盘中将字节复制到 Buffer 中。完成一次读取时,则从这个 Buffer 中通过 slice() 方法取出部分数据作为一个小 Buffer 对象,再通过 data 事件传递给调用方。如果 Buffer 用完,则重新分配一个,如果还有剩余,则继续使用

highWaterMark 的大小对性能有两个影响:

  • highWaterMark 设置对 Buffer 内存的分配和使用有一定影响
  • highWaterMark 设置过小,可能导致系统调用次数过多

由于 fs.createReadStream() 内部采用 fs.read() 实现,将会引起对磁盘的系统调用,对于大文件而言,highWaterMark 的大小决定会触发系统调用和 data 事件的次数。读取一个相同的大文件时,highWaterMark 的值越大,读取速度越快

网络编程

Node 提供了 net、dgram、http、https 4个模块分别用于处理 TCP、UDP、HTTP、HTTPS

构建 TCP 服务

TCP

TCP 全名为传输控制协议,在 OSI 模型中属于传输层协议。许多应用层协议基于 TCP 构建,典型的是 HTTP、SMTP、IMAP 的协议

osi模型(七层协议)

TCP 是面向连接的协议,其显著特征是在传输之前需要3次握手形成会话

tcp在传输之前的3次握手

只有会话形成之后,服务器端和客户端之间才能互相发送数据。在创建会话的过程中,服务器端和客户端分别提供一个套接字,这两个套接字共同形成一个连接,服务器端与客户端则通过套接字实现两者之间连接的操作

创建 TCP 服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var net = require('net')
var server = net.createServer(function(socket){
// 新的连接
socket.on('data', function(data){
socket.write('你好')
})
socket.on('end', function(){
console.log('连接断开')
})
socket.write('欢迎光临')
})
socket.listen(8124, function(){
console.log('server bond')
})

通过 net.createServer(listener) 即可创建一个 TCP 服务器,listener 是连接事件 connection 的侦听器,也可以采用如下方式进行侦听:

1
2
3
4
5
var server = net.createServer()
server.on('connection', function(socket){
// 新的连接
})
server.listen(8124)

也可以通过 net 模块构建客户端进行会话

1
2
3
4
5
6
7
8
9
10
11
var net = require('net')
var client = net.connec({port: 8124}, function(){
// 连接监听
client.write('world')
})
client.on('data', function(data){
client.end()
})
client.on('end', function(){
console.log('连接关闭')
})

TCP 服务的事件

上述的示例中,代码分为服务器事件和连接事件

服务器事件:

  • listening:在调用 server.listen() 绑定端口或 Domain Socket 后触发,简洁写法为 server.listen(port, listeningListener),通过 listen() 方法的第二个参数传入
  • connection:每个客户端套接字连接到服务器端时触发,简洁写法为通过 net.createServer(),最后一个参数传递
  • close:当服务器关闭时触发,在调用 server.close() 后,服务器将停止接受新的套接字连接,但保持当前存在的连接,等待所有连接都断开后,会触发该事件
  • error:当服务器发生异常时,将会触发该事件。比如侦听一个使用中的端口,将会触发一个异常,如果不侦听 error 事件,服务器将会抛出异常

连接事件:

服务器可以同时与多个客户端保持连接,Stream 对象用于服务器和客户端之间的通信,既可以通过 data 事件从一端读取另一端发来的数据,也可以通过 write() 方法从一端向另一端发送数据。它具有如下自定义事件:

  • data:当一端调用 write() 发送数据时,另一端会触发 data 事件,事件传递的数据即是 write() 发送的数据
  • end:当连接中的任意一端发送了 FIN 数据时,将会触发该事件
  • connect:该事件用于客户端,当套接字与服务器端连接成功时会被触发
  • drain:当任意一端调用 write() 发送数据时,当前这端会触发该事件
  • error:当异常发生时,触发该事件
  • close:当套接字完全关闭时,触发该事件
  • timeout:当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前该连接已经被闲置了

如果每次只发送一个字节的内容而不优化,网络中将充满只有极少数有效数据的数据包,十分浪费网络资源,为此,在 Node 中,TCP 默认启用了 Nagle 算法。该算法要求缓冲区的数据达到一定数量或者一定时间后才将其发出,以此来优化网络。可以调用 socket.setNoDelay(true) 去掉 Nagle 算法,使得 write() 可以立即发送数据到网络中

尽管在网络的一端调用 write() 会触发另一端的 data 事件,但并不意味着每次 write() 都会触发一次 data 事件,在关闭 Nagle 算法后,另一端可能会将接收到的多个小数据包合并,然后只触发一次 data 事件

构建 UDP 服务

UDP 又称用户数据包协议,与 TCP 一样同属于网络传输层。在 UDP 中,一个套接字可以与多个 UDP 服务通信,它虽然提供面向事物的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是由于无须连接,资源消耗低,处理快速且灵活,所以常常应用在那种偶尔丢一两个数据包也不会产生重大影响的场景,比如音频、视频等。UDP 目前应用广泛,DNS 服务即是基于它实现的

创建 UDP 套接字

UDP 套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器端接收数据。下面的代码创建了一个 UDP 套接字

1
2
var dgram = require('dgram')
var socket = dgram.createSocket('udp4')

创建 UDP 服务器端

若想让 UDP 套接字接受网络消息,只要调用 dgram.bind(port, [address]) 方法对网卡和端口进行绑定即可,以下为一个完整的服务器端示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
var dgram = require('dgram')
var server = dgram.createSocket('udp4')
// udp4 表示使用 ipv4 协议
// udp6 表示使用 ipv6 协议

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(41234)

该套接字将接收所有网卡上 41234 端口上的消息。在绑定完成后,将触发 listening 事件

创建 UDP 客户端

1
2
3
4
5
6
var dgram = require('dgram')
var msg = new Buffer('深入浅出Node.js')
var client = dgram.createSocket('udp4')
client.send(meg, 0, message.length, 41234, 'localhost', function(err, bytes){
client.close()
})

保存上述代码并执行,服务器端的命令行将会有如下输出:

1
2
server listening 0.0.0.0:41234
server got: 深入浅出Node.js from 127.0.0.1:58682

send() 方法参数如下:

1
socket.send(buf, offset, length, port, address, [callback])

这些参数分别为:

  • buf:要发送的 Buffer
  • offset:Buffer 的偏移
  • length:Buffer 的长度
  • port:目标端口
  • address:目标地址
  • callback:发送完成后的回调

UDP 套接字事件

  • message:当 UDP 套接字侦听网卡端口后,接收到消息时触发该事件,触发携带的数据为消息 Buffer 对象和一个远程地址信息
  • listening:当 UDP 套接字开始侦听时触发该事件
  • close:调用 close() 方法时触发该事件,并不再触发 message 事件。如需再次触发,重新绑定即可
  • error:当异常发生时触发该事件。如果不侦听,异常将直接抛出,使进程退出

构建 HTTP 服务

HTTP

HTTP 全称是超文本传输协议。HTTP 构建在 TCP 之上,属于应用层协议。在 HTTP 的两端是服务器和浏览器,即著名的 B/S 模式(browser/server),Web 即是 HTTP 的应用

http 模块

Node 的 http 模块包含对 HTTP 处理的封装。在 Node 中,HTTP 服务继承自 TCP 服务器(net 模块)。HTTP 服务与 TCP 服务模型有区别的地方在于,在开启 keepalive 后,一个 TCP 会话可以用于多次请求和响应,TCP 服务以 connection 为单位进行服务,HTTP 服务以 request 为单位进行服务。http 模块即是将 connection 到 request 的过程进行了封装

http模块将connection到request的过程进行了封装

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

http模块产生请求的流程

请求头解析后放置在 req.headers 属性,请求体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则要在这个数据流结束后才能进行操作

HTTP 请求对象和 HTTP 响应对象是相对较底层的封装,现行的 Web 框架如 Connect 和 Express 都是在这两个对象的基础上进行高层封装完成的

HTTP 响应封装了对底层连接的写操作,可以将其看成一个可写的流对象。它影响响应报文头部信息的 API 为 res.setHeader() 和 res.writeHead()

1
2
3
4
5
/**
* setHeader() 只能对单个属性进行设置
*/
res.setHeader('statusCode', 200)
res.writeHead(200, { 'Content-Type': 'text/plain' })

上面代码会生成如下报文

1
2
HTTP/1.1 200 OK
Content-Type: text/plain

我们可以调用 setHeader 进行多次设置,但只有调用 writeHead 后,报头才会写入到连接中

报文体部分是调用 res.write() 和 res.end() 方法实现,后者与前者的差别在于 res.end() 会先调用 write() 发送数据,然后发送信号告知服务器这次响应结束。响应结束后,HTTP 服务器可能会将当前的连接用于下一个请求,或者关闭连接。注意,报头是在报文体发送前发送的,一旦开始了数据的发送,writeHead() 和 setHeader() 将不再生效

HTTP 服务的事件:

  • connection:当 TCP 连接建立时,服务器触发一次 connection 事件
  • request:建立 TCP 连接后,http 模块解析出 HTTP 请求头后,会触发该事件;在 res.end() 后,TCP 连接可能将用于下一次请求响应
  • close:与 TCP 服务器的行为一直,当已有的连接都断开时,触发该事件
  • checkContinue:某些客户端在发送较大的数据时,不会直接发送数据,而是先发送一个头部带 Expect: 100-continue 的请求到服务器,服务器会触发 checkContinue 事件。如果没有为服务器监听这个事件,服务器会自动响应客户端100 Continue 的状态码,表示接受数据上传,如果不接受,响应客户端400 Bad Request。该事件发生时不会触发 request 事件,两个事件之间互斥。当客户端收到100 Continue后重新发起请求时,才会触发 request 事件
  • connect:当客户端发起 connect 请求时触发,而发起 connect 请求通常在 HTTP 代理时出现,如果不监听该事件,发起该请求的连接将会关闭
  • upgrade:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上 Upgrade 字段,服务器在收到这样的请求时会触发该事件。如果不监听该事件,发起该请求的连接将会关闭
  • clientError:连接的客户端触发 error 事件时,这个错误会传递到服务器端,此时触发该事件

HTTP 客户端

为了重用 TCP 连接,http 模块包含了一个默认的客户端代理对象 http.globalAgent。它对每个服务器端(host + port)创建的连接进行了管理,默认情况下,通过 clientRequest 对象对同一个服务器端发起的 HTTP 请求最多可以创建5个连接。它的实质是一个连接池

http代理对服务器端创建的连接进行管理

调用 HTTP 客户端同时对一个服务器发起10次 HTTP 请求时,其实质只有5个请求处于并发状态,后续的请求需要等待某个请求完成服务后才真正发出

HTTP 客户端事件:

  • response:与服务器端的 request 事件对应的客户端在请求发出后得到服务器端响应时会触发该事件
  • socket:当底层连接池中建立的连接分配给当前请求对象时,触发该事件
  • connect:当客户端向服务器端发起 connect 请求时,如果服务器端响应了 200 状态码,客户端将会触发该事件
  • upgrade:客户端向服务器端发起 upgrade 请求时,如果服务器端响应了 101 Switching Protocols 状态,客户端将会触发该事件
  • continue:客户端向服务器端发起 Expect: 100-continue 头信息,以试图发送较大数据量,如果服务器端响应 100 Continue 状态,客户端将触发该事件

构建 WebSocket 服务

虽然 WebSocket 的握手部分是由 HTTP 完成的,但 WebSocket 并没有在 HTTP 的基础上模拟服务器端的推送,而是在 TCP 上定义独立的协议

WebSocket 握手

客户端建立连接时,通过 HTTP 发起请求报文,如下所示:

1
2
3
4
5
6
7
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

与普通 HTTP 请求协议有区别的部分如下:

1
2
3
4
5
6
7
8
9
10
11
// 这两个字段表示请求服务器端升级协议为 websocket
Upgrade: websocket
Connection: Upgrade
// 用于安全校验。为随机生成的base64编码的字符串。服务端接收到之后将其与字符串
258EAFA5-E914-47DA-95CA-C5AB0DC85B11相连,形成字符串dGhlIHNhbXBsZSBub25jZQ==258EAFA5-
E914-47DA-95CA-C5AB0DC85B11,然后通过sha1安全散列算法计算出结果后,再进行Base64编码,
最后返回给客户端
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
// 下面两个字段指定子协议和版本号
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务器端在处理完请求后,响应如下报文:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

WebSocket 数据传输

websocket协议升级过程示意图

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

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

1
socket.onopen = function(){}

服务器端没有 onopen() 方法。为了完成 TCP 套接字事件到 WebSocket 事件的封装,需要在接收数据时进行处理,WebSocket 的数据帧协议即是在底层 data 事件上完成封装的,代码如下:

1
2
3
4
WebSocket.prototype.setSocket = function(socket){
this.socket = socket
this.socket.on('data', this.receiver)
}

同样的数据发送时,也需要做封装,代码如下

1
2
3
WebSocket.prototype.send = function(data){
this._send(data)
}

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

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

网络服务与安全

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

Node 在底层采用的是 openssl 实现 TLS/SSL 的

构建 Web 应用

基础功能

服务器端告知客户端的方式是通过响应报文实现的,响应的 Cookie 值在 Set-Cookie 字段中,规范中对它的定义如下:

1
Set-Cookie: name=value; Path=/;Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

其中 name=value 是必须包含的部分,其余部分都是可选参数。这些可选参数将会影响浏览器在后续将 Cookie 发送给服务器端的行为

  • path:表示这个 Cookie 影响到的路径,当前访问的路径不满足该匹配时,浏览器则不会发送这个 Cookie
  • Expires 和 Max-Age 用来设置 Cookie 的有效期,如果不设置,在关闭浏览器时会丢失这个 Cookie。如果设置了过期时间,浏览器将会把 Cookie 内容写入到磁盘中并保存。Expires 的值是一个 UTC 格式的时间字符串,告诉浏览器此 Cookie 何时过期,Max-Age 则是告诉浏览器此 Cookie 多久后过期
  • HttpOnly:告知浏览器不允许通过脚本 document.cookie 去更改这个 Cookie 值。事实上,设置 HttpOnly 之后,这个值在 document.cookie 中不可见
  • Secure:该值为 true 时,表示创建的 Cookie 只能在 HTTPS 连接中被浏览器传递到服务器端进行会话验证,如果是 HTTP 连接则不会传递

Cookie 优化:

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

Basic 认证

Basic 认证是当客户端与服务器端进行请求时,允许通过用户名和密码实现的一种身份认证方式

如果一个页面需要 Basic 认证,它会检查请求报文头中的 Authorization 字段的内容,该字段的值由认证方式和加密值构成

数据上传

在 HTTP_Parser 解析报头结束后,报文内容部分会通过 data 事件触发,我们只需以流的方式处理即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function(req, res){
if(hasBody(req)){
var buffers = []
req.on('data', function(chunk){
buffers.push(chunk)
})
req.on('end', function(){
req.rawBody = Buffer.concat(buffers).toString()
handle(req, res)
})
}else{
handle(req, res)
}
}

将接收到的 Buffer 列表转化为一个 Buffer 对象后,再转换为没有乱码的字符串,暂时挂置在 req.rawBody 处

表单数据

默认的表单提交,请求头中的 Content-Type 字段值为 application/x-www-form-urlencoded

由于它的报文体内容跟查询字符串相同,因此解析十分容易:

1
2
3
4
5
6
var handle = function(req, res){
if(req.headers['content-type'] === 'application/x-www-form-urlencoded'){
req.body = querystring.parse(req.rawBody)
}
todo(req, res)
}

后续业务中直接访问 req.body 就可以得到表单中提交的数据

其他格式

除了表单数据外,常见的提交还有 JSON 和 XML 文件等,判断和解析都是依据 Content-Type 中的值决定。JSON 类型的值为 application/json,XML 的值为 application/xml

在 Content-Type 中可能还附带如下编码信息:

1
Content-Type: application/json; charset-utf-8

所以在做判断时,需要注意区分

1
2
3
4
var mime = function(req){
var str = req.headers['content-type'] || ''
return str.split(';')[0]
}

附件上传

在前端 HTML 代码中,特殊表单与普通表单的差异在于该表单中可以含有 file 类型的控件,以及需要指定表单属性 enctype 为 multipart/form-data

浏览器在遇到 multipart/form-data 表单提交时,它的报头中特殊处如下:

1
2
Content-Type: multipart/form-data; boundary=AaB03x
Content-Type: 18233

它代表本次提交的内容是由多部分构成的,其中 boundary=AaB03x 指定的是每部分内容的分界符,AaB03x 是随机生成的一段字符串,报文体的内容将通过在它前面添加 – 进行分割,报文结束时在它前后都加上 – 表示结束。Content-Length 的值必须确保是报文体的长度

路由解析

文件路径型

1、静态文件
这种方式 URL 的路径与网站目录的路径一致,无须转换,处理也十分简单,将请求路径对应的文件发送给客户端即可
2、动态文件
在 MVC 模型流行起来之前,根据文件路径执行动态脚本也是基本的路由方式。它的处理原理是 Web 服务器根据 URL 路径找到对应的文件,如 /index.asp 或 /index.php。Web 服务器根据文件名后缀去寻找脚本的解析器,并传入 HTTP 请求的上下文

MVC

MVC 模型的主要思想是将业务逻辑按职责分离,主要分为以下几种:

  • 控制器(Controller) 一组行为的集合
  • 模型(Model) 数据相关的操作和封装
  • 视图(View) 视图的渲染

分层模式

它的工作模式如下:

  • 路由解析 根据 URL 寻找对应的控制器和行为
  • 行为调用相关的模型,进行数据操作
  • 数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端

RESTful

REST 的全程是 Representational State Transfer,中文含义为表现层状态转化。符合 REST 规范的设计,我们称为 RESTful 设计。它的设计哲学主要将服务器端提供的内容实体看作一个资源,并表现在 URL 上

中间件

中间件的行为比较类似 Java 中过滤器的工作原理,就是在进入具体的业务处理之前,先让过滤器处理。它的工作模型如图所示:

中间件的工作模型

常见的中间件性能优化方法有几种:

  • 使用高效的方法 必要时通过 jsperf.com 测试基准性能
  • 缓存需要重复计算的结果(需要控制缓存用量)
  • 避免不必要的计算。比如 HTTP 报文体的解析,对于 GET 方法完全不需要

页面渲染

内容响应

内容响应的过程中,响应报头中的 Content-* 字段十分重要。在下面的示例响应报文中,服务器端告知客户端内容是以 gzip 编码的,其内容长度为21170个字节,内容类型为 JavaScript,字符集为 UTF-8

1
2
3
Content-Encoding: gzip
Content-Length: 21170
Content-Type: text/javascript; charset=utf-8

客户端在接收到这个报文后,通过 gzip 来解码报文体中的内容,用长度校验报文体内容是否正确,然后再以字符集 UTF-8 将解码后的脚本插入到文档节点中

MIME

MIME(multipurpose internet mail extensions)

有专门的 mime 模块可以用来判断文件类型

1
2
3
4
let mime = require('mime')

mime.lookup('file.txt') // 'text/plain'
mime.lookup('htm') // 'text/html'
附件下载

客户端会根据 Content-Disposition 的值判断将报文数据当作即时浏览的内容还是可下载的附件。当内容只需即时查看时,它的值为 inline,当数据可以存为附件时,它的值为 attachment。另外,Content-Disposition 字段还能通过参数指定保存时应该使用的文件名,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Content-Disposition: attachment; filename='filename.ext'

// 示例响应附件下载的 API
res.sendfile = function(filepath) {
fs.stat(filepath, function(err, stat){
var stream = fs.createReadStream(filepath)
// 设置内容
res.setHeader('Content-Type', mime.lookup(filepath))
// 设置长度
res.setHeader('Content-Lenth', stat.size)
// 设置为附件
res.setHeader('Content-Disposition: attachment; filename="' + path.basename(filepath) + '"');
res.writeHead(200)
stream.pipe(res)
})
}
响应跳转
1
2
3
4
5
res.redirect = function(url) {
res.setHeader('Location', url)
res.writeHead(302)
res.end('Redirect to ' + url)
}

产品化

项目工程化

目录结构

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|---- History.md // 项目改动历史
|---- Install.md // 安装说明
|---- Makefile // Makefile 文件
|---- benchmark // 基准测试
|---- controllers // 控制器
|---- lib // 没有模块化的文件目录
|---- middlewares // 中间件
|---- package.json // 包描述文件,项目依赖项配置等
|---- proxy // 数据代理目录,类似 MVC 中的 M
|---- test // 测试目录
|---- tools // 工具目录
|---- views // 视图目录
|---- routes.js // 路由注册表
|---- dispatch.js // 多进程管理
|---- README.md // 项目说明目录
|---- assets // 静态文件目录
|---- assets.json // 静态文件与 CDN 路径的映射文件
|---- bin // 可执行脚本
|---- config // 配置目录
|---- logs // 日志目录
└─--- app.js // 工作进程

构建工具

有了源代码项目,只是完成了第一步。要想真正能用上源代码,还需要一定的操作,这些操作主要有合并静态文件、压缩文件大小、打包应用、编译模块等

补充知识

Web 应用架构

Web 应用架构示例图

  • Client - 客户端,一般指浏览器,浏览器可以通过 HTTP 协议向服务器请求数据
  • Server - 服务器,一般指 Web 服务器,可以接受客户端请求,并向客户端发送响应数据
  • Business - 业务层,通过 Web 服务器处理应用程序,如与数据库交互、逻辑运算、调用外部程序等
  • Data - 数据层,一般由数据库组成

响应方法

方法 描述
res.download() 提示下载文件
res.end() 结束响应进程
res.json() 发送 JSON 响应
res.jsonp() 发送支持 JSONP 的 JSON 响应
res.redirect() 重定向请求
res.render() 呈现视图模板
res.send() 发送各种类型的响应
res.sendFile() 发送八进制流文件
res.sendStatus() 设置响应状态码并将其以字符串形式放在响应体中发送

HTTP 动词

express.Router还为其他 HTTP 动词提供路由方法,大都用法相同:post()put()delete()options()trace()copy()lock()mkcol()move()purge()propfind()proppatch()unlock()report()mkactivity()checkout()merge()m-search()notify()subscribe()unsubscribe()patch()search()connect()

路由参数

路径参数

路径参数是命名的 URL 段,用于捕获在 URL 中的位置指定的值。命名段以冒号为前缀,然后是名称,捕获的值保存在req.params对象中

1
2
3
4
5
6
// eg:一个包含用户和藏书信息的 URL:http://localhost:3000/users/34/books/8989,可以这样提取信息(使用 userId 和 bookId 路径参数)
app.get("/users/:userId/books/:bookId", (req, res) => {
// 通过 req.params.userId 访问 userId
// 通过 res.params.bookId 访问 bookId
res.send(req.params)
})

路由参数名称必须由“单词字符”(/[A-Za-z0-9_]/)组成

URL /book/create 会匹配 /book/:bookId 这样的路由,因此,/book/create 的路由处理器必须定义在 /book/:bookId 路由之前,才能妥善处理

API 说明
path.resolve 拼接规范的绝对路径
path.sep 获取操作系统的路径分隔符
path.parse 解析路径并返回对象
path.basename 获取路径的基础名称
path.dirname 获取路径的目录名
path.extname 获取路径的扩展名