最近在学习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自定义了输出格式。
打印出的日志如下:
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这种方式,它打印出来的数据如下:
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层 ,用来处理业务逻辑