记录我的NestJS探究历程(十一)——编写对Module增强的自定义装饰器

前言

本文是一篇比较实用的经验分享,可能大家也遇到过我类似的情况。

我先给大家阐述一下业务场景,我们公司的BFF是服务于各种运营活动的。各种运营活动它比较小,开发周期快,可能只需要开发一周,然后上线运行一周,这个活动将来可能就不会再用了。

我们通过对运营活动进行抽象,将一些通用的逻辑抽离了统一的接口,即所有的活动都可以共用。但是,仍然会有某些运营活动需要有自己的特殊的逻辑,这种逻辑就只能BFF按业务单独提供接口。

通过分析,我们得出结论,业务活动只需要一个与之对应的Controller和Service。

于是,我的项目路径就可以像这样来拆分成这样:

但是这样的设计始终有一个问题,对于业务开发的同学,他始终需要在对应的module导入他负责业务的Controller和Service。

而我预期希望能够在module上面直接指定加载某个路径下面的所有的Service和Controller,这样开发业务的同事就不用再自行配置了,也就间接避免了每次活动都module这个文件总会有改动的问题。

寻找线索

在大学的时候,跟着师兄学过很短一段时间的SpringBoot,SpringBoot是有这种配置的,以下是一个demo。

java 复制代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;

@SpringBootApplication
// 指定实体类所在的包
@EntityScan(basePackages = "com.example.domain") 
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

另外,似乎,好像,或许,大概在前端项目中看到过(读者不要觉得我废话,我是真的绞尽脑汁才想起来了TypeORM,理解一下我的艰难)😂。

以下是从TypeORM官方文档上得到的例子。

ts 复制代码
import { DataSource } from "typeorm" 
const dataSource = new DataSource({ 
    type: "mysql", 
    host: "localhost", 
    port: 3306, 
    username: "test",
    password: "test",
    database: "test",
    entities: ["entity/*.js"],
})

既然支持这种按模式扫描,那我遇到的问题就有解了。

我们先翻一下typeorm的源码,凭之前的开发经验,猜测应该是使用的glob实现的这种模式匹配,于是我在它的项目里面搜索glob,能够找到一个看起来似乎对我们有用的方法

这个里面,typeorm定义了一个工具方法来完成的引入,核心就是使用require进行加载。

理论基础

CommonJS和ESM的对比

CommonJS和ESM(ECMAScript模块)是JS中目前最主流的两种主要模块系统。以下是它们的区别:

  1. 模块加载机制 :
    • CommonJS: 同步加载模块,主要用于服务器端(如Node.js),因为服务器上模块文件通常已经存在,同步加载不会产生显著的延迟。
    • ESM: 支持异步加载模块,适合用于浏览器,因为网络加载资源通常需要更多时间。
  2. 语法 :
    • CommonJS : 使用require()引入模块,module.exportsexports导出模块。
    • ESM : 使用importexport语句。
  3. 模块解析 :
    • CommonJS: 在运行时动态解析,允许条件性地加载模块和动态的路径解析。
    • ESM : 在编译时静态解析,这意味着importexport语句必须位于模块的顶层,并且路径必须是静态的。
  4. 模块对象 :
    • CommonJS : 导出一个模块对象,可以是任何JavaScript对象,导出的是模块的拷贝
    • ESM : 导出接口是静态的(即导出的成员在运行时不可更改),并且ESM始终导出一个live的模块实例,我们使用的时候需要小心,如果对它产生了非预期的修改,那整个项目里这个模块也就被修改了
  5. 模块循环依赖处理 :
    • CommonJS: 循环依赖时可能导致问题,因为它们会返回一个未完全执行的模块的导出对象。
    • ESM : 更好地处理循环依赖,因为import提供的是一个动态的、只读的视图。
  6. 跨平台兼容性 :
    • CommonJS: 主要用于Node.js,浏览器对其的原生支持有限。
    • ESM: 是ECMAScript标准的一部分,由现代浏览器和最新版本的Node.js支持。
  7. 性能 :
    • CommonJS: 由于同步加载模块,可能会对性能产生负面影响,特别是在大型应用中。
    • ESM: 由于支持异步加载,可以优化性能和加载时间。

因为NestJS是使用TS编写,即便我们在代码中使用的是ESM风格的代码,但是经过编译以后,最终的运行时代码仍然被转成了CommonJS。通过以上的对比,我们知道CommonJS可以在运行时动态解析,那就具备了完成这个自动导入的理论基础了,接下来再看一下NestJS的编译产物。

NestJS的编译后的运行时产物分析

以下是一个Module的编译结果:

