用优雅的代码武装我们的koa2项目

8,190 阅读6分钟

众所周知,koa2是基于nodejs的一款非常轻量级的服务端框架,其简单易上手的特性更是大大节省了前端人员开发服务端api的成本。尽管许多功能能够实现,但是作为一个有素养的开发人员,代码的层次性、后期可维护性都是需要考虑周到的。

实话说,按照koa官方文档来照葫芦画瓢,我们的代码是写不漂亮的。

这里需要我们在编码之前有一个非常清晰的认识:我们的代码如何组织?如何分层?如何复用?

在经历一系列的思考斟酌以及一些项目的实践之后,我总结了一些关于koa的开发技巧,能够大幅度的提高项目的代码质量,再也不用让同伴笑话代码写的烂啦!

一、路由的自动加载

之前我们的路由总是手动注册的?大概是这样的:

//app.js
const Koa = require('koa');
const app = new Koa(); 

const user = require('./app/api/user');
const store = require('./app/api/store');

app.use(user.routes());
app.use(classic.routes());

对于写过koa项目的人来说,这段代码是不是相当熟悉呢?其实现在只有两个路由文件还好,但实际上这样的文件数量庞大到一定的程度,再像这样引入再use方式未免会显得繁琐拖沓。那有没有办法让这些文件自动被引入、自动被use呢?

有的。现在让我们来安装一个非常好用的包:

npm install require-directory --save

现在只需要这么做:

//...
const Router = require('koa-router'); 
const requireDirectory = require('require-directory');
//module为固定参数,'./api'为路由文件所在的路径(支持嵌套目录下的文件),第三个参数中的visit为回调函数
const modules = requireDirectory(module, './app/api', {
    visit: whenLoadModule
});
function whenLoadModule(obj) {
    if(obj instanceof Router) {
        app.use(obj.routes());
    }
}

由此可见,好的代码是可以提升效率的,这样的自动加载路由省去了很多注册配置的功夫,是不是非常酷炫?

二、用管理器将入口文件内容抽离

相信很多人都这样做:路由注册代码写在了入口文件app.js中,以后进行相应中间件的导入也是写在这个文件。但是对于入口文件来说,我们是不希望让它变得十分臃肿的,因此我们可以适当地将一些操作抽离出来。

在根目录下建一个文件夹core,以后一些公共的代码都存放在这里。

//core/init.js
const requireDirectory = require('require-directory');
const Router = require('koa-router'); 

class InitManager {
    static initCore(app) {
        //把app.js中的koa实例传进来
        InitManager.app = app;
        InitManager.initLoadRouters();
    }
    static initLoadRouters() {
        //注意这里的路径是依赖于当前文件所在位置的
        //最好写成绝对路径
        const apiDirectory = `${process.cwd()}/app/api`
        const modules = requireDirectory(module, apiDirectory, {
            visit: whenLoadModule
        });
        function whenLoadModule(obj) {
            if(obj instanceof Router) {
                InitManager.app.use(obj.routes())
            }
        }
    }
}

module.exports = InitManager;

现在在app.js中

const Koa = require('koa');
const app = new Koa();

const InitManager = require('./core/init');
InitManager.initCore(app);

可以说已经精简很多了,而且功能的实现照样没有问题。

三、开发环境和生产环境的区分

有时候,在两种不同的环境下,我们需要做不同的处理,这时候就需要我们提前在全局中注入相应的参数。

首先在项目根目录中,创建config文件夹:

//config/config.js
module.exports = {
  environment: 'dev'
}
//core/init.js的initManager类中增加如下内容
static loadConfig() {
    const configPath = process.cwd() + '/config/config.js';
    const config = require(configPath);
    global.config = config;
}

现在通过全局的global变量中就可以取到当前的环境啦。

四、全局异常处理中间件

1、异步异常处理的坑

在服务端api编写的过程中,异常处理是非常重要的一环,因为不可能每个函数返回的结果都是我们想要的。无论是语法的错误,还是业务逻辑上的错误,都需要让异常抛出,让问题以最直观的方式暴露,而不是直接忽略。关于编码风格,《代码大全》里面也强调过,在一个函数遇到异常时,最好的方式不是直接return false/null,而是让异常直接抛出。

而在JS中,很多时候我们都在写异步代码,例如定时器,Promise等等,这就会产生一个问题,如果用try/catch的话,这样的异步代码中的错误我们是无法捕捉的。例如:

function func1() {
  try {
    func2();
  } catch (error) {
    console.log('error');
  }
}

function func2() {
  setTimeout(() => {
    throw new Error('error')
  }, 1000)
}

func1();

执行这些代码,你会发现过了一秒后程序直接报错,console.log('error')并没有执行,也就是func1并没有捕捉到func2的异常。这就是异步的问题所在。

