Abo

浅谈异步以及 Nodejs 框架 Express VS Koa

最近在学习koa的使用, 由于koa是相当基础的web框架,所以一个完整的web应用所需要的东西大都以中间件的形式引入,比如koa-router, koa-view等


20191110003241

异步建议结合上一篇:任务队列一起看


        

往事回顾谈异步

基本概念

所谓”异步”,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。可以理解成阻塞了,而如果我们通过异步就可以跳到下一个块去执行任务,等执行完返回阻塞块,好像更加迷糊了😅

进阶理解

如果在函数返回结果的时候,调用者能够拿到预期的结果(就是函数计算的结果),那么这个函数就是同步的.

1
console.log('hello');//执行后,获得了返回结果

如果函数是同步的,即使调用函数执行任务比较耗时,也会一致等待直到得到执行结果。如下面的代码:

1
2
3
4
5
6
7
function wait(){
var time = (new Date()).getTime();//获取当前的unix时间戳
while((new Date()).getTime() - time < 5000){}
console.log('5秒过去了');
}
wait();
console.log('慢死了');

上面代码中,函数wait是一个耗时程序,持续5秒,在它执行的这漫长的5秒中,下面的console.log()函数只能等待,这就是同步。

如果在函数返回的时候,调用者还不能购得到预期结果,而是将来通过一定的手段得到(例如回调函数),这就是异步。例如ajax操作。
如果函数是异步的,发出调用之后,马上返回,但是不会马上返回预期结果。调用者不必主动等待,当被调用者得到结果之后会通过回调函数主动通知调用者。

1

下面以AJAX请求为例,来看一下同步和异步的区别:

  • 异步AJAX:
  • 主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
  • AJAX线程:“好的,主线程。我马上去发,但可能要花点儿时间呢,你可以先去忙别的。”
  • 主线程::“谢谢,你拿到响应后告诉我一声啊。”
  • (接着,主线程做其他事情去了。一顿饭的时间后,它收到了响应到达的通知。)
  • 同步AJAX:
  • 主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
  • AJAX线程:“……”
  • 主线程::“喂,AJAX线程,你怎么不说话?”
  • AJAX线程:“……”
  • 主线程::“喂!喂喂喂!”
  • AJAX线程:“……”
  • (一炷香的时间后)
  • 主线程::“喂!求你说句话吧!”
  • AJAX线程:“主线程,不好意思,我在工作的时候不能说话。你的请求已经发完了,拿到响应数据了,给你。”

了解完同步和异步之后,我们再来看看我们的问题:单线程又怎么会有异步呢?
JavaScript其实就是一门语言,说是单线程还是多线程得结合具体运行环境。众所周知,js的运行环境就是浏览器,具体由js引擎取解析和执行。下面我们来了解下浏览器。

消息队列与事件循环

一个浏览器通常由以下几个常驻的线程:

  • 渲染引擎线程,负责页面的渲染
  • js引擎线程,负责js的解析和执行
  • 定时触发器线程,处理setInterval和setTimeout
  • 事件触发线程,处理DOM事件
  • 异步http请求线程,处理http请求

故浏览器中多个线程的合作完成了异步的操作,那么异步的回调函数又是怎样完成执行的呢?

2

如上图所示,左边的栈存储的是同步任务,就是那些能立即执行、不耗时的任务,如变量和函数的初始化、事件的绑定等等那些不需要回调函数的操作都可归为这一类。

右边的堆用来存储声明的变量、对象。下面的队列就是消息队列,一旦某个异步任务有了响应就会被推入队列中。如用户的点击事件、浏览器收到服务的响应和setTimeout中待执行的事件,每个异步任务都和回调函数相关联。

JS引擎线程用来执行栈中的同步任务,当所有同步任务执行完毕后,栈被清空,然后读取消息队列中的一个待处理任务,并把相关回调函数压入栈中,单线程开始执行新的同步任务。

JS引擎线程从消息队列中读取任务是不断循环的,每次栈被清空后,都会在消息队列中读取新的任务,如果没有新的任务,就会等待,直到有新的任务,这就叫事件循环。

11

面试题实战

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
//执行下面这段代码,执行后,在 5s 内点击两下,过一段时间(>5s)后,再点击两下,整个过程的输出结果是什么?
setTimeout(function(){
for(var i = 0; i < 100000000; i++){}
console.log('timer a');
}, 0)

for(var j = 0; j < 5; j++){
console.log(j);
}

setTimeout(function(){
console.log('timer b');
}, 0)

function waitFiveSeconds(){
var now = (new Date()).getTime();
while(((new Date()).getTime() - now) < 5000){}
console.log('finished waiting');
}

document.addEventListener('click', function(){
console.log('click');
})

console.log('click begin');
waitFiveSeconds();

