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,在浏览器中运行可以得到下图的效果说明运行成功。

[图片上传失败...(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。

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.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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容