Node 异步编程

为什么要异步编程

异步的概念之所以会火起来,是因为浏览器中Javascript在单线程执行,而且它与UI渲染共用一个进程。如果脚本执行时间超过100ms,用户就会感到卡顿。如果Javascript要从服务器上获取资源,并且以同步的方式进行,那么JavaScript要等资源完全获取后才会继续运行,那么这时UI渲染就会停滞,导致用户体验下降。通过异步消可以除阻塞的现象,JavaScript和UI渲染可以同时进行,给用户一个鲜活的页面。

函数式编程

高阶函数

在通常语言中,函数的参数只接受基本数据类型,返回值也是基本数据类型。高阶函数则是把函数作为参数或作为函数的返回值,如下面代码:

1
2
3
4
5
function foo(x) {
return function () {
return x;
}
}

高阶函数比普通函数灵活很多,除了通常意义的函数调用外,还形成了一种后续传递风格的结果接受方式,而非单一的返回值形式。

1
2
3
function foo(x,bar){
return bar(x)
}

对于相同的foo函数,传入的bar参数不同,可以得到不同的结果。一个经典的例子便是数组的sort方法。

1
2
3
var arr=[40,100,1,2,5,25,66]
arr.sort((a,b)=>b-a)
console.log(arr) // [100,66,40,25,5,2,1]

通过改动sort方法的参数,可以产生不同的排序方式。ES5中提供的一些数组方法(forEach、map、filter、every)都是高阶函数。

偏函数

偏函数是指创建一个调用另外一个部分(参数或变量)已经预置的函数,如下面代码:

1
2
3
4
5
6
7
var toString=Object.prototype.toString
var isString=function(obj){
toString.call(obj)=='[object String]'
}
var isObject=function(obj){
toString.call(obj)=='[object Object]'
}

上面的代码只有两个函数定义,但是我们需要重复定义很多类似的函数,如果有更多的isXXX,就会出现很多冗余代码。为了解决重复代码,我们引入一个新的函数,这个函数可以创建类似的函数,如下面的代码

1
2
3
4
5
6
7
8
var toString = Object.prototype.toString
var isType = function (type) {
return function (obj) {
return toString.call(obj) == `[object ${type}]`
}
}
var isString = isType("String")
var isObject = isType("Object")

这种通过指定部分参数来产生一个新的定制函数的形式就是偏函数。

异步编程的优势与难点

优势

Node的最大特性就是非阻塞I/O模型,非阻塞的I/O可以使CPU与I/O并不相互以来等待,使资源得到更好的利用。

难点

异常处理

JavaScript处理异常,通常使用try/catch语句块来捕获异常,但是这对异步编程不一定适用。异步I/O的实现有两个阶段:提交请求和处理结果,这两个阶段之间有事件循环的调度,两者不关联。异步方法在第一阶段提交请求后立即返回,然而异常有可能会发生在第二阶段,因此try/catch的功效在此不会发挥作用。Node在异常处理上形成了约定,将异常作为回调函数的第一个实参,如果为空值,则表明异步调用没有产生异常。

在编写异步函数时,也要遵循以下原则:

  1. 必须执行调用者传入的回调函数
  2. 正确传递回异常供调用者判断

函数嵌套过深

这是Node饱受诟病的地方,在Node中事务中存在多个异步调用的场景有很多,比如一个遍历目录的操作:

1
2
3
4
5
6
7
8
var fs=require("fs")
fs.readdir("./",(err,files)=>{
files.forEach(file=>{
fs.readFile(file,"utf-8",(err,file)=>{
//TODO
})
})
})

由于两次操作存在依赖关系,函数的嵌套也情有可原。虽然在结果上是没有问题的,但是没有利用好异步IO的并行优势,这是异步编程的典型问题。

阻塞代码

Javascript没有sleep这样的线程沉睡功能,唯独有setInterval和setTimeout两个函数,这两个函数并不能阻塞后续代码的持续执行,

1
2
3
4
5
6
console.log(1)
setTimeout(()=>{console.log(2)},1000)
console.log(3)
// 1
// 3
// 2

异步编程解决方案

事件发布/订阅模式

Node自身提供的events模块是发布/订阅模式的简单实现,操作极其简单:

1
2
3
4
5
6
7
var events = require('events');
var emitter = new events.EventEmitter();
emitter.on('my_event', (message) => {
console.log(message);
});
emitter.emit('my_event',"test");
// test

事件发布/订阅模式自身没有同步和异步调用问题,常常用来解耦业务逻辑。事件监听器也是一种钩子机制,利用钩子导出内部数据或状态给外部的调用者。

  1. 继承events模块

    Node中Stream对象继承EventEmitter的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const EventEmitter = require('events');

class MyStream extends EventEmitter {
write(data) {
this.emit('data', data);
}
}

const stream = new MyStream();

stream.on('data', (data) => {
console.log(`Received data: "${data}"`);
});
stream.write('With ES6');
  1. 利用事件队列解决雪崩问题

在事件订阅/发布模式中,通常有一个once方法,通过它添加的监听器只能执行一次,在执行后就会将它与事件的关联移除。这个特性可以帮助我们过滤一些重复性的事件响应。

下面是一个数据库查询语句的调用:

1
2
3
4
5
var select=function(callback){
db.select("SQL",(result)=>{
callback(result)
})
}

如果服务刚好启动,这样缓存中是不存在数据的,如果访问量巨大,同一句SQL会被发送到数据库中反复查询,影响服务性能,一种改进方案是添加状态锁:

1
2
3
4
5
6
7
8
9
10
var status = "ready"
var select = function (callback) {
if (status === "ready") {
status = "pending"
db.select("SQL", (result) => {
status = "ready"
callback(result)
})
}
}

但是在多次调用select时,只有第一次是生效的,后续的select是无效的,这时可以引入事件队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
var events = require("events")
var proxy = new events.EventEmitter()
var status = "ready"
var select = (callback) => {
proxy.once("selected", callback)
if (status === "ready") {
status = "pending"
db.select("SQL", (result) => {
proxy.emit("selected",result)
status = "ready"
})
}
}

流程控制

使用ES6的async语法。ES6-async

异步并发控制

bagpipe解决方案

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

1
2
3
4
5
6
7
8
9
10
11
var Bagpipe = require("bagpipe")
// 设定直达并发数为10
var bagpipe = Bagpipe(10)
for (var i = 0; i < 100; 1++) {
bagpipe.push(async, function () {
// 异步回调执行
})
}
bagpipe.on("full", function (length) {
console.log(length)
})