要想了解上述代码的输出结果,首先介绍下定时器。

setTimeout 的作用是在间隔一定的时间后,将回调函数插入消息队列中,等栈中的同步任务都执行完毕后,再执行。因为栈中的同步任务也会耗时, 所以间隔的时间一般会大于等于指定的时间 。

setTimeout(fn, 0) 的意思是,将回调函数fn立刻插入消息队列,等待执行,而不是立即执行。看一个例子:

1
2
3
4
5
6
7
8
setTimeout(function() {
console.log("a")
}, 0)

for(let i=0; i<10000; i++) {}
console.log("b")
//打印结果,说明回调函数没有立即执行,而是等待同步任务执行完成后才执行的
b a

下面来解释一下面试题吧。

先执行同步任务,for循环,然后是console.log(‘click begin’) 最后是waitFiveSeconds函数

在同步任务执行的期间,‘timera’,‘timerb’对应的回调和click事件的回调先后入队列。

同步任务结束后,js引擎线程空闲后会线查看是否有事件可执行,接着在处理其他异步任务,因此会有下面的输出:

1
2
3
4
5
6
7
8
9
10
11
0
1
2
3
4
click begin
finished waiting
2 click //5s中两次点击
timer a
timer b
2 click //5s后两次点击

ES6 诞生以前,异步编程的方法,大概有下面四种。

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise 对象

而ES6引入了generator跟async函数

回调函数

JavaScript 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字callback,直译过来就是”重新调用”。

读取文件进行处理,是这样写的。

1
2
3
4
fs.readFile('/etc/passwd', 'utf-8', function (err, data) {
if (err) throw err;
console.log(data);
});

上面代码中,readFile函数的第三个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了/etc/passwd这个文件以后,回调函数才会执行。

一个有趣的问题是,为什么 Node 约定,回调函数的第一个参数,必须是错误对象err(如果没有错误,该参数就是null)?

原因是执行分成两段,第一段执行完以后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,原来的上下文环境已经无法捕捉,只能当作参数,传入第二段。

Promise

回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件,代码如下。

1
2
3
4
5
fs.readFile(fileA, 'utf-8', function (err, data) {
fs.readFile(fileB, 'utf-8', function (err, data) {
// ...
});
});

不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。这种情况就称为”回调函数地狱”(callback hell)。

Promise 对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用 Promise,连续读取多个文件,写法如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var readFile = require('fs-readfile-promise');

readFile(fileA)
.then(function (data) {
console.log(data.toString());
})
.then(function () {
return readFile(fileB);
})
.then(function (data) {
console.log(data.toString());
})
.catch(function (err) {
console.log(err);
});

上面代码中,我使用了fs-readfile-promise模块,它的作用就是返回一个 Promise 版本的readFile函数。Promise 提供then方法加载回调函数,catch方法捕捉执行过程中抛出的错误。

可以看到,Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。

Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆then,原来的语义变得很不清楚。

那么,有没有更好的写法呢?

generator

从原生到promise到generator,我们来看三段代码

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
//回调地狱版
let url1 = 'http://xxx.xxx.1';
let url2 = 'http://xxx.xxx.2';
let url3 = 'http://xxx.xxx.3';
$.ajax({
url:url1,
error:function (error) {},
success:function (data1) {
console.log(data1);
$.ajax({
url:url2,
data:data1,
error:function (error) {},
success:function (data2) {
console.log(data2);
$.ajax({
url:url3,
data,
error:function (error) {},
success:function (data3) {
console.log(data3);
}
});
}
});
}
});
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
// promise美化版
function request(url,data = {}){
return new Promise((resolve,reject)=>{
$.ajax({
url,
data,
success:function (data) {
resolve(data);
},
error:function (error) {
reject(error);
}
})
});
}
let url1 = 'http://xxx.xxx.1';
let url2 = 'http://xxx.xxx.2';
let url3 = 'http://xxx.xxx.3';
request(url1)
.then((data)=>{
console.log(data);
return request(url2,data)
})
.then((data)=>{
console.log(data);
return request(url3,data)
})
.then((data)=>{
console.log(data)
})
.catch((error)=>{
console.log(error);
});

发现每次调用下一个块,比如申请完url1,申请url2都要return一层,略显麻烦(其实我并不怎么觉得)

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
//generator
var fs = require('fs');

var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) return reject(error);
resolve(data);
});
});
};

var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
var g = gen();

g.next().value.then(function(data){
g.next(data).value.then(function(data){
g.next(data);
});
});

通过yield把调用顺序定制好,接下来只需要靠next()来走流程即可

传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做”协程”(coroutine),意思是多个线程互相协作,完成异步任务。

