搭建koa2项目基础框架

1,235 阅读5分钟

最近在学习koa2,记录一下自己的学习笔记。

Koa2 是一个基于 Node.js 的轻量级 Web 框架,它提供了一种简洁、灵活的方式来构建 Web 应用程序。它没有集成各种中间件功能,所以它虽然小,但是不进行封装直接使用的话,是很难用的。

本文记录使用 koa2 + MySQL + sequelize + Joi 封装一个基础框架,并编写一个登录注册api接口

安装koa

  • 全局安装

    • npm install -g koa-generator
  • 构建项目

    • koa2 yourProjectName
  • 进入项目并初始化

    • cd yourProjectName && npm i
  • 启动项目

    • npm start

恭喜你,最基础的koa项目框架搭建完了

业务分层

但是作为一个相对完善的服务端框架,还是需要根据项目大小进行业务分层。业务逻辑一般是写在Model层,如果项目复杂一些,可以增加Service层

  • Controller层(控制器层): 负责接收客户端的请求,处理输入参数,并调用相应的Service层方法来完成业务逻辑。它还负责将Service层返回的结果进行适当的处理,生成相应返回给客户端
  • Service层(服务层): 包含核心的业务逻辑,通常是对数据的额处理、计算和操作。这一层专注于实现具体的业务功能,而不关心数据的获取和存储细节
  • Repository层(数据访问层): 也称为DAO(Data Access Object, 数据访问对象)层,负责与数据库或其他数据源进行交互,执行数据的增删改查操作
  • **Model层 (模型层):**定义数据的结构和数据之间的关系,通常使用对象关系映射(ORM)框架来映射数据库表到对象
  • Middleware层(中间件层): 用于处理一些通用的、跨请求的功能,如请求的日志记录、权限验证、错误处理等。
  • Router层(路由层): 定义应用的路由规则,将不同的URL路由映射到相应的Controller方法
  • Config层(配置层): 集中管理应用的配置信息,如数据库连接配置,服务器端口配置等。
  • Exception层(异常处理层): 专门用于处理和封装应用中可能出现的各种异常情况,提供统一的异常处理机制。

这些分层并非绝对固定,具体的分层结构会根据项目的规模,复杂性和团队的开发习惯而有所不同

require-directory 实现路由自动加载

现在的项目中已经有一个routes文件了,这里面就放着路由文件,在app.js中对这些路由文件进行注册

const index = require('./routes/index')
const users = require('./routes/users')

// routes
app.use(index.routes(), index.allowedMethods())
app.use(users.routes(), users.allowedMethods())

如果路由比较少的时候没问题,但是如果我们有很多的路由文件的时候,就需要这样去注册很多路由。那么有没有什么方法可以全局一次注册所有路由呢。

require-directory 插件

require-directory 插件:递归迭代指定的目录,获取里面的每个文件。这样我们就可以获取路由里面的每个文件,实现自动注册路由

  • 下载

    • npm i require-directory
  • 全局注册路由:

-- core
    |-- init.js

在根目录下创建一个core文件,里面创建init.js

init.js

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

class InitManager {
    static initCore(app) {
        InitManager.app = app
        InitManager.initLoadRoutes()
    }
    
    static initLoadRoutes() {
        // 获取根目录下的routes目录
        const routeDirectory = `${process.cwd()}/routes`
        // 迭代routes目录下的每个文件
        requireDirectory(module, routeDirectory, {
            visit: whenLoadModule
        })
        
        function whenLoadModule(obj) {
            // 如果是Router的实例
            if (obj instanceof Router) {
                InitManager.app.use(obj.routes(), obj.allowedMethods())
            }
        }
    }
}

module.exports = InitManager

app.js

const Koa = require('koa')
const InitManager = require('./core/init')

const app = new Koa()

InitManager.initCore(app)

现在我们就通过require-directory实现自动注册路由功能了

实现 Exception层 (异常处理层)

在前后端接口交互的时候,接口报错经常报一大串看不懂的错误提示,导致前端报错信息处理不好,这其实就是后端的异常处理不到位。所以封装一个好的异常处理层是非常有必要的。

那么如何去捕获所有的异常进行处理呢,这就需要用到中间件。

--middlewares
    |-- exception.js

在middlewares文件里面创建一个exception.js文件

const catchError = async (ctx, next) => {
    try {
        await next()
    } catch(error) {
        // 这里就能捕获到所有接口报的异常了
        console.log(error)
    }
}

app.js

const Koa = require('koa')
const catchError = require('./middlewares/exception')

const app = new Koa()
app.use(catchError)

使用use把catchError注册成为中间件

但是这样我们还是无法控制异常信息呀?

异常分为已知异常和未知异常。已知异常就是我们在处理逻辑中报出的异常,比如参数不合法,类型不正确,权限不足,拒绝访问等等。未知异常就是在运行过程中出现了错误

我们需要创建一个文件来定义一些常见的已知异常报错

./core/http-exception.js

class HttpException extends Error {
    constructor(msg = '服务端异常', errorCode = 10001, code = 500) {
        super()
        this.msg = msg
        this.errorCode = errorCode
        this.code = code
    }
}

class ParameterException extends HttpException {
    constructor(msg, errorCode) {
        super()
        this.msg = msg || '参数错误'
        thos.errorCode = errorCode || 10002
        this.code = 400
    }
}

class AuthFailed extends HttpException {
    constructor(msg, errorCode) {
        super()
        this.msg = msg || '暂未授权'
        this.errorCode = errorCode || 10004
        this.code = 401
    }
}

class Forbbiden extends HttpException {
    constructor(msg, errorCode) {
        super()
        this.msg = msg || '拒绝访问'
        this.errorCode = errorCode || 10005
        this.code = 403
    }
}

创建完已知报错后,就可以在exception.js文件里区分

const { HttpException } = require('../core/http-exception')
const catchError = async (ctx, next) => {
    try {
        await next()
    } catch (error) {
        if (error instanceof HttpException) {
            const params = {
                msg: error.msg,
                errorCode: error.errorCode,
                requireUrl: `${ ctx.method }: ${ ctx.path }`
            }
            if (/^2\d/.test(error.code)) {
                params.data = error.data || null
            }
            ctx.body = params
            ctx.status = error.code
        } else {
            // 这是未知异常
            ctx.body = {
                msg: error.message,
                errorCode: 99999,
                requireUrl: `${ ctx.method }: ${ ctx.path }`
            }
            ctx.status = 500
        }
    }
}

module.exports = catchError

这样我们就创建完了,此时如果是运行中的意外报错,就会走未知异常,如果是我们自己抛出的错误,就会走已知异常

如 注册接口

const Router = require('koa-router')
const router = new Router({
    prefix: '/v1/user'
})
const { ParameterException } = require('/core/http-exception')
// 注册
router.post('/register', async (ctx) => {
    // 这里没有进行校验,直接抛出异常
     throw new ParameterException('该账号已存在')
})

像上面 throw new ParameterException(...)直接抛出异常的,就会走已知异常。

这是我们的异常处理层 就做完了

通过koa-onerror创建异常处理层

如果是用koa-generator创建的项目,它会集成一个koa-onerror的错误处理中间件,所有异常都会走这边。

这样我们就不需要自己去创建 exception.js文件了,直接在onerror里面写

onerror(app, {
    json: (err, ctx) => {
        const isHttpException = err instanceof HttpException
        if (isHttpException) {
            // 已知异常
            let resultData = {
                msg: err.msg,
                error_code: err.errCode,
                request: `${ctx.method}: ${ctx.path}`
            }
            if (err.data) {
                resultData.data = err.data
            }
            ctx.body = resultData
            ctx.status = err.code

        } else {
            // 未知异常
            let errMsg = (err.errors && err.errors[0]?.message) || err.message
            
            ctx.body = {
                msg: errMsg,
                error_code: 99999,
                request: `${ctx.method}: ${ctx.path}`
            }
            ctx.status = 500
        }
    },
    accepts: function() {
        return 'json'
    }
})

但是这么多代码写在app.js里面也不好,我们把里面的处理逻辑拆分出来

/core/exception.js

const { HttpException, ParameterException } = require('./http-exception')

module.exports = {
    json: (err, ctx) => {
        const isHttpException = err instanceof HttpException
        if (isHttpException) {
            // 已知异常
            let resultData = {
                msg: err.msg,
                error_code: err.errCode,
                request: `${ctx.method}: ${ctx.path}`
            }
            if (err.data) {
                resultData.data = err.data
            }
            ctx.body = resultData
            ctx.status = err.code

        } else {
            // 未知异常
            let errMsg = (err.errors && err.errors[0]?.message) || err.message
            
            ctx.body = {
                msg: errMsg,
                error_code: 99999,
                request: `${ctx.method}: ${ctx.path}`
            }
            ctx.status = 500
        }
    },
    accepts: function() {
        return 'json'
    }
}

app.js

const Koa = require('koa')
const errorConf = require('./core/exception')
const InitManager = require('./core/init')
const app = new Koa()

onerror(app, errorConf)
InitManager.initCore(app)

用这两种哪一个创建异常处理层都是可以的,下一步就来创建日志

日志

在前端,如果出现报错了,可以通过控制台查看报错信息。但是服务端呢,如果发布上线出现了报错了,除非通过前端接口查看到报错的信息,否则你什么都不知道。所以我们需要编写一下日志,把请求信息放到日志文件里面去,这里描述两种日志的方式

1. koa-morgan

const morgan = require('koa-morgan')
const path = require('path')
const fs = require('fs')

const ENV = process.env.NODE_ENV
morgan.token('localDate',function getDate(req, res) {
  let date = new Date();
  return date.toLocaleString()
})
morgan.format('combined', ':remote-addr - :remote-user [:localDate]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"');

if (ENV !== 'dev') {
  // 开发环境 / 测试环境
  app.use(morgan('dev'));
} else {
  // 线上环境
  const logFileName = path.join(__dirname, 'logs', 'access.log')
  const writeStream = fs.createWriteStream(logFileName, {
    flags: 'a'
  })
  app.use(morgan('combined', {
    skip: function (req, res) { return res.statusCode < 400 },
    stream: writeStream
  }));
}

这个是通过koa-morgan插件编写的导出日志信息,它有好几种模式: dev, combined ... 都是已经定义好的日志输出格式。先判断本地环境还是开发环境,本地环境使用dev格式,直接打印在控制台。开发环境使用combined,这里是通过morgan.token定义了时间格式,再通过format自定义了输出格式。

打印出的日志如下:

image.png

log4js

新建middleware/logger.js

import fs from 'fs';
import path from 'path';
import log4js from 'log4js';

const logsPath = path.resolve(__dirname, '../../logs/koa.log')
// 判断是否有logs目录,没有就新建,用来存放日志
const logsDir = path.parse(logsPath).dir;

if (!fs.existsSync(logsDir)) {
    fs.mkdirSync(logsDir)
}

// 配置log4.js
log4js.configure({
    appenders: {
        console: { type: 'console' },
        dateFile: {
            type: 'dateFile',
            filename: logsPath,
            pattern: '-yyyy-MM-dd'
        }
    },
    categories: {
        default: {
            // 或添加到控制台和上面定义的文件里面
            appenderes: ['console', 'dateFile'],
            level: 'all'
        }
    }
})

export const logger = log4js.getLogger('[default]');

// logger中间件
export const loggerMiddleWare = async (ctx, next) => {
    // 请求开始时间
    const start = +new Date();
    await next();
    // 结束时间
    const ms = +new Date() - start;
    // 打印出请求相关参数
    const remoteAddress = ctx.headers['x-forwarded-for'] || ctx.ip || ctx.ips;
    const logText = `${ctx.method} ${ctx.status} ${ctx.url} 请求参数: ${JSON.stringify(ctx.request.body)} 响应参数: ${JSON.stringify(ctx.body)} - ${remoteAddress} - ${ms}ms`;
    
    logger.info(logText);
    
}

app.js

import Koa from 'koa';
import { loggerMiddleware } from './middleware/logger';

const app = new Koa();
// 日志中间件
app.use(loggerMiddleware);

更推荐logjs这种方式,它打印出来的数据如下:

image.png

