w3ctech

[译]使用 Async 函数和 Koa 2 构建服务端应用

原文作者 Alex Rudenko

在众多 JavaScript 新特性中,我最喜欢的是 Async 函数。这篇文章我会用一个很实用的例子 —— 使用 Koa 2 构建服务端应用来向大家介绍 Async 函数,以及依赖 Async 函数的 Web 框架 Koa 2 的使用。

首先我会概述 Async 函数的概念及其工作原理。然后我会重点比较 Koa 1 和 Koa 2。最后简述使用 Koa 2 构建的 Demo App,其中涵盖了开发的所有方面,包括测试(使用 Mocha,Chai 和 Supertest)以及部署(使用 PM2)。

Async 函数

大型 JavaScript 应用的一个老生常谈的问题 —— 如何处理回调和组织代码避免所谓的 “回调地狱”。随着时间的推移,已经探索出了几种解决办法。一些还是基于 Callback,另一些则是基于 JavaScript 较新的特性 —— Promise 和 Generator。下面我们用一个简单的例子 —— 依次获取两个 JSON 文件,来比较 Callback,Promise 和 Generator。

// 使用回调的方式依次获取两个 JSON 文件
function doWork(cb) {
  fetch('data1.json', (err1, result1) => {
    if (err1) {
      return cb(err1);
    }
    fetch('data2.json', (err2, result2) => {
      if (err2) {
        return cb(err2);
      }
      cb(null, {
        result1,
        result2,
      });
    })
  });
}

嵌套的匿名内联回调函数是 Callback Hell 的主要标志。你可以重构代码并用模块的方式分隔函数,但你还是得依赖 Callback 的方式。

// 使用 Promise 的方式依次获取两个 JSON 文件
function doWork(cb) {
  fetch('data1.json')
    .then(result1 =>
      fetch('data2.json')
        .then(result2 => cb(null, {
          result1,
          result2,
        })))
    .catch(cb);
}

基于 Promise 的版本看起来要好一点,但是调用方式仍然是嵌套的,并且我们需要按照执行的顺序重新整合代码。

// 使用 Generator 的方式依次获取两个 JSON 文件
function* doWork() {
  var result1 = yield fetch('data1.json');
  var result2 = yield fetch('data2.json');
  return { result1, result2 };
}

Generator 是最简洁的解决办法,并且看起来像是同步代码,而 Callback 和 Promise 的代码片段明显是异步的并且嵌套严重。但 Generator 的方式需要我们在关键字 function 后面添加 * 将函数变为 Generator 类型,还得以一种特殊的方式调用 doWork。这看起来有点不直观,而 async/await 语法提供了更好的抽象来解决这个问题。看一下使用 async 函数的例子:

async function doWork() {
  // 使用 async/await 的方式依次获取两个 JSON 文件
  var result1 = await fetch('data1.json');
  var result2 = await fetch('data2.json');
  return { result1, result2 };
}

这个语法可以理解为:被 async 关键字标记的函数,可以对其使用 await 关键字来暂停函数的执行直到异步操作结束。异步操作可以是 Generator、Promise 或其他异步函数。同时,你可以使用 try/catch 来处理在 await 异步操作的过程中产生的 Error 或 Rejection。这种错误处理机制也可应用在基于 Generator 的控制流中。

Koa 是什么?

Koa 被定位为下一代 Web 框架,作者 TJ 也是 Express 的作者。Koa 非常地轻量级、模块化,并能避免写 Callback 的代码。Koa 应用是一组中间件函数的集合,中间件依次执行来处理接收的 requests 并响应一个 response。每一个中间件都能访问到 context 对象,这是一个封装了原生 Node 请求和响应的对象,并提供了许多开发 web 应用和 API 有用的方法。一个简单的 Koa 应用看起来是这样子的:

// 这是 Koa 1
var koa = require('koa');
var app = koa();

app.use(function *(){
  this.body = 'Hello World';
});

app.listen(3000);

这里只是 Koa 的核心,高级功能的介绍在第三部分。

Koa 2 VS. Koa 1