协程有点像函数,又有点像线程。它的运行流程大致如下。

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停,执行权转移到协程B
  • 第三步,(一段时间后)协程B交还执行权。
  • 第四步,协程A恢复执行。

这即是中间件的思路

上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。

举例来说,读取文件的协程写法如下。

1
2
3
4
5
function* asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。

开门见山谈使用

身边逐渐都在使用Koa而淘汰Express,express有很多中间件,所以使用express的人很多。

express基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//express.js
var express = require('express')
var app = express()
// 外联形式
var mid = function(req,res,next){
req.body='mark'
next()
res.send(req.body + 'done')
}
app.use(mid)
app.use(function(req,res,next){
//申请中间件,req主要用来获取请求的信息,res返回处理的结果,把当前函数的控制权转交给下一个代码的单元来处理,如果next里面传递一个参数的话,这也是符合nodejs的约定,就是说,如果这个时候出错了,它的错误信息作为一个参数,在next里面传递下去,也可以在这个中间件里面做很多事情,比如对body追加内容,可以内联的形式,也可以外联,内联就是在这里:
req.body='saved'
next();
// 外联的话就是定义在use外面
})
app.listen(3000)

// 当一个http请求进来后,首先会进入到mid这个中间件来,然后对body设置一个初始的字符串,然后调用next(),就会进入到下一个代码的单元,也就是新的一个use,就是saved那里,再调用next(),这是没有其他代码单元,就会回到mid里面去,去执行send,所以等代码运行结束后,我们在浏览器收到的内容为:mark saved done

以上即是express中间件的一个形态

express基本使用-异步

如若我需要在中间件内加入异步代码:比如查一段数据,对远端api的调用,我们这里设置一个延时的函数,只要它是一个异步的任务,我们利用callback的形式,来控制中间件的控制权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//express.js
var express = require('express')
var app = express()
var asyncIO = function(cb){
setTimeout(function(){
cb()
},500)
}
var mid = function(req,res,next){
req.body='mark'
next()
res.send(req.body + 'done')
}
app.use(mid)
app.use(function(req,res,next){
asyncIO(function(){
req.body='saved'
next();
})

})
app.listen(3000)

如果异步的代码多了,尤其是并发的异步,层层的回调会让测试跟维护代码很复杂,特别是异常处理,都交给了下一层不透明,所以express的异步回调机制有点靠不住,再加上js越来越强,所以koa就横空出世了,那么它的中间件怎么来实现呢?我们传进去的function不再是普通function,而是一个generator函数

koa基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//koa.js
var koa = require('koa')
var app = koa();
var asyncIO = function() {
return new Promise(function(resolve){
setTimeout(function(){
resolve()
},500)
})
}
var mid = function () {
return function *(next){
this.body = 'mark'
yeild next
this.body += 'done'
}
}
app.listen(3000)
app.use(mid) //调用中间件
app.use(function *(next){
yeild asyncIO()
this.body += 'saved'
yield next //通过yeild来调用next
})

结果跟express差不多,但是至少没有了回调的过程,扁平化了

koa2基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Koa2.js
const Koa = require('koa')
const app = new Koa() //新版本要用到new关键字
const asyncIO = () => {
return new Promise(resolve => setTimeout(resolve,500))
}
const mid = () => async(ctx,next) => {
ctx.body = 'mark'
await next()
ctx.body = ctx.body + 'done'
}
app.use(mid())
app.use(async(ctx,next)=> {
await asyncIO()
ctx.body += 'saved'
await next()
} )
app.listen(3000)

express 提供了next模式,可以在中间件做异步操作,但是仅限于异步完成后通知下一个中间件。而该中间件本身是无法执行异步完成之后的逻辑,next仅仅是一个同步函数,,执行完就结束了。koa用到了async await ,所有中间件本身的异步逻辑完成之后还可以完成后该中间件的后置逻辑,一次请求会往返该中间件两次,很多逻辑就会只变得异常简单,代码易于维护。、 异常处理在express 中其实挺坑爹的,但是koa中异常处理非常简单。不管是同步还是异步,用promise包装之后都能被捕获。

回归正题谈区别

异步流程控制

Express 采用 callback 来处理异步,Koa v1 采用 generator,Koa v2 采用 async/await。
下面分别对 js 当中 callback、promise、generator、async/await 这四种异步流程控制进行了对比,
generator 和 async/await 使用同步的写法来处理异步,明显好于 callback 和 promise,async/await 在语义化上又要比 generator 更强。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// callback
var api1 = 'https://anapioficeandfire.com/api/characters/583'
var api2 = 'https://anapioficeandfire.com/api/characters/584'

function fetchData () {
$.ajax({
type: 'GET',
url: api1,
dataType: 'json',
success: function (data1) {
$.ajax({
type: 'GET',
url: api2,
dataType: 'json',
success: function (data2) {
console.log(`${data1.name} and ${data2.name} are two characters in Game of Thrones`)
}
})
}
})
}

fetchData()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Promise
var api1 = 'https://anapioficeandfire.com/api/characters/583'
var api2 = 'https://anapioficeandfire.com/api/characters/584'

function fetchData () {
fetch(api1).then(res1 => {
res1.json().then(data1 => {
fetch(api2).then(res2 => {
res2.json().then(data2 => console.log(`${data1.name} and ${data2.name} are two characters in Game of Thrones`))
})
})
})
}

fetchData()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// generator
var api1 = 'https://anapioficeandfire.com/api/characters/583'
var api2 = 'https://anapioficeandfire.com/api/characters/584'

function *fetchData () {
var name1 = yield request(api1)
var name2 = yield request(api2)
console.log(`${name1} and ${name2} are two characters in Game of Thrones`)
}

function request (url) {
fetch(url).then(res => res.json()).then(data => it.next(data.name))
}

var it = fetchData()
it.next()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// async/await
var api1 = 'https://anapioficeandfire.com/api/characters/583'
var api2 = 'https://anapioficeandfire.com/api/characters/584'

async function fetchData () {
var name1 = await request(api1)
var name2 = await request(api2)
console.log(`${name1} and ${name2} are two characters in Game of Thrones`)
}

function request (url) {
return fetch(url).then(res => res.json()).then(data => data.name)
}

fetchData()

错误处理

Express 使用 callback 捕获异常,对于深层次的异常捕获不了,
Koa 使用 try catch,能更好地解决异常捕获。

1
2
3
4
5
// Express callback
app.use(function (err, req, res, next) {
console.error(err.stack)
res.status(500).send('Something broke!')
})
1
2
3
4
5
6
7
8
9
10
// Koa generator
app.use(function *(next) {
try {
yield next
} catch (err) {
this.status = err.status || 500
this.body = { message: err.message }
this.app.emit('error', err, this)
}
})
1
2
3
4
5
6
7
8
9
10
// Koa async/await
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.status || 500
ctx.body = { message: err.message }
ctx.app.emit('error', err, this)
}
})

Hello World

两者创建一个基础的 Web 服务都非常简单,写法也基本相同,最大的区别是路由处理 Express 是自身集成的,而 Koa 需要引入中间件。

1
2
3
4
5
6
7
8
9
// Express
var express = require('express')
var app = express()

app.get('/', function (req, res) {
res.send('Hello World!')
})

app.listen(3000)
1
2
3
4
5
6
7
8
9
10
11
// Koa
var koa = require('koa')
var route = require('koa-route')

var app = koa()

app.use(route.get('/', function *(){
this.body = 'Hello World'
}))

app.listen(3000)

Views

Express 自身集成了视图功能,提供了 consolidate.js 功能,支持几乎所有 JavaScript 模板引擎,并提供了视图设置的便利方法。
Koa 需要引入 co-views 中间件。

1
2
3
4
5
6
7
8
9
10
11
12
// Express
var express = require('express')
var app = express()

app.set('views', __dirname + '/views')
app.set('view engine', 'jade')

app.get('/', function (req, res) {
res.render('index', {
title: 'bilibili'
})
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Koa
var koa = require('koa')
var route = require('koa-route')
var views = require('co-views')

var render = views(__dirname + '/views', {
default: "jade"
})

var app = koa()

app.use(route.get('/', function *() {
this.body = yield render('index', {
title: 'bilibili'
})
}))

HTTP Request

两个框架都封装了HTTP Request对象,有一点不同是 Koa v1 使用 this 取代 Express 的 req、res。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Express
var app = require('express')()

app.get('/room/:id', function (req, res) {
console.log(req.params)
})

// 获取POST数据需要 body-parser 中间件
var bodyParser = require('body-parser')
app.use(bodyParser.json())
app.post('/sendgift', function (req, res) {
console.log(req.body)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Koa
var app = require('koa')()
var route = require('koa-route')

app.use(route.get('/room/:id', function *() {
console.log(this.req.query)
}))

// 获取POST数据需要 co-body 中间件
var parse = require('co-body')
app.use(route.post('/sendgift', function *() {
var post = yield parse(this.request)
console.log(post)
}))

总结

Express

优点:线性逻辑,通过中间件形式把业务逻辑细分、简化,一个请求进来经过一系列中间件处理后再响应给用户,清晰明了。
缺点:基于 callback 组合业务逻辑,业务逻辑复杂时嵌套过多,异常捕获困难。

Koa

优点:首先,借助 co 和 generator,很好地解决了异步流程控制和异常捕获问题。其次,Koa 把 Express 中内置的 router、view 等功能都移除了,使得框架本身更轻量。
缺点:社区相对较小。