peng-yin / note

:shipit: This is My GitHub Pages
https://note-seven-rho.vercel.app/
8 stars 0 forks source link

egg.js vs nest.js && 原理 #120

Open peng-yin opened 1 year ago

peng-yin commented 1 year ago

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的框架主要有以下几个:

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

nest.js

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

术语介绍:

基础概念

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

Module

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

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

Controller

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

支持写法:

Provider

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

写法:

Middleware

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

写法:

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');
  }
}
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,你也可以基于这个去自定义自己的异常过滤器,具体如下图:

写法:

Pipe

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

写法:

Guard

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

写法:

Interceptor

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

写法:

其他

PS: 装饰器是什么?

// 这是一个装饰器工厂——有助于将用户参数传给装饰器声明
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的生命周期分为三个阶段:初始化、运行和终止,下图详细生命周期的各个子阶段:

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

上手实战

第一步安装:

$ 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

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

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

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

IoC

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

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

DI

DI,Dependency Injection,依赖注入

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

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

问题

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

JavaScript的Reflect

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

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

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

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

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

简单理解这个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: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装饰器
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的核心实现原理:

分层

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

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

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

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

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

peng-yin commented 11 months ago

市面上 NodeJS 的服务端框架有很多,如Koa、Express、EggJS、Midway等,它们功能都很强大,也有很好的生态,插件非常丰富,为什么还需要Nest呢?

如果是一个简单的应用,其实用什么框架都无所谓,一个框架用 100 行代码实现,另一个用 80 行,区别不大。但涉及到企业级的应用,分分钟有上万行的代码,代码的组织结构就变得很重要了。如果代码拆分不合理,一个 JS 文件就有上千行的代码,后期的维护成本会非常的高。再考虑到复杂项目参与者众多,没有一个规范去约束的话,每个人写出来的代码风格迥异,协作起来会很难受。上文提到的几个框架对项目代码的架构要么是没约束,要么就是约束比较弱或者看起来很别扭。相比之下Nest的实现就很简洁,用起来很顺手。具体细节将在下文进行描述。

Nest还通过依赖注入的形式实现了控制反转,只要声明模块中的依赖,Nest就会在启动的时候去创建依赖,然后自动注入到相应的地方。依赖注入最大的作用是代码解耦,依赖的对象根据不同的情况可以有多种实现,如单元测试的时候可以在不改业务代码的情况下将依赖的对象换成 Mock 数据。

Nest还践行了面向切面编程的思想,除了Middleware外,还有Exception Filter、Pipes、Guards和Interceptors几个预定义的切面,可以集中进行异常处理、数据验证、权限验证和逻辑扩展等功能。Nest自带如数据验证等一些常用的基于切面的功能,也可以通过继承的方式来进行扩展。这些预定义的切面是代码架构的组成部分,按照这些约定来组织代码会大大降低日后的维护成本。

类型系统是后端开发很重要的一环,Nest是使用TypeScript实现的框架,因此原生就支持TypeScript,而且还大量使用了注解,熟悉 Spring 的朋友会感到十分亲切。

另外,Nest是基于Express实现的,需要的话可以取到底层的对象,如request和response

peng-yin commented 8 months ago

对于接触新的技术,一上来撸文档或看视频,是一种低效并且不持久的学习方式,零碎的知识点就像是每一个神经元,相互没有连接成网络最终会形成一盘散沙,大脑并不擅长处理这种结构。而要多方面去渗透理解,如发展历史,着重解决哪些问题,相比其他类似技术,优势在哪?从这些角度入手,容易形成自己的知识框架。例如,在node生态中,express解决了什么问题?之后为什么又诞生孪生兄弟koa,两者有什么区别?后起之秀nest凭借什么能够脱颖而出,成为目前最流行企业级框架之一?按照这个逻辑链,梳理形成属于自己的知识体系。