Nest入门前需知的基础概念

发布于:2024-05-03 ⋅ 阅读:(24) ⋅ 点赞:(0)

前言

不知从什么开始KoaExpress甚至Egg.js都已经不再是前端开发者首选的Node.js服务端框架了,转而框架Top榜变成了NestMidway之类的服务端框架了。

古早时期,刚学KoaExpress的时候,就觉得原来写服务端这么简单,但是自己一旦进行开发,写出来的代码是乱糟糟的,毫无架构设计可言,并且Koa或者Express投入实际的生产中还是比较单薄的,并且开发过程中可能还需要一堆中间件和插件组合开发,所以这类框架算不上企业级的框架。

后面开始学习和使用Egg.js,这或许也是大家使用的第一个企业级的Node.js服务端。其以koa为底层进行封装,把常用的中间件和三方工具集成起来,基本做到开箱即用,并且Egg.js的各种约定设计,也让我们项目的可维护性更高。但是我们这种写习惯函数(没办法,JS就是万物皆函数)的前端开发者来说,需要开始写Class,开始面向对象编程,确实还是有一定成本的。

现在随着TypeScript的普及,Egg.js也在慢慢落幕,新型Node.js服务端框架更加强大,例如NestMidway都是构建于TypeScript之上,并且这些框架中大量的使用依赖注入和模块化设计使得组件之间的耦合度降低,提升项目的可维护性。听着很酷,但是酷的代价就是它们带来了一堆可能前端很少接触或者没接触到的设计模式和编程概念,例如装饰器、反射、控制反转和依赖注入等等。这让学习成本直线上升。

估计大家第一时间看Nest的文档,头是炸的,说实话这些技术和概念其实和前端还是有些许割裂的,真想说一句学不动了,如果写过SpringBoot的同学会发现,这些框架除了JS那点语法之外,跟用JavaSpringBoot写服务端几乎没有区别了。所以如果是一个后端的开发者,几乎可以无缝接手 “前端开发者”NestMidway写的后台项目。

而本文主要从通过讲解装饰器、反射、控制反转、依赖注入的概念,来理解Nest这类新兴框架为什么要这么设计?

装饰器

装饰器这个东西对前端来说好像忽远忽近的,近是因为我记得他一直在ECMAScriptTypescript的提案中,并且我们可能react-redux等各种三方库中使用过。说远呢,是因为在其他的语言(JavaPython)早就存在了,通常运用于类和类的成员变量或函数中,虽然ES6推出了class(底层实现其实还是函数,只是语法糖而已),但在函数是第一公民的JavaScript中,我们前端开发人员对面向对象的设计模式总是不那么“感冒”。

相比于纯前端的Web开发的不同,服务端使用面向对象的设计模式可以让我们对代码做更好的抽象,并且通过装饰器这种强大的语法特性,能够提高代码的可读性、可维护性和可扩展性,使开发者能够更加灵活地处理复杂的业务逻辑和功能需求,所以装饰器这个设计模式还是很重要的。

接下来我们介绍一下 TypeScript 装饰器的基础用法。本文的案例也是围绕5.0以下的TypeScript展开。建议开启tsconfig.json的如下配置:

{
    "compilerOptions": {
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
    }
}

装饰器的使用

类装饰器

类装饰器是直接作用在类上的装饰器,它在执行时的入参只有一个,那就是这个类本身(可以理解是构造函数,而不是原型对象),可参考下图:

Pasted Graphic 47.png

案例一: 为类添加一个实例方法

@setMethod()
class Person {
  private name = "jack";

  constructor(name: string) {
    this.name = name;
  }
}

function setMethod(): ClassDecorator {
  return (target: any) => {
    console.log("target", target); // Class Person

    target.prototype.output = () => {
      console.log("this is decorators method");
    };
  };
}

const person = new Person("caos");

person.output(); // this is decorators method

