Skip to content

egg.js vs nest.js 原理 #120

Description

@peng-yin

egg已经不太满足的开发效率和开发模式,主要有以下几点:

对typescript支持度不够,这是由于egg.js本身就不是typescript开发
egg.js封装web架构,约定大于编码,如:强制将web应用分级为: controller、service、middleware、extend等,自由度相对比较弱,当你需要定制化开发内容,你需要深入了解egg.js的整个运行原理才能实现
虽然部门内部定制化开发 @controller @service等注解,减少路由配置,但是这一块插件还存在一些隐藏规则,需要开发注意
当然egg.js运行的web应用还是比较稳定,而且相关插件生态也比较丰富,只是当egg.js迭代更新速度在2020年后就逐步放缓,更不上变化,我们就需要迎接一些新的框架来满足要求。

框架对比

我从近两年听到或者网上收集的,基于Node.js的框架主要有以下几个:

  • 基础框架,基本上还是以express、koa、Fastify.js等为主
  • egg.js,以MVC为架构的web框架
  • nest.js,以Ioc 控制反转作为核心概念的web框架,对typescript支持友好
  • nuxt.js,以Vue.js作为SSR服务端渲染核心的web框架,最新是Nuxt3(以Vue3为核心)
  • next.js,以React.js作为SSR服务端渲染核心的
  • Meteor.js,full-stack javascript平台,最大的特点是当数据发生改变的时候,所有依赖该数据的地方自动发生相应的改变。
  • Fastify.js,号称最快的node.js web框架,特点是内置了基于 JSON schema 的 validation 和 serialization,比JSON.stringify还快的json序列化算法,虽然是借助借助第三方库 ajv。
  • strapi.js,快速生成API接口的web框架,同时实现各种后端所需要的鉴权、权限、文件上传等轮子

对比一下,我们主要用来开发后端api接口,不需要SSR,不需要过于重或过于轻量的框架,因此最后挑选了nest.js。

nest.js

Nest (NestJS) 是一个用于构建高效、可扩展的Node.js服务器端应用程序的框架。它使用渐进式 JavaScript,构建并完全支持TypeScript(但仍然允许开发人员使用纯 JavaScript 进行编码)并结合了 OOP(面向对象编程)、FP(函数式编程)和 FRP(函数式响应式编程)的元素。

术语介绍:

  • 什么是渐进式?简单说,就一开始你不需要了解它的全部功能,能快速上手,有些功能特性不用也可以正常使用。
  • OOP 面向对象编程,万物皆可用对象来描述,如: class Dog{ say(return 'one one!')}
  • FP 函数式编程,以函数作为入口,而不是去声明一个对象类,如: say('one one!')
  • RP 响应式编程,一种面向数据流和变化传播的编程范式,如:a = 5; b=6; c=a+b; ab变化的时候,c会随之变化
  • FRP 函数式响应式编程,依赖数据流的函数式编程,如:str='one one~'; say(str), 当str变化,会自动触发say

基础概念

Nest.js的核心是基于IoC控制反转 + DI 依赖注入 去实现类的声明和实例化的。如果你了解过Spring Boot其实很容易上手nest.js。

Module

Module 其实是nest.js用来将一个web应用拆分成各个子模块的分类规定,web应用根模块一般叫app.module.ts,官方设计图如下:

Module应该由以下几个部分组成:

  • providers: 允许交给模块实例化的类,包括不限于Service等
  • controllers:必须实例的controller类
  • imports: 模块依赖其他模块
  • exports:模块对外提供的方法类

Controller

Controller就一个作用,分割路由,调用处理方法,返回http请求结果。

支持写法:

  • @Controller('test')
  • @Get() @Post() @Put() @Del()代表各种请求方法(http Method)
  • 还支持一些特殊写法: @Session() @Body(key?: string) @Param(key?: string)

Provider

Provider其实就是不仅仅是Service层,还包括:Sql的Dao层、工具方法等提供。它和其他层关系如下图:

写法:

  • @Injectable() 声明该类是一个Provider,允许其他类实现依赖注入
  • @Optional() 允许构造不传
  • @Inject() 自动依赖注入

Middleware

Middleware中间件,其实和egg.js的中间件概念一样,就是当http请求来了之后,被中间件处理一遍之后才会到对应的Controller层。

写法:

  • implements NestMiddleware,必须实现NestMiddleware接口,以及内部方法use(req: Request, res: Response, next: NextFunction),同时内部方法必须调用next
  • Module层注册中间件,这里需要可以设置
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • 了解 MiddlewareConsumer,中间件消费者工具类,主要把中间件加上一些配置项功能,如:forRoutes支持路由匹配,exclude不包含路由
  • 函数式声明中间件,因为Middleware是基于expres,所以写法与express基本上一致
import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
};

ExceptionFilter

Filter过滤器,这个应该是所有web框架都具备的功能,拦截用户请求和web返回数据。在Nest.js中,只实现ExeptionFilter,你也可以基于这个去自定义自己的异常过滤器,具体如下图:

写法:

  • @Catch(HttpException) class HttpExceptionFilter implements ExceptionFilter 实现自定义异常过滤器
  • @UseFilters(new HttpExceptionFilter()) 能给具体接口包裹上一层自定义的异常过滤器

Pipe

Pipe 管道流,是指的Http请求里的内容数据流,它支持数据验证、数据转换等功能,有点类似Filter的功能。

写法:

  • @Param('id', ParseIntPipe) id: number,将参数id转换为number类型
  • class ValidationPipe implements PipeTransform自定义 Pipe,同时必须实现方法transform(value: any, metadata: ArgumentMetadata)
  • @UsePipes(new Pipe()),支持在controller配置自定义的Pipe

Guard

Guard 守卫,也是处Http请求中的一层特殊中间件,但是与中间件不同的时候,中间件不知道next()是去哪个执行代码,而Guard则可以获取ExecutionContext实例,可以获知整个请求的生命周期和内置内容,通常用来接口登录和权限控制。

写法:

  • class AuthGuard implements CanActivate
  • 必须实现方法canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>
  • 注册使用:@UseGuards Controller层使用, app.useGlobalGuards(new RolesGuard()); 全局注册

Interceptor

Interceptor是面向切面编程理念影响的概念,它允许你在方法执行前后扩展原有函数功能,如:改变返回结果,扩展基本功能等,常用的场景:添加常规日志。

写法:

  • class LoggingInterceptor implements NestInterceptor,自定义实现
  • intercept(context: ExecutionContext, next: CallHandler): Observable<any>方法实现,同时需要返回对应结果next()
  • 注册使用:@UseInterceptors(LoggingInterceptor)可以在类或方法前进行注册, app.useGlobalInterceptors(new LoggingInterceptor());全局注册

其他

  • 自定义参数装饰器createParamDecorator,可以从request对象中抽取固定的参数。
  • applyDecorators 可以将多个装饰器 方法合在一起验证,然后形成一个新的注装饰器

PS: 装饰器是什么?

  • 装饰器在JavaScript中暂时是没有,只有TypeScript才可以实现一种语法糖
  • 装饰器使用 @expression 的形式,其中 expression 必须能够演算为在运行时调用的函数,其中包括装饰声明信息。
  • 自定义装饰器代码如下:
// 这是一个装饰器工厂——有助于将用户参数传给装饰器声明
function f() {
  console.log("f(): evaluated");
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("f(): called");
  }
}

function g() {
  console.log("g(): evaluated");
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("g(): called");
  }
}

class C {
  @f()
  @g()
  method() {}
}

// f(): evaluated
// g(): evaluated
// g(): called
// f(): called

生命周期

Nest.js的生命周期分为三个阶段:初始化、运行和终止,下图详细生命周期的各个子阶段:

允许监听的生命周期函数:

  • onModuleInit(), 模块初始化时候调用
  • onApplicationBootstrap(),所有模块都准备好了,但是在web应用正式启用前会被调用
  • onModuleDestroy(),模块准备被停止
  • beforeApplicationShutdown(), web应用准备被停止之前
  • onApplicationShutdown(),web应用被停止之后,在进程退出之前

上手实战

第一步安装:

$ npm i -g @nestjs/cli
$ nest new project-name --strict

生成项目结构

src
|-- app.controller.spec.ts // controller层的单元测试
|-- app.controller.ts // controller层 控制路由接口层
|-- app.module.ts // 应用根模块
|-- app.service.ts // service层 给controller提供各种业务处理方法
|-- main.ts // 入口文件

运行

$ yarn
$ yarn start:dev

打开 http://localhost:3000 就可以访问了。

前置知识

在了解实现原理之前有几个知识概念,需要了解一下:

  • IoC和DI
  • JavaScript的Reflect
  • TypeScript的装饰器

IoC和DI

IoC和DI其实同属于一个技术理念,下面维基百科的介绍:

IoC,控制反转(英语:Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称DI),还有一种方式叫“依赖查找”(Dependency Lookup)。

简单的说IoC是一个开发代码的设计原则,DI则是实现这个设计原则的方案。

IoC

