1. 程式人生 > >koa2 專案基本構建

koa2 專案基本構建

前傳

出於興趣最近開始研究koa2,由於之前有過一些express經驗,以為koa還是很好上手的,但是用起來發現還是有些地方容易懵逼,因此整理此文,希望能夠幫助到一些新人。

如果你不懂javascript,建議你先去擼一遍紅寶書javascript高階程式設計

因為我也是新人,我只是整理了我的學習經歷,如何填平踩到的坑。

如果有讀者發現我有寫錯的地方希望你能及時留言給我,別讓我誤導了其他新手。

本文的系統環境Mac OS
編譯器 VScode

1 構建專案

想使用koa,我們肯定首先想到去官網看看,沒準有個guide之類的能夠輕鬆入門,可是koa官網跟koa本身一樣簡潔。

如果要我一點點搭建環境的話,感覺好麻煩,所以先去找了找有沒有專案生成器,然後就發現了狼叔-桑世龍寫的koa-generator

1.1 安裝koa-generator

在終端輸入:

$ npm install -g koa-generator

1.2 使用koa-generator生成koa2專案

在你的工作目錄下,輸入:

$ koa2 HelloKoa2

成功建立專案後,進入專案目錄,並執行<code>npm install</code>命令

$ cd HelloKoa2 
$ npm install

1.3 啟動專案

在終端輸入:

$ npm start

專案啟動後,預設埠號是3000,在瀏覽器中執行可以得到下圖的效果說明執行成功。

[圖片上傳失敗...(image-aca657-1539076881864)]

在此再次感謝狼叔-桑世龍

當前專案的檔案目錄如下圖

[圖片上傳失敗...(image-c09d4d-1539076881864)]

1.4 關於koa2

1.4.1 中介軟體的執行順序

koa的中介軟體是由generator組成的,這決定了中介軟體的執行順序。
Express的中介軟體是順序執行,從第一個中介軟體執行到最後一箇中間件,發出響應。

[圖片上傳失敗...(image-dad050-1539076881864)]

koa是從第一個中介軟體開始執行,遇到<code>next</code>進入下一個中介軟體,一直執行到最後一箇中間件,在逆序,執行上一個中介軟體<code>next</code>之後的程式碼,一直到第一個中介軟體執行結束才發出響應。

[圖片上傳失敗...(image-d9058f-1539076881864)]

1.4.2 async await語法支援

koa2增加了<code>async</code> <code>await</code>語法的支援.

原來koa的中介軟體寫法

app.use(function *(next){
  var start = new Date;
  yield next;
  var ms = new Date - start;
  this.set('X-Response-Time', ms + 'ms');
});

koa2中的寫法

app.use(async (next) => {
  var start = new Date;
  await next();
  var ms = new Date - start;
  this.set('X-Response-Time', ms + 'ms');
});

koa宣告說要在v3版本中取消對generator中介軟體的支援,所以為了長久考慮還是用async語法的好。
如果想要繼續使用<code>function*</code>語法,可以使用 <code>koa-convert</code> 這個中介軟體進行轉換。這也是你看到專案中會有下面程式碼的原因

const convert = require('koa-convert');

app.use(convert(bodyparser));
app.use(convert(json()));
app.use(convert(logger()));

1.4.3 Context

Context封裝了node中的request和response。

[email protected]使用this引用Context物件:

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

[email protected]中使用ctx來訪問Context物件:

app.use(async (ctx, next) => {
  await next();
  ctx.body = 'Hello World';
});

上面程式碼中的<code>ctx.body = 'Hello World'</code>這行程式碼表示設定response.body的值為'Hello World'。

如果你看文件就有可能懵逼,那麼我傳送post請求的引數應該怎麼獲取呢?
貌似ctx不能直接獲取request的body,想要獲取post請求中的引數要使用<code>ctx.request.body</code>。

如需檢視專案程式碼 –> 程式碼地址:

https://github.com/tough1985/hello-koa2
選擇Tag -> step1

2 專案配置

這裡的配置指的是執行環境的配置,比如我們在開發階段使用本地的資料庫,測試要使用測試庫,釋出上線時候使用線上的庫,也會有不同的埠號。

2.1 當我們輸入npm start的時候都幹了些什麼

package.json檔案中

"scripts": {
    "start": "./node_modules/.bin/nodemon bin/run",
    "koa": "./node_modules/.bin/runkoa bin/www",
    "pm2": "pm2 start bin/run ",
    "test": "echo \"Error: no test specified\" && exit 1"
  }

可以看到這部分內容,當我們在終端輸入:

$ npm start

在就會執行package.jsonscripts物件對應的start欄位後面的內容,相當於你在終端輸入:

$ ./node_modules/.bin/nodemon bin/run

nodemon外掛的作用是在你啟動了服務之後,修改檔案可以自動重啟服務。
關於nodemon的更多內容 --> nodemon

如果不考慮自動重啟功能,其實這句程式碼相當於執行了<code>node bin/run</code>
我們可以看到專案的bin目錄下,有一個run檔案,程式碼如下:

#!/usr/bin/env node

var current_path = process.cwd();

require('runkoa')(current_path + '/bin/www' )

這裡引入了一個runkoa,這個元件是狼叔寫的koa2對babel環境依賴的一個封裝外掛。

關於runkoa相關內容說明 --> runkoa。這裡我們最終會執行bin目錄下的www檔案來啟動服務。

2.2 npm scripts

我們在scripts物件中新增一段程式碼"start_koa": "bin/run",修改後scripts物件的內容如下:

"scripts": {
    "start": "./node_modules/.bin/nodemon bin/run",
    "koa": "./node_modules/.bin/runkoa bin/www",
    "pm2": "pm2 start bin/run ",
    "test": "echo \"Error: no test specified\" && exit 1",
    "start_koa": "bin/run"
  }

那麼既然輸入<code>npm start</code>執行start後面的指令碼,聰明的你一定會想:是不是我輸入<code>npm start_koa</code>就可以執行start_koa後面相關的程式碼了呢?
不管你是怎麼想的,反正我當時就想的這麼天真。
事實上我們輸入<code>npm start_koa</code>之後,終端會提示npm沒有相關的命令。
那麼在scripts中的start_koa命令要怎麼使用呢,其實要加一個run命令才能執行,在終端輸入:

$ npm run start_koa

可以看到服務正常運行了。

npm中,有四個常用的縮寫

npm start是npm run start
npm stop是npm run stop的簡寫
npm test是npm run test的簡寫
npm restart是npm run stop && npm run restart && npm run start的簡寫

其他的都要使用<code>npm run</code>來執行了。

推薦讀一遍阮一峰老師寫的npm scripts 使用指南,很有幫助。

2.3 配置環境

關於配置環境常用的有development、test、production、debug。
可以使用node提供的<code>process.env.NODE_ENV</code>來設定。

在啟動服務的時候可以對NODE_ENV進行賦值,例如:

$ NODE_ENV=test npm start 

然後我們可以在bin/www檔案中輸出一下,看看是否配置成功,新增如下程式碼:

console.log("process.env.NODE_ENV=" + process.env.NODE_ENV);

然後在終端輸入

$ NODE_ENV=test npm start 

可以看到終端列印:

process.env.NODE_ENV=test

我們可以在scripts物件中將環境配置好,例如我們將starttest分別設定developmenttest環境,程式碼如下:

"scripts": {
    "start": "NODE_ENV=development ./node_modules/.bin/nodemon bin/run",
    "koa": "./node_modules/.bin/runkoa bin/www",
    "pm2": "pm2 start bin/run ",
    "test": "NODE_ENV=test echo \"Error: no test specified\" && exit 1",
    "start_koa": "bin/run"
},

可以在終端分別輸入<code>npm start</code>和<code>npm test</code>來測試環境配置是否生效。

由於並沒有測試內容,現在的test指令碼會退出,後面我們在詳談koa的測試。

2.4 配置檔案

為了能夠根據不同的執行環境載入不同的配置內容,我們需要新增一些配置檔案。
首先在專案根目錄下新增config目錄,在config目錄下新增index.js、test.js、development.js三個檔案,內容如下。

development.js

/**
 * 開發環境的配置內容
 */

module.exports = {
    env: 'development', //環境名稱
    port: 3001,         //服務埠號
    mongodb_url: '',    //資料庫地址
    redis_url:'',       //redis地址
    redis_port: ''      //redis埠號
}

test.js

/**
 * 測試環境的配置內容
 */

module.exports = {
    env: 'test',        //環境名稱
    port: 3002,         //服務埠號
    mongodb_url: '',    //資料庫地址
    redis_url:'',       //redis地址
    redis_port: ''      //redis埠號
}

index.js

var development_env = require('./development');
var test_env = require('./test');

//根據不同的NODE_ENV,輸出不同的配置物件,預設輸出development的配置物件
module.exports = {
    development: development_env,
    test: test_env
}[process.env.NODE_ENV || 'development']

程式碼應該都沒什麼可解釋的,然後我們再來編輯bin/www檔案。

bin/www新增如下程式碼

//引入配置檔案
var config = require('../config');

// 將埠號設定為配置檔案的埠號,預設值為3000
var port = normalizePort(config.port || '3000');
// 列印輸出埠號
console.log('port = ' + config.port);

測試效果,在終端輸入<code>npm start</code>,可以看到

process.env.NODE_ENV=development
port = 3001

到瀏覽器中訪問http://127.0.0.1:3001,可以看到原來的輸入內容,說明配置檔案已經生效。

如需檢視專案程式碼 –> 程式碼地址:

https://github.com/tough1985/hello-koa2
選擇Tag -> step2

3 日誌

狼叔koa-generator已經添加了koa-logger,在app.js檔案你可以找到這樣的程式碼:

const logger = require('koa-logger');
...
...
app.use(convert(logger()));

koa-loggertj大神寫的koa開發時替換console.log輸出的一個外掛。

如果你需要按照時間或者按照檔案大小,本地輸出log檔案的話,建議還是採用log4js-node

3.1 log4js

log4js提供了多個日誌等級分類,同時也能替換console.log輸出,另外他還可以按照檔案大小或者日期來生成本地日誌檔案,還可以使用郵件等形式傳送日誌。

我們在這演示用infoerror兩種日誌等級分別記錄響應日誌和錯誤日誌。

3.2 log4js 配置

config目錄下建立一個log_config.js檔案,內容如下:

var path = require('path');

//錯誤日誌輸出完整路徑
var errorLogPath = path.resolve(__dirname, "../logs/error/error");

//響應日誌輸出完整路徑
var responseLogPath = path.resolve(__dirname, "../logs/response/response");

module.exports = {
    "appenders":
    [
        //錯誤日誌
        {
            "category":"errorLogger",             //logger名稱
            "type": "dateFile",                   //日誌型別
            "filename": errorLogPath,             //日誌輸出位置
            "alwaysIncludePattern":true,          //是否總是有後綴名
            "pattern": "-yyyy-MM-dd-hh.log"       //字尾,每小時建立一個新的日誌檔案
        },
        //響應日誌
        {
            "category":"resLogger",
            "type": "dateFile",
            "filename": responseLogPath,
            "alwaysIncludePattern":true,
            "pattern": "-yyyy-MM-dd-hh.log"
        }
    ],
    "levels":                                     //設定logger名稱對應的的日誌等級
    {
        "errorLogger":"ERROR",
        "resLogger":"ALL"
    }
}

然後建立一個utils目錄,新增log_util.js檔案,內容如下:

var log4js = require('log4js');

var log_config = require('../config/log_config');

//載入配置檔案
log4js.configure(log_config);

var logUtil = {};

var errorLogger = log4js.getLogger('errorLogger');
var resLogger = log4js.getLogger('resLogger');

//封裝錯誤日誌
logUtil.logError = function (ctx, error, resTime) {
    if (ctx && error) {
        errorLogger.error(formatError(ctx, error, resTime));
    }
};

//封裝響應日誌
logUtil.logResponse = function (ctx, resTime) {
    if (ctx) {
        resLogger.info(formatRes(ctx, resTime));
    }
};

//格式化響應日誌
var formatRes = function (ctx, resTime) {
    var logText = new String();

    //響應日誌開始
    logText += "\n" + "*************** response log start ***************" + "\n";

    //新增請求日誌
    logText += formatReqLog(ctx.request, resTime);

    //響應狀態碼
    logText += "response status: " + ctx.status + "\n";

    //響應內容
    logText += "response body: " + "\n" + JSON.stringify(ctx.body) + "\n";

    //響應日誌結束
    logText += "*************** response log end ***************" + "\n";

    return logText;

}

//格式化錯誤日誌
var formatError = function (ctx, err, resTime) {
    var logText = new String();

    //錯誤資訊開始
    logText += "\n" + "*************** error log start ***************" + "\n";

    //新增請求日誌
    logText += formatReqLog(ctx.request, resTime);

    //錯誤名稱
    logText += "err name: " + err.name + "\n";
    //錯誤資訊
    logText += "err message: " + err.message + "\n";
    //錯誤詳情
    logText += "err stack: " + err.stack + "\n";

    //錯誤資訊結束
    logText += "*************** error log end ***************" + "\n";

    return logText;
};

//格式化請求日誌
var formatReqLog = function (req, resTime) {

    var logText = new String();

    var method = req.method;
    //訪問方法
    logText += "request method: " + method + "\n";

    //請求原始地址
    logText += "request originalUrl:  " + req.originalUrl + "\n";

    //客戶端ip
    logText += "request client ip:  " + req.ip + "\n";

    //開始時間
    var startTime;
    //請求引數
    if (method === 'GET') {
        logText += "request query:  " + JSON.stringify(req.query) + "\n";
        // startTime = req.query.requestStartTime;
    } else {
        logText += "request body: " + "\n" + JSON.stringify(req.body) + "\n";
        // startTime = req.body.requestStartTime;
    }
    //伺服器響應時間
    logText += "response time: " + resTime + "\n";

    return logText;
}

module.exports = logUtil;

接下來修改app.js 檔案中的logger部分。

//log工具
const logUtil = require('./utils/log_util');

// logger
app.use(async (ctx, next) => {
  //響應開始時間
  const start = new Date();
  //響應間隔時間
  var ms;
  try {
    //開始進入到下一個中介軟體
    await next();

    ms = new Date() - start;
    //記錄響應日誌
    logUtil.logResponse(ctx, ms);

  } catch (error) {

    ms = new Date() - start;
    //記錄異常日誌
    logUtil.logError(ctx, error, ms);
  }
});

在這將<code>await next();</code>放到了一個<code>try catch</code>裡面,這樣後面的中介軟體有異常都可以在這集中處理。

比如你會將一些API異常作為正常值返回給客戶端,就可以在這集中進行處理。然後後面的中介軟體只要<code>throw</code>自定義的API異常就可以了。

在啟動服務之前不要忘記先安裝log4js外掛:

$ npm install log4js --save

啟動服務

$ npm start

這時候會啟動失敗,控制檯會輸出沒有檔案或檔案目錄。原因是我們在配置裡面雖然配置了檔案目錄,但是並沒有建立相關目錄,解決的辦法是手動建立相關目錄,或者在服務啟動的時候,確認一下目錄是否存在,如果不存在則建立相關目錄。

3.3 初始化logs檔案目錄

先來修改一下log_config.js檔案,讓後面的建立過程更舒適。

修改後的程式碼:

var path = require('path');

//日誌根目錄
var baseLogPath = path.resolve(__dirname, '../logs')

//錯誤日誌目錄
var errorPath = "/error";
//錯誤日誌檔名
var errorFileName = "error";
//錯誤日誌輸出完整路徑
var errorLogPath = baseLogPath + errorPath + "/" + errorFileName;
// var errorLogPath = path.resolve(__dirname, "../logs/error/error");

//響應日誌目錄
var responsePath = "/response";
//響應日誌檔名
var responseFileName = "response";
//響應日誌輸出完整路徑
var responseLogPath = baseLogPath + responsePath + "/" + responseFileName;
// var responseLogPath = path.resolve(__dirname, "../logs/response/response");

module.exports = {
    "appenders":
    [
        //錯誤日誌
        {
            "category":"errorLogger",             //logger名稱
            "type": "dateFile",                   //日誌型別
            "filename": errorLogPath,             //日誌輸出位置
            "alwaysIncludePattern":true,          //是否總是有後綴名
            "pattern": "-yyyy-MM-dd-hh.log",      //字尾,每小時建立一個新的日誌檔案
            "path": errorPath                     //自定義屬性,錯誤日誌的根目錄
        },
        //響應日誌
        {
            "category":"resLogger",
            "type": "dateFile",
            "filename": responseLogPath,
            "alwaysIncludePattern":true,
            "pattern": "-yyyy-MM-dd-hh.log",
            "path": responsePath  
        }
    ],
    "levels":                                   //設定logger名稱對應的的日誌等級
    {
        "errorLogger":"ERROR",
        "resLogger":"ALL"
    },
    "baseLogPath": baseLogPath                  //logs根目錄
}

然後開啟bin/www檔案,新增如下程式碼:

var fs = require('fs');
var logConfig = require('../config/log_config');

/**
 * 確定目錄是否存在,如果不存在則建立目錄
 */
var confirmPath = function(pathStr) {

  if(!fs.existsSync(pathStr)){
      fs.mkdirSync(pathStr);
      console.log('createPath: ' + pathStr);
    }
}

/**
 * 初始化log相關目錄
 */
var initLogPath = function(){
  //建立log的根目錄'logs'
  if(logConfig.baseLogPath){
    confirmPath(logConfig.baseLogPath)
    //根據不同的logType建立不同的檔案目錄
    for(var i = 0, len = logConfig.appenders.length; i < len; i++){
      if(logConfig.appenders[i].path){
        confirmPath(logConfig.baseLogPath + logConfig.appenders[i].path);
      }
    }
  }
}

initLogPath();

這樣每次啟動服務的時候,都會去確認一下相關的檔案目錄是否存在,如果不存在就建立相關的檔案目錄。

現在在來啟動服務。在瀏覽器訪問,可以看到專案中多了logs目錄以及相關子目錄,併產生了日子檔案。

[圖片上傳失敗...(image-2c145-1539076881864)]

內容如下:

[2016-10-31 12:58:48.832] [INFO] resLogger - 
*************** response log start ***************
request method: GET
request originalUrl:  /
request client ip:  ::ffff:127.0.0.1
request query:  {}
response time: 418
response status: 200
response body: 
"<!DOCTYPE html><html><head><title>koa2 title</title><link rel=\"stylesheet\" href=\"/stylesheets/style.css\"></head><body><h1>koa2 title</h1><p>Welcome to koa2 title</p></body></html>"
*************** response log end ***************

可以根據自己的需求,定製相關的日誌格式。

另外關於配置檔案的選項可以參考log4js-node Appenders說明

如需檢視專案程式碼 –> 程式碼地址:

https://github.com/tough1985/hello-koa2
選擇Tag -> step3

4 格式化輸出

假設我們現在開發的是一個API服務介面,會有一個統一的響應格式,同時也希望發生API錯誤時統一錯誤格式。

4.1 建立一個API介面

為當前的服務新增兩個介面,一個getUser一個registerUser。

先在當前專案下建立一個app/controllers目錄,在該目錄下新增一個user_controller.js檔案。

[圖片上傳失敗...(image-794d63-1539076881864)]

程式碼如下:

//獲取使用者
exports.getUser = async (ctx, next) => {
    ctx.body = {
        username: '阿,希爸',
        age: 30
    }
}

//使用者註冊
exports.registerUser = async (ctx, next) => {
    console.log('registerUser', ctx.request.body);
}

簡單的模擬一下。getUser返回一個user物件,registerUser只是列印輸出一下請求引數。

接下來為這兩個方法配置路由。

4.2 為API介面配置路由

我們希望服務的地址的組成是這要的

域名 + 埠號 /api/功能型別/具體埠

例如

127.0.0.1:3001/api/users/getUser

先來新增一個api的路由和其他路由分開管理。在routes目錄下建立一個api目錄,新增user_router.js檔案,程式碼如下:

var router = require('koa-router')();
var user_controller = require('../../app/controllers/user_controller');

router.get('/getUser', user_controller.getUser);
router.post('/registerUser', user_controller.registerUser);

module.exports = router;

這樣就完成了getUserregisterUser進行了路由配置,其中getUserGET方式請求,registerUser是用POST方式請求。

接下來對users這個功能模組進行路由配置,在routes/api目錄下新增一個index.js檔案,程式碼如下:

var router = require('koa-router')();
var user_router = require('./user_router');

router.use('/users', user_router.routes(), user_router.allowedMethods());

module.exports = router;

最後對api進行路由配置,在app.js檔案中新增如下程式碼:

const api = require('./routes/api');
......
router.use('/api', api.routes(), api.allowedMethods());

啟動服務,在瀏覽器中訪問127.0.0.1:3001/api/users/getUser可以得到如下輸出,說明配置成功。

{
  "username": "阿,希爸",
  "age": 30
}

4.3 格式化輸出

作為一個API介面,我們可能希望統一返回格式,例如getUser的輸出給客戶端的返回值是這樣的:

{
    "code": 0,
    "message": "成功",
    "data": {
      "username": "阿,希爸",
      "age": 30
    }
}

按照koa的中介軟體執行順序,我們要處理資料應該在傳送響應之前和路由得到資料之後新增一箇中間件。在專案的根目錄下新增一個middlewares目錄,在該目錄下新增response_formatter.js檔案,內容如下:

/**
 * 在app.use(router)之前呼叫
 */
var response_formatter = async (ctx, next) => {
    //先去執行路由
    await next();

    //如果有返回資料,將返回資料新增到data中
    if (ctx.body) {
        ctx.body = {
            code: 0,
            message: 'success',
            data: ctx.body
        }
    } else {
        ctx.body = {
            code: 0,
            message: 'success'
        }
    }
}

module.exports = response_formatter;

然後在app.js中載入。

const response_formatter = require('./middlewares/response_formatter');
...
//新增格式化處理響應結果的中介軟體,在新增路由之前呼叫
app.use(response_formatter);

router.use('/', index.routes(), index.allowedMethods());
router.use('/users', users.routes(), users.allowedMethods());
router.use('/api', api.routes(), api.allowedMethods());

app.use(router.routes(), router.allowedMethods());

啟動服務,在瀏覽器中訪問127.0.0.1:3001/api/users/getUser可以得到如下輸出,說明配置成功。

{
  "code": 0,
  "message": "success",
  "data": {
    "username": "阿,希爸",
    "age": 30
  }
}

4.4 對URL進行過濾

為什麼一定要在router之前設定?
其實在router之後設定也可以,但是必須在controller裡面執行<code>await next()</code>才會呼叫。也就是說誰需要格式化輸出結果自己手動呼叫。

在router前面設定也有一個問題,就是所有的路由響應輸出都會進行格式化輸出,這顯然也不符合預期,那麼我們要對URL進行過濾,通過過濾的才對他進行格式化處理。

重新改造一下response_formatter中介軟體,讓他接受一個引數,然後返回一個async function做為中介軟體。改造後的程式碼如下:

/**
 * 在app.use(router)之前呼叫
 */
var response_formatter = (ctx) => {
    //如果有返回資料,將返回資料新增到data中
    if (ctx.body) {
        ctx.body = {
            code: 0,
            message: 'success',
            data: ctx.body
        }
    } else {
        ctx.body = {
            code: 0,
            message: 'success'
        }
    }
}

var url_filter = function(pattern){

    return async function(ctx, next){
        var reg = new RegExp(pattern);
        //先去執行路由
        await next();
        //通過正則的url進行格式化處理
        if(reg.test(ctx.originalUrl)){
            response_formatter(ctx);
        }
    }
}
module.exports = url_filter;

app.js中對應的程式碼改為:

//僅對/api開頭的url進行格式化處理
app.use(response_formatter('^/api'));

現在訪問127.0.0.1:3001/api/users/getUser這樣以api開頭的地址都會進行格式化處理,而其他的地址則不會。

4.5 API異常處理

要集中處理API異常,首先要建立一個API異常類,在app目錄下新建一個error目錄,新增ApiError.js檔案,程式碼如下:


/**
 * 自定義Api異常
 */
class ApiError extends Error{

    //構造方法
    constructor(error_name, error_code,  error_message){
        super();
        this.name = error_name;
        this.code = error_code;
        this.message = error_message;
    }
}

module.exports = ApiError;

為了讓自定義Api異常能夠更好的使用,我們建立一個ApiErrorNames.js檔案來封裝API異常資訊,並可以通過API錯誤名稱獲取異常資訊。程式碼如下:

/**
 * API錯誤名稱
 */
var ApiErrorNames = {};

ApiErrorNames.UNKNOW_ERROR = "unknowError";
ApiErrorNames.USER_NOT_EXIST = "userNotExist";

/**
 * API錯誤名稱對應的錯誤資訊
 */
const error_map = new Map();

error_map.set(ApiErrorNames.UNKNOW_ERROR, { code: -1, message: '未知錯誤' });
error_map.set(ApiErrorNames.USER_NOT_EXIST, { code: 101, message: '使用者不存在' });

//根據錯誤名稱獲取錯誤資訊
ApiErrorNames.getErrorInfo = (error_name) => {

    var error_info;

    if (error_name) {
        error_info = error_map.get(error_name);
    }

    //如果沒有對應的錯誤資訊,預設'未知錯誤'
    if (!error_info) {
        error_name = UNKNOW_ERROR;
        error_info = error_map.get(error_name);
    }

    return error_info;
}

module.exports = ApiErrorNames;

修改ApiError.js檔案,引入ApiErrorNames

ApiError.js

const ApiErrorNames = require('./ApiErrorNames');

/**
 * 自定義Api異常
 */
class ApiError extends Error{
    //構造方法
    constructor(error_name){
        super();

        var error_info = ApiErrorNames.getErrorInfo(error_name);

        this.name = error_name;
        this.code = error_info.code;
        this.message = error_info.message;
    }
}

module.exports = ApiError;

response_formatter.js檔案中處理API異常。

先引入ApiError:
<code>var ApiError = require('../app/error/ApiError');</code>

然後修改url_filter

var url_filter = (pattern) => {
    return async (ctx, next) => {
        var reg = new RegExp(pattern);
        try {
            //先去執行路由
            await next();
        } catch (error) {
            //如果異常型別是API異常並且通過正則驗證的url,將錯誤資訊新增到響應體中返回。
            if(error instanceof ApiError && reg.test(ctx.originalUrl)){
                ctx.status = 200;
                ctx.body = {
                    code: error.code,
                    message: error.message
                }
            }
            //繼續拋,讓外層中介軟體處理日誌
            throw error;
        }

        //通過正則的url進行格式化處理
        if(reg.test(ctx.originalUrl)){
            response_formatter(ctx);
        }
    }
}

解釋一下這段程式碼

  1. 使用<code>try catch</code>包裹<code>await next();</code>,這樣後面的中介軟體丟擲的異常都可以在這幾集中處理;

  2. <code>throw error;</code>是為了讓外層的logger中介軟體能夠處理日誌。

為了模擬執行效果,我們修改user_controller.js檔案,內容如下:

const ApiError = require('../error/ApiError');
const ApiErrorNames = require('../error/ApiErrorNames');
//獲取使用者
exports.getUser = async (ctx, next) => {
   //如果id != 1丟擲API 異常
    if(ctx.query.id != 1){
        throw new ApiError(ApiErrorNames.USER_NOT_EXIST);
    }
    ctx.body = {
        username: '阿,希爸',
        age: 30
    }
}

啟動服務,在瀏覽器中訪問127.0.0.1:3001/api/users/getUser可以得到結果如下:

{
  "code": 101,
  "message": "使用者不存在"
}

在瀏覽器中訪問127.0.0.1:3001/api/users/getUser?id=1可以得到結果如下:

{
  "code": 0,
  "message": "success",
  "data": {
    "username": "阿,希爸",
    "age": 30
  }
}

如需檢視專案程式碼 –> 程式碼地址:

https://github.com/tough1985/hello-koa2
選擇Tag -> step4

5 測試

node使用主流的測試框架基本就是mochaAVA了,這裡主要以mocha為基礎進行構建相關的測試。

5.1 mocha

安裝mocha

在終端輸入

$ npm install --save-dev mocha

--dev表示只在development環境下新增依賴。

使用mocha

在專案的根目錄下新增test目錄,新增一個test.js檔案,內容如下:

var assert = require('assert');
/**
 * describe 測試套件 test suite 表示一組相關的測試
 * it 測試用例 test case 表示一個單獨的測試
 * assert 斷言 表示對結果的預期
 */
describe('Array', function() {
    describe('#indexOf()', function() {
        it('should return -1 when the value is not present', function(){
            assert.equal(-1, [1,2,3].indexOf(4));
        })
    })
});

在終端輸入:

$ mocha

可以得到輸出如下:

  Array
    #indexOf()
      ✓ should return -1 when the value is not present

  1 passing (9ms)

mocha預設執行test目錄下的測試檔案,測試檔案一般與要測試的腳步檔案同名以<code>.test.js</code>作為字尾名。例如add.js的測試指令碼名字就是add.test.js

describe表示測試套件,每個測試指令碼至少應該包含一個<code>describe</code>。

it表示測試用例。

每個describe可以包含多個describe或多個it

assert是node提供的斷言庫。

assert.equal(-1, [1,2,3].indexOf(4));

這句程式碼的意思是我們期望[1,2,3].indexOf(4)的值應該是-1,如果[1,2,3].indexOf(4)的執行結果是-1,則通過測試,否則不通過。

可以把-1改成-2再試一下。

上面的例子是mocha提供的,mocha官網

測試環境

之前說過環境配置的內容,我們需要執行測試的時候,載入相關的測試配置該怎麼做?

在終端輸入

$ NODE_ENV=test mocha

為了避免每次都去輸入NODE_ENV=test,可以修改package.json檔案中的scripts.test改為:

"test": "NODE_ENV=test mocha",

以後執行測試直接輸入npm test就可以了。

常用的引數

mocha在執行時可以攜帶很多引數,這裡介紹幾個常用的。

--recursive

mocha預設執行test目錄下的測試指令碼,但是不會執行test下的子目錄中的指令碼。
想要執行子目錄中的測試指令碼,可以在執行時新增--recursive引數。

$ mocha --recursive

--grep

如果你寫了很多測試用例,當你添加了一個新的測試,執行之後要在結果裡面找半天。這種情況就可以考慮--grep引數。
--grep可以只執行單個測試用例,也就是執行某一個it。比如將剛才的測試修改如下:

describe('Array', function() {
    describe('#indexOf()', function() {
        it('should return -1 when the value is not present', function(){
            assert.equal(-1, [1,2,3].indexOf(4));
        })

        it('length', function(){
            assert.equal(3, [1, 2, 3].length);
        })
    })
});

添加了一個length測試用例,想要單獨執行這個測試用例就要在終端輸入:

$ mocha --grep 'length'

可以看到length用例被單獨執行了。

這裡有一點需要注意,因為我們配置了npm test,如果直接執行

$ npm test --grep 'length'

這樣是不能達到效果的。

要給npm scripts指令碼傳參需要先輸入--然後在輸入引數,所以想要執行上面的效果應該輸入:

$ npm test -- --grep 'length'

關於mocha就簡單的介紹這麼多,想要了解更多相關的內容,推薦仔細閱讀一遍阮一峰老師寫的測試框架 Mocha 例項教程

5.2 chai

chai是一個斷言庫。之前的例子中,我們使用的是node提供的斷言庫,他的功能比較少,基本上只有equalokfail這樣簡單的功能,很難滿足日常的需求。

mocha官方表示你愛用什麼斷言用什麼斷言,反正老子都支援。

選擇chai是因為他對斷言的幾種語法都支援,而且功能也比較全面 --> chai官網

chai支援shouldexpectassert三種斷言形式。

assert語法之前我們已經見過了,chai只是豐富了功能,語法並沒有變化。
expectshould的語法更接近自然語言的習慣,但是should使用的時候會出現一些意想不到的情況。所以比較常用的還是expect

官方的DEMO

var expect = chai.expect;

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.length(3);
expect(tea).to.have.property('flavors')
  .with.length(3);

明顯語法的可讀性更好,更接近人類的語言。

簡單的解釋其中的tobe這樣的語法。

chai使用了鏈式語法,為了使語法更加接近自然語言,添加了很多表達語義但是沒有任何功能的詞彙。

  • to
  • be
  • been
  • is
  • that
  • which
  • and
  • has
  • have
  • with
  • at
  • of
  • same

上面列出的這些詞沒有任何功能,只是為了增強語義。

也就是說
expect(1+1).to.be.equal(2)

expect(1+1).equal(2)
是完全相同的。

安裝chai

在終端輸入:

$ npm install --save-dev chai

使用chai

test目錄下新建一個chai.test.js檔案,內容如下:

const expect = require('chai').expect;

describe('chai expect demo', function() {
    it('expect equal', function() {
        expect(1+1).to.equal(2);
        expect(1+1).not.equal(3);
    });
});

在終端輸入:

$ npm test -- --grep 'expect equal'

得到輸出:

  chai expect demo
    ✓ expect equal

  1 passing (6ms)

說明配置成功。有關chai的更多功能請檢視官方API --> chai_api

5.3 supertest

目前我們可以使用測試框架做一些簡單的測試,想要測試介面的相應資料,就要用到supertest了。

supertest主要功能就是對HTTP進行測試。尤其是對REST API,我們對get請求很容易模擬,但是post方法就很難(當然你也可以使用postman這樣的外掛)。

supertest可以模擬HTTP的各種請求,設定header,新增請求資料,並對響應進行斷言。

安裝supertest

在終端輸入:

$ npm install --save-dev supertest

使用supertest

我們對現有的兩個API介面getUserregisterUser進行測試。在test目錄下建立user_api.test.js檔案,內容如下:

const request = require('supertest');
const expect = require('chai').expect;
const app = require('../app.js');

describe('user_api', () => {

    it('getUser', (done) => {

        request(app.listen())
            .get('/api/users/getUser?id=1')     //get方法
            .expect(200)                        //斷言狀態碼為200
            .end((err, res) => {

                console.log(res.body);
                //斷言data屬性是一個物件
                expect(res.body.data).to.be.an('object');

                done();
            });
    })

    it('registerUser', (done) => {

        // 請求引數,模擬使用者物件
        var user = {
            username: '阿,希爸',
            age: 31
        }

        request(app.listen())
            .post('/api/users/registerUser')            //post方法
            .send(user)                                 //新增請求引數
            .set('Content-Type', 'application/json')    //設定header的Content-Type為json
            .expect(200)                                //斷言狀態碼為200
            .end((err, res) => {

                console.log(res.body);
                //斷言返回的code是0
                expect(res.body.code).to.be.equal(0);
                done();
            })
    })
})

如果現在直接執行npm test進行測試會報錯,原因是mocha預設是不支援async await語法,解決的辦法是Babel

Babel的主要作用是對不同版本的js進行轉碼。

如果你對Babel不瞭解,請仔細閱讀Babel 入門教程Babel官網

由於koa-generator已經幫我們新增相關的Babel依賴,我們只需要新增相關的規則就可以了。在專案的根目錄下新增一個.babelrc檔案,內容如下:

{
  "env": {
    "test": {
        "presets": ["es2015-node5"],
        "plugins": [
            "transform-async-to-generator",
            "syntax-async-functions"
        ]
    }
  }
}

這段檔案的意思是對當env=test時,應用es2015-node5transform-async-to-generatorsyntax-async-functions規則進行轉碼。

Babel我們設定好了,想要mocha應用這個規則還要在執行時新增一個命令。
開啟package.json,將scripts.test修改為:

"test": "NODE_ENV=test mocha --compilers js:babel-core/register",

在終端執行npm test,輸出如下內容說明測試通過。

  user_api
  <-- GET /api/users/getUser?id=1
  --> GET /api/users/getUser?id=1 200 14ms 74b
{ code: 0,
  message: 'success',
  data: { username: '阿,希爸', age: 30 } }
    ✓ getUser (57ms)
  <-- POST /api/users/registerUser
registerUser { username: '阿,希爸', age: 31 }
  --> POST /api/users/registerUser 200 2ms 30b
{ code: 0, message: 'success' }
    ✓ registerUser