👏 众所周知,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呢?
有的。现在让我们来安装一个非常好用的包:
yarn add require-directory
现在只需要这么做:
//...
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
}
})
返回的接口这样:
{
"msg": "网络请求错误",
"error_code":10001,
"request": "POST /user"
}
这样就抛出了一个特定类型的错误。但是在业务中错误的类型是非常复杂的,现在我就把我编写的一些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()
this.code = 400
this.msg = msg || '参数错误'
this.errorCode = errorCode || 10000
}
}
class NotFound extends HttpException{
constructor(msg, errorCode) {
super();
this.msg = msg || '资源未找到';
this.errorCode = errorCode || 10001;
this.code = 404;
}
}
class AuthFailed extends HttpException{
constructor(msg, errorCode) {
super();
this.msg = msg || '授权失败';
this.errorCode = errorCode || 10002;
this.code = 404;
}
}
class Forbidden extends HttpException{
constructor(msg, errorCode) {
super();
this.msg = msg || '禁止访问';
this.errorCode = errorCode || 10003;
this.code = 404;
}
}
module.exports = {
HttpException,
ParameterException,
Success,
NotFound,
AuthFailed,
Forbidden
}
对于这种经常需要调用的错误处理的代码,有必要将它放到全局,不用每次都导入。
现在的init.js中是这样的:
// 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
先安装两个包:
yarn add jsonwebtoken basic-auth
//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将路径别名就可以。
yarn add module-alias
//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的代码优化,就先分享到这里,未完待续,后续会不断补充。欢迎点赞、留言!