那怎么解决这个坑呢?

最简便的方式是采取async-await。

async function func1() {
  try {
    await func2();
  } catch (error) {
    console.log('error');
  }
}

function func2() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject()
    }, 1000)
  })
}

func1();

在这里的异步函数被Promise封装,然后reject触发func1中的catch,这就捕捉到了func2中的异常。庆幸的是,像func2这样的异步代码,现在常用的库(如axios、sequelize)已经为我们封装好了Promise对象,不用我们自己去封装了,直接去通过async-await的方式去try/catch就行了。

忠告: 通过这种方式,只要是异步代码,执行之前必须要加await,不加会报Unhandled promise rejection的错误。血的教训!

2、设计异常处理中间件

//middlewares/exception.js
//这里的工作是捕获异常生成返回的接口
const catchError = async (ctx, next) => {
  try {
    await next();
  } catch (error) {
    if(error.errorCode) {
      ctx.body = {
        msg: error.msg,
        error_code: error.errorCode,
        request: `${ctx.method} ${ctx.path}`
      };
    } else {
      //对于未知的异常,采用特别处理
      ctx.body = {
        msg: 'we made a mistake',
      };
    }
  }
}
module.exports = catchError;

到入口文件使用这个中间件。

//app.js
const catchError = require('./middlewares/exception');
app.use(catchError)

接着我们来以HttpException为例生成特定类型的异常。

//core/http-exception.js
class HttpException extends Error {
  //msg为异常信息,errorCode为错误码(开发人员内部约定),code为HTTP状态码
  constructor(msg='服务器异常', errorCode=10000, code=400) {
    super()
    this.errorCode = errorCode
    this.code = code
    this.msg = msg
  }
}

module.exports = {
  HttpException
}
//app/api/user.js
const Router = require('koa-router')
const router = new Router()
const { HttpException } = require('../../core/http-exception')

router.post('/user', (ctx, next) => {
    if(true){
        const error = new HttpException('网络请求错误', 10001, 400)
        throw error
  }
})
module.exports = router;

返回的接口这样:

这样就抛出了一个特定类型的错误。但是在业务中错误的类型是非常复杂的,现在我就把我编写的一些Exception类分享一下,供大家来参考:

//http-exception.js
class HttpException extends Error {
  constructor(msg = '服务器异常', errorCode=10000, code=400) {
    super()
    this.error_code = errorCode
    this.code = code
    this.msg = msg
  }
}

class ParameterException extends HttpException{
  constructor(msg, errorCode){
    super(400, msg='参数错误', errorCode=10000);
  }
}

class NotFound extends HttpException{
  constructor(msg, errorCode) {
    super(404, msg='资源未找到', errorCode=10001);
  }
}

class AuthFailed extends HttpException{
  constructor(msg, errorCode) {
    super(404, msg='授权失败', errorCode=10002);
  }
}

class Forbidden extends HttpException{
  constructor(msg, errorCode) {
    super(404, msg='禁止访问', errorCode=10003);
    this.msg = msg || '禁止访问';
    this.errorCode = errorCode || 10003;
    this.code = 404;
  }
}

module.exports = {
  HttpException,
  ParameterException,
  Success,
  NotFound,
  AuthFailed,
  Forbidden
}

对于这种经常需要调用的错误处理的代码,有必要将它放到全局,不用每次都导入。

现在的init.js中是这样的:

const requireDirectory = require('require-directory');
const Router = require('koa-router');

class InitManager {
  static initCore(app) {
    //入口方法
    InitManager.app = app;
    InitManager.initLoadRouters();
    InitManager.loadConfig();
    InitManager.loadHttpException();//加入全局的Exception
  }
  static initLoadRouters() {
    // path config
    const apiDirectory = `${process.cwd()}/app/api/v1`;
    requireDirectory(module, apiDirectory, {
      visit: whenLoadModule
    });

    function whenLoadModule(obj) {
      if (obj instanceof Router) {
        InitManager.app.use(obj.routes());
      }
    }
  }
  static loadConfig(path = '') {
    const configPath = path || process.cwd() + '/config/config.js';
    const config = require(configPath);
    global.config = config;
  }
  static loadHttpException() {
    const errors = require('./http-exception');
    global.errs = errors;
  }
}

module.exports = InitManager;

五、使用JWT完成认证授权

JWT(即Json Web Token)目前最流行的跨域身份验证解决方案之一。它的工作流程是这样的:

1.前端向后端传递用户名和密码

2.用户名和密码在后端核实成功后,返回前端一个token(或存在cookie中)

3.前端拿到token并进行保存

4.前端访问后端接口时先进行token认证,认证通过才能访问接口。

那么在koa中我们需要做哪些事情?

在生成token阶段:首先是验证账户,然后生成token令牌,传给前端。