解析: 上面是实现了一个setMethod的装饰器,用于装饰Person这个类,其实我们能发现所谓的装饰器实际上就是一个函数,我们setMethod这个装饰器方法中target代表的就是class Person这个类,可以看见Person上是没有output方法的,通过setMethod我们最终的实现就是往Person的原型上添加 (JS的Class实际上就是函数+原型的语法糖)output方法,最后Person的实例上能直接调用output方法。

案例二:方法覆盖

@overload
class Person {
  public name = "jack";

  constructor(name: string) {
    console.log("Person constructor", name);
    this.name = name;
  }

  getName() {
    console.log("Person setName", this.name);
  }
}

function overload(target: any) {
  return class extends target {
    setName(value: string) {
      this.name = value;
    }

    getName() {
      console.log("decorators name is", this.name);
    }
  };
}

const person = new Person("caos"); // 输出:Person constructor caos

person.setName("jacks");
person.getName(); // 输出:decorators name is jacks

解析: class extends target 是 TypeScript 中的一种语法,用于创建一个类并继承自另一个类或者构造函数。overload通过继承被装饰的Person的类,通过装饰器中的新方法setName中修改Person实例name的值,并覆盖原PersongetName方法。

函数装饰器

方法装饰器的入参包括类的原型方法名以及方法的属性描述符PropertyDescriptor),而通过属性描述符你可以控制这个方法的内部实现 (即 value)、可变性 **(即 writable)**等信息。

案例三:实现中间件装饰器计算方法执行时间

class Data {
  @middleware
  public static output(data) {
    console.log("data is ", data);
  }
}

function middleware(target: any, property: string, descriptor: PropertyDescriptor) {
  const metaFunction = target[property];
  descriptor.value = (...args) => {
    const now = Date.now();
    console.log("before");
    metaFunction.apply(target, args);
    console.log("after", Date.now() - now);
  };
}

Data.output("jackcaos");
// 输出
// before
// data is jackcaos
// after

解析: static function是作为静态函数,直接挂载在Data的类上的,无需实例化,可以直接调用。通过target[property]获取到被装饰的函数,之后通过descriptor直接重写原函数的value

属性装饰器

属性装饰器入参只有类的原型属性名称,我们可以直接通过直接在类的原型上赋值来修改属性。比如在开发中可能需要向实例中注入一些配置,下面通过实现Inject装饰器,完成属性值的注入。

案例四:给类注入属性值

const config = {
  a: {
    b: 1,
  },
};

class Person {
  @inject("a") public config;

  getData() {
    console.log(this.config);
  }
}

function inject(key: string) {
  // 这是入参
  return (target: any, property: string) => {
    const value = config[key];
    Reflect.set(target, property, value);
  };
}

new Person().getData(); // 输出:{ b: 1 }

解析: 获取config值后,直接注入到Person的原型上,实例化之后通过getData可以访问到注入的值。

参数装饰器

参数装饰器它的入参包括类的原型参数所在的方法名参数在函数参数中的索引值(即第几个参数)。

案例五:打印参数信息

class Person {
  print(index: number, @param() name: string) {
    console.log("执行print方法", index, name);
  }
}

function param() {
  return (target: any, property: string, propertyIndex: number) => {
    console.log("参数装饰器被执行:", target.constructor.name, property, propertyIndex);
  };
}

new Person().print(1, "jackcaos"); // 输出: { name: 'jackcaos', age: 18 }

其实有时候我们单纯使用装饰器是不够的,我们知道装饰器实际上是在被装饰的方法和类之前执行的。但是有时候希望在程序运行时动态地检查、获取和修改对象的属性,这时候单纯靠装饰器,而无法访问参数的元数据类型的,这就得依托反射的技术了。

反射

什么是反射?

反射(Reflection)是一种编程技术,它允许程序在运行时动态地检查、获取和修改对象的属性和行为。反射使得程序能够在运行时获取类型信息、调用方法、访问属性等,而不需要在编译时就确定这些信息。

JavaScript不像Java那样有专门的反射相关的API,但是也可以通过一些内置的方法来实现反射功能:

  • 利用Object相关的API去获取对象的属性,或者修改对象的属性
  • 利用typeof获取变量的类型

