使用 Async 函數和 Koa 2 構建服務端應用

35kivr0236 8年前發布 | 32K 次閱讀 Koa.js JavaScript開發

在眾多 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 hell”(回調的坑)。隨著時間的推移,已經探索出了幾種解決辦法。一些還是基于 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 VS. Express

Koa 建立在 Node.js 的 HTTP 模塊之上,比 Express 更容易,更簡練。Express 提供了許多內置功能,如路由、模板、發送文件等。而 Koa 只提供了非常少的功能,如基于 generator(Koa 1) 或基于 async/await(Koa 2) 的控制流,路由、模板和其他功能是作為獨立模塊由社區提供。通常有多種方案可供選擇。

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.js 。 watch 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 了。

應用的代碼

首先來看一下 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);

這個模塊中讀取了兩個環境變量: PORT 和 NODE_ENV 。 NODE_ENV 的值是 development 或 production 。在開發模式下, 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 頂層定義。例如 validateKey 和 validateCollection 都是 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';

從 chai 和 supertest 導入 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 list , pm2 info , pm2 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,實際項目中手動( rsysc , scp )或者建立一個持續集成的服務器。并且為了順應這個 demo, app 的內部結構非常簡單。而大型的 app 需要更多的東西,例如 gateway,mapper,repository 等等,當這些東西都可以利用 async 函數實現。

 

來自:http://www.zcfy.cc/article/building-a-server-side-application-with-async-functions-and-koa-2-1777.html

 

 本文由用戶 35kivr0236 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!