从代码层上来讲解IoC,简单的说就是:

  • Class A中用到了Class B的对象b,一般情况下,需要在A的代码中显式地用 new 创建 B 的对象。
  • 使用IoC设计原则后,A 的代码只需要定义一个 private 的B对象,不需要直接 new 来获得这个对象,而是通过相关的容器控制程序来将B对象在外部new出来并注入到A类里的引用中。
  • IoC将采用依赖注入或依赖查找两种方案去实现

再通俗一点,就是有一个IoC容器管家,负责你开发的代码类的归置,你只管使用代码类,不用管它放在哪里,只需要调用即可。

DI

DI,Dependency Injection,依赖注入

依赖注入是被动的接收对象,在类A的实例创建过程中即创建了依赖的B对象,通过类型或名称来判断将不同的对象注入到不同的属性中
依赖查找是主动索取相应类型的对象,获得依赖对象的时间也可以在代码中自由控制

简单的说,就是依赖注入是将需要注入的对象完全交给框架去实现,而依赖查找则是开发者通过框架提供的方法,由自己控制需要注入的时间点。

问题

采用IoC和DI,需要注意的问题是:

  • 循环依赖,就是A依赖B,B依赖A,如何避免这种情况发生,或者框架提供什么样的方案去避免?
  • 如果依赖的类越来越多,会不会导致项目启动速度变慢,因为需要初始化类很多,尤其当遇到一些类初始化可能会错误,但其实是可以忽略的?
  • 初始化类的顺序如何控制,如:A依赖B,需要B实例化后才能实例?

JavaScript的Reflect

Reflect在MDN网站是这么解释的:

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers (en-US)的方法相同。Reflect不是一个函数对象,因此它是不可构造的。
其中的一些方法与 Object 相同,尽管二者之间存在某些细微上的差别。

按照前端开发者理解来说,Reflect能解决开发中遇到很多this的代理问题,虽然大部分方案都可以通过其他方式解决,但是Reflect的定义能帮助我们快速实现这些功能。

Reflect符合ES6标准的提供的API有如下几个:

  • Reflect.apply(target, thisArgument, argumentsList),和 Function.prototype.apply(thisArgument, argumentsList) 功能类似,也是调用函数,且允许将函数的this指向thisArgument
  • Reflect.construct(target, argumentsList[, newTarget]),new一个target,且可以将target的this的指向新的newTarget对象
  • Reflect.defineProperty(target, propertyKey, attributes),拦截target对象的操作,和 Object.defineProperty() 类似
  • Reflect.deleteProperty(target, propertyKey),作为函数的delete操作符,相当于执行 delete target[name]。
  • Reflect.get(target, propertyKey[, receiver]),获取target的属性值,和target[name]的区别在于可以receiver,可以指定调用属性值的时候this
  • Reflect.getOwnPropertyDescriptor(target, propertyKey),类似于 Object.getOwnPropertyDescriptor()。如果对象中存在该属性,则返回对应的属性描述符,否则返回 undefined。
  • Reflect.getPrototypeOf(target),返回指定对象的原型(即内部的 [[Prototype]] 属性的值)
  • Reflect.has(target, propertyKey),判断一个对象是否存在某个属性,和 in 运算符 的功能完全相同
  • Reflect.ownKeys(target),返回一个包含所有自身属性(不包含继承属性)的数组。(类似于 Object.keys(), 但不会受enumerable 影响).
  • Reflect.isExtensible(target), 判断一个对象是否可扩展(即是否能够添加新的属性)
  • Reflect.preventExtensions(target),阻止新属性添加到对象
  • Reflect.set(target, propertyKey, value[, receiver]),将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true。
  • Reflect.setPrototypeOf(target, prototype),可设置对象的原型,即内部的 [[Prototype]] 属性)为另一个对象或 null,利用原型链用来强制给某个对象增加额外方法

当然还有一些没有进入标准,但是在ES7提案的方法Reflect Metadata(Typescript已实现),后面Nest.js已采用的方法,主要有以下几个:

  • Reflect.getMetadata(metadataKey, target, propertyKey), 用于获取某个类的元数据
  • Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);, 用于设置某个类的元数据

简单理解这个api方法,你可以通过Reflect.defineMetadata获取到类或者函数的参数类型,也可以给类或者函数设置元数据再获取,具体代码如下:

function Prop(): PropertyDecorator {
  return (target, key: string) => {
    const type = Reflect.getMetadata('design:type', target, key);
    console.log(`${key} type: ${type.name}`);
    // other...
  };
}

class SomeClass {
  @Prop()
  public Aprop!: string;
}

TypeScript 的优势了,TypeScript 支持编译时自动添加一些 metadata 数据,如下所示:

  • Reflect.getMetadata("design:type", target, key), 获取target函数类型
  • Reflect.getMetadata("design:paramtypes", target, key), 获取target函数参数类型
  • Reflect.getMetadata("design:returntype", target, key), 获取target函数返回值类型

这个Reflect.getMetadata("design:paramtypes", target, key)基本上就是Nest.js实现Ioc和DI的核心代码。

TypeScript的装饰器

装饰器(Decorators)为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。
装饰器是一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。

如何实现一个装饰器呢?

如果我们要定制一个修饰器如何应用到一个声明上,我们得写一个装饰器工厂函数。 装饰器工厂就是一个简单的函数,它返回一个表达式,以供装饰器在运行时调用。

function color(value: string) { // 这是一个装饰器工厂
    return function (target) { //  这是装饰器
        // do something with "target" and "value"...
    }
}

@color('blue')
function say(){
    ....
}

IoC和DI实现原理

其实在了解完Reflect.getMetadata,我们就大概知道IoC和DI的实现原理,我们以一个@Controller为例, 具体步骤如下:

  • 实现@Controller装饰器工厂,标识待注入的类
  • 实现IoC容器,注册要被依赖注入的类
  • 获取待注入的类其构造函数所需要的参数类型,并实例化,返回待注入的类

具体代码如下:

// 实现Controller装饰器
function Controller(path: string){
    return function(target){
        Reflect.defineMetadata('Controller', path, target);
    }
}

// 需要依赖注入的类
class A(){
    say(){
        console.log('aaaaaa');
    }
}

// 引用
@Controller("/api")
class Demo(){
    construtor(a: A ){
        this.a = A;
    }

    say(){
        this.a.say();
    }
}


// 实现Ioc容器和DI依赖注入
class Container {
  provides = new Map()
  
  // 注册要被依赖注入类,形成IoC容器 后续可以做
  addProvide(provider) {
    this.provides.set(provider.name, provider)
  }
  // 注入依赖类
  inject(target) {
      // 获取参数类型
      const paramTypes = Reflect.getMetadata('design:paramtypes', target) || []
      const args = paramTypes.map((type) => {
        return new type() // 简单做一下实例化
      })
      return Reflect.construct(target, args)
  }
}

const container = new Container()
const project = container.inject(Project)

// project就是最终生成返回使用的类

project.say(); // 输出 aaaaaa

所以Nest.js实现IoC和DI的核心实现原理:

  • 通过装饰器给 class 或者对象添加 metadata
  • 运行的时候通过这些元数据来实现依赖的扫描,对象的创建等等功能

分层

nestJS经常被调侃为srpingJS,所以这里参考java项目的阿里分层规范,其架构图如下:

  • 开放接口层:可直接封装Service方法暴露成RPC接口;通过Web封装成http接口;进行网关安全控制、流量控制等。
  • 终端显示层:各个端的模板渲染并执行显示的层。当前主要是velocity渲染,JS渲染,JSP渲染,移动端展示等。
  • Web层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。
  • Service层:相对具体的业务逻辑服务层。
  • Manager层:通用业务处理层,它有如下特征:

对第三方平台封装的层,预处理返回结果及转化异常信息;
对Service层通用能力的下沉,如缓存方案、中间件通用处理;
与DAO层交互,对多个DAO的组合复用。

  • DAO层:数据访问层,与底层MySQL、Oracle、Hbase等进行数据交互。
  • 外部接口或第三方平台:包括其它部门RPC开放接口,基础平台,其它公司的HTTP接口。

不同的业务场景,不同的应用大小,程序复杂度高低,可以灵活的增删上述某些结构。无论是nest还是egg,官方demo里都没有明确提到dao层,直接在service层操作数据库了。这对于简单的业务逻辑没问题,如果业务逻辑变得复杂,service层的维护将会变得非常困难。业务一开始一般都很简单,它一定会向着复杂的方向演化,如果从长远考虑,一开始就应该保留dao层,在nestJS中并未查看到相关规定,可根据开发者场景自行考虑。如下是nestJS的分层架构图:

对于Web层:在nestJS中,如果使用restful风格,就是controller;如果使用graphql规范,就是resolver...对于同一个业务逻辑,我们可以使用不同的接口方式暴露出去。
经常被问到和提起的问题就是为什么需要有service层:

首先service作用就是在里面编写业务逻辑代码,一般来说,都是为了增加代码复用率,实现高内聚,低耦合等...
体现在这里的好处就是上述提到的同一段业务代码可以使用不同的接口方式暴露出去,或者可以在一个service内调用其他service,而非在一个接口函数里面调用另外一个内部接口,这是极其不优雅的。
当然,老生常谈的就是不同功能目的的代码分开写方便维护管理等等

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions