Egg.js & Nest.js 简易上手比较

·  阅读 785

今年二月份的时候忙于夜大的毕业设计,做了一个密钥管理生成的小项目。

image.png 当时的前端使用vue-cli + vue3实现,后端当时出于快速实现功能,用Egg.js开发了出来,最近出于学习的目的,学习了一下Nest.js,然后基于Nest.js我把之前做的后端的主要功能又用Nest.js实现了一遍。于是借着学习的契机回顾了一下Egg.js想写篇文章谈一下自己对这两个框架额使用感受。

主要实现思路

Egg.js基于Koa做的二次开发,Nest.js我用的是基于Express实现的版本,这二者的具体实现上还是有些差异,但整体上都是基于Controller - Service的实现思路。 可以先看看Egg.jsd的目录结构

image.png

Egg.js的实现思路较为简单,后端的所有功能最后都会绑定在全局变量app上,app就像一个巨大的容器,它包含了路由,控制器,服务,扩展,中间件等等,所有的东西都是全局的,router.js则充当了项目的主入口文件,在这里可以自行配置最终给前端调用的接口。

// router.js
const baseURL = '/api'

module.exports = app => {
  const {
    router,
    controller,
    middleware,
    config
  } = app;
  const jwt = middleware.jwt(config.jwt);

  router.get('/', controller.home.index);

  // 一些查询
  router.get(`${baseURL}/dict/roles`, controller.home.roles);
  ...

  // 登录
  router.post(`${baseURL}/login`, controller.login.login);

  // 首页
  router.get(`${baseURL}/dashboard/userroles`, jwt, controller.dashboard.userroles);
  ...
复制代码

这种模式确实会让开发变得简单,比如我需要获取请求头的token可以直接在app的全局变量中获取,但是这也会导致app的功能过于庞杂有一点臃肿。

Nest.js的目录结构大概是这样的

image.png

相比之下,Nest.js的结构可以说更清晰一些,main.ts作为项目的入口文件,而Nest比Egg多一项的概念则是模块,即Module - Controller - Service的结构,app.module.ts导入并整合各个模块,最终将这个大模块用于main.ts中

// app.module.ts
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MailerModule } from '@nestjs-modules/mailer';
import { PugAdapter } from '@nestjs-modules/mailer/dist/adapters/pug.adapter';
import { UsersModule } from './users/users.module';
...

@Module({
  imports: [
    SequelizeModule.forRoot({
      dialect: 'mysql',
      host: '127.0.0.1',
      port: 3306,
      username: 'root',
      password: '1234567',
      database: 'mykms',
      autoLoadModels: true,
      synchronize: true,
    }),
    MailerModule.forRoot({
    transport: {
        host: "smtp.qq.com",
        port: "465",
        auth: {
          user: "1534739331@qq.com",
          pass: "qaurwlgawczubace"
        }
      },
      defaults: {
        from: '陈晟 <1534739331@qq.com>',
      },
    }),
    UsersModule,
    DictModule,
    LoginModule,
    DashboardModule,
    ScheduleModule.forRoot(),
    ...
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
复制代码
// main.ts
import { NestFactory } from '@nestjs/core';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { AppModule } from './app.module';
import { AuthService } from 'src/auth/auth.service';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const refAuthService = app.get<AuthService>(AuthService);

  app.useGlobalInterceptors(new TransformInterceptor(refAuthService));
  app.useGlobalFilters(new HttpExceptionFilter(refAuthService));
  app.setGlobalPrefix('api');
  await app.listen(7001);
}
bootstrap();
复制代码

相较而言,Nest的思路和组织更为清晰,Nest的思路其实就是万物皆模块,如果一个功能用一个模块不能实现,那就再一个模块,Nest的另一个思路是功能皆装饰,这个会在后面再说。Nest这样设计的好处很明显,代码更容易组织,缺点就是如果控制不好会写的过于零散,除非自己设置,很多东西也不是全局的,比如token,我需要在每一个具体的请求中从具体的请求头获取,而Egg就可以从全局app中获取,这个见仁见智吧。

具体请求的编写

我以一个POST的请求为例,使用Egg的话请求接口的具体地址是在router.js中编写实现,同时,该请求需要token权限才能访问,那么Egg的思路是将token作为中间件,作为参数传到router.js中具体的post的请求上

// router.js
// 在全局变量中获取中间件和配置项
 const {
    router,
    controller,
    middleware,
    config
  } = app;
  // 生成中间件token
  const jwt = middleware.jwt(config.jwt);
  
  ...
  // 在具体的请求中添加token中间件
  router.post(`${baseURL}/users/add`, jwt, controller.users.create);
复制代码

然后,路由函数会调用controller对应的方法,controller会再从对应的service中找到对应的方法执行并返回结果

// controller/users.js
async create() {
    const {
      ctx,
      app
    } = this;
    const body = ctx.request.body;
    const result = await ctx.service.users.create(body);
    if (!!result.code && result.code != 200) {
      if (!body.id) {
        // 插入操作文档
        await this.ctx.service.home.addOperaLog({
          url: '/app/users/add',
          method: 'POST',
          action: '新增',
          status: 0
        });
      } else {
        // 插入操作文档
        await this.ctx.service.home.addOperaLog({
          url: '/app/users/update',
          method: 'PUT',
          action: '修改',
          status: 0
        });
      }

      this.result(null, result.code, result.success, result.message);
    } else {
      // 发送邮件
      const {
        roles
      } = await ctx.service.home.roles();
      let title = body.id ? '修改' : '创建';
      let rolesText = "";
      let userRoles = body.roles;
      userRoles.forEach(id => {
        for (let i = 0; i < roles.length; i++) {
          if (id == roles[i].code) {
            rolesText += `${roles[i].text}, `;
            break;
          }
        }
      });
      // console.log(body.email);

      const mailresult = await app.email.sendEmail(
        `账号${title}成功`,
        `您的账号已${title}成功`,
        `${body.email}`,
        [{
          data: `<html>
            <p>您的账户已${title}成功</p>
            <p>登录名:<b>${body.loginName}</b></p>
            <p>密码:<b>${body.password}</b></p>
            <p>角色:<b>${rolesText}</b></p>
            <p>如有疑问请联系管理员</p>
            </html>`,
          alternative: true
        }]
      );

      if (!body.id) {
        // 插入操作文档
        await this.ctx.service.home.addOperaLog({
          url: '/app/users/add',
          method: 'POST',
          action: '新增',
          status: 1
        });
      } else {
        // 插入操作文档
        await this.ctx.service.home.addOperaLog({
          url: '/app/users/update',
          method: 'PUT',
          action: '修改',
          status: 1
        });
      }

      this.result(mailresult);
    }
  }
复制代码
// service/users.js
 // 插入/修改一条数据
  async create(params) {
    const users = await this.app.mysql.select('users');
    let insertData = JSON.parse(JSON.stringify(params));
    insertData.roles = insertData.roles.join(",");
    insertData.hexPassword = getMd5Data(params.password);
    if (!params.hasOwnProperty('id')) {
      insertData.id = `user_${users.length}_${Math.random().toFixed(5)}`;
    }

    const findUsers = await this.app.mysql.select('users', {
      where: {
        loginName: params.loginName
      }
    });
    // console.log('findUsers: ', findUsers);

    if (params.hasOwnProperty('id')) {
      // 更新操作
      if (findUsers.length === 0 || (findUsers.length === 1 && findUsers[0].id === params.id)) {
        const result = await this.app.mysql.update('users', insertData);
        if (result.affectedRows === 1) {
          return {
            result
          };
        } else {
          return {
            code: 500,
            success: false,
            message: '更新失败'
          }
        }
      } else {
        return {
          code: 4001,
          success: false,
          message: '登录名重复'
        }
      }
    } else {
      // 新增操作
      if (findUsers.length > 0) {
        return {
          code: 4001,
          success: false,
          message: '登录名已存在'
        }
      } else {
        const result = await this.app.mysql.insert('users', insertData);
        if (result.protocol41) {
          return {
            result
          };
        } else {
          return {
            code: 500,
            success: false,
            message: '添加失败'
          }
        }
      }
    }
  }
复制代码

正如我之前所说,Nest是万物皆模块,所以Nest要想实现相应功能,需要先定义好users的模块,再将users的controller和service用于模块里并导出

// users/users.module.ts
...
@Module({
  imports: [
    SequelizeModule.forFeature([Users, Roles, Usertokens]), 
    MailModule,
    OperalogModule
  ],
  providers: [UsersService],
  controllers: [UsersController]
})
export class UsersModule {}
复制代码

那么Nest怎样实现同样地址的路由,这则需要在controller中实现,不同于Egg的路由有一个固定的方法,Nest引入了装饰器的思想,Controller作为路由的主入口代表第一级,而具体的Post/Get装饰器中定义的字符串为第二级。而关于权限的拦截也是基于装饰器的思想,使用UseGuards装饰器在具体路由的上方填入token进行拦截处理。

// users/users.controller.ts
import { Controller, Get, Post, Put, UseGuards, Request, Delete } from '@nestjs/common';
import { JwtAuthGuard } from 'src/auth/guards/jwt.guard';
import { UsersService } from './users.service';
import { OperalogService } from 'src/operaLog/operaLog.service';

@Controller('users') // controller装饰器里的users是路由名称的第一级
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly operalogService: OperalogService,
  ) {}
  
  ...
  
  // 新增用户
  @UseGuards(JwtAuthGuard) // 通过装饰器注入token拦截
  @Post('add') // Post装饰器表示该请求为Post亲请求里面的add表示第二级也就是/users/add
  async add(@Request() req): Promise<any> {
    const result:any = await this.create(req);
    if (result && result.code == 200) {
      await this.addUserLog(1, req);
    } else {
      await this.addUserLog(0, req);
    }
    return result;
  }
}
复制代码

我们可以看到这里Egg和Nest的思想不同处,Egg基于全局通过中间件对事物进行处理Nest基于具体模块通过装饰器对事物进行处理

其他异同

数据的返回问题

通常情况下,我们会对数据库查询的内容包装一层再返回给前端,这里Egg和Nest的处理思路有很大不同,Egg在controller通过ctx.result返回,如果在service中强行赋值一个自己给的错误码则可以修改数据返回的状态码,如之前举例中的users.service错误码赋值500是可以返回回来的,平常情况下则可以自定义一个基础的BaseController返回包装的数据结构。

// controller/base.js
const Controller = require('egg').Controller;

class BaseController extends Controller {
  result(data, code = 200, isSuccess = true, message = '操作成功') {
    this.ctx.body = {
      code: code,
      success: isSuccess,
      message: message,
      data
    }
  }
}

module.exports = BaseController;
复制代码

Nest修改状态码则不够灵活,只能通过装饰器@HttpStatus修改,但是在一些全局处理上这样修改很复杂,比如token失效我希望状态码变成401这样就很难,我看了文档也没找到合适的办法,最终只能采取折中方案,在自定义的返回中写入一个状态码,前端以这个自定义的状态码为准进行判断,实际上之前我也是这么写的。

token的生成

token这里Egg和Nest的思路也是不一样的,Egg有专门的生成token的插件,利用插件生成token后在全局使用,生成之后可以使用一个中间件作为拦截,判断当token失效以后返回401

// middleware/jwt.js
module.exports = options => {
  return async function jwt(ctx, next) {
    const token = ctx.request.header.authorization;
    let decode;
    if (token) {
      try {
        // 解码token
        decode = ctx.app.jwt.verify(token, options.secret);
        const user = await ctx.app.mysql.get('usertokens', {
          loginName: decode.loginName
        });

        if (token == user.token) {
          await next();
        } else {
          ctx.status = 401;
          ctx.body = {
            message: '该账户已在其他设备登录',
          };
          return;
        }
      } catch (error) {
        if (error.message == 'jwt expired') {
          ctx.status = 401;
        }
        ctx.body = {
          message: error.message,
        };
        return;
      }
    } else {
      ctx.status = 401;
      ctx.body = {
        message: '没有token',
      };
      return;
    }
  };
};
复制代码

Nest则已经秉持模块的思想,可以定一个名为auth的模块,叫其他名字也行,在该文件夹下使用官方的方法生成token并引入到auth的service中使用,最后将token作为拦截器在具体的接口之上用拦截装饰器导入

// auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { secret } from 'src/common/conmstr';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: secret,
    });
  }

  async validate(payload: any) {
    return { token: payload.token }
  }
}

复制代码

那要怎样在token失效后返回错误消息退出呢?我目前做到的也就是我之前提到的定义一个全局拦截器,在失效之后返回自定义的401错误码交由前端处理。

// /common/interceptors/transform.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  CallHandler,
  ExecutionContext, 
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { AuthService } from 'src/auth/auth.service';
import { secret } from '../conmstr';

interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  constructor(
    private readonly authService: AuthService,
  ) {}

  async intercept( context: ExecutionContext, next: CallHandler<T>): Promise<Observable<Response<T>>> {
    const request = context.switchToHttp().getRequest();
    const path = request.route.path;
    let hasToken = true;
    const mesData = {
      code: 200,
      message: path === '/api/login' ? '登录成功' : '请求成功',
      success: true,
    }

    // 获取并解析token
    if (request.headers.authorization) {
      const usertokens = await this.authService.findUsertokens();
      const token = (request.headers.authorization.split(' '))[1]; // request.headers.authorization;
      const decode = this.authService.verifyToken(token, secret);
      const user = usertokens.find((item) => { return item.loginName == decode.loginName });

      if (user.token !== token) {
        hasToken = false;
      }
    }

    return next.handle().pipe(
      map((data: any) => {
        if (!hasToken && path != '/api/login') {
          data = null;
          mesData.message = '该账户已在其他设备登录';
          mesData.code = 401;
          mesData.success = false;
        } else if (!hasToken && path === '/api/login') {
          Object.keys(data).forEach(key => { mesData[key] = data[key] });
        } else if (hasToken && data && data.code) {
          Object.keys(data).forEach(key => { mesData[key] = data[key] });
        } else if (!data) {
          mesData.message = '请求失败';
          mesData.code = 1;
          mesData.success = false;
        } 

        return {
          data,
          ...mesData
        };
      }),
    );
  }
}
复制代码

目前有一个问题我不知道怎么处理,就是Egg生成的token,前端请求时Authrotion不需要携带"Bearer",但是Nest生成的token前端必须要携带"Bearer"。这也是前端部分我唯一修改的一处代码,我查了很多资料,目前没想到合适的办法让Nest的请求和Egg保持一致。

// 前端 /api/axios.js
...
// 全局拦截添加token (Egg.js不需要Bearer, Nest.js需要Bearer, 我目前不知道怎样在后端解决这个问题)
if (store.state.token != "") {
  config.headers.common['Authorization'] = 'Bearer ' + store.state.token
  // config.headers.common["Authorization"] = store.state.token;
}
...
复制代码

关于数据库

数据库我使用MySql,Egg操作数据库非常简单,Egg本身就可以找到mysql的数据库插件,大部分的查询和新增操作利用插件功能可以完成,小部分的查询编写sql可以实现,例如keys.js下的审核和查询等

// service/keys.js
...
// 审核
  async audit(params) {
    const {
      app
    } = this;
    let subData = {
      id: params.id,
      status: params.result,
      reason: params.reason,
      auditDate: app.mysql.literals.now
    }

    const result = await this.app.mysql.update('theKeys', subData); // 利用Egg自带的插件更新

    if (result.affectedRows === 1) {
      return {
        result
      };
    } else {
      return {
        code: 500,
        success: false,
        message: '更新失败'
      }
    }
  }

  // 用户可操作性密钥
  async userKeys(params) {
    const user = await this.app.mysql.get('users', {
      loginName: params.loginName
    });
    // console.log('user: ', user);
    let sql = `select id,keyName from theKeys where keyUser like "%${user.id}%" and status=2`;
    const list = await this.app.mysql.query(sql);
    return list;
  }
复制代码

但是Nest的数据库操作就稍微复杂了一点,Nest没有有个比较官方的方案,在Mysql上目前可以配合官方插件使用第三方库TypeORM或者Sequelize操作数据库,我这里使用的是Sequelize,但不管使用哪个库,nest都不能直接查询或修改数据,而是要为每一个表建立相应的数据模型,再在不同的模块service下通过模型操作数据。比如,首先我们先定义用户数据表的模型

image.png

// common/models/users.model.ts
import { Column, Model, Table } from 'sequelize-typescript';

@Table({
  tableName: 'users',
  timestamps: false,
  freezeTableName: true
})
export class Users extends Model<Users> {
  @Column({ primaryKey: true })
  id: string;

  @Column
  loginName: string;

  @Column
  userName: string;

  @Column
  password: string;

  @Column
  roles: string;

  @Column
  email: string;

  @Column
  hexPassword: string;
}
复制代码

然后在需要使用模型的module和service里分别引入,module需要在impots里声明使用了该模型

import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
...

@Module({
  imports: [
    SequelizeModule.forFeature([Users]),  // 表示使用了Users模型
    ...
  ],
  ...
})
export class UsersModule {}

复制代码

在对应的servcice里再次引入模型,并在constructor定义模型变量并具体使用

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { Users } from 'src/common/models/users.model';
...

@Injectable()
export class UsersService {
  constructor(
    ...
    @InjectModel(Users)
    private readonly usersModel: typeof Users, // 定义具体的使用变量
  ) {}
  
  ...
  
  // 修改密码
  async editPassword(params) {
    const user = await this.usersModel.findOne({ where: { id:  params.userId } });
    if (user.password !== params.oldPass) {
      return {
        code: 500,
        success: false,
        message: '旧密码有误'
      }
    } else {
      let insertData = {
        id: params.userId,
        password: params.newPass,
        hexPassword: getMd5Data(params.newPass)
      }

      const result = await this.usersModel.update(insertData, { where: {id: insertData.id} });
      const userEditer = await this.usersModel.findOne({ where: { id:  params.userId } });
      console.log('userEditer: ', userEditer);

      if (result && result[0] === 1) {
        await this.sendMail(userEditer);
        return {
          code: 200,
          success: true,
          message: '密码修改成功'
        }
      } else {
        return {
          code: 500,
          success: false,
          message: '更新失败'
        }
      }
    }
  }
  
}
复制代码

也就是说,如果想对数据库的具体数据进行操作,前提是必须要事先实现对应的数据模型,这一点倒是比Egg相对严谨一些。

小结

以上就是我对Egg和Nest分别开发相同项目的一些比较感受,在我看来Egg的思想是便捷,一个像我这样对后端不怎么了解的开发者只要按照文档,使用对应的插件就可以开发出一个还不错的后端项目。而Nest则更加强调规范,严格的三层架构,基于模块的思想和装饰器的广泛使用,确保了开发者能编写出通用的代码,但是对于开发者对项目的组织能力也有一定的要求。如果是一个长期维护的后端项目,显然当下比较流行的Nest.js还是更胜一筹的。

分类:
后端
标签:
分类:
后端
标签: