EggJS系列 | 开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R
EggStarter Repo
模板地址: github.com/QC2168/egg-…
目前模板已经集成了以下功能
- 🛠️ 统一错误处理机制
- 🔒 JWT验证模块
- 🧰 集成常用工具函数
- 🔄 更好的路由管理
- 🚀 基于EggJs快速构建Restful Api
- 🌐 纯Javascript
- 🐳 Sequelize Mysql
- 🍭 支持 DB Migration / Model Sync
- 📂 基于文件系统缓存服务
- 📚 集成Swaggar文档
- 🦄 集成Vscode代码片段
- 🔧 ESlint
- 💪 这些还不够? 欢迎您来提
Issues / PR
创建一个新的项目作为模板
这里和之前一样,我们还是通过egg.js
提供的脚手架工具,创建一个simple
的模板
这里,我们要先确定以下,我们的模板需要使用哪些规范,这里我列在下方
RestfulApi
请求规范Api
版本控制- 采用
Http
状态码传递处理结果 - 数据返回结构采用
JSONApi
规范 - 支持
Swagger
文档
这个模板不采用Typescript语言,因为Egg相关插件已经提供了Index.d.ts文件支持,更友好属性提示
如果您想使用Typescript编写,请阅读
https://www.eggjs.org/zh-CN/tutorials/typescript
请求参数校验
简单数据校验
得益于Egg.js
强大的插件生态,我们不用再为这个功能造轮子,可以直接拿现成的插件进行集成
npm i egg-validate
在config/plugin.js
中配置它
// config/plugin.js
exports.validate = {
enable: true,
package: 'egg-validate',
};
接下来,如果您想要使用它,只想要在controller
中调用ctx.validate
即可
// app/controller/home.js
const Controller = require('egg').Controller;
class HomeController extends Controller {
async index() {
const { ctx } = this;
ctx.validate({ id: 'id' });
}
}
如果校验有误,它会直接返回422
的http
状态码和具体的错误信息来告诉前端调用者、
这样子我们的参数校验功能就完成了,是不是很简单?
模型参数校验
如果你的项目需要引入schema
模型(例如你把它写到的swaggar
文档中),可以使用ajv
这个工具,它可以基于schema
进行模型的校验
如何将模型写入到swaggar后续会介绍
pnpm i ajv
// copy form https://ajv.js.org/
const Ajv = require("ajv")
const ajv = new Ajv()
// 定义你的模型
// JSON Schema
const schema = {
type: "object",
properties: {
foo: {type: "integer"},
bar: {type: "string"}
},
// 表示必须存在的属性
required: ["foo"],
// 表示不允许有schema中未定义的额外属性
additionalProperties: false
}
// 模拟要验证的数据对象,实际上应该是ctx.request.body
const data = {foo: 1, bar: "abc"}
// 验证数据
const valid = ajv.validate(schema, data)
// 错误时打印数据,会说明错误问题
if (!valid) console.log(ajv.errors)
如果接口涉及到模型操作,推荐该方法进行验证
扩展学习AJV
,请前往ajv
扩展工具函数
在我们开发前端项目中,我常常会在src
文件夹中创建类似utils
,common/utils
等命名的文件夹来存放一些常用或者通用工具函数,方便我们在业务中引用
虽然在egg.js
中,我们也可以这样子干,但是egg.js
中,提供了helper
模块,我们可以将一系列的工具函数写入到app/extend/helper.js
,之后我们无论在何处,只要有ctx
对象,我们都可以调用到helper
中的工具函数
比如我们写两个通用的方法,分别是生成和验证jwt
的方法
process.env 是nodejs的环境变量
JWT_SECRET是JWT密钥
JWT_EXPIRES_IN是JWT过期时间
这两个环境变量是需要在env文件中自己配置定义的,后续会介绍
我们需要安装下jsonwebtoken
这个库,这是一个用Node.js
实现的JWT
库
// app/extend/helper.js
const jwt = require('jsonwebtoken');
module.exports = {
// 生成jwt
generateToken(data) {
return jwt.sign(data, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN }); // 生成token
},
// 验证jwt
verifyToken(token) {
return jwt.verify(token, process.env.JWT_SECRET); // 验证token
}
}
扩展学习
helper
请前往 helper扩展学习
node-jsonwebtoken
请前往 node-jsonwebtoken
中间件
中间件是一个很核心的概念,它可以介入每一次请求中的某一部分,例如判断用户登录状态,如果没有中间件,我们可能需要在每一个controller
中添加上判断语句,而有了中间件我们可以直接在请求前判断用户发送请求时是否携带了authentication
请求头
中间件可以帮助我们干很多事情
我们需要在egg.js
框架中指定的目录下创建中间件(app/middleware
)
这里以校验用户登录状态的场景为例
这里提一下为什么不使用现有的
egg-jwt
插件,因为这个插件没有维护了,而且之前在使用这个插件的时候遇到了问题也无法解决,所以直接使用了jsonwebtoken
这个库
接下来,我们在middleware
,文件夹中创建一个auth.js
文件,这是一个auth
中间件
// /app/middleware/auth.js
const jwt = require('jsonwebtoken');
module.exports = () => {
return async function auth(ctx, next) {
const token = ctx.get('Authorization'); // 假设token放在Authorization header中
if (!token) {
ctx.status = 401;
ctx.body = {
message: '请先登录用户',
};
return;
}
try {
const decoded = jwt.verify(token.replace('Bearer ', ''), process.env.JWT_SECRET);
ctx.state.user = decoded; // 将解码后的用户信息存到ctx.state中,方便后续使用
await next();
} catch (err) {
// 更通用的错误处理,可根据实际情况调整错误码和消息
ctx.status = 401;
ctx.body = {
message: '认证过程中发生错误,请重试',
};
return;
}
};
};
在写完中间件后,这个时候中间件并不会生效,因为我们没有注册使用它,这里我们可以通过路由,全局注册,规则匹配的方式使用它,这里我们用路由的方式来注册一下它
路由注册中间件
// router.js
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller,middleware } = app;
const apiPrefix = '/api';
// 这里第三个参数,告诉egg.js该路由需要先走`auth`中间件
router.resources('users', `${apiPrefix}/users`,middleware.auth(), controller.users);
};
带参数的中间件
中间件其实是可以带参数的,我们可以在路由中传递参数或者在config
中传递给中间件
// 这里的options指的是外部传递进来的参数
// 这里参数可以是全局参数,也可以是路由中传递进来的
module.exports = options => {
return async function auth(ctx, next) {
// 比如在验证用户时,多一层判断是否为超级用户
if(options.super){
// 判断请求用户是否为超级用户访问
// 一些业务逻辑
await next();
}else{
// 一些业务逻辑
await next();
}
}
}
通过路由中间件传递参数的方式
// router.js
const superAuth = app.middleware.auth({ super: true });
// 只能超级用户访问的路由
router.post('/api/admin/users/create', superAuth, controller.admin.users.create)
全局中间件参数传递
// config/config.default.js
module.exports = {
// 全局注册,全部路由接口都需要走这个中间件
middleware: ['auth'],
auth: {
// 所有的用户必须的超级用户
super:true
},
};
无效请求处理
我们再来写一个中间件,是关于请求地址有误的中间件,当用户请求到一个无效的Api
地址时,我们统一返回404
状态码和Not Found
module.exports = () => {
return async function notFoundHandler(ctx, next) {
await next();
if (ctx.status === 404 && !ctx.body) {
if (ctx.acceptJSON) {
ctx.status = 404
ctx.body = { error: 'Not Found' };
} else {
ctx.status = 404
ctx.body = '<h1>Page Not Found</h1>';
}
}
};
};
这个中间件,我们采用全局注册的方式来是使用它
这里我们只需要把中间件的名字传递给config.middleware
的数组中即可,因为egg.js
内部会自动寻找到这个中间件
// config/config.default.js
config.middleware = ['notfoundHandler'];
错误处理
业务错误处理
这一块我们可以编写一个中间件来实现业务代码的处理
// app/middleware/errorHandler.js
module.exports = () => {
return async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
const { app } = ctx;
// 记录错误日志,方便追踪问题
app.emit('error', err, ctx);
const status = err.status || 500;
// 判断运行环境,如果是生产环境,不返回详细错误信息
const error = status === 500 && app.config.env === 'prod' ? 'Internal Server Error' : err.message;
ctx.body = { error };
ctx.status = status;
}
};
};
挂载中间件到全局中,之后代码中遇到的异常被这个中间件处理
// config/config.default.js
module.exports = {
middleware: [ 'errorHandler' ],
};
兜底错误处理
虽然我们可以在业务代码中使用try..catch
进行捕获错误并处理
但是某些情况下,它无法捕获,例如在回调函数中的错误,它无法正常走向catch
代码块
如果我们想要对业务进行全局错误处理,我们可以使用egg.js
提供的app.onerror
事件
// config/config.default.js
module.exports = {
onerror: {
all(err, ctx) {
ctx.body = '服务器出错,请联系管理员';
ctx.status = 500;
}
}
};
扩展学习请前往 Middleware 学习
Env 环境变量
egg.js
本身提供了app.config.env
的能力,但是在有些场景下,并不能满足开发需求(这里指某些单独的文件无法访问到ctx
对象),所以这里我们需要使用到dotenvx
这个库
这里,如果您的开发环境Node.js版本是使用的20版本以上,可以不安装dotenvx,因为Node.js本身自带的读取env的能力
安装后,我们如果需要定义环境变量的话,可以在env
文件中填写对应的key-value
// .env
# JWT Config
JWT_SECRET=LxXJi6Rv9t3JPLci
JWT_EXPIRES_IN=7d
在代码中获取环境变量
console.log(process.env.JWT_SECRET);
// LxXJi6Rv9t3JPLci
如果你想要区分环境模式,可以使用.env.development
,.env.production
文件来定义环境变量
启动命令如下
# 生产环境
dotenvx run -f .env.production -- egg-scripts start --daemon --title=egg-server
# 开发环境
dotenvx run -f .env.development -- egg-bin dev
扩展学习请前往 dotenvx 官网
多路由管理
路由管理是一个很重要的概念,它决定了我们的Api
的访问地址,以及路由的分发
前面一篇文章我们的快速crud
案例已经展示了路由的使用,但是还不够全面,在实际项目开发中,我们可能会有很多的路由地址,这个时候,我们就需要使用到路由管理了
我们可以将不同模块的Router
路径拆分出来,比如下面的代码,我们将store
模块和admin
模块的路由拆分出来,最终在router
中引入使用
// app/router.js
module.exports = (app) => {
require('./router/store')(app);
require('./router/admin')(app);
};
// app/router/store.js
module.exports = (app) => {
app.router.resources('store','/store/list', app.controller.store);
};
// app/router/admin.js
module.exports = (app) => {
app.router.resources('adminUser','/admin/user', app.controller.admin);
};
配置Swaggar文档
在与前端开发时,往往离不开一份API
接口文档,它可以帮助前端开发者更好的使用我们的接口,这里我们使用一个第三方插件来帮助我们快速生成文档
pnpm i swagger-egg
在config/plugin.local.js
中注册插件
这里需要注意的是,我们使用了
local
环境,也即是在本地开发环境下才会生成swaggar文档
/** @type Egg.EggPlugin */
module.exports = {
swaggerEgg: {
enable: true,
package: 'swagger-egg',
}
}
在config/config.local.js
文件中,配置插件的信息
// {app_root}/config/config.local.js
exports.swaggerEgg = {
schema: {
// 你的schema文件夹路径
path: '/app/schema',
},
swagger: {
info: {
title: 'EggJs模板API文档',
description: 'EggJs模板API文档',
version: '1.0.0'
},
externalDocs: {
url: 'https://swagger.io',
description: 'Find more info here'
},
// 服务地址
host: '127.0.0.1:7001',
schemes: ['http', 'https'],
consumes: ['application/json'],
produces: ['application/json'],
// 定义标签,用于分类
tags: [
{ name: 'User', description: '用户模块' },
{ name: 'Demo', description: 'Demo模块' }
],
typescriptJsonSchema: false,
}
};
我们先定义一个schema
,即数据模型,写入到项目中
它是基于
JSON Schema
标准,一种用于描述和验证 JSON 数据格式的规范 扩展了解可以访问json-schema
// 注意路径
// app/schema/demo.js
const schema = {
type: "object",
properties: {
foo: {type: "integer"},
bar: {type: "string"}
},
// 表示必须存在的属性
required: ["foo"],
// 表示不允许有schema中未定义的额外属性
additionalProperties: false
}
module.exports = schema
在控制器中,通过注释的方式来定义接口文档
这里我们写了一些信息,分别代表函数名称,接口描述信息,接口传入参数类型信息,以及返回响应的模型
/**
* #swagger-api
* @function demo
* @description #tags Demo
* @description #parameters id path string true - parameter id
* @description #responses 200 schema.demo - demo模型
* @summary 测试-返回demo模型
*/
async demo() {
// 忽略一些代码
}
此时我们可以跑一下服务,并访问http://127.0.0.1:7001/public/index.html
,即可以看到我们刚刚定义的接口文档了
如果您还想配置更多swagger-egg
信息,可以查阅文档JsonMa/swagger-egg
ORM
全称Object Relational Mapping
,是一种为了解决面向对象与关系数据库存在的互不匹配的现象的技术
传统开发中,我们可能会写很多重复的数据库SQL语句,如果使用ORM方式进行开发,我们可以节省很多的重复语句,并且可以更好的进行数据模型的管理
我们可以使用sequelize
来管理我们的数据库,它提供了非常丰富的功能,可以满足我们的需求
eggjs
官方已经将sequelize
封装成了一个插件,更方便我们开发者进行开发调用
这里我们采用的是mysql
数据库,所以需要安装mysql2
插件
如果你使用的是其他数据库,请自行安装对应的插件
pnpm i egg-sequelize sequelize-cli mysql2
# pnpm i pg pg-hstore # PostgreSQL
在plugin.js
和config.default.js
中引入它,并配置
// config/plugin.js
exports.sequelize = {
enable: true,
package: 'egg-sequelize'
}
// config/config.default.js
exports.sequelize = {
dialect: 'mysql',
database: 'egg-starter',
host: 'localhost',
port: 3306,
username: 'root',
password: '',
};
这里推荐把数据库的配置放在环境变量中,这样方便管理,后续根据不同的环境在env
文件中获取值即可
注意:我并没有创建一个config.prod.js,我整个项目是通过env文件进行变量管理的
config.sequelize = {
dialect: process.env.DB_DIALECT,
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
database: process.env.DB_NAME,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
};
接下来,我们需要编写定义模型,这里是模型指的是我们的数据库表结构
我的做法是通过sequelize中的migration来进行模型的管理
我们需要执行以下命令,告诉sequelize
生成一个记录文件
--name
指的是我们本次记录的文件名称,也方便我们后续查询操作
sequelize migration:generate --name=init-users
当你执行完后,你会在终端中看到以下字样,代表创建成功
PS E:\project\egg-starter> pnpm exec sequelize migration:generate --name=init-users
Sequelize CLI [Node: 20.15.0, CLI: 6.6.2, ORM: 6.37.3]
migrations folder at "E:\project\egg-starter\database\migrations" already exists.
New migration was created at E:\project\egg-starter\database\migrations\20240805055333-init-users.js .
打开生成后的文件,你会看到有两个函数,一个是up
函数,一个是down
函数,分别代表创建和删除数据库表的操作
在这两个函数中,你可以使用queryInterface
属性进行数据库的操作,比如创建表
我们填写一些用户表的属性
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// 使用 queryInterface 创建一个新表
const RoleEnum = Sequelize.ENUM('admin', 'user');
await queryInterface.createTable('users', {
// 表的字段定义
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
avatar: Sequelize.STRING(255),
role: RoleEnum,
username: Sequelize.STRING(30),
mobile: Sequelize.STRING(11),
email: Sequelize.STRING(30),
password: Sequelize.STRING(255),
last_logied: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW,
},
});
},
async down(queryInterface, Sequelize) {
// 使用 queryInterface 删除表
await queryInterface.dropTable('users');
}
};
我们在这个文件中,我们定义了一个Users
表,包含id
、username
、email
、password
、createdAt
、updatedAt
五个字段
这里面的每个字段我们都配置一些属性,例如id
字段,我们设置了primaryKey
属性,代表这个字段是主键,autoIncrement
属性,代表这个字段是自增的,allowNull
属性,代表这个字段可以为空,unique
属性,代表这个字段必须唯一
如果你想要了解更多的属性配置,可以查阅sequelize文档
接下来,我们需要跑一下这个migration
文件,执行以下命令
pnpm exec sequelize db:migrate
接下来,再查看数据库,就会看到我们刚刚创建的Users
表了
这个时候,我们的代码还不能直接和数据库进行交互,我们需要定义模型
我们需要在app/model
中创建一个Users.js
模型
注意,在创建表的时候表字段我们使用的是
snake
写法,但模型里我们使用的是camel
写法当我们在调用
sequelize
的方法时,模型会将字段camel
转为snake
写法
'use strict';
module.exports = app => {
const { STRING, INTEGER, DATE, NOW, ENUM } = app.Sequelize;
const RoleEnum = ENUM('admin', 'user');
const Users = app.model.define('Users', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
avatar: STRING(255),
role: RoleEnum,
username: STRING(30),
mobile: STRING(11),
email: STRING(30),
password: STRING(255),
lastLogied: {
type: DATE,
defaultValue: NOW,
},
createdAt: {
type: DATE,
defaultValue: NOW,
},
updatedAt: {
type: DATE,
defaultValue: NOW,
},
});
return Users;
};
这里我们定义了一个Users
模型,里面包含了我们刚刚定义的Users
表的字段
大家有没有发现,模型里面的属性和之前我们写在init-users文件中的属性是一样的 我现在是通过copy的方式将字段拷贝到model文件中,不知道是否有更好的方式,再更新数据库时直接更新模型的方式?
sequelize是有提供了
sync
方法,但是这个是通过模型同步到数据库结构,不好追踪变化记录,所以不使用这个方法了,而是使用migration的方式
接下来,如果我们想要对User表进行操作,我们可以使用app.model.Users
进行操作
创建用户
这里举一个创建用户的例子,来看看如何使用sequelize
进行数据库操作
在路由表中添加用户模块请求记录
// app/router.js
router.resources('users','/users', controller.users);
在控制器中添加创建用户的方法
// app/controller/user.js
const Controller = require('egg').Controller;
class UserController extends Controller {
async create() {
const { ctx } = this;
ctx.model.Users.create({
username:'_island',
email:'example@example.com',
password:'h9nUQ92B'
});
ctx.body = {
message:'创建成功'
};
}
}
module.exports = UserController;
接着,我们调用以下它,可以看到我们已经成功创建了一个用户
通过这个例子,相信大家已经对新增数据的操作有了一定的了解,那么如何对数据进行查询,修改数据,删除数据呢?
其实也很简单
查询所有用户
const Controller = require('egg').Controller;
class UsersController extends Controller {
async findAll() {
const users = await this.ctx.model.User.findAll();
this.ctx.body = users;
}
}
module.exports = UsersController;
查询特定条件的用户
class UsersController extends Controller {
// 忽略其他代码
async findByUsername() {
const user = await this.ctx.model.User.findOne({
where: {
username: '_island_',
},
});
this.ctx.body = user;
}
}
修改用户
const Controller = require('egg').Controller;
class UsersController extends Controller {
// 忽略其他代码
// 修改用户信息
async updateUser() {
const { ctx } = this;
const userId = ctx.params.id; // 获取用户ID
const updates = {
username: ctx.request.body.username,
email: ctx.request.body.email,
};
const [affectedCount] = await ctx.model.User.update(updates, {
where: { id: userId },
});
if (affectedCount === 0) {
ctx.status = 404;
ctx.body = { message: '用户未找到' };
} else {
ctx.body = { message: '用户信息更新成功' };
}
}
}
module.exports = UserController;
删除用户
class UsersController extends Controller {
// 忽略其他代码
// 删除用户
async deleteUser() {
const { ctx } = this;
const id = ctx.params.id; // 获取用户ID
const result = await ctx.model.User.destroy({
where: { id },
});
if (result === 0) {
ctx.status = 404;
ctx.body = { message: '用户未找到' };
} else {
ctx.body = { message: '用户删除成功' };
}
}
}
这里给大家简单展示了CRUD
操作,大家可以自己根据需求进行扩展
关于更多数据库操作,请移步sequelize文档
扩展补充
还记得我们上面配置了swaggar
吗,我们还需要编写一个JSON schema
来描述模型,这样我们就可以使用这个模型渲染swagger
文档中的类型了
我们在项目中创建app/schema/definitions/users.js
,该文件内容如下
'use strict';
module.exports = {
type: 'object',
properties: {
id: {
type: 'integer',
},
avatar: {
type: 'string',
maxLength: 255,
},
role: {
type: 'string',
enum: ['admin', 'user'],
},
username: {
type: 'string',
maxLength: 30,
},
mobile: {
type: 'string',
maxLength: 11,
},
email: {
type: 'string',
maxLength: 30,
},
password: {
type: 'string',
maxLength: 255,
},
last_login: {
type: 'string',
format: 'string',
},
created_at: {
type: 'string',
format: 'string',
},
updated_at: {
type: 'string',
format: 'string',
},
},
required: ['id', 'username', 'email', 'password', 'role'], // 确定哪些字段是必填的
// 表示不允许有 schema 中未定义的额外属性
additionalProperties: false,
};
最后
感谢大家的阅读学习,如果大家喜欢,请点个star
,谢谢
附上模板地址: github.com/QC2168/egg-…
该模板还是一个比较简单的模板,只是集成了一些最基础的常用功能,像用户模块之类的功能后续可能会添加上去,如果大家有什么建议可以在评论区或issues
中提出,欢迎大家提出宝贵意见,共同进步
转载自:https://juejin.cn/post/7401773394956681255