sequelize + MySQL + Joi + koa-router

  • sequelize: 是一个orm框架,什么是orm呢?即Object-Relationl Mapping,它的作用是在关系型数据库和对象之间作一个映射,这样,我们在具体的操作数据库的时候,就不需要再去和复杂的SQL语句打交道,只要像平时操作对象一样操作它就可以了。

  • joi: 参数校验工具,可以用来进行接口参数校验

下面我们就通过sequelize来创建一个user模型,并实现用户注册接口

1. 创建config文件

创建congig文件用来存放数据库信息

  • config/config.js
module.exports = {
    database: {
        dbName: '数据库名',
        port: '端口 || 3306',
        user: '数据库登录用户名 || root',
        password: '数据库登录密码',
        host: 'localhost'
    },
}

2. 创建sequelize实例

core/db.js

const Sequelize = require('sequelize')

const {
    dbName, port, user, password, host
} = require('../config/config').database

const sequelize = new Sequelize(
    dbName,
    user,
    password,
    {
        host,
        port,
        dialect: 'mysql',
        // 设置时区,不然默认生成的时间不对
        timezong: '+08:00',
        define: {
            // 自动默认新增 createdAt和updatedAt
            timestamps: true,
            // 自动默认新增 deleteAt
            paranoid: true,
            createdAt: 'created_at',
            updatedAt: 'updated_at',
            deletedAt: 'deleted_at'
        }
    }
)

module.exports = {
    sequelize
}

创建user模型

models/user.js

const { Sequelize, Model } = require('sequelize')
const { sequelize } = require('./core/db')

class User extends Model {}

User.init({
    nickname: Sequelize.STRING,
    email: {
        type: Sequelize.STRING(128),
        unique: true
    },
    password: {
        type: Sequelize.STRING
    },
    openid: {
        type: Sequelize.STRING(64),
        unique: true,
        allowNULL: true
    }
}, {
    sequelize,
    tableName: 'user'
})


module.exports = User

把模型同步到数据库

models/sync.js

const { sequelize } = require('./core/db')
const User = require('./user')

//测试连接
sequelize.authenticate().then(() => {
	console.log('sequelize content success!')
}).catch(() => {
	console.error('sequelize connect failed...')
})

sequelize.sync({alter: true}).then(() => {
    process.exit()
})

sequelize.sync就是把user数据模型同步到数据库里面

创建注册路由

这里我们假设是邮箱和密码注册,有四个参数: email, password, repassword, nickname

那么我们第一步要先写个参数校验,校验前端传过来的参数是否合法

  • validators/user-validator.js
const Joi = require('joi')
const { ParameterException } = require(./core/http-exception)

const RegisterValidator = (parameter) => {
    const schema = Joi.object({
        email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } }).required(),
        password: Joi.string().pattern(/^[a-zA-Z0-9]{6,30}$/).required(),
        repassword: Joi.string.required().valid(Joi.ref('password')),
        nickname: Joi.string().min(1).max(10).required().message({
            "string.empty": "用户名必填",
            "any.required": "用户名必填",
            "string.max": "用户名长度不能超过10"
        })
    })
    
    const result = schema.validate(parameter)
    // 如果有错误,result会有error字段,否则没有
    if (result.error) {
       throw new ParameterException(result.error.details[0].message) 
    }
    return result.value
}

module.exports = {
    RegisterValidator
}

router/user.js

const Router = require('koa-router')
const router = new Router({
    prefix: '/v1/user'
})
const { RegisterValidator } = require('./validators/user-validator')
const { ParameterException } = require('./core/http-exception')
const User = require('./models/user')

router.post('/register', async (ctx) => {
    const parameter = RegisterValidator(ctx.request.body)
    const user = await User.findOne({
        where: {
            email: parameter.email
        }
    })
    if (user) {
        throw new ParameterException('该邮箱已存在')
    }
    const result = await User.create(parameter)
    // 这里可以建一个成功的异常去导出
    ctx.body = {
        msg: '注册成功'
    }
    
})

总结

此时我们的框架就已经有了:

  • Model层 (模型层)
  • Middleware层(中间件层)
  • Router层(路由层)
  • Config层(配置层)
  • Exception层(异常处理层)

我们还可以新建一个Controller层,把Model层里面的业务代码放到Controller层里面去,如果是比较复杂的业务流程,可以增加一个Service层 ,用来处理业务逻辑