Mongoose House Technical Edition

Node.js 异步编程实践

如何编写易于维护的 Node.js 代码?在多次异步调用下,如何避免Callback Hell?在复杂的业务流程中,如何根据返回值,控制业务流走向?本文将根据实际项目中的经验讨论这几个问题。

背景

考虑某个典型业务流程如下,

  1. 首先,根据客户端请求中的 session key,去 redis 服务器中取得用户的 session 信息;
  2. 其次,根据 session 中的用户信息,去数据库中查询用户表,看数据库中是否有用户信息;
  3. 如果数据库中没有用户信息,则给客户端返回“用户未注册”的响应,处理结束;
  4. 如果数据库中有用户信息,则根据取出的用户信息调用第三方登录 API,登录第三方服务;
  5. 然后,根据第三方登录服务返回的 token,调用第三方业务 API;
  6. 最后,把第三方业务 API 返回的结果返回给客户端。

在这次客户端的请求中,用到的函数命名如下,

  • Redis 客户端redis.get(key, callback)
  • 数据库客户端mysql.query(sql, params, callback)
  • 第三方登录 API:fetch(signin_url, params, callback)
  • 第三方业务 API:fetch(busiess_url, params, callback)

回调地狱(Callback Hell)

在使用 Express 框架的环境里,处理客户端请求的代码大致如下,

app.post('/user', function(req, res) {
  // 处理逻辑
})

为了方便(并且之后会用到 ES7 的 async/await),以下使用 ECMAScript 6 语法演示。所以,上述代码为,

// 把funtion改为ES6的箭头函数
app.post('/user', (req, res) => {
  // 处理逻辑
})

一般的,使用回调函数的方式,实现上述业务逻辑的代码大致为,

redis.get(req.body.session_key, (err, val) => {
    if (err)
        return res.send(err.message)
    mysql.query(sql, [ val.user_id ], (err, rows) => {
        if (err)
            return res.send(err.message)
        if (rows.length < 1)
            return res.send('用户未注册')
        fetch(signin_url, { name: rows[0].username }, (err, token) => {
            if (err)
                return res.send(err.message)
            fetch(busiess_url, { token: token }, (err, biz_value) => {
                if (err)
                    return res.send(err.message)
                res.send(biz_value)
            })
        })
    })
})

省略地写,代码大致如上那样,但实际上需要处理的情况要更复杂。但即便是上述那样的代码,也可以看出,几乎很难维护了,且不说形式也很难看。

需要注明的一点是,上述代码我为了说明如何整理业务流程而基本上忽略了错误处理,直接使用res.send(err.message),而返回值也直接使用了res.send(...)来返回给客户端。

这样做是不对的。

首先,错误都需要记入日志,以便今后定位问题;其次,需要根据客户端对错误的处理形式不同,给错误分类或编号,以便客户端接收到错误消息以后,可以判断出如何处理此类错误消息——提示给用户亦或显示系统暂时不可以使用的页面,这要根据用户是否有能力来处理这种错误来决定。

展开谈还能谈很多,也许今后为此再写一篇文章。

Promise的曙光

为了避免Callback Hell,我们可以使用 Promise 来封装需要回调参数的函数。Promise 本身还是比较复杂的,包括并行(Promise.all)等其他的一些扩展。另外,现在很多 API 都提供 Promise 的使用方式,或者 Promise 兼容的函数形态

对于一般的 API 来说,为了使用 Promise,首先需要使用 Promise 封装 API。自己封装 API 的另一个好处是可以更加灵活的控制 API 的异常和返回值,屏蔽一些业务逻辑。

展开来讲,譬如,某个 API 可以返回一个数组或也有可能发生 Error ——如 I/O 操作——当然,Node.js 中所有的 I/O 操作都是异步的——即需要 Callback 调用,那么,自己使用 Promise 封装好 API 后,可以在 API 发生 Error 后,返回一个空的数组,表示没有数据,这样做,可以使调用 API 的调用方更容易处理,代码更简洁。考虑到可以类比 Java 一般不需要特别处理 IOException 一样。

使用 Promise 封装 API 的一般形式如下,

// 封装 redis.get(key, callback)
function redis_get(key) {
    return Promise((resolve, reject) => {
        redis.get(key, (err, val) => {
            if (err)
                return reject(err)
            resolve(val)
        })
    })
}

上述这种封装,我们根据业务需要,可以改为“如果发生 Error 就返回 null”,或者“如果值为 null,就 Error”。

// 如果发生 Error 就返回 null
function redis_get(key) {
    return Promise((resolve, reject) => {
        redis.get(key, (err, val) => {
            if (err)
                return resolve(null)
            resolve(val)
        })
    })
}

// 如果值为 null,就 Error
function redis_get(key) {
    return Promise((resolve, reject) => {
        redis.get(key, (err, val) => {
            if (err)
                return reject(err)
            if (!val)
                return reject(val)
            resolve(val)
        })
    })
}

当然,如果不想麻烦,可以直接

Promise.promisify(redis.get)
// 或者
Promise.promisifyAll(redis)

把 API 使用 Promise 封装后,把上述业务逻辑代码改写为 Promise Chain 的形式如下,

redis_get(key)
.then(val => mysql.query(sql, [ val.user_id ]))
.then(rows => {
    if (rows.length < 1)
        return res.send('用户未注册')
    promise_fetch(signin_url, { name: rows[0].username })
    .then(token => promise_fetch(busiess_url, { token: token }))
    .then(res.send)
    .catch(err => res.send(err.message))
})
.catch(err => res.send(err.message))

无需多言,是不是代码结构比 Callback 的形式简单了许多?另外,对于 Promise Chain,可以使用 throw new Error 的形式来控制流程走向。上述业务逻辑代码的另一种写法,

redis_get(key)
.then(val => mysql.query(sql, [ val.user_id ]))
.then(rows => {
    if (rows.length < 1)
        throw new UnregisteredError('用户未注册')
    return promise_fetch(signin_url, { name: rows[0].username })
})
.then(token => promise_fetch(busiess_url, { token: token }))
.then(res.send)
.catch(UnregisteredError, err => res.send(err.message)) // 或省略,和下面 catch 合并
.catch(err => res.send(err.message))

async/await解决方案

Promise 形式和 Callback 形式相比,确实简化了代码,结构也看起来更美观,也更容易维护,但是对于我们写惯了串行(同步)程序的程序员来说,还是不顺手,譬如,一个简单的流程判断都需要 throw 个 Error 来实现,且不论还有流程的循环等,实在是太不方便了。

现在好了,使用 async/await 的方式可以让我们像写串行程序一样来写并行(异步)程序。

async/await 是 ES7 的语法,现在要使用,需要用 Babel 预编译(不过我们确实也一直在用 Babel 预编译,用 Webpack 打包,不是吗?)。

使用 async/await 来改写上述业务逻辑代码就几乎和串行程序别无二至了。下面我把 Express 框架连在一起写出来,注意需要用 async 包裹在外层函数,并注意 await 的使用时机。

app.post('/user', async (req, res) => {
    const val = await redis_get(key)
    const rows = await mysql.query(sql, [ val.user_id ])
    if (rows.length < 1)
        return res.send('用户未注册')
    const token = await promise_fetch(signin_url, { name: rows[0].username })
    const biz_value = await promise_fetch(busiess_url, { token: token })
    return res.send(biz_value)
})