koa2
https://koa.bootcss.com/
為啥入坑,Express 原班人馬打造 更小、更健壯、更富有表現力
一直很想研究下koa2,最近得空,加上自己擠出來的時間,終於入坑了koa2。由於之前有過一些express經驗,開發過一些后端的東西。所以以為koa還是很好上手的,但是用起來發現懵逼了,雖然大致結構上差不多,但是一些方法的細節還是有些差別的。重大的差別就是response, 另外采用了es6語法,在寫法上更加的飄逸。為了避免剛入坑的小伙伴爬不出來,因此整理此文。
項目構建
先介紹下目錄結構,如下
. ├── README.md 項目描述 ├── app 業務側代碼 │ ├── controller 與路由關聯的api方法 │ └── modal 數據模型 ├── app.js 入口文件 ├── bin nodemon │ ├── run nodemon 的入口文件 │ └── www ├── config 配置文件 │ ├── dbConfig.js 數據庫配置 │ ├── logConfig.js 日志配置 │ └── serverConfig.js 服務配置 ├── logs 日志目錄 │ ├── error 錯誤日志 │ └── response 普通響應日志 (還可以繼續拆分,系統日志,業務日志) ├── middleware 中間件 │ └── loggers.js 日志中間件 ├── public │ └── stylesheets 公用文件 ├── routes 路由 │ ├── allRoute.js 總路由配置 │ ├── files.js 各個模塊路由配置 │ ├── index.js │ └── users.js ├── uploads 上傳文件夾 │ └── 2017-8-29 ├── utils 公用方法 │ ├── logUtil.js │ └── mkdir.js ├── views 頁面層 │ ├── error.jade │ ├── index.jade │ └── layout.jade └── package.json tree 目錄生成命令 tree -L 3 -I "node_modules" brew install tree || apt-get install tree
- tree -d 只顯示文件夾;
- tree -L n 顯示項目的層級。n表示層級數。比如想要顯示項目三層結構,可以用tree -l 3;
- tree -I pattern 用於過濾不想要顯示的文件或者文件夾。比如你想要過濾項目中的node_modules文件夾,可以使用tree -I "node_modules";
- tree > tree.md 將項目結構輸出到tree.md這個文件。
首先是寫法
之前用express的時候,用的是es5的語法規范 koa2用采用了es6,7的新特性,盡情的使用let吧 nodemon babelrc的福音,自動轉碼,不用配置.babelrc, 也不需要再裝一些列bable轉碼了。
寫異步
以前是.then方法里的各種callback
exports.getUserList = function() {
user.find({
_id: id,
}, arr, function(e, numberAffected, raw) {
if(e){
respondata={
"code":"9900",
"message":"error"
};
}else{
respondata={
"code":"0000",
"message":"success"
};
}
});
}
現在可以用 async await
exports.getUserList = async (ctx, next) => {
try {
let list = await user.find();
let respon = {
code: '0000',
message: 'success',
data: list
}
return respon;
} catch (err) {
let respon = {
code: '9999',
message: 'error',
data: err
}
return respon;
}
}
因為后端的很多操作方法,比如文件,數據庫,都是異步的,所以這種將異步寫法變為同步寫法,是代碼的可讀性大大提高。
Route 路由
koa-route 采用的是restful設計模式,可以參考阮一峰老師的《RESTful API 設計指南》 www.ruanyifeng.com/blog/2014/0…
路由的模塊化 路由規則是域名+模塊+方法
例如:localhost:8080/users/getUser
<allroute.js>
const router = require('koa-router')();
const index = require('./index');
const users = require('./users');
const files = require('./files');
router.use('/', index.routes(), index.allowedMethods());
router.use('/users', users.routes(), users.allowedMethods());
router.use('/files', files.routes(), files.allowedMethods());
module.exports = router;
<users.js>
const router = require('koa-router')();
import {getUserList, register, removeUser} from '../app/controller/user'
router.get('/', function (ctx, next) {
ctx.body = 'this a users response!';
});
router.get('/getUser', async (ctx, next) => {
ctx.body = await getUserList(ctx, next);
});
router.post('/register', async (ctx, next) => {
console.log(ctx.request.body);
let reqBody = ctx.request.body;
ctx.body = await register(reqBody);
});
router.del('/removeUser', async (ctx, next) => {
console.log(ctx.request.body);
let reqBody = ctx.request.body;
ctx.body = await removeUser(reqBody);
});
module.exports = router;
reseful的路由,如果你的請求方式不是get | post | del,或者與其不匹配,統一返回404 not found
Middleware 中間件
中間件就是類似於一個過濾器的東西,在客戶端和應用程序之間的一個處理請求和響應的的方法。
.middleware1 {
// (1) do some stuff
.middleware2 {
// (2) do some other stuff
.middleware3 {
// (3) NO next yield !
// this.body = 'hello world'
}
// (4) do some other stuff later
}
// (5) do some stuff lastest and return
}
中間件的執行很像一個洋蔥,但並不是一層一層的執行,而是以next為分界,先執行本層中next以前的部分,當下一層中間件執行完后,再執行本層next以后的部分。
let koa = require('koa');
let app = new koa();
app.use((ctx, next) => {
console.log(1)
next(); // next不寫會報錯
console.log(5)
});
app.use((ctx, next) => {
console.log(2)
next();
console.log(4)
});
app.use((ctx, next) => {
console.log(3)
ctx.body = 'Hello World';
});
app.listen(3000);
// 打印出1、2、3、4、5
上述簡單的應用打印出1、2、3、4、5,這個其實就是koa中間件控制的核心,一個洋蔥結構,從上往下一層一層進來,再從下往上一層一層回去,乍一看很復雜,為什么不直接一層一層下來就結束呢,就像express/connect一樣,我們就只要next就去下一個中間件,干嘛還要回來?
其實這就是為了解決復雜應用中頻繁的回調而設計的級聯代碼,並不直接把控制權完全交給下一個中間件,而是碰到next去下一個中間件,等下面都執行完了,還會執行next以下的內容
解決頻繁的回調,這又有什么依據呢?舉個簡單的例子,假如我們需要知道穿過中間件的時間,我們使用koa可以輕松地寫出來,但是使用express呢,可以去看下express reponse-time的源碼,它就只能通過監聽header被write out的時候然后觸發回調函數計算時間,但是koa完全不用寫callback,我們只需要在next后面加幾行代碼就解決了(直接使用.then()都可以)
Logs 日志
log4js接入及使用方法
let log4js = require('log4js');
let logConfig = require('../config/logConfig');
//加載配置文件
log4js.configure(logConfig);
let logUtil = {};
let errorLogger = log4js.getLogger('error'); //categories的元素
let resLogger = log4js.getLogger('response');
//封裝錯誤日志
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));
}
};
config : {
"appenders":{
error: {
"category":"errorLogger", //logger名稱
"type": "dateFile", //日志類型
"filename": errorLogPath, //日志輸出位置
"alwaysIncludePattern":true, //是否總是有后綴名
"pattern": "-yyyy-MM-dd-hh.log", //后綴,每小時創建一個新的日志文件
"path": errorPath
},
response: {
"category":"resLogger",
"type": "dateFile",
"filename": responseLogPath,
"alwaysIncludePattern":true,
"pattern": "-yyyy-MM-dd-hh.log",
"path": responsePath,
}
},
"categories" : {
error: { appenders: ['error'], level: 'error' },
response: { appenders: ['response'], level: 'info' },
default: { appenders: ['response'], level: 'info' },
}
}
File 文件系統
nodejs 文件 I/O 是對標准 POSIX 函數的簡單封裝。 通過 require('fs') 使用該模塊。 所有的方法都有異步和同步的形式。
異步方法的最后一個參數都是一個回調函數。 傳給回調函數的參數取決於具體方法,但回調函數的第一個參數都會保留給異常。 如果操作成功完成,則第一個參數會是 null 或 undefined。
當使用同步方法時,任何異常都會被立即拋出。 可以使用 try/catch 來處理異常,或讓異常向上冒泡。
比如要做一個圖片上傳和圖片展示的功能,需要用到以下幾個方法
existsSync 檢測文件是否存在(同步方法) mkdirsSync 創建目錄(同步方法) readFileSync 讀取文件 createWriteStream 創建一個寫入流 createReadStream 創建一個讀取流 unlinkSync 文件刪除(同步方法)
文件上傳步驟
- 拿到上傳的file對象
- 規定好文件存放的路徑
- 創建目標路徑的寫入流和file.path(緩存路徑)的讀入流
- 以讀入流為基礎放入寫入流中
- 刪除緩存路徑的文件
- 數據庫記錄
file = ctx.request.body.files
targetInfo = getFileInfo(type);
tmpPath = file.path;
type = file.type;
targetInfo = getFileInfo(type);
// targetInfo 包含 {targetName 文件名稱,targetPaths 全路徑目標目錄, resultPath 加上文件名的目標目錄, relativePath 相對路徑目標目錄}
mkdirs.mkdirsSync(targetInfo.targetPaths); // 目錄
stream = fs.createWriteStream(targetInfo.resultPath);//創建一個可寫流
fs.createReadStream(tmpPath).pipe(stream);
unlinkStatus = fs.unlinkSync(tmpPath);
獲取文件 通過readFileSync 拿到Buffer形式的文件
獲取文件的路徑
filepath = files.find({_id: id}); //通過查詢數據庫拿到
ctx.body = fs.readFileSync(filepath);
ctx.res.writeHead(200, {'Content-Type': 'image/png'});
mongodb crud 數據庫
connect 數據庫連接
let dbName = "nodeapi";
let dbHost = "mongodb://localhost/";
let mongoose = require("mongoose");
exports.connect = function(request, response) {
mongoose.connect(dbHost + dbName, { useMongoClient: true }); // useMongoClient防止報錯
let db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function (callback) {
console.log('connet success!');
});
}
mongoose.Schema 字段對象模式
增刪改查 modal
let mongoose = require("mongoose");
let Schema = mongoose.Schema;
let FilesSchema = new Schema({
fileName: String,
filePath: String,
content: String,
createTime: {
type: Date,
dafault: Date.now()
},
updateTime: {
type: Date,
dafault: Date.now()
},
})
FilesSchema.pre('save', function(next) {
if (this.isNew) {
this.createTime = this.updateTime = Date.now()
}
else {
this.updateTime = Date.now()
}
next()
})
class Files{
constructor() {
this.files = mongoose.model("files", FilesSchema);
}
find(dataArr={}) {
const self = this;
return new Promise(function (resolve, reject){
self.files.find(dataArr, function(e, docs) {
if(e){
console.log('e:',e);
reject(e);
}else{
resolve(docs);
}
})
})
}
create(dataArr) {
const self = this;
return new Promise(function (resolve, reject){
let user = new self.files({
fileName: dataArr.fileName,
filePath: dataArr.filePath,
content: dataArr.content,
});
user.save(function(e, data, numberAffected) {
// if (e) response.send(e.message);
if(e){
reject(e);
}else{
resolve(data);
}
});
})
}
delete(dataArr) {
const self = this;
return new Promise(function (resolve, reject){
self.files.remove({
_id: dataArr.id
}, function(e, data) {
if(e){
reject(e);
}else{
resolve(data);
}
});
})
}
}
let files = new Files()
export {files}
以模塊的形式進行封裝,可以更方便外層調用
async 異步寫操作數據庫
import {files} from '../modal/files'
readFile = async (id) => {
try {
let list = await files.find({_id: id});
console.log(list)
if(list && list.length > 0) {
return fs.readFileSync(list[0].content);
} else {
return errdata(null,'9999', 'can not find file')
}
} catch (err) {
return errdata(err);
}
}
koa2學習地址參考
出於興趣最近開始研究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
成功創建項目后,進入項目目錄,並執行<code>npm install</code>命令
$ cd HelloKoa2
$ npm install
1.3 啟動項目
在終端輸入:
$ npm start
項目啟動后,默認端口號是3000,在瀏覽器中運行可以得到下圖的效果說明運行成功。

在此再次感謝狼叔-桑世龍。
當前項目的文件目錄如下圖

1.4 關於koa2
1.4.1 中間件的執行順序
koa的中間件是由generator組成的,這決定了中間件的執行順序。
Express的中間件是順序執行,從第一個中間件執行到最后一個中間件,發出響應。

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

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。
koa@1.x使用this引用Context對象:
app.use(function *(){ this.body = 'Hello World'; });
koa@2.x中使用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.json中scripts對象對應的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對象中將環境配置好,例如我們將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" },
可以在終端分別輸入<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-logger是tj大神寫的koa開發時替換console.log輸出的一個插件。
如果你需要按照時間或者按照文件大小,本地輸出log文件的話,建議還是采用log4js-node。
3.1 log4js
log4js提供了多個日志等級分類,同時也能替換console.log輸出,另外他還可以按照文件大小或者日期來生成本地日志文件,還可以使用郵件等形式發送日志。
我們在這演示用info和error兩種日志等級分別記錄響應日志和錯誤日志。
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目錄以及相關子目錄,並產生了日子文件。

內容如下:
[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文件。

代碼如下:
//獲取用戶 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;
這樣就完成了getUser和registerUser進行了路由配置,其中getUser是GET方式請求,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); } } }
解釋一下這段代碼
-
使用<code>try catch</code>包裹<code>await next();</code>,這樣后面的中間件拋出的異常都可以在這幾集中處理;
-
<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使用主流的測試框架基本就是mocha和AVA了,這里主要以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提供的斷言庫,他的功能比較少,基本上只有equal、ok、fail這樣簡單的功能,很難滿足日常的需求。
mocha官方表示你愛用什么斷言用什么斷言,反正老子都支持。
選擇chai是因為他對斷言的幾種語法都支持,而且功能也比較全面 --> chai官網。
chai支持should
、expect
和assert
三種斷言形式。
assert語法之前我們已經見過了,chai只是豐富了功能,語法並沒有變化。
expect和should的語法更接近自然語言的習慣,但是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);
明顯語法的可讀性更好,更接近人類的語言。
簡單的解釋其中的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文件,內容如下:
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接口getUser和registerUser進行測試。在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-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