原文鏈接:http://www.lxweimin.com/p/6b816c609669
前傳
出于興趣最近開始研究koa2,由于之前有過一些express經驗,以為koa還是很好上手的,但是用起來發現還是有些地方容易懵逼,因此整理此文,希望能夠幫助到一些新人。
如果你不懂javascript,建議你先去擼一遍紅寶書javascript高級程序設計
如果你不熟悉ES6,建議你先去擼一遍阮一峰老師的ECMAScript 6入門
因為我也是新人,我只是整理了我的學習經歷,如何填平踩到的坑。
如果有讀者發現我有寫錯的地方希望你能及時留言給我,別讓我誤導了其他新手。
本文的系統環境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
成功創建項目后,進入項目目錄,并執行npm install命令
$ cd HelloKoa2
$ npm install
1.3 啟動項目
在終端輸入:
$ npm start
項目啟動后,默認端口號是3000,在瀏覽器中運行可以得到下圖的效果說明運行成功。
在此再次感謝狼叔-桑世龍。
當前項目的文件目錄如下圖
FileTree
1.4 關于koa2
1.4.1 中間件的執行順序
koa的中間件是由generator組成的,這決定了中間件的執行順序。
Express的中間件是順序執行,從第一個中間件執行到最后一個中間件,發出響應。
koa是從第一個中間件開始執行,遇到next進入下一個中間件,一直執行到最后一個中間件,在逆序,執行上一個中間件next之后的代碼,一直到第一個中間件執行結束才發出響應。
1.4.2 async await語法支持
koa2增加了asyncawait語法的支持.
原來koa的中間件寫法
app.use(function*(next){varstart =newDate;yieldnext;varms =newDate- start;this.set('X-Response-Time', ms +'ms');});
koa2中的寫法
app.use(async(next) => {varstart =newDate;awaitnext();varms =newDate- start;this.set('X-Response-Time', ms +'ms');});
koa聲明說要在v3版本中取消對generator中間件的支持,所以為了長久考慮還是用async語法的好。
如果想要繼續使用function*語法,可以使用koa-convert這個中間件進行轉換。這也是你看到項目中會有下面代碼的原因
constconvert =require('koa-convert');app.use(convert(bodyparser));app.use(convert(json()));app.use(convert(logger()));
1.4.3 Context
Context封裝了node中的request和response。
koa@1.x使用this引用Context對象:
app.use(function*(){this.body ='Hello World';});
koa@2.x中使用ctx來訪問Context對象:
app.use(async(ctx, next) => {awaitnext();? ctx.body ='Hello World';});
上面代碼中的ctx.body = 'Hello World'這行代碼表示設置response.body的值為'Hello World'。
如果你看文檔就有可能懵逼,那么我發送post請求的參數應該怎么獲取呢?
貌似ctx不能直接獲取request的body,想要獲取post請求中的參數要使用ctx.request.body。
如需查看項目代碼 –> 代碼地址:
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.json中scripts對象對應的start字段后面的內容,相當于你在終端輸入:
$ ./node_modules/.bin/nodemon bin/run
nodemon插件的作用是在你啟動了服務之后,修改文件可以自動重啟服務。
關于nodemon的更多內容 -->nodemon
如果不考慮自動重啟功能,其實這句代碼相當于執行了node bin/run
我們可以看到項目的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"
}
那么既然輸入npm start執行start后面的腳本,聰明的你一定會想:是不是我輸入npm start_koa就可以執行start_koa后面相關的代碼了呢?
不管你是怎么想的,反正我當時就想的這么天真。
事實上我們輸入npm start_koa之后,終端會提示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的簡寫
其他的都要使用npm run來執行了。
推薦讀一遍阮一峰老師寫的npm scripts 使用指南,很有幫助。
2.3 配置環境
關于配置環境常用的有development、test、production、debug。
可以使用node提供的process.env.NODE_ENV來設置。
在啟動服務的時候可以對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對象中將環境配置好,例如我們將start和test分別設置development和test環境,代碼如下:
"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"
},
可以在終端分別輸入npm start和npm test來測試環境配置是否生效。
由于并沒有測試內容,現在的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
vardevelopment_env =require('./development');vartest_env =require('./test');//根據不同的NODE_ENV,輸出不同的配置對象,默認輸出development的配置對象module.exports = {development: development_env,test: test_env}[process.env.NODE_ENV ||'development']
代碼應該都沒什么可解釋的,然后我們再來編輯bin/www文件。
bin/www添加如下代碼
//引入配置文件varconfig =require('../config');// 將端口號設置為配置文件的端口號,默認值為3000varport = normalizePort(config.port ||'3000');// 打印輸出端口號console.log('port = '+ config.port);
測試效果,在終端輸入npm start,可以看到
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文件你可以找到這樣的代碼:
constlogger =require('koa-logger');......app.use(convert(logger()));
koa-logger是tj大神寫的koa開發時替換console.log輸出的一個插件。
如果你需要按照時間或者按照文件大小,本地輸出log文件的話,建議還是采用log4js-node。
3.1 log4js
log4js提供了多個日志等級分類,同時也能替換console.log輸出,另外他還可以按照文件大小或者日期來生成本地日志文件,還可以使用郵件等形式發送日志。
我們在這演示用info和error兩種日志等級分別記錄響應日志和錯誤日志。
3.2 log4js 配置
在config目錄下創建一個log_config.js文件,內容如下:
varpath =require('path');//錯誤日志輸出完整路徑varerrorLogPath = path.resolve(__dirname,"../logs/error/error");//響應日志輸出完整路徑varresponseLogPath = path.resolve(__dirname,"../logs/response/response");module.exports = {"appenders":? ? [//錯誤日志{"category":"errorLogger",//logger名稱"type":"dateFile",//日志類型"filename": errorLogPath,//日志輸出位置"alwaysIncludePattern":true,//是否總是有后綴名"pattern":"-yyyy-MM-dd-hh.log"http://后綴,每小時創建一個新的日志文件},//響應日志{"category":"resLogger","type":"dateFile","filename": responseLogPath,"alwaysIncludePattern":true,"pattern":"-yyyy-MM-dd-hh.log"}? ? ],"levels"://設置logger名稱對應的的日志等級{"errorLogger":"ERROR","resLogger":"ALL"}}
然后創建一個utils目錄,添加log_util.js文件,內容如下:
varlog4js =require('log4js');varlog_config =require('../config/log_config');//加載配置文件log4js.configure(log_config);varlogUtil = {};varerrorLogger = log4js.getLogger('errorLogger');varresLogger = 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));? ? }};//格式化響應日志varformatRes =function(ctx, resTime){varlogText =newString();//響應日志開始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";returnlogText;}//格式化錯誤日志varformatError =function(ctx, err, resTime){varlogText =newString();//錯誤信息開始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";returnlogText;};//格式化請求日志varformatReqLog =function(req, resTime){varlogText =newString();varmethod = req.method;//訪問方法logText +="request method: "+ method +"\n";//請求原始地址logText +="request originalUrl:? "+ req.originalUrl +"\n";//客戶端iplogText +="request client ip:? "+ req.ip +"\n";//開始時間varstartTime;//請求參數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";returnlogText;}module.exports = logUtil;
接下來修改app.js文件中的logger部分。
//log工具constlogUtil =require('./utils/log_util');// loggerapp.use(async(ctx, next) => {//響應開始時間conststart =newDate();//響應間隔時間varms;try{//開始進入到下一個中間件awaitnext();? ? ms =newDate() - start;//記錄響應日志logUtil.logResponse(ctx, ms);? }catch(error) {? ? ms =newDate() - start;//記錄異常日志logUtil.logError(ctx, error, ms);? }});
在這將await next();放到了一個try catch里面,這樣后面的中間件有異常都可以在這集中處理。
比如你會將一些API異常作為正常值返回給客戶端,就可以在這集中進行處理。然后后面的中間件只要throw自定義的API異常就可以了。
在啟動服務之前不要忘記先安裝log4js插件:
$ npm install log4js --save
啟動服務
$ npm start
這時候會啟動失敗,控制臺會輸出沒有文件或文件目錄。原因是我們在配置里面雖然配置了文件目錄,但是并沒有創建相關目錄,解決的辦法是手動創建相關目錄,或者在服務啟動的時候,確認一下目錄是否存在,如果不存在則創建相關目錄。
3.3 初始化logs文件目錄
先來修改一下log_config.js文件,讓后面的創建過程更舒適。
修改后的代碼:
varpath =require('path');//日志根目錄varbaseLogPath = path.resolve(__dirname,'../logs')//錯誤日志目錄varerrorPath ="/error";//錯誤日志文件名varerrorFileName ="error";//錯誤日志輸出完整路徑varerrorLogPath = baseLogPath + errorPath +"/"+ errorFileName;// var errorLogPath = path.resolve(__dirname, "../logs/error/error");//響應日志目錄varresponsePath ="/response";//響應日志文件名varresponseFileName ="response";//響應日志輸出完整路徑varresponseLogPath = 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文件,添加如下代碼:
varfs =require('fs');varlogConfig =require('../config/log_config');/**
* 確定目錄是否存在,如果不存在則創建目錄
*/varconfirmPath =function(pathStr){if(!fs.existsSync(pathStr)){? ? ? fs.mkdirSync(pathStr);console.log('createPath: '+ pathStr);? ? }}/**
* 初始化log相關目錄
*/varinitLogPath =function(){//創建log的根目錄'logs'if(logConfig.baseLogPath){? ? confirmPath(logConfig.baseLogPath)//根據不同的logType創建不同的文件目錄for(vari =0, len = logConfig.appenders.length; i < len; i++){if(logConfig.appenders[i].path){? ? ? ? confirmPath(logConfig.baseLogPath + logConfig.appenders[i].path);? ? ? }? ? }? }}initLogPath();
這樣每次啟動服務的時候,都會去確認一下相關的文件目錄是否存在,如果不存在就創建相關的文件目錄。
現在在來啟動服務。在瀏覽器訪問,可以看到項目中多了logs目錄以及相關子目錄,并產生了日子文件。
內容如下:
[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:
"koa2 title
koa2 title
Welcome to koa2 title
"*************** 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文件。
代碼如下:
//獲取用戶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文件,代碼如下:
varrouter =require('koa-router')();varuser_controller =require('../../app/controllers/user_controller');router.get('/getUser', user_controller.getUser);router.post('/registerUser', user_controller.registerUser);module.exports = router;
這樣就完成了getUser和registerUser進行了路由配置,其中getUser是GET方式請求,registerUser是用POST方式請求。
接下來對users這個功能模塊進行路由配置,在routes/api目錄下添加一個index.js文件,代碼如下:
varrouter =require('koa-router')();varuser_router =require('./user_router');router.use('/users', user_router.routes(), user_router.allowedMethods());module.exports = router;
最后對api進行路由配置,在app.js文件中添加如下代碼:
constapi =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)之前調用
*/varresponse_formatter =async(ctx, next) => {//先去執行路由awaitnext();//如果有返回數據,將返回數據添加到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中載入。
constresponse_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里面執行await next()才會調用。也就是說誰需要格式化輸出結果自己手動調用。
在router前面設置也有一個問題,就是所有的路由響應輸出都會進行格式化輸出,這顯然也不符合預期,那么我們要對URL進行過濾,通過過濾的才對他進行格式化處理。
重新改造一下response_formatter中間件,讓他接受一個參數,然后返回一個async function做為中間件。改造后的代碼如下:
/**
* 在app.use(router)之前調用
*/varresponse_formatter =(ctx) =>{//如果有返回數據,將返回數據添加到data中if(ctx.body) {? ? ? ? ctx.body = {code:0,message:'success',data: ctx.body? ? ? ? }? ? }else{? ? ? ? ctx.body = {code:0,message:'success'}? ? }}varurl_filter =function(pattern){returnasyncfunction(ctx, next){varreg =newRegExp(pattern);//先去執行路由awaitnext();//通過正則的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異常
*/classApiErrorextendsError{//構造方法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錯誤名稱
*/varApiErrorNames = {};ApiErrorNames.UNKNOW_ERROR ="unknowError";ApiErrorNames.USER_NOT_EXIST ="userNotExist";/**
* API錯誤名稱對應的錯誤信息
*/consterror_map =newMap();error_map.set(ApiErrorNames.UNKNOW_ERROR, {code:-1,message:'未知錯誤'});error_map.set(ApiErrorNames.USER_NOT_EXIST, {code:101,message:'用戶不存在'});//根據錯誤名稱獲取錯誤信息ApiErrorNames.getErrorInfo =(error_name) =>{varerror_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);? ? }returnerror_info;}module.exports = ApiErrorNames;
修改ApiError.js文件,引入ApiErrorNames
ApiError.js
constApiErrorNames =require('./ApiErrorNames');/**
* 自定義Api異常
*/classApiErrorextendsError{//構造方法constructor(error_name){super();varerror_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:
var ApiError = require('../app/error/ApiError');
然后修改url_filter
varurl_filter =(pattern) =>{returnasync(ctx, next) => {varreg =newRegExp(pattern);try{//先去執行路由awaitnext();? ? ? ? }catch(error) {//如果異常類型是API異常并且通過正則驗證的url,將錯誤信息添加到響應體中返回。if(errorinstanceofApiError && reg.test(ctx.originalUrl)){? ? ? ? ? ? ? ? ctx.status =200;? ? ? ? ? ? ? ? ctx.body = {code: error.code,message: error.message? ? ? ? ? ? ? ? }? ? ? ? ? ? }//繼續拋,讓外層中間件處理日志throwerror;? ? ? ? }//通過正則的url進行格式化處理if(reg.test(ctx.originalUrl)){? ? ? ? ? ? response_formatter(ctx);? ? ? ? }? ? }}
解釋一下這段代碼
使用try catch包裹await next();,這樣后面的中間件拋出的異常都可以在這幾集中處理;
throw error;是為了讓外層的logger中間件能夠處理日志。
為了模擬運行效果,我們修改user_controller.js文件,內容如下:
constApiError =require('../error/ApiError');constApiErrorNames =require('../error/ApiErrorNames');//獲取用戶exports.getUser =async(ctx, next) => {//如果id != 1拋出API 異常if(ctx.query.id !=1){thrownewApiError(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使用主流的測試框架基本就是mocha和AVA了,這里主要以mocha為基礎進行構建相關的測試。
5.1 mocha
安裝mocha
在終端輸入
$ npm install --save-dev mocha
--dev表示只在development環境下添加依賴。
使用mocha
在項目的根目錄下添加test目錄,添加一個test.js文件,內容如下:
varassert =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目錄下的測試文件,測試文件一般與要測試的腳步文件同名以.test.js作為后綴名。例如add.js的測試腳本名字就是add.test.js。
describe表示測試套件,每個測試腳本至少應該包含一個describe。
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提供的斷言庫,他的功能比較少,基本上只有equal、ok、fail這樣簡單的功能,很難滿足日常的需求。
mocha官方表示你愛用什么斷言用什么斷言,反正老子都支持。
選擇chai是因為他對斷言的幾種語法都支持,而且功能也比較全面 -->chai官網。
chai支持should、expect和assert三種斷言形式。
assert語法之前我們已經見過了,chai只是豐富了功能,語法并沒有變化。
expect和should的語法更接近自然語言的習慣,但是should使用的時候會出現一些意想不到的情況。所以比較常用的還是expect。
官方的DEMO
varexpect = 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);
明顯語法的可讀性更好,更接近人類的語言。
簡單的解釋其中的to、be這樣的語法。
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文件,內容如下:
constexpect =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接口getUser和registerUser進行測試。在test目錄下創建user_api.test.js文件,內容如下:
constrequest =require('supertest');constexpect =require('chai').expect;constapp =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) => {// 請求參數,模擬用戶對象varuser = {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是0expect(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-node5、transform-async-to-generator、syntax-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
有關supertest的更多用法請參考github_supertest。
如需查看項目代碼 –> 代碼地址:
https://github.com/tough1985/hello-koa2
選擇Tag -> step5
作者:阿_希爸
鏈接:http://www.lxweimin.com/p/6b816c609669
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。