TypeScript 中可以通过Reflect-metadata中的 Reflect API 提供了一种与装饰器关联的元数据进行交互的方法。

Reflect API 包括用于访问和修改给定装饰器的元数据的方法,除了常规的Reflect API外,Reflect-metadata还提供以下方法:

  • 通过design:type 可以获取属性的类型
  • 通过design:paramtypes 可以获取函数的参数的类型
  • 通过design:returntype 可以获取函数的返回值类型

什么是元数据?

元数据是描述数据(代码)的数据,它提供了关于数据的额外信息,帮助程序理解和处理数据。

接下来我们来看一个使用Reflect-metadata的案例:

案例六:获取装饰的属性和函数的类型

import "reflect-metadata";
class Person {
  @getPropertyType private name: string;

  @getFunctionType
  setName(name: string): boolean {
    this.name = name;
    return true;
  }
}

function getPropertyType(target: any, property: string) {
  // 获取被装饰的属性的类型
  const type = Reflect.getMetadata("design:type", target, property);

  Reflect.defineMetadata("attribute.type", type, target, property);
}

function getFunctionType(target: any, property: string, descriptor: PropertyDescriptor) {
  // 获取被装饰的方法的参数类型
  const paramsType = Reflect.getMetadata("design:paramtypes", target, property);
  // 获取被装饰的方法的返回值类型
  const returnType = Reflect.getMetadata("design:returntype", target, property);

  Reflect.defineMetadata("function.returnType", returnType, target, property);
}

const person = new Person();
// 对象运行时动态获取Reflect定义的值
console.log(Reflect.getMetadata("attribute.type", person, "name"));
// 输出 [Function: String]
console.log(Reflect.getMetadata("function.returnType", person, "setName"));
// 输出 [Function: Boolean]

解析: 我们通过getMetadata获取到被装饰的属性的类型以及函数参数的类型,然后通过defineMetadata定义所需要的元数据,最终我们可以在函数运行的时候装饰器中定义的数据。

接下来我们尝试着实现之前说的所谓的validate装饰器,去校验我们所调用函数的参数情况,实际上我们利用Reflect.getMetadata便可以获取所有的参数类型,从而完成参数的校验工作。

案例七:获取装饰的属性和函数的类型

import "reflect-metadata";
function validate(target: any, property: string, descriptor: PropertyDescriptor) {
  descriptor.value = function(...args) {
    // 这里返回的参数是装箱类型 例如 [Function: Number], [Function: Object], [Function: Array]
    const metaTypes = Reflect.getMetadata("design:paramtypes", target, property);
    // 所以需要转换为JS的类型进行比较
    const types = transformTypes(metaTypes);
    for (let i = 0; i < args.length; i++) {
      const paramType = Object.prototype.toString.call(args[i]);
      if (paramType === types[i]) {
        continue;
      } else {
        console.error(
          `validate fail: Invalid parameter type at index ${i}, got type ${paramType.slice(8, -1)}`,
        );
      }
    }
  };
  return descriptor;
}

function transformTypes(types) {
  return types.map((item) => Object.prototype.toString.call(item()));
}

class Person {
  @validate
  inputMsg(index: number, name: string, data: number[]) {
    console.log("inputMsg", index, name, data);
  }
}

const person = new Person();
person.inputMsg(1, 2, [3]);
// 输出:validate fail: Invalid parameter type at index 1, got type Number

依赖注入&控制反转

我们知道在 Nest 或者 Midway.js框架里几乎到处都是依赖注入(DI),并且文档中也到处都提及了控制反转的概念,那这两个东西到底是什么呢?其实前面所了解的装饰器也好,利用Reflect实现反射也好,最终都是为了本节做铺垫的。

现在我们假设一个场景,我们需要在类UserController中,调用UserService中的getData方法,模拟我们controller处理请求,然后service获取数据的场景。

class UserController {
  handleRequest() {
    const userService = new UserService();
    const data = userService.getData();
    console.log("data", data);
  }
}

class UserService {
  getData() {
    return {
      name: "jackcaos",
      age: 18,
    };
  }
}

new UserController().handleRequest(); // 输出: { name: 'jackcaos', age: 18 }

我们常规的写法都是这样的,先初始化对应的类,之后再调用相关的方法。当对象比较少的时候,这么写是没什么问题,但是一个大型的系统肯定不止几个类,可能会出现几十上百个类,对象之间的依赖关系也越来越复杂,经常会出现对象之间的多重依赖性关系,这样针对一个类进行改动,实际上就是牵一发而动全身的,类似于下图的情况。

image.png

我们可以看到一个个的对象类似于齿轮一样彼此耦合的,而为了降低代码之间的耦合度,工程师们提出了IoC理论,来实现对象之间的解耦。

image.png

所以我们看到出现了一个IoC容器串联起来了各个对象,而不会出现彼此耦合的情况。

控制反转

我们看到没有IoC容器的时候,我们需要使用某个类的方法的话,是需要去主动去初始化然后再去调用的,这种叫做控制正转,因为这个初始化的控制权是在具体的类手中,比如上述例子中在UserController初始化UserService

而当UserService的初始化的工作交给了IoC容器,然后IoC容器通过向UserController注入UserService实例,所以此时不是UserController不再是自己主动去初始化UserService类了,也无需关心初始化实例的细节,仅需被动接受一个UserService实例,这就是控制反转

优点

简单来说控制反转(IoC)通过将组件之间的依赖关系交由容器管理,实现了组件之间的解耦合、灵活性、可测试性、可扩展性和集中管理,从而提高了代码的质量和可维护性,降低了系统的复杂度和耦合度。

依赖注入

根据上面我们顺水推舟的也推出了依赖注入(DI)的概念,上面UserController依赖UserService实例,而loC容器将实例注入到需要他们的对象中,实际上这就是依赖注入,接下来我们把上面的代码修改为依赖注入的形式。

import "reflect-metadata";

class DependencyInjection {
  private static locContainer: Map<string, any> = new Map();

  static set(key: string, target: any): void {
    this.locContainer.set(key, target);
  }

  static get<T>(target: any): T {
    const isInjectable = Reflect.getMetadata("injectable", target);
    if (!isInjectable) {
      throw new Error("Target is not injectable");
    }

    const dependencies = Reflect.getMetadata("design:paramtypes", target) || [];
    // 初始化依赖
    const instances = dependencies.map((dep) => {
      const obj = this.locContainer.get(dep.name);
      return new obj();
    });
    return new target(...instances);
  }
}

function Injectable() {
  return function(target: any) {
    Reflect.defineMetadata("injectable", true, target);
  };
}

function Provide() {
  return function(target: any) {
    DependencyInjection.set(target.name, target);
  };
}

@Provide()
class UserService {
  getData() {
    return {
      name: "jackcaos",
      age: 18,
    };
  }
}

@Injectable()
class UserController {
  constructor(private userService: UserService) {}

  handleRequest() {
    const data = this.userService.getData();
    console.log("data:", data); // 输出: { name: 'jackcaos', age: 18 }
  }
}

const userController = DependencyInjection.get(UserController) as UserController;
userController.handleRequest();

解析: 我们实现了两个装饰器ProvideInjectableProvide是将需要注入的对象放入locContainer中,然后Injectable这个装饰器是负责标记需要注入的对象,最后通过DependencyInjectionget方法 完成UserService的实例化, 最终我们就完成了userService属性的装配工作,而无需显式的实例化对象。

结语

无论是Nest也好,还是Midway也好,虽然极大的提升的项目的可维护性,降低了模块之间的耦合,但是也导致它们所需要的学习成本也是不低的,简单学习了下Nest的文档后,发现这些Node.js的服务端框架也在无限的向其他语言的服务端框架靠拢。

未来前端开发也许会渐渐的向全栈工程师靠拢,前后端可能也不会再有那么清晰的界限了,也许以后前端会更卷吧,也或许正是因为JavaScript语言之前的不完备性,而随着现在前端技术的不断发展,后人再不断的去填坑吧。


网站公告

今日签到

点亮在社区的每一天
去签到