js 复制代码
Object.defineProperty(exports, "__esModule", { value: true });
exports.AppModule = void 0;
const common_1 = require("@nestjs/common");
const module_1 = require("./modules/communication/module");
const core_1 = require("@nestjs/core");
const standard_error_capture_1 = require("./common/filters/standard-error-capture");
const config_1 = require("@nestjs/config");
const config_2 = require("./common/config");
const business_module_1 = require("./business/business.module");
const act_foundation_api_module_1 = require("./modules/act-foundation-api/act-foundation.api.module");
const infrastructure_module_1 = require("./modules/infrastructure/infrastructure.module");
const log_interceptor_1 = require("./common/interceptors/log.interceptor");
const trace_middleware_1 = require("./common/middlewares/trace.middleware");
const encrypt_interceptor_1 = require("./common/interceptors/encrypt.interceptor");
let AppModule = class AppModule {};
AppModule = __decorate([
    (0, common_1.Module)({
        imports: [
            config_1.ConfigModule.forRoot({
                envFilePath: envFiles,
            }),
            module_1.CommunicationModule,
            act_foundation_api_module_1.ActFoundationApiModule,
            infrastructure_module_1.InfrastructureModule,
            business_module_1.BusinessModule
        ],
        providers: [
            {
                provide: core_1.APP_FILTER,
                useClass: standard_error_capture_1.StandardErrorCaptureExceptionFilter,
            },
            {
                provide: core_1.APP_INTERCEPTOR,
                useClass: log_interceptor_1.LoggingInterceptor,
            },
            {
                provide: core_1.APP_INTERCEPTOR,
                useClass: encrypt_interceptor_1.EncryptInterceptor,
            },
        ],
    })
], AppModule);
exports.AppModule = AppModule;

从上面的结果看起来,我们在程序运行时,通过调用require方法,得到module定义Key-Value,然后取值绑定到这个Module装饰器上,这个也是能行得通的。

在能够确定技术路线没有问题之后,剩下的任务就是编写代码了。

代码设计

这个时候,考虑到的问题就是使用是否友好了。

首先,一个基本点,这个增强一定是要完全兼容NestJS原生Module装饰器的,然后才是我们增加的语法。

以下是我们增强语法的类型定义:

ts 复制代码
export interface ComponentEnhanceLoadType {
  /**
   * 指定匹配的路径
   */
  pattern: string;
  /**
   * 指定加载的上下文路径
   */
  ctxDir: string;
  /**
   * 排除的路径
   */
  exclude?: string | string[];
}

剩下的,就是需要把原来NestJS的Module本来支持的那些类型,进行重新定义,以下是我编写的类型定义。

ts 复制代码
// NestJS本来的imports定义
export type NestImportDefine = Type<any> | DynamicModule | Promise<DynamicModule> | ForwardReference;
// NestJS本来的controllers定义
export type NestControllerDefine = Type<any>;
// NestJs本来的providers定义
export type NestProviderDefine = Provider;
// NestJS本来的exports定义
export type NestExportDefine =
  | DynamicModule
  | Promise<DynamicModule>
  | string
  | symbol
  | Provider
  | ForwardReference
  | Abstract<any>
  | Function;
// 结合我们的增强语法之后的新定义
export type LoaderEnhancedImportDefine = NestImportDefine | ComponentEnhanceLoadType;
export type LoaderEnhancedControllerDefine = NestControllerDefine | ComponentEnhanceLoadType;
export type LoaderEnhancedProviderDefine = NestProviderDefine | ComponentEnhanceLoadType;
export type LoaderEnhancedExportDefine = NestExportDefine | ComponentEnhanceLoadType;
// 增加之后的ModuleMetadata定义
export interface ModuleLoaderMetadata {
  imports: ComponentEnhanceLoadType | Array<LoaderEnhancedImportDefine>;
  controllers: ComponentEnhanceLoadType | Array<LoaderEnhancedControllerDefine>;
  providers: ComponentEnhanceLoadType | Array<LoaderEnhancedProviderDefine>;
  exports: ComponentEnhanceLoadType | Array<LoaderEnhancedExportDefine>;
}

上面,因为我考虑了实际项目中的使用便利性,因此增加了基于对象的扫描指定路径加载内容的配置(NestJS本来都配置全部都是数组)

因为Module本身是一个装饰器,只不过它是在添加元数据,因此,我们对外提供的也是一个函数,在这个函数里面,把Module装饰器执行以下 ,传入给它需要的元数据即可。

以下就是完整的代码实现:

ts 复制代码
import { Module } from "@nestjs/common";
import { globSync } from "glob";
import { resolve } from "path";
import {
  ComponentEnhanceLoadType,
  LoaderEnhancedControllerDefine,
  LoaderEnhancedExportDefine,
  LoaderEnhancedImportDefine,
  LoaderEnhancedProviderDefine,
  ModuleLoaderMetadata,
  NestControllerDefine,
  NestExportDefine,
  NestImportDefine,
  NestProviderDefine,
} from "./interfaces";

function isObj(obj: unknown) {
  return Object.prototype.toString.call(obj) === "[object Object]";
}

function isGlobLoadConfig(obj: unknown) {
  return isObj(obj) && typeof (obj as ComponentEnhanceLoadType).pattern === "string";
}

function mergeMeta<T, R>(controls: T | T[] = []): R[] {
  // 我们自定义的增强配置筛选出来进行预处理
  const batchMetaDefine = (
    Array.isArray(controls) ? controls.filter((v) => isGlobLoadConfig(v)) : [controls]
  ) as ComponentEnhanceLoadType[];
  // 透传给NestJS的配置
  const transmissionMetaDefine = Array.isArray(controls)
    ? (controls.filter((v) => !isGlobLoadConfig(v)) as unknown as R[])
    : [];
  let parsedMetaRecord: R[] = [];
  batchMetaDefine.forEach((config) => {
    const moduleFiles = globSync(config.pattern, {
      cwd: config.ctxDir,
      ignore: config.exclude || [],
    });
    // 通过require动态导入glob解析到的文件中导出的变量集合
    parsedMetaRecord = parsedMetaRecord.concat(
      moduleFiles.reduce((accumulateImports, file) => {
        // 将相对路径拼接成决对路径导入
        const absPath = resolve(config.ctxDir, file);
        const defineModuleEntry = require(absPath);
        const defineModule = Object.values(defineModuleEntry);
        return accumulateImports.concat(defineModule);
      }, [])
    );
  });
  // 透传的配置,和glob解析到的配置进行合并
  const mergedImports: R[] = [...transmissionMetaDefine, ...parsedMetaRecord];
  return mergedImports as R[];
}

export function EnhancedModule(metadata: Partial<ModuleLoaderMetadata>): ClassDecorator {
  // 分别处理Module装饰器的所有属性,组合之后再透传给它
  const imports = mergeMeta<LoaderEnhancedImportDefine, NestImportDefine>(metadata.imports);
  const controllers = mergeMeta<LoaderEnhancedControllerDefine, NestControllerDefine>(metadata.controllers);
  const providers = mergeMeta<LoaderEnhancedProviderDefine, NestProviderDefine>(metadata.providers);
  const exportsDefine = mergeMeta<LoaderEnhancedExportDefine, NestExportDefine>(metadata.exports);
  return Module({
    imports,
    controllers,
    providers,
    exports: exportsDefine,
  });
}

代码编写好了,接下来就可以使用了(因为考虑到复用,我事先已经将这些代码抽离到了一个单独的npm包中了,如果大家要使用的话,可以直接安装nestjs-module-loader

在项目中使用:

ts 复制代码
// import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnhancedModule } from 'nestjs-module-loader';
// 将原本使用NestJS提供的Module装饰器替换成我们的增强装饰器
// @Module({
//   imports: [],
//   controllers: [AppController],
//   providers: [AppService],
// })
@EnhancedModule({
  // 指定imports加载的目录,对象语法
  imports: {
      pattern: 'modules/**/*.module.js',
      ctxDir: __dirname,
   },
  controllers: [
    // 指定扫描的Controller目录
    {
      pattern: 'controllers/*.controller.js',
      ctxDir: __dirname,
    },
    // 兼容原始语法
    AppController,
  ],
  providers: [AppService],
})
export class AppModule {}

可以看到,NestJS成功的解析到了配置:

以上有一个非常关键的重点,为什么我的ctxDir参数指定的是__dirname呢,为什么这个ctxDir不能内聚在EnhancedModule内部呢,因为装饰器执行的代码跟被装饰的类的位置肯定是不一样的,在EnhancedModule内部调用的话,那么就会导致得到的__dirname是EnhancedModule所在的文件的路径,而不是被装饰的类的路径。

另外,还需要注意的是,我们必须指定扫描的是*.js,为什么是这样的呢,因为虽然我们在写代码的时候源码是TS,但是编译的结果是JS,因此配置glob的扫描只能扫描JS文件。

总结

以上就是我在项目中我认为的一个最亮眼的优化了,哈哈哈。经过这个优化之后,我不用担心将来我们的项目太大了,需要加载很多的Controller和Service而导致BFF的启动太慢,我只需要配置glob要过滤的路径,不需要经过代码层面的修改。

这种办法,不仅方便快捷,而且写法上也是非常优雅的,即便我们的BFF服务经过长时间的迭代,也不会出现启动的性能问题。

当然,其实我最开始并不是使用的这种方案来设计我们的BFF架构。

在此也跟大家聊一下我最开始的设计,当时没有想到CommonJS可以动态加载脚本,然后我就设计了一个脚本,在程序启动的时候先执行自己编写的脚本,扫描某个目录下面的所有的Controller和Service,然后将这些文件读取出来,生成到一个文件里面。

然后在Module文件里面导入这个临时文件就行,这个临时文件就不用托管进git,每次启动的时候都生成,然后完成这件事之后,再执行NestJS的启动命令。

但是这种方式我觉得还是不够直接,而且还有一个显著的问题,用户编写的导出内容必须要遵循相应的规范,这样才能按规则生成那个临时文件,所以我就不断思考该如何优化这个笨拙的设计。

最后,使用这种两种加载方式加载的Controller都可能会存在路由覆盖(在我的这种设计中,Controller上定义的路径就起到了一个类似命名空间的效果),B用户不知道A用户已经把某个路由用上了仍然重复定义,对于项目经过长期的迭代是完全可能出现这个问题的。因此,我们应该把这种出现覆盖的错误暴露给用户比较好。

因此,可以自定义一个装饰器来处理这个问题。

ts 复制代码
import { PATH_METADATA } from '@nestjs/common/constants';
const pathSet = new Set();

export function DuplicateRoutePredicate(target: object) {
  // 反解NestJS定义的路由信息
  const path = Reflect.getMetadata(PATH_METADATA, target);
  // 判断路由是否已经存在,如果存在则给用户提示错误信息
  if (pathSet.has(path)) {
    throw new Error(`duplicate controller route ${path} was defined`);
  }
  // 否则将其加入到一个记录中,用作判断重复的依据。
  pathSet.add(path);
}

最后,为所有的Controller追加上这个注解,比如:

ts 复制代码
import { Controller, Get } from '@nestjs/common';
import { DuplicateRoutePredicate } from 'src/decorators/duplicate-route-predicate.decorator';

@DuplicateRoutePredicate
@Controller('/demo1')
export class Demo1Controller {
  @Get('/demo1')
  getDemo1() {
    return 'demo1';
  }
}

另外一种方式,也可以把这个装饰器跟Controller封装到一起,然后大家就不再使用NestJS内置的Controller装饰啦,这种约束可以形成规范,然后在团队做CodeReview的时候或者写一个工具进行校验即可。

例如:

ts 复制代码
// act.controller.ts
import { Controller, ControllerOptions, applyDecorators } from '@nestjs/common';
import { DuplicateRoutePredicate } from './duplicate-route-predicate.decorator';

export function ActController(options: string | string[] | ControllerOptions) {
  return applyDecorators(
    // Controller一定要写在前面,因为必须是先定义后才找的到
    Controller(options as unknown),
    DuplicateRoutePredicate,
  );
}
// demo1.controller.ts
import { Get } from '@nestjs/common';
import { ActController } from 'src/decorators/act-controller';

@ActController('/demo22')
export class Demo2Controller {
  @Get('/demo1')
  getDemo1() {
    return 'demo1';
  }
}

最后,这仅仅是我目前想到的设计,如果大家觉得有优化的空间,可以联系我补充,谢谢大家。

相关推荐
hzw051017 分钟前
nrm的安装及使用
node.js
明辉光焱18 分钟前
[Electron]总结:如何创建Electron+Element Plus的项目
前端·javascript·electron
牧码岛38 分钟前
Web前端之汉字排序、sort与localeCompare的介绍、编码顺序与字典顺序的区别
前端·javascript·web·web前端
云空1 小时前
《InsCode AI IDE:编程新时代的引领者》
java·javascript·c++·ide·人工智能·python·php
咔咔库奇1 小时前
ES6基础
前端·javascript·es6
徐小夕2 小时前
Flowmix/Docx 多模态文档编辑器:V1.3.5版本,全面升级
前端·javascript·架构
迂 幵2 小时前
vue el-table 超出隐藏移入弹窗显示
javascript·vue.js·elementui
上趣工作室2 小时前
vue2在el-dialog打开的时候使该el-dialog中的某个输入框获得焦点方法总结
前端·javascript·vue.js
家里有只小肥猫2 小时前
el-tree 父节点隐藏
前端·javascript·vue.js
zxg_神说要有光3 小时前
自由职业第二年,我忘记了为什么出发
前端·javascript·程序员