在认证token阶段: 完成认证中间件的编写,对前端的访问做一层拦截,token认证过后才能访问后面的接口。

1.生成token

先安装两个包:

npm install jsonwebtoken basic-auth --save
//config.js
module.exports = {
  environment: 'dev',
  database: {
    dbName: 'island',
    host: 'localhost',
    port: 3306,
    user: 'root',
    password: 'fjfj'
  },
  security: {
    secretKey: 'lajsdflsdjfljsdljfls',//用来生成token的key值
    expiresIn: 60 * 60//过期时间
  }
}

//utils.js 
//生成token令牌函数,uid为用户id,scope为权限等级(类型为数字,内部约定)
const generateToken = function(uid, scope){
    const { secretKey, expiresIn } = global.config.security
    //第一个参数为用户信息的js对象,第二个为用来生成token的key值,第三个为配置项
    const token = jwt.sign({
        uid,
        scope
    },secretKey,{
        expiresIn
    })
    return token
}

2.Auth中间件实现拦截

//前端传token方式
//在请求头中加上Authorization:`Basic ${base64(token+":")}`即可
//其中base64为第三方库js-base64导出的一个方法

//middlewares/auth.js
const basicAuth = require('basic-auth');
const jwt = require('jsonwebtoken');

class Auth {
  constructor(level) {
    Auth.USER = 8;
    Auth.ADMIN = 16;
    this.level = level || 1;
  }
  //注意这里的m是一个属性
  get m() {
    return async (ctx, next) => {
      const userToken = basicAuth(ctx.req);
      let errMsg = 'token不合法';

      if(!userToken || !userToken.name) {
        throw new global.errs.Forbidden();
      }
      try {
        //将前端传过来的token值进行认证,如果成功会返回一个decode对象,包含uid和scope
        var decode = jwt.verify(userToken.name, global.config.security.secretKey);
      } catch (error) {
        // token不合法
        // 或token过期
        // 抛异常
        errMsg = '//根据情况定义'
        throw new global.errs.Forbidden(errMsg);
      }
      //将uid和scope挂载ctx中
      ctx.auth = {
        uid: decode.uid,
        scope: decode.scope
      };
      //现在走到这里token认证通过
      await next();
    }
  }
}
module.exports = Auth;

在路由相应文件中编写如下:

//中间件先行,如果中间件中认证未通过,则不会走到路由处理逻辑这里来
router.post('/xxx', new Auth().m , async (ctx, next) => {
    //......
})

六、require路径别名

在开发的过程,当项目的目录越来越复杂的时候,包的引用路径也变得越来越麻烦。曾经就出现过这样的导入路径:

const Favor = require('../../../models/favor');

甚至还有比这个更加冗长的导入方式,作为一个有代码洁癖的程序员,实在让人看的非常不爽。其实通过绝对路径process.cwd()的方式也是可以解决这样一个问题的,但是当目录深到一定程度的时候,导入的代码也非常繁冗。那有没有更好的解决方式呢?

使用module-alias将路径别名就可以。

npm install module-alias --save
//package.json添加如下内容
  "_moduleAliases": {
    "@models": "app/models"
  },

然后在app.js引入这个库:

//引入即可
require('module-alias/register');

现在引入代码就变成这样了:

const Favor = require('@models/favor');

简洁清晰了许多,也更容易让人维护。

七、利用sequelize的事务解决数据不一致问题

当一个业务要进行多项数据库的操作时,拿点赞功能为例,首先你得在点赞记录的表中增加记录,然后你要将对应对象的点赞数加1,这两个操作是必须要一起完成的,如果有一个操作成功,另一个操作出现了问题,那就会导致数据不一致,这是一个非常严重的安全问题。

我们希望如果出现了任何问题,直接回滚到未操作之前的状态。这个时候建议用数据库事务的操作。利用sequelize的transaction是可以完成的,把业务部分的代码贴一下:

async like(art_id, uid) {
    //查找是否有重复的
    const favor = await Favor.findOne({
      where: { art_id, uid }
      }
    );
    //有重复则抛异常
    if (favor) {
      throw new global.errs.LikeError('你已经点过赞了');
    }
    //db为sequelize的实例
    //下面是事务的操作
    return db.transaction(async t => {
      //1.创建点赞记录
      await Favor.create({ art_id, uid }, { transaction: t });
      //2.增加点赞数
      const art = await Art.getData(art_id, type);//拿到被点赞的对象
      await art.increment('fav_nums', { by: 1, transaction: t });//加1操作
    });
  }

sequelize中的transaction大概就是这样做的,官方文档是promise的方式,看起来实在太不美观,改成async/await方式会好很多,但是千万不要忘了写await。

关于koa2的代码优化,就先分享到这里,未完待续,后续会不断补充。欢迎点赞、留言!