深入浅出Node.js

553 阅读12分钟

本文系朴灵《深入浅出Node.js》读书笔记

第一章 Node简介

Node的主要要点:

  • 事件驱动
  • 非阻塞I/O

为什么叫Node?

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

1.1 Node的特点

  • 异步I/O

Don't call me, I will call you(异步调用原则)

var fs = require('fs');
fs.readFile('/path', function(err, file){
    console.log('读取文件完成')
});
console.log('发起读取文件');
  • 事件与回调函数
var http = require('http');
var querystring = require('querystring');

...
  • 单线程

在Node中,JS与其余线程是无法共享任何状态的。单线程最大的好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

单线程的弱点:

- 无法利用多核CPU
- 错误会引起整个应用退出,应用的健壮性值得考验
- 大量计算占用CPU导致无法继续调用异步I/O

Web Workers能够创建工作线程来进行计算,以解决JS大计算阻塞UI渲染的问题。工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这使得工作线程不能访问到主线程的UI。

  • 跨平台

1.2 Node的应用场景(I/O密集型)

思考一个问题: Node在商城中的实践,具体可以做哪些事情,要怎么来实现,可以达到一个怎样的效果?

  • 服务端渲染首页快速加载
  • 单元测试

第二章 模块机制

2.1 CommonJS规范(希望Javascript能够在任何地方运行)

CommonJS规范的提出,主要事为了弥补当前Javascript没有标准的缺陷,以达到Python,Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段。

  • Node与浏览器以及W3C组织,CommonJS组织,ECMAScript之间的关系图:

2.2 Node的模块实现

在Node中引入模块需要经历三个步骤:

  • (1)路径分析
  • (2)文件定位
  • (3)编译执行

Node模块可以分为两类:

  • 核心模块(Node)--- 加载快
  • 文件模块(用户自定义)--- 加载慢

与前端浏览器会缓存静态脚本文件以提高性能一样,Node对引入的模块都会进行缓存,以减少二次引入时的开销。不同在于,浏览器仅缓存文件,而Node缓存的是编译和执行之后的的对象。

2.3 核心模块(-_-)

2.4 C/C++拓展模块(-_-)

2.5 NPM

模块规范:

  • CommonJS ---> 后端Javascript(安全)
  • AMD/CMD ---> 前端Javascript(加载速度)

第三章 异步I/O

Node的基调:异步I/O,事件驱动和单线程。

Nginx与Node的区别:Nginx具备面向客户端管理连接的强大能力,但是它的背后依然受限于各种同步方式的编程语言。但Node却是全方位的,既可以作为服务器去处理客户端带来的大量并发请求,也能作为客户端网络中的各个应用进行并发请求。

3.1 为什么要异步I/O?

  • 用户体验

浏览器中Javascript在单线程上执行,而且它还与UI渲染共用一个线程,那意味着在执行脚本的时候UI渲染时处于停滞状态的。

I/O 是昂贵的,分布式I/O是更昂贵的。

  • 资源分配

计算机中的组件:I/O设备和计算设备。 异步I/O的调用示意图p50

3.2 异步I/O实现现状

由于Window和*nix平台的差异,Node提供了libuv作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的Node与下层的自定义线程池及IOCP之间各自独立。

我们时常提到Node是单线程的,这里的单线程仅仅只是Javascript执行在单线程中罢了。在Node中,无论是*nix还是window平台,内部完成I/O任务的另有线程池。

3.3 Node的异步I/O

  • 事件循环
  • 观察者
  • 请求对象(从Javascript发起调用到内核执行完I/O操作过渡过程的中间产物。)

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

  • 执行回调

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

在Node中,除了Javascript是单线程外,Node自身其实是多线程的,只是I/O线程使用的CPU较少。另一个需要重视的观点是,除了用户代码无法并行执行外,所有的I/O(磁盘I/O和网络I/O等)则是可以并行起来的。

3.4 非I/O的异步API

  • 定时器
  • process.nextTick()
  • setImmediate
//加入两个nextTick()回调函数
process.nextTick( ()=> {
    console.log('nextTick延迟执行1')
})
process.nextTick( ()=> {
    console.log('nextTick延迟执行2')
})

//加入两个setTmmediate()的回调函数
setImmediate( ()=> {
    console.log('setImmdiate延迟执行1');
    //进入下次循环
    process.nextTick( ()=> {
        console.log('强势插入')
    })
})
setImmediate( ()=> {
    console.log('setImmdiate延迟执行2');
})
console.log('正常执行')
//正常执行
//nextTick延迟执行1
//nextTick延迟执行2
setImmediate延迟执行1
强势插入
setImmediate延迟执行2

3.5 事件驱动与高性能服务器

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

异步I/O实现,其主旨是使I/O操作与CPU操作分离
事件循环是异步实现的核心

第四章 异步编程

4.1 函数式编程

  • 高阶函数

高阶函数是可以把函数作为参数,或者将函数作为返回值的函数。

function foo() {
    return function() {
        return x;
    }
}

<!--sort排序-->
var points = [3,5,8,1,44,80,132,88];
points.sort(function(a, b){
    return a - b; // return b - a
});
  • 偏函数用法

偏函数用法是指创建一个调用另外一个部分 —— 参数或变量已经预置的函数 —— 的函数的额用法。

// 待优化代码
var toString = Object.prototype.toString

var isString = function (obj) {
    return toString.call(obj) == '[object String]'
}
var isFunction = function (obj) {
    return toString.call(obj) == '[object Function]'
}
// 优化代码, 工厂模式
var isType = function(type) {
    return function (obj) {
        return toString.call(obj) == '[object' + type + ']'
    }
}
var isString = isType('String')
var isFunction = isType('Function')

4.2 异步编程的优势和难点

  • 优势

Node带来的最大特性莫过于基于事件驱动的非阻塞I/O模型。

JavaScript线程就像一个分配任务和处理结果的大管家, I/O线程池里的各个I/O线程都是小二,负责兢兢业业的完成分配来的任务,小二与管家之间互不依赖,可以保持整体的高效率。

  • 难点

    难点1:异常处理

    第三章中提到,异步I/O的实现主营包含两个阶段: 提交阶段和处理结果。这两个阶段中间有事件循环的调度,两者互不关联。异步方法则通常在一个阶段提交请求后立即返回,因为异常并不一定发生在这个阶段,使用try-catch无效,使用try-catch只能捕获当次事件循环的异常。

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

    自行编写异步方法遵循的原则: 1). 必须执行调用者传入的回调函数; 2). 正确传递回异常供调用者判断。

    var async = function(callback) {
        process.nextTick(function(){
            var results = something;
            if(error){
                return callback(error)
            }
            callback(null, results)
        })
    }
    

    难点2:函数嵌套过深(并行异步I/O)

    难点3:阻塞代码(wind...await)

    难点4:多线程编程

    Web Workers: 通过将JavaScript执行与UI渲染分离,可以更好的利用多核CPU为大量计算服务。

    难点5:异步转同步

4.3 异步编程解决方案

事件发布/订阅模式
Promise/Deferred模式
流程控制库
  • 事件发布/订阅模式

事件监听器模式是一种广泛用于异步编程的模式,是回调函数的事件化,又称发布订阅模式。

基本事件监听模式:addListener/on(), once(), removeListener(), removeAllListeners(), emit().

//订阅
emitter.on('event1', function(message){
    console.log(message)
})
// 发布
emitter.emit('event1', "I am message")
1) 继承events模块

```
var events = require('events');

function Stream() {
    events.EventEmitter.call(this);
}

util.inherits(Stream, events.eventEmitter);
```

Node在util模块中封装了继承方法,开发者可以通过这样的方式轻松继承EventEmitter类,利用事件机制解决业务问题。

  1. 利用事件队列解决雪崩问题

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

最开始代码:

    var select = function(callback){
        db.select("SQL", function(results){
            callback(results)
        });
    };

使用状态锁改进:

   var status = "ready";
   var select = function(callback){
       if(status === ready){
           status = "pending";
           db.select("SQL", function(results){
               status = ready;
               callback(results);
           })
       }
   }

再引入事件队列

   var proxy = new events.EventEmitter();
   var status = "ready";
   var select = function(callback){
       proxy.once("selected", callback); // 保证每一个回调只会执行一次
       if(status === ready){
           status = "pending";
           db.select("SQL", function(results){
               proxy.emit("seleted", results)
               status = ready;
           })
       }
   }
3) 多异步之间的协作方案

使用偏函数和哨兵变量来处理:

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 模式

该模式包含两部分,即Promise和Deferred。

1)Promise/A

* Promise操作只会处在三种状态中的一种:未完成态,完成态和失败态。
* Promise的状态只会出现从未完成态向完成态或失败态转化,不能逆反。完成态和失败态不能相互转化。
* Promise的状态一旦转化,将不能再更改。
<!--延迟对象-->
var Deferred = function() {
    this.state = 'unfulfilled'
    this.promise = new Promise()
}

Deferred.prototype.resolve = function(obj) {
    this.state = 'fulfilled'
    this.promise.emit('success', obj)
}

Deferred.prototype.reject = function(err) {
    this.state = 'failed'
    this.promise.emit('error', err)
}

Deferred.prototype.progress = function(data) {
    this.promise.emit('progress', data)
}

Deferred主要是用于内部,用于维护异步模型的状态;Promise则作用域外部,通过then()方法暴露给外部以添加自定义逻辑。

2)Promise的多异步协作

deferred.all()

3)Promise的进阶知识

Promise的秘诀在于对队列的操作。

  • 流程控制库
  1. 尾触发与Next
function createServer() {
    <!--创建HTTP服务器的request事件处理函数-->
    function app(req, res){ app.handle(req, res); }
    utils.merge(app, proto);
    utils.merge(app, EventEmitter.prototype);
    app.route = '/';
    <!--服务器内部维护的中间件队列,通过调用use方法可以将中间件放进队列中-->
    app.stack = [];
    for(var i = 0; i < arguments; ++i){
        app.use(arguments)
    }
    return app
}

use方法的重要组成部分:

app.use = function(route, fn){
    // some code
    this.stack.push({ route: route, handle: fn });
    return this;
};

Node原生Http模块实现监听:

app.listen = function() {
    var server = http.createServer(this);
    return server.listen.apply(server, arguments)
}

回到app.handle()方法:

app.handle = function(req, res, out){
    // some code
    next();
}

next()方法实现:

function next(err) {
    // some code
    // next callback
    layer = stack[index++]
    layer.handle(req, res, next)
}
  1. async

    • async.series() --- 异步的串行执行
    • async.parallel() --- 异步的并行执行
    • async.waterfall() --- 异步调用的依赖处理
    • async.auto() --- 自动依赖处理
  2. step

    • 并行任务执行
    • 结果分组
  3. wind (动态冒泡排序展示)

4.4 异步并发控制

bagpipe的思路:

  • 通过一个队列来控制并发量
  • 如果当前活跃(指调用发起但未执行回调)的异步调用量小于限定值,从队列中取出执行
  • 如果活跃调用达到限定值,调用暂时存放在队列中
  • 每个异步调用结束时,从队列中取出新的异步调用执行

第五章 内存控制

5.1 V8的垃圾回收机制和内存限制

JS和Java一样,由垃圾回收机制来进行自动内存管理。

  • V8的对象分配
$ node
> process.memoryYsage();
{
    rss: 14958592,
    heapTotal: 7195904,
    heapUsed: 2821496
}
当我们在代码中声明变量并复制时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,只带堆的大小超过V8的限制为止。
  • V8的垃圾回收机制

1)V8的主要的垃圾回收算法(分代式垃圾回收机制)

V8使用的内存没有办法根据使用情况自动扩充,当内存分配过程中超过极限值时,就会引起过程出错。

V8的内存分代(新生代 + 老生代)
Scavenge算法(新生代)--- 牺牲空间换取时间的算法
Mark-Sweep(标记清除) & Mark-Compact(标记整理)
Incremental Marking(增量标记)

5.2 高效使用内存

  • 作用域

    如果变量是全局变量(不通过var声明或定义在global上),由于全局作用域需要直到进程退出才能释放。

  • 闭包(实现外部作用域访问内部作用域中变量的方法叫做闭包)

var foo = function() {
    var bar = function() {
        var local = '局部变量'
        return function() {
            return local
        };
    };
    var baz = bar();
    console.log(baz()); // '局部变量'
}

5.3 内存指标

  • 查看内存使用情况

(1)查看进程的内存占用

$ node
> process.memoryUsage()

rss是resident set size的缩写,即进程的常驻部分。进程的内存总共有几部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesysten)中。

(2) 查看系统的内存占用

$ node
> os.totalmem()
> os.freemem()
  • 对外内存

堆中的内存用量总是小于进程的常驻内存用量,这意味着Node中的内存使用并非都是通过V8进行分配的。讲那些不是经过V8进程分配的内存称为堆外内存。

Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。

5.4 内存泄漏

内存泄漏的情况不尽相同,但是实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。

内存泄漏通常原因有以下几个:

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

1) 慎将内存当做缓存

- 严格意义的缓存有着完善的过期策略,而普通对象的键值并没有。

- 进程之间无法共享内存

如何使用大量缓存,目前比较好的解决方案是采用进程外的缓存,进程自身不存储状态。外部的缓存软件有着良好的缓存过期策略以及自有的内存管理,不影响Node进程的性能。redis/memcached

- 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。
- 进程之间可以共享缓存

2)关注队列状态(消费者-生产者模型)

5.5 内存泄漏排查(node-heapdump + node-memwatch)

  • V8的内存限制导致使用者不能使用fs.readFile()fs.writeFile()直接进行大文件的操作,但可以使用fs.createReadStream()fs.createWriteStream()方法通过流的方式实现对大文件的操作。
  • 如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。