likes
comments
collection
share

EggJS系列 | 开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R

作者站长头像
站长
· 阅读数 47

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' });
  }
}

如果校验有误,它会直接返回422http状态码和具体的错误信息来告诉前端调用者、

这样子我们的参数校验功能就完成了,是不是很简单?

模型参数校验

如果你的项目需要引入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文件夹中创建类似utilscommon/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的能力

具体请阅读How to read environment variables from Node.js

安装后,我们如果需要定义环境变量的话,可以在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,即可以看到我们刚刚定义的接口文档了

EggJS系列 |  开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R

如果您还想配置更多swagger-egg信息,可以查阅文档JsonMa/swagger-egg

ORM

全称Object Relational Mapping,是一种为了解决面向对象与关系数据库存在的互不匹配的现象的技术

传统开发中,我们可能会写很多重复的数据库SQL语句,如果使用ORM方式进行开发,我们可以节省很多的重复语句,并且可以更好的进行数据模型的管理

EggJS系列 |  开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R

我们可以使用sequelize来管理我们的数据库,它提供了非常丰富的功能,可以满足我们的需求

eggjs官方已经将sequelize封装成了一个插件,更方便我们开发者进行开发调用

这里我们采用的是mysql数据库,所以需要安装mysql2插件

如果你使用的是其他数据库,请自行安装对应的插件

pnpm i egg-sequelize sequelize-cli mysql2
# pnpm i pg pg-hstore # PostgreSQL

plugin.jsconfig.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表,包含idusernameemailpasswordcreatedAtupdatedAt五个字段

这里面的每个字段我们都配置一些属性,例如id字段,我们设置了primaryKey属性,代表这个字段是主键,autoIncrement属性,代表这个字段是自增的,allowNull属性,代表这个字段可以为空,unique属性,代表这个字段必须唯一

如果你想要了解更多的属性配置,可以查阅sequelize文档

接下来,我们需要跑一下这个migration文件,执行以下命令

pnpm exec sequelize db:migrate

接下来,再查看数据库,就会看到我们刚刚创建的Users表了 EggJS系列 |  开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R EggJS系列 |  开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R

这个时候,我们的代码还不能直接和数据库进行交互,我们需要定义模型

我们需要在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;

接着,我们调用以下它,可以看到我们已经成功创建了一个用户

EggJS系列 |  开箱即用的开发模板 大大提高效率Egg.js模板集成了错误处理、JWT验证、工具函数、路由管理、R

通过这个例子,相信大家已经对新增数据的操作有了一定的了解,那么如何对数据进行查询,修改数据,删除数据呢?

其实也很简单

查询所有用户

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
评论
请登录