koa中间件与async

写在前面

相比express的保守,koa则相对激进,目前Node Stable已经是v7.10.0了,async&await是在v7.6加入豪华午餐的,这么好的东西必须用起来

从目前历史来看,以顺序形式编写异步代码是自然选择的结果。微软出品的一系列语言,比如F# 2.0(2010年)就支持了该特性,C# 5.0(2012年)也添加了该特性,而JS在ES2016才考虑支持async&await,期间生态出现了一些过渡产品,比如EventProxy、Step、Wind等异步控制库,ES2015推出的Promise、yield,以及在此基础上实现的co模块,都是为了让异步流程控制更简单

async&await最自然的方式(顺序形式,与同步代码形式上没区别),也是目前最优的方案

P.S.关于JS异步编程的更多信息,请查看:

一.中间件

不像PHP内置了查询字符串解析、请求体接收、Cookie解析注入等基本的细节处理支持

Node提供的是赤果果的HTTP连接,没有内置这些细节处理环节,需要手动实现,比如先来个路由分发请求,再解析Cookie、查询字符串、请求体,对应路由处理完毕后,响应请求时要先包装原始数据,设置响应头,处理JSONP支持等等。每过来一个请求,这整个过程中的各个环节处理都必不可少,每个环节都是中间件

中间件的工作方式类似于车间流水线,过来一张订单(原始请求数据),路由分发给对应部门,取出Cookie字段,解析完毕把结果填上去,取出查询字符串,解析出各参数对,填上去,读取请求体,解析包装一下,填上去……根据订单上补充的信息,车间吐出一个产品……添上统一规格的简单包装(包装原始数据),贴上标签(响应头),考虑精装还是平装(处理JSONP支持),最后发货

所以中间件用来封装底层细节,组织基础功能,分离基础设施和业务逻辑

尾触发

最常见的中间件组织方式是尾触发,例如:

// 一般中间件的结构:尾触发下一个中间件
var middleware = function(err, req, res, next) {
    // 把处理结果挂到请求对象上
    req.middlewareData = handle(req);
    // 通过next传递err,捕获异步错误
    if (errorOccurs) {
        return next(error);
    }

    next();
};

把所有中间件按顺序串起来,走到业务逻辑环节时,需要的所有输入项都预先准备好并挂在请求对象上了(由请求相关的中间件完成),业务逻辑执行完毕得到响应数据,直接往后抛,走响应相关的一系列中间件,最终请求方得到了符合预期的响应内容,而实际上我们只需要关注业务逻辑,前后的事情都是由一串中间件完成的

尾触发串行执行所有中间件,存在2个问题

  • 缺少并行优化

  • 错误捕获机制繁琐

对中间件按依赖关系分组,并行执行,能够提高性能,加一层抽象就能解决。错误需要手动往后抛,沿中间件链手动传递,比较麻烦,不容易解决

koa2.0中间件

看起来很漂亮:

app.use(async (ctx, next) => {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

一个简单的响应耗时记录中间件,如果放到中间件队首,就能得到所有中间件执行的总耗时

与上面介绍的尾触发不同,有了await就可以在任意位置触发后续中间件了,例如上面两个时间戳之间的next(),这样就不需要按照非常严格的顺序来组织中间件了,灵活很多

之前之所以用尾触发,就是因为异步中间件会立即返回,只能通过回调函数控制,所以约定尾触发顺序执行各中间件

async&await能够等待异步操作结束(这里的等待是真正意义上的等待,机制类似于yield),不用再特别关照异步中间件,尾触发就不那么必要了

二.路由

路由也是一种中间件,负责分发请求,例如:

router
  .get('/', function (ctx, next) {
    ctx.body = 'Hello World!';
  })
  .post('/users', function (ctx, next) {
    // ...
  })
  .put('/users/:id', function (ctx, next) {
    // ...
  })
  .del('/users/:id', function (ctx, next) {
    // ...
  })
  .all('/users/:id', function (ctx, next) {
    // ...
  });

常见的RESTful API,把请求按methodurl分发给对应的route。路由与一般中间件的区别是路由通常与主要业务逻辑紧密相关,可以把请求处理过程分成3段:

请求预处理 -> 主要业务逻辑 -> 响应包装处理

对应到中间件类型:

请求相关的中间件 -> 路由 -> 响应相关的中间件

虽然功能不同,但从结构上看,路由和一般的中间件没有任何区别。router是请求分发中间件,用来维护urlroute的关系,把请求交给对应route

三.错误捕获

await myPromise方式中reject的错误能够被外层try...catch捕获,例如:

(async () => {
    try {
        await new Promise((resolve, reject) => {
            setTimeout(() => {
                let err = new Error('err');
                reject(err);
            }, 100);
        });
    } catch (ex) {
        console.log('caught ' + ex);
    }
})();
console.log('first log here');

注意try...catch错误捕获仅限于reject(err),直接throw的或者运行时异常无法捕获。此外,只有在异步函数创建的那层作用域的try...catch才能捕获到异常,外层的不行,例如:

try {
    (async () => {
        await new Promise((resolve, reject) => {
            setTimeout(() => {
                let err = new Error('err');
                reject(err);
            }, 100);
        });
    })();
    console.log('first log here');
} catch (ex) {
    console.log('caught ' + ex);
}

因为异步函数自身执行后立即返回,外层try...catch无法捕获这样的异步异常,会先看到first log here100ms后抛出未捕获的异常

Promise有一个特殊机制:

特殊的:如果resolve的参数是Promise对象,则该对象最终的[[PromiseValue]]会传递给外层Promise对象后续的then的onFulfilled/onRejected

(摘自完全理解Promise)

也就是说通过resolve(nextPromise)建立的Promise链上任意一环的reject错误都会沿着Promise链往外抛,例如:

(async () => {
    try {
        await new Promise((resolve, reject) => {
            resolve(new Promise((rs, rj) => {
                rs(new Promise((s, j) => {
                    setTimeout(() => {
                        j(new Error('err'));
                    }, 100);
                }))
            }))
        });
    } catch (ex) {
        console.log('caught ' + ex)
    }
})();

仍然能够捕获到最内层的错误

捕获中间件错误

利用这个特性,可以实现用来捕获中间件错误的中间件,如下:

// middleware/onerror.js
// global error handling for middlewares
module.exports = async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        err.status = err.statusCode || err.status || 500;
        let errBody = JSON.stringify({
            code: -1,
            data: err.message
        });
        ctx.body = errBody;
    }
};

把这个中间件放在最前面,就能捕获到后续所有中间件reject的错误以及同步错误

全局错误捕获

上面捕获了reject的错误和同步执行过程中产生的错误,但异步throw的错误(包括异步运行时错误)还是捕获不到

而轻轻一个Uncaught Error就能让Node服务整个挂掉,所以有必要添上全局错误处理作为最后一道保障:

// global catch
process.on('uncaughtException', (error) => {
    console.error('uncaughtException ' + error);
});

这个自然要尽量放在所有代码之前执行,而且要保证自身没有错误

粗暴的全局错误捕获不是万能的,比如无法在错误发生后响应一个500,这部分是错误捕获中间件的职责

四.示例Demo

一个简单的RSS服务,中间件组织如下:

middleware/
  header.js     # 设置响应头
  json.js       # 响应数据转规格统一的JSON
  onerror.js    # 捕获中间件错误
route/
    html.js     # /index对应的路由
    index.js    # /html/:url对应的路由
    pipe.js     # /pipe对应的路由
    rss.js      # /rss/:url对应的路由

按顺序应用各中间件:

// global catch for middles error
app.use(onerror);

// router
router
    .get('/', function (ctx, next) {
        ctx.body = 'RSSHelper';
    })
    .get('/index', require('./route/index.js'))
    .get('/rss/:url', require('./route/rss.js'))
    .get('/html/:url', require('./route/html.js'))
    .get('/pipe', require('./route/pipe.js'))
app
    .use(router.routes())
    .use(router.allowedMethods())

// custom middlewares
app
    .use(header)
    .use(json)

请求预处理和响应数据包装都由前后的中间件完成,路由只负责产生输出(原始响应数据),例如:

// route /html
const fetch = require('../fetch/fetch.js');
module.exports = async (ctx, next) => {
    await new Promise((resolve, reject) => {
        const url = ctx.params.url;

        let onsuccess = (data) => {
            data = data || {};
            ctx.state.data = data;
            resolve();
        }
        let onerror = reject;
        fetch('html', url)
            .on('success', onsuccess)
            .on('error', onerror)
    });

    next();
};

抓取成功后,把data挂到ctx.state上,resolve()通知等待结束,next()交由下一个中间件包装响应数据,非常清爽

项目地址:https://github.com/ayqy/RSSHelper/tree/master/node

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code