Koa 1 因其很早采用 generator 并很好地支持基于 generator 控制流而出名。下面是一段典型的 Koa 1 代码,将中间件级联使用,并增加了错误处理:

// 改编自 https://github.com/koajs/koa
// Koa 1.0 中的中间件就是 generator 函数。
app.use(function *(next) {
  try {
    yield next;
  } catch (err) {
    // 请求的 context 是 `this`
    this.body = { message: err.message }
    this.status = err.status || 500
  }
})

app.use(function *(next) {
  const user = yield User.getById(this.session.id);
  this.body = user;
})

Koa 2 移除了内置的 Generator 支持,使用 async 函数替代。中间件函数的签名也改为支持 async 箭头函数。同样的代码使用 Koa 2 改写如下:

// 摘录自 https://github.com/koajs/koa
// 使用 async 箭头函数
app.use(async (ctx, next) = > {
  try {
    await next(); // next 是一个函数,用 await 替代 yield
  } catch (err) {
    ctx.body = { message: err.message };
    ctx.status = err.status || 500;
  }
});

app.use(async ctx => {
  // await 替换 yield
  const user = await User.getById(ctx.session.id);
  // ctx 替换 this
  ctx.body = user;
});

仍然可以使用正常的函数、Promise 和 Generator 函数,请参考 Koa 2 的文档

Koa VS. Express

Koa 建立在 Node.js 的 HTTP 模块之上,比 Express 更容易,更简练。Express 提供了许多内置功能,如路由、模板、发送文件等。而 Koa 只提供了非常少的功能,如基于 generator(Koa 1) 或基于 async/await(Koa 2) 的控制流,路由、模板和其他功能是作为独立模块由社区提供。通常有多种方案可供选择。Koa GitHub 上有篇关于 Koa 和 Express 比较的文档。

Koa 2 的现状

Koa 2 会在 Node.js 原生支持 async/await 特性后 release。但还好我们可以借助 Babel 使用 async/await 和 Koa 2.0,很快我会讲到这。为了向大家展示 Koa 2 和 async 函数,我做了一个 demo,让我们来过一下这个 demo 吧 ~

Demo 应用

Demo 的主要目的是追踪一个静态网站的 PV —— 有点像 Google Analytics,但更简单。这个 Demo 有两个端点:

  • 一个用来存储事件(如一个页面查看)信息;
  • 另一个用来获取事件总数;
  • 另外,终端必须要校验 API key。

使用 Redis 来存储事件数据。整体功能通过单元测试和 API 测试。App 源码放在 Github

APP 依赖

首先我们来看看 app 依赖的模块,以及为什么需要依赖它们。先看看运行时的依赖:

npm install --save \
  # babel-polyfill 提供运行时是因为 async 函数依赖 babel-polyfill
  # Koa 框架本身
  koa@next \
  # 因为 Koa 是最基础的,我们需要 koa-bodyparser 来解析请求的 body 的 JSON。
  koa-bodyparser@next \
  # 添加路由
  koa-router@next \
  # redis 模块存储 app 的数据。
  redis \
  # kcors 模块处理跨域请求
  kcors@next \

注意 Koa 模块的版本使用的是 next,意思是版本兼容 Koa 2,并且有许多模块可供其使用。下面是开发和测试需要用到的模块:

npm install --save-dev \
  # 断言库
  chai \
  # 流行的测试框架
  mocha \
  # API 测试
  supertest \
  # Babel CLI 用来构建 app
  babel-cli \
  # 众多 Babel 插件用来支撑 ES6 特性
  babel-preset-es2015 \
  # 用于支持 stage-3 特性
  babel-preset-stage-3 \
  # 重写 Node.js 运行时的 require 和 compile 模块
  babel-register \
  # watcher
  nodemon

如何组织应用?

经过多种文件组织方式的尝试,我得出下面这个简单的结构,适用于小应用和小团队:

  • index.js app 的主入口文件
  • ecosystem.json PM2 ecosystem, 用来描述如何启动 app
  • src app 源码,Babel 编译前的 JavaScript 文件
  • config app 的配置文件
  • build 编译后的 app, 即从 src 编译的代码

src 目录包含如下文件:

  • api.js 定义 app 的 API 的模块
  • app.js 实例化及配置 Koa app 的模块
  • config.js 为 app 到其他模块提供配置的模块

If additional files or modules are needed as the app grows, we would put them in a subfolder of the src folder — for example, src/models for application models, src/routes for more modular API definitions, src/gateways for modules that interact with external services, and so on. 随着 app 的扩展,需要增加的文件或者模块,可以把他们放在 src 文件夹的下级目录,例如 src/models 放置应用的模型,src/routes 放置更多模块化的 API 定义,src/gateways 放置跟外部服务交互的模块等等。

NPM Scripts 作为任务执行器

用过 Gulp 和 Grunt 作为任务执行器之后,我认为作为服务端项目运行时 npm script 更好用。npm scripts 有个好处就是它允许你像调用全局安装的模块那样调用本地依赖的模块。下面是我在 package.json 中的用法:

"scripts": {
  "start": "node index.js",
  "watch": "nodemon --exec npm run start",
  "build": "babel src -d build",
  "test": "npm run build; mocha --require 'babel-polyfill' --compilers js:babel-register"
}

start script 运行 index.jswatch script 使用 nodemon 工具执行 start script 脚本,nodemon 能在修改 app 后自动重启。注意 nodemon 是作为一个本地开发依赖安装而不是全局安装。

build script 执行 Babel 编译 src 文件夹下的文件并输出结果到 build 文件夹。test script 首先执行 build script 然后使用 mocha 测试。Mocha 依赖两个模块:babel-polyfill —— 用来提供编译运行时的依赖,babel-register —— 执行之前编译测试文件。

另外,Babel 配置也需要放在 package.json,而不用在命令行写这些配置:

{
  "babel": {
    "presets": [
      "es2015",
      "stage-3"
    ]
  }
}

这个配置开启了所有 ECMAScript 2015 特性以及当前在 stage 3 的 ES 特性。有了这些安装和配置,我们可以开始开发 demo 了。

app 的代码

首先来看一下 index.js:

const port = process.env.PORT || 4000;
const env = process.env.NODE_ENV || 'development';
const src = env === 'production' ? './build/app' : './src/app';

require('babel-polyfill');
if (env === 'development') {
  // 开发环境使用 babel/register 更快地在运行时编译
  require('babel-register');
}

const app = require(src).default;
app.listen(port);

这个模块中读取了两个环境变量:PORTNODE_ENVNODE_ENV 的值是 developmentproduction。在开发模式下,babel-register 用在运行时编译模块。babel-register 缓存了编译的结果,因此减少服务启动次数,因此你可以在开发时快速迭代。

index.js 是项目中唯一不被 Babel 编译而且必须遵从原生模块语法(如 CommonJS)。应用程序的实例位于导入的 app 模块的 default 属性中。该模块是一个 ECMAScript 6 模块,并使用默认 export 导出 app 的实例。

`export default app;`

如果你使用 ECMAScript 6 和 CommonJS 模块,请注意。

现在来看看 app.js 文件本身。这个文件和下面讨论的其他文件在开发环境和生成环境都会被 Babel 编译,所以可以自由地使用新的语法(包括 async 函数):

import Koa from 'koa';
import api from './api';
import config from './config';
import bodyParser from 'koa-bodyparser';
import cors from 'kcors';

const app = new Koa()
  .use(cors())
  .use(async (ctx, next) => {
    ctx.state.collections = config.collections;
    ctx.state.authorizationHeader = `Key ${config.key}`;
    await next();
  })
  .use(bodyParser())
  .use(api.routes())
  .use(api.allowedMethods());

export default app;

使用 ECMAScript 2015 的 import 语法来导入依赖的模块。然后创建一个新的 Koa 应用实例,并使用 use 方法来链接多个中间件函数。最后导出 app 供 index.js 使用。

第二个中间件函数同时使用了 async 函数和箭头函数:

app.use(async (ctx, next) => {
  // Set up the request context
  ctx..state.collections = config.collections;
  ctx..state.authorizationHeader = `Key ${config.key}`;
  await next();
  // 当 next 函数返回并且所有的异步任务执行完成,才会执行到这
  // console.log('Request is done');
})

Koa 2 里的 next 参数是一个 async 函数,用来触发中间件列表中的下一个中间件。就像 Koa 1,你可以控制当前中间件函数的执行任务在 next 之前或之后,你也可以在 try/catch 块中通过 await next() 来捕获下游中间件产生的异常。

定义 API

api.js 文件是 app 里面的核心逻辑。因为 Koa 不提供内置的路由功能,app 必须使用 koa-router 模块:

import KoaRouter from 'koa-router';

const api = KoaRouter();

koa-router 提供函数将定义的中间件函数申明 HTTP 方法和路径 —— 如下是保存事件到数据库的路由:

// 申明一个 post 请求及其用途
// :collection 是一个参数
api.post('/:collection',
  // 首先验证 auth key
  validateKey,
  // 然后验证 collection 是否存在
  validateCollection,
  // 向 collection 插入新记录
  async (ctx, next) => {
    // 使用 ES6 destructuring 来提取 collection 参数
    const { collection } = ctx.params;
    // 阻塞到项目保存至持久层
    const count = await ctx
      .state
      .collections[collection]
      .add(ctx.request.body);

    // 当数据被保存了,应答 201
    ctx.status = 201;
  });

每个方法可有多个处理器,这些处理器具有完全一样的签名,他们按照顺序执行,并作为中间件函数在 app.js 顶层定义。例如 validateKeyvalidateCollection 都是 async 函数,用来验证传入的请求。当提供的事件集合不存在或者 API key 不合法,则分别返回 404 或 401:

const validateCollection = async (ctx, next) => {
  const { collection } = ctx.params;
  if (!(collection in ctx.state.collections)) {
    return ctx.throw(404);
  }
  await next();
}

const validateKey = async (ctx, next) => {
  const { authorization } = ctx.request.headers;
  if (authorization !== ctx.state.authorizationHeader) {
    return ctx.throw(401);
  }
  await next();
}

注意箭头函数的中间件不能在当前请求的上下文中引用 this(即 this 在这个例子中总是 undefined)。因此,可以通过上下文对象 ctx 来访问 请求和响应对象以及 Koa 对象。Koa 1 没有单独的上下文对象,是通过 this 来引用当前请求的上下文。

之后定义其他 API 方法,最终我们会导出 API ,供 app.js 使用:

`export default api;`

持久化层

api.js 中,我们访问的上下文 ctx 对象中的 collections 数组,是在 app.js 中初始化的。这些 collection 对象是负责保存和检索存放在 Redis 中的数据。Collection 类如下:

// 基于 Promise 的 Redis 客户端
const redis = require('promise-redis')();
const db = redis.createClient();

class Collection {

  // 完整的源代码:
  // https://github.com/OrKoN/koa2-example-app/blob/master/src/collection.js

  async count() {
    // 可以 `await`  promises
    // await 语法可用于声明了 async 调用的异步函数
    var count = await db
      .zcount(this.name, '-inf', '+inf');
    return Number(count);
  }

  async add(event) {
    await db
      .zadd(this.name, 1, JSON.stringify(event));

    await this._incrGroups(event);
  }

  async _incrGroups(event) {
    // ES6 中 for:of 语法更简单的迭代
    // groupBy 是一个数组用来保存时间可能的属性
    for (let attr of this.groupBy) {
      // 我们可以在循环内部使用 await,因此循环内的 async 操作会被顺序调用。
      await db.hincrby(`${this.name}_by_${attr}`, event[attr], 1);
    }
  }
}

export default Collection;

async/await 语法使得我们可以轻松地组织多个异步操作 —— 如循环内的异步操作。但有很重要的一点需要注意。请看下面的 _incrGroups 方法:

async _incrGroups(event) {
  // ES6 中 for:of 语法更简单的迭代
  for (let attr of this.groupBy) {
    // 我们可以在循环内部使用 await,因此循环内的 async 操作会被顺序调用。
    await db.hincrby(`${this.name}_by_${attr}`, event[attr], 1);
  }
}

这里的 key 是顺序录入的,即之前的录入成功之后才会录入下一个 key。但是这种任务可以并行执行!使用 async/await 不容易实现并行执行,而 Promise 可以:

// 所有录入并行执行,因为在遍历回调内部不需要阻塞。
const promises = this.groupBy.map(attr =>
  db.hincrby(`${this.name}_by_${attr}`, event[attr], 1));
// 等到所有录入执行完成
await Promise.all(promises);

Promise 和 async 一起使用效果不错。

测试

app 的测试代码放在 test 文件夹apiSpec.js 中有关于 app 的 API 的详细的测试用例:

import { expect } from 'chai';
import request from 'supertest';
import app from '../build/app';
import config from '../build/config';

chaisupertest 导入 expect。我们使用 app 的预编译版本,为了保证相同代码测试的准确性,我们配置为生成环境。然后我们为 API 编写测试用例,利用 async/await 语法来保证测试步骤的执行顺序:

describe('API', () => {
  const inst = app.listen(4000);

  describe('POST /:collection', () => {
    it('should add an event', async () => {
      const page = 'http://google.com';
      const encoded = encodeURIComponent(page);
      const res = await request(inst)
        .post(<code>/pageviews</code>)
        .set({
          Authorization: 'Key ' + config.key
        })
        .send({
          time: 'now',
          referrer: 'a',
          agent: 'b',
          source: 'c',
          medium: 'd',
          campaign: 'e',
          page: page
        })
        .expect(201);
      //到此,res 已经可用了,你可以使用 res.headers、res.body 等。不需要回调函数
      expect(res.body).to.be.empty;
    });
  });
});

注意传给 it 的函数都需要标记为 async。这意味着可以用 await 来执行异步任务,包括返回 then-able 对象的 supertest 请求,同样还可以 await 断言(expects)。

使用 PM2 部署

当 app 开发完成并且测试通过,就可以将其部署在生产环境了。首先我们声明 ecosystem.json 用来维护 app 生产环境的配置:

{
  "apps" : [
      {
        "name"        : "koa2-example-app",
        // 编译版本的 app 的入口
        "script"      : "index.js",
        // 在生产环节不用兼听文件的变化
        "watch"       : false,
        // 合并搜索实例产生的日志
        "merge_logs"  : true,
        // 日志详情加上自定义的时间戳格式
        "log_date_format": "YYYY-MM-DD HH:mm Z",
        "env": {
          // app 所需的环境变量
          "NODE_ENV": "production",
          "PORT": 4000
        },
        // 为 app 启动两个进程,并均衡负载。
        "instances": 2,
        // 以 cluster 方式启动 app
        "exec_mode"  : "cluster_mode",
        // 监听程序错误自动重启进程
        "autorestart": true
      }
  ]
}

如果你的 app 需要额外的服务(如一个定时任务),你可以把他们加到 ecosystem.json,他们会跟主服务同时启动。线上可以这样启动你的 app:

`pm2 start ecosystem.json`

保存当前进程列表:

`pm2 save`

PM2 也提供了许多监测的功能(pm2 listpm2 infopm2 monit)。它可以展示你的应用的内存使用情况。最基础的 Koa 应用每个 Node.js 进程消耗 44MB 内存。

结束语

有了 Babel,我们可以在 app 中使用原生不可用的 ECMAScript 新语法,例如 async/await,使得我们更享受异步代码的编写。使用 async/await 语法的代码更容易阅读和维护。Koa 2 和 Babel 让你可以即刻使用 async 函数。

但是 Babel 带来了额外的开销、额外的配置以及额外的 build 步骤。因此等到 async/await 被 Node.js 原生支持了才更推荐 Koa 2。那时,Koa 2 将是 Express 很好的替代方案,因为 Koa 2 模块化程度更高,更简单易用,并能按照你想要的方式来配置使用。

这个教程的开发章节可能比较简单、扩展性差。但它解决了如何以及什么时候 build,实际项目中手动(rsyscscp)或者建立一个持续集成的服务器。并且为了顺应这个 demo, app 的内部结构非常简单。而大型的 app 需要更多的东西,例如 gateway,mapper,repository 等等,当这些东西都可以利用 async 函数实现。

相关链接

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复