异步、promise 与缓存

异步操作缓存

在 Node 开发过程中要常常和异步调用打交道,例如发送 http 请求、读取文件内容等。有时我们需要将这些异步 I/O 操作的结果缓存下来,使得程序运行的速度更快,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var fileContent = null;
function readFile(callback) {
    if (fileContent !== null) {
        process.nextTick(function () {
            callback(null, fileContent);
        });
        return;
    }

    fs.readFile(FILE_PATH, function (err, content) {
        if (err) {
            callback(err);
            return;
        }

        fileContent = content;
        callback(null, content);
    });
}

上面的代码用 fileContent 变量做了一个简单的内存缓存,在 readFile 函数中,如果发现缓存中存在内容,则跳过文件读取操作。

这是个非常简单的缓存应用案例,我们可以将代码中的缓存逻辑抽出来,与业务逻辑分离,成为一个通用的缓存方法 cacheAsync:

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
32
33
34
function cacheAsync(logical) {
    var cache = null;
    return function (callback) {
        if (cache) {
            process.nextTick(function () {
                callback(null, cache);
            });
            return;
        }

        logical(function (err, result) {
            if (err) {
                logical(err);
                return;
            }

            cache = result;
            callback(null, result);
        });
    };
}

var readFile = cacheAsync(function (callback) {
    fs.readFile(FILE_PATH, callback);
});

readFile(function (err, content) {
    if (err) {
        throw err;
        return;
    }

    console.log('file:', content);
});

使用 cacheAsync 方法可以很容易的给一个异步回调函数加上缓存。

promise

promise 是和上面代码中示范的 callback 异步回调方式截然不同的另一种异步范式。promise 范式下的异步函数不再接收一个 callback 函数,而是返回一个 promise 对象。promise 对象通过 then 方法来绑定回调函数,通过 catch 方法绑定错误处理函数。

nodejs 目前有很多个 promise 异步范式的库,最流行的是 q。这里以 q 为例,示范一个简单的 promise 异步范式:

1
2
3
4
5
6
7
8
9
10
11
12
13
var readFile = function () {
    // q.nfcall 使一个 callback 风格的异步函数返回一个 promise 对象
    return q.nfcall(fs.readFile, FILE_PATH);
};

// 下面这行看起来就和同步调用一样
var fileContent = readFile();

fileContent.then(function (content) {
    console.log('file:', content);
}).catch(function (err) {
    console.log(err);
});

看起来是不是感觉代码更复杂了?除了能看到异常逻辑和正常流程被分离了以外,似乎没有太多好处?那么看一个更复杂的例子,这个例子尝试从两个数据源读取用户信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getUserSync() {
    var user = fetchUserFromWeiboSync();

    if (!user) {
        user = fetchUserFromRenrenSync();
    }

    if (!user) {
        throw new Error(404);
    }

    return user;
}

callback 回调版本,一个常见的 callback hell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function getUserAsync(callback) {
    fetchUserFromWeiboAsync(function (err) {
        if (err) {
            fetchUserFromRenrenAsync(function (err, user) {
                if (err) {
                    callback(new Error(404));
                } else {
                    callback(null, user);
                }
            });
        } else {
            callback(null, user);
        }
    })
}

promise 版本,代码之少令人惊讶:

1
2
3
function getUserQ() {
    return fetchUserFromWeiboQ().catch(fetchUserFromRenrenQ);
}

promise 与缓存

给一个 promise 范式下的异步函数加缓存是一件非常轻松的事情,因为你不需要处理任何的异常流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var q = require('q');

function cacheQ(logical) {
    var cache = null;

    return function () {
        if (cache !== null) {
            return q(cache);
        }

        // 这里不需要再处理任何的异常,缓存方法和程序逻辑进一步解耦
        return logical().then(function (result) {
            cache = result;
            // 这里必须 return,因为这里的返回值会作为参数传递给下一个 then 方法绑定的回调函数
            return result;
        });
    };
}

var readFile = cacheQ(function () {
    // q.nfcall 使一个 callback 风格的异步函数返回一个 promise 对象
    return q.nfcall(fs.readFile, FILE_PATH);
});

更灵活的缓存

在实际应用中,一个缓存函数还应当支持以下特性:

  1. 缓存应该是一个 key-value 存储,而不是只能缓存一个值
  2. 避免使用一个简单的内存对象来做缓存,更好的选择有 lru-cache、redis 以及 memcached 等
  3. 缓存函数应该对逻辑函数透明,即逻辑函数是否被缓存,不应该影响整个程序的执行结果

以下给一个实际项目中应用的例子:

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
32
var q = require('q');
var LRU = require('lru-cache');

function cacheQ(logical, key) {
    var cache = new LRU({
        max: 100, // 最大缓存数量,防止内存泄露
        maxAge: 60000 // 一分钟过期
    });

    return function () {
        var args = arguments;
        key = typeof key === 'function' ? key.apply(this, args) : '__default__';

        // 这里支持 key 作为一个 promise 对象,所以需要用 q 来包装这个对象
        return q(key).then(function (key) {
            if (cache.has(key)) {
                return cache.get(key);
            }

            return logical.apply(this, args).then(function (result) {
                cache.set(key, result);
                return result;
            });
        }.bind(this)); // 注意 this 的传递
    };
}

var readFileCached = cacheQ(function (path) {
    return q.nfcall(fs.readFile, path);
}, function (path) {
    return path;
});

- FIN -

Node 出现 uncaughtException 之后的优雅退出方案

Node 的异步特性是它最大的魅力,但是在带来便利的同时也带来了不少麻烦和坑,错误捕获就是一个。由于 Node 的异步特性,导致我们无法使用 try/catch 来捕获回调函数中的异常,例如:

1
2
3
4
5
6
7
8
9
10
11
12
try {
    console.log('进入 try/catch');
    require('fs').stat('SOME_FILE_DOES_NOT_EXIST', function readCallback(err, content) {
        if (err) {
            throw err; // 抛出异常
        }
    });
} catch (e) {
    // 这里捕获不到 readCallback 函数中抛出的异常
} finally {
    console.log('离开 try/catch');
}

运行结果是:

1
2
3
4
5
6
7
进入 try/catch
离开 try/catch

test.js:7
            throw err; // 抛出异常
                  ^
Error: ENOENT, stat 'SOME_FILE_DOES_NOT_EXIST'

上面代码中由于 fs.stat 去查询一个不存在的文件的状态,导致 readCallback 抛出了一个异常。由于 fs.read 的异步特性,readCallback 函数的调用发生在 try/catch 块结束之后,所以该异常不会被 try/catch 捕获。之后 Node 会触发 uncaughtException 事件,如果这个事件依然没有得到响应,整个进程(process)就会 crash。

程序员永远无法保证代码中不出现 uncaughtException,即便是自己代码写的足够小心,也不能保证用的第三方模块没有 bug,例如:

1
2
3
4
5
6
7
8
9
var deserialize = require('deserialize'); // 假设 deserialize 是一个带有 bug 的第三方模块

// app 是一个 express 服务对象
app.get('/users', function (req, res) {
    mysql.query('SELECT * FROM user WHERE id=1', function (err, user) {
        var config = deserialize(user.config); // 假如这里触发了 deserialize 的 bug
        res.send(config);
    });
});

如果不幸触发了 deserialize 模块的 bug,这里就会抛出一个异常,最终结果是整个服务 crash。

当这种情况发生在 Web 服务上时结果是灾难性的。uncaughtException 错误会导致当前的所有的用户连接都被中断,甚至不能返回一个正常的 HTTP 错误码,用户只能等到浏览器超时才能看到一个 no data received 错误。

这是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为 uncaughtException 导致服务器崩溃。一个友好的错误处理机制应该满足三个条件:

  1. 对于引发异常的用户,返回 500 页面
  2. 其他用户不受影响,可以正常访问
  3. 不影响整个进程的正常运行

很遗憾的是,保证 uncaughtException 不影响整个进程的健康运转是不可能的。当 Node 抛出 uncaughtException 异常时就会丢失当前环境的堆栈,导致 Node 不能正常进行内存回收。也就是说,每一次 uncaughtException 都有可能导致内存泄露。

既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程以便重启服务。

用 domain 来捕获异步异常

普遍的思路是,如果可以通过某种方式来捕获回调函数中的异常,那么就不会有 uncaughtException 错误导致的崩溃。为了解决这个问题,Node 0.8 之后的版本新增了 domain 模块,它可以用来捕获回调函数中抛出的异常。

domain 主要的 API 有 domain.runerror 事件。简单的说,通过 domain.run 执行的函数中引发的异常都可以通过 domainerror 事件捕获,例如:

1
2
3
4
5
6
7
8
9
10
11
var domain = require('domain');
var d = domain.create();
d.run(function () {
    setTimeout(function () {
        throw new Error('async error'); // 抛出一个异步异常
    }, 1000);
});

d.on('error', function (err) {
    console.log('catch err:', err); // 这里可以捕获异步异常
});

通过 domain 模块,以及 JavaScript 的词法作用域特性,可以很轻易的为引发异常的用户返回 500 页面。以 express 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var app = express();
var server = require('http').createServer(app);
var domain = require('domain');

app.use(function (req, res, next) {
    var reqDomain = domain.create();
    reqDomain.on('error', function (err) { // 下面抛出的异常在这里被捕获
        res.send(500, err.stack); // 成功给用户返回了 500
    });

    reqDomain.run(next);
});

app.get('/', function () {
    setTimeout(function () {
        throw new Error('async exception'); // 抛出一个异步异常
    }, 1000);
});

上面的代码将 domain 作为一个中间件来使用,保证之后 express 所有的中间件都在 domain.run 函数内部执行。这些中间件内的异常都可以通过 error 事件来捕获。

尽管借助于闭包,我们可以正常的给用户返回 500 错误,但是 domain 捕获到错误时依然会丢失堆栈信息,此时已经无法保证程序的健康运行,必须退出。Node http server 提供了 close 方法,该方法在调用时会停止 server 接收新的请求,但不会断开当前已经建立的连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
reqDomain.on('error', function () {
    try {
        // 强制退出机制
        var killTimer = setTimeout(function () {
            process.exit(1);
        }, 30000);
        killTimer.unref(); // 非常重要

        // 自动退出机制,停止接收新链接,等待当前已建立连接的关闭
        server.close(function () {
            // 此时所有连接均已关闭,此时 Node 会自动退出,不需要再调用 process.exit(1) 来结束进程
        });
    } catch(e) {
        console.log('err', e.stack);
    }
});

这个例子来自 Node 的文档。其中有几个关键点:

  • Node 有个非常好的特性,所有连接都被释放后进程会自动结束,所以不需要再 server.close 方法的回调函数中退出进程
  • 强制退出机制: 因为用户连接有可能因为某些原因无法释放,在这种情况下应该强制退出整个进程。
  • killTimer.unref(): 如果不使用 unref 方法,那么即使 server 的所有连接都关闭,Node 也会保持运行直到 killTimer 的回调函数被调用。unref 可以创建一个”不保持程序运行”的计时器。
  • 处理异常时要小心的把异常处理逻辑用 try/catch 包住,避免处理异常时抛出新的异常

通过 domain 似乎就已经解决了我们的需求: 给触发异常的用户一个 500,停止接收新请求,提供正常的服务给已经建立连接的用户,直到所有请求都已结束,退出进程。但是,理想很丰满,现实很骨感,domain 有个最大的问题,它不能捕获所有的异步异常!。也就是说,即使用了 domain,程序依然有因为 uncaughtException crash 的可能。

所幸的是我们可以监听 uncaughtException 事件。

uncaughtException 事件

uncaughtException 是一个非常古老的事件。当 Node 发现一个未捕获的异常时,会触发这个事件。并且如果这个事件存在回调函数,Node 就不会强制结束进程。这个特性,可以用来弥补 domain 的不足:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
process.on('uncaughtException', function (err) {
    console.log(err);

    try {
        var killTimer = setTimeout(function () {
            process.exit(1);
        }, 30000);
        killTimer.unref();

        server.close();
    } catch (e) {
        console.log('error when exit', e.stack);
    }
});

uncaughtException 事件的缺点在于无法为抛出异常的用户请求返回一个 500 错误,这是由于 uncaughtException 丢失了当前环境的上下文,比如下面的例子就是它做不到的:

1
2
3
4
5
6
7
8
9
10
app.get('/', function (req, res) {
    setTimeout(function () {
        throw new Error('async error'); // uncaughtException, 导致 req 的引用丢失
        res.send(200);
    }, 1000);
});

process.on('uncaughtException', function (err) {
    res.send(500); // 做不到,拿不到当前请求的 res 对象
});

最终出错的用户只能等待浏览器超时。

domain + uncaughtException

所以,我们可以结合两种异常捕获机制,用 domain 来捕获大部分的异常,并且提供友好的 500 页面以及优雅退出。对于剩下的异常,通过 uncaughtException 事件来避免服务器直接 crash。

代码如下:

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
32
33
34
35
36
37
38
39
40
var app = express();
var server = require('http').create(app);
var domain = require('domain');

// 使用 domain 来捕获大部分异常
app.use(function (req, res, next) {
    var reqDomain = domain.create();
    reqDomain.on('error', function () {
        try {
            var killTimer = setTimeout(function () {
                process.exit(1);
            }, 30000);
            killTimer.unref();

            server.close();

            res.send(500);
        } catch (e) {
            console.log('error when exit', e.stack);
        }
    });

    reqDomain.run(next);
});

// uncaughtException 避免程序崩溃
process.on('uncaughtException', function (err) {
    console.log(err);

    try {
        var killTimer = setTimeout(function () {
            process.exit(1);
        }, 30000);
        killTimer.unref();

        server.close();
    } catch (e) {
        console.log('error when exit', e.stack);
    }
});

其他的一些问题

express 中异常的处理

使用 express 时记住一定不要在 controller 的异步回调中抛出异常,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
app.get('/', function (req, res, next) { // 总是接收 next 参数
    mysql.query('SELECT * FROM users', function (err, results) {
        // 不要这样做
        if (err) throw err;

        // 应该将 err 传递给 errorHandler 处理
        if (err) return next(err);
    });
});

app.use(function (err, req, res, next) { // 带有四个参数的 middleware 专门用来处理异常
    res.render(500, err.stack);
});

和 cluster 一起使用

cluster 是 node 自带的负载均衡模块,使用 cluster 模块可以方便的建立起一套 master/slave 服务。在使用 cluster 模块时,需要注意不仅需要调用 server.close() 来关闭连接,同时还需要调用 cluster.worker.disconnect() 通知 master 进程已停止服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var cluster = require('cluster');

process.on('uncaughtException', function (err) {
    console.log(err);

    try {
        var killTimer = setTimeout(function () {
            process.exit(1);
        }, 30000);
        killTimer.unref();

        server.close();

        if (cluster.worker) {
            cluster.worker.disconnect();
        }
    } catch (e) {
        console.log('error when exit', e.stack);
    }
});

不要通过 uncaughtException 来忽略错误

uncaughtException 事件有一个以上的 listener 时,会阻止 Node 结束进程。因此就有一个广泛流传的做法是监听 processuncaughtException 事件来阻止进程退出,这种做法有内存泄露的风险,所以千万不要这么做:

1
2
3
process.on('uncaughtException', function (err) { // 不要这么做
    console.log(err);
});

pm2 对于 uncaughtException 的额外处理

如果你在用 pm2 0.7.1 之前的版本,那么要当心。pm2 有一个 bug,如果进程抛出了 uncaughtException,无论代码中是否捕获了这个事件,进程都会被 pm2 杀死。0.7.2 之后的 pm2 解决了这个问题。

要小心 worker.disconnect()

如果你在退出进程时希望可以发消息给监控服务器,并且还使用了 cluster,那么这个时候要特别小心,比如下面的代码:

1
2
3
4
5
6
7
8
9
var udpLog = dgram.createSocket('udp4');
var cluster = require('cluster');

process.on('uncaughtException', function (err) {
    udpLog.send('process ' + process.pid + ' down', /* ... 一些发送 udp 消息的参数 ...*/);

    server.close();
    cluster.worker.disconnect();
});

这份代码就不能正常的将消息发送出去。因为 udpLog.send 是一个异步方法,真正发消息的操作发生在下一个事件循环中。而在真正的发送消息之前 cluster.worker.disconnect() 就已经执行了。worker.disconnect() 会在当前进程没有任何链接之后,杀掉整个进程,这种情况有可能发生在发送 log 数据之前,导致 log 数据发不出去。

一个解决方法是在 udpLog.send 方法发送完数据后再调用 worker.disconnect:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var udpLog = dgram.createSocket('udp4');
var cluster = require('cluster');

process.on('uncaughtException', function (err) {
    udpLog.send('process ' + process.pid + ' down', /* ... 一些发送 udp 消息的参数 ...*/, function () {
        cluster.worker.disconnect();
    });

    server.close();

    // 保证 worker.disconnect 不会拖太久..
    setTimeout(function () {
        cluster.worker.disconnect();
    }, 100).unref();
});

小节

说了这么多,结论是,目前为止(Node 0.10.25),依然没有一个完美的方案来解决任意异常的优雅退出问题。用 domain 来捕获大部分异常,并且通过 uncaughtException 避免程序 crash 是目前来说最理想的方案。回调异常的退出问题在遇到 cluster 以后会更加复杂,特别是对于连接关闭的处理要格外小心。

参考文章

- FIN -

用 node.js 做 Web 前端服务器的一些经验

前不久 NCZ 发表了新文章 Node.js and the new web front-end译文),描述了用 node.js 做 Web 前端服务器的种种优势。NCZ 在文章中推荐了一套服务器模型(图片来源自Node.js and the new web front-end)。

这个模型在传统的后台服务器前,增加了一层 node.js 实现的 Frontend Server 层。这种架构的最大好处是前后端开发人员的依赖分离,让后端开发人员不必再关心数据在页面间如何传递、用户数据获取是通过 Ajax 还是刷新页面等前端开发所涉及的方方面面,前端开发人员也不必再关心数据如何在数据库中存储等等后端问题。

The front-end and back-end now have a perfect split of concerns amongst the engineers who are working on those parts. The front-end has expanded back onto the server where the Node.js UI layer now exists, and the rest of the stack remains the realm of back-end engineers. — Nicholas C. Zakas

碰巧前不久,我在公司内部尝试了这种架构,这里正好分享一些 node.js 做 Web 前端服务器的经验。

与后台服务器的交互

在用户的一次请求中,往往需要请求多个不同的后台接口。由于 node.js 的异步特性,写多次 HTTP 请求并处理回调是一件非常痛苦的事情,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var request = require('request');

exports.index = function (req, res) {
    request('API_A', function (err, response, body) {
        if (err) {
            // ...
        }
        request('API_B', function (err, response, body) {
            if (err) {
                // ...
            }

            request('API_C', function (err, response, body) {
                if (err) {
                    // ...
                }

                // ...
            });
        });
    });
};

这种情况通过 [async] 库可以很很好的解决这个问题。[async] 是一个工具包,提供了各种各样的小函数来简化 node.js 的异步回调处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var request = require('request');
var async = require('async');

exports.index = function (req, res) {
    async.map(['API_A', 'API_B', 'API_C', /* ... */], request, function (err, results) {
        if (err) {
            // ...
        }

        var resultA = results[0];
        var resultB = results[1];
        var resultC = results[2];
        // ...
    });
};

通过 async.map 可以很轻易的实现并行请求数据。如果需要串行请求数据,可以使用 async.Series 函数。除此之外,还可以使用 async.mapLimit 来限制 node.js 的并发连接数。

常用 API 数据的获取

有些 API 数据是几乎每个页面都会用到的,例如当前用户的个人信息等。对于这类数据,可以通过 middleware 的方式来将它传递给 controller。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var request = require('request');
var async = require('async');

function userdata (req, res, next) {
    request('GET_USER_API', function (err, response, body) {
        if (err) {
            next(err);
            return;
        }

        req.user = JSON.parse(body);
        next();
    });
}

app.get('/pageA', userdata, pageAController);
app.get('/pageB', userdata, pageBController);
app.get('/pageC', userdata, pageCController);

如果 API 接口需要验证 Cookie,那么 node.js 在发送 API 请求时,需要将用户的 Cookie 信息发到后台服务器。同样的,如果后台 API 接口修改了用户 Cookie,例如登陆 API,那么还需要 node.js 将设置用户 Cookie 的请求转发给用户。这就需要实现一个 cookieRequest 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
var request = require('request');
var cookieRequest = function (userRequest, userResponse, url, callback) {
    var options = {
        url: url,
        headers: {}
    };
    options.headers.Cookie = userRequest.header('Cookie'); // 将用户的 Cookie 传递给后台服务器
    
    request(options, function (error, response, body) {
        userResponse.setHeader('Cookie', response.headers.cookie);
        callback.apply(null, arguments);
    });
};

多核优化

由于 node.js 的单进程特性,只启动一个 node.js 实例的话不能充分发挥多核 CPU 的性能。因此 node.js 提供了 cluster 模块来解决这个问题。cluster 可以管理多个服务器进程,充分发挥多核 CPU 的性能。

用法很简单,只需要创建一个新的启动脚本,调用 cluster 模块来启动服务即可,node.js 官方的 API 文档给了一个很简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var cluster = require('cluster');
var http = require('http');
var numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  // Fork workers.
  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', function(worker, code, signal) {
    console.log('worker ' + worker.process.pid + ' died');
  });
} else {
  // Workers can share any TCP connection
  // In this case its a HTTP server
  http.createServer(function(req, res) {
    res.writeHead(200);
    res.end("hello world\n");
  }).listen(8000);
}

性能

老的 Web 服务器使用的是 tomcat + java 的架构,用 node.js 重写整个前端层之后,整个服务的性能提升了不少。目前这个项目只是个人娱乐,所以也没有做太专业的性能测试,只是用 ab 随便打了打压力,在我的 iMac(2.7G i5, 12G) 上大约有 20% 的性能提升。这和 node.js HTTP 模块的高性能以及并发请求 API 不无关系。

结语

随着项目规模的扩展,Web 前端服务器与后端服务器分离是一个不可避免的趋势。而 node.js 提供了一套对前端开发人员更加友好的 Web 前端服务器方案,这一方案将前后端开发人员从彼此不擅长的领域中解救出来,降低了沟通成本,对于提升开发效率有着非常大的帮助。

- FIN -