前言
本文是一篇比较实用的经验分享,可能大家也遇到过我类似的情况。
我先给大家阐述一下业务场景,我们公司的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中目前最主流的两种主要模块系统。以下是它们的区别:
- 模块加载机制 :
- CommonJS: 同步加载模块,主要用于服务器端(如Node.js),因为服务器上模块文件通常已经存在,同步加载不会产生显著的延迟。
- ESM: 支持异步加载模块,适合用于浏览器,因为网络加载资源通常需要更多时间。
- 语法 :
- CommonJS : 使用
require()
引入模块,module.exports
或exports
导出模块。 - ESM : 使用
import
和export
语句。
- CommonJS : 使用
- 模块解析 :
- CommonJS: 在运行时动态解析,允许条件性地加载模块和动态的路径解析。
- ESM : 在编译时静态解析,这意味着
import
和export
语句必须位于模块的顶层,并且路径必须是静态的。
- 模块对象 :
- CommonJS : 导出一个模块对象,可以是任何JavaScript对象,导出的是模块的拷贝。
- ESM : 导出接口是静态的(即导出的成员在运行时不可更改),并且ESM始终导出一个
live
的模块实例,我们使用的时候需要小心,如果对它产生了非预期的修改,那整个项目里这个模块也就被修改了。
- 模块循环依赖处理 :
- CommonJS: 循环依赖时可能导致问题,因为它们会返回一个未完全执行的模块的导出对象。
- ESM : 更好地处理循环依赖,因为
import
提供的是一个动态的、只读的视图。
- 跨平台兼容性 :
- CommonJS: 主要用于Node.js,浏览器对其的原生支持有限。
- ESM: 是ECMAScript标准的一部分,由现代浏览器和最新版本的Node.js支持。
- 性能 :
- 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';
}
}
最后,这仅仅是我目前想到的设计,如果大家觉得有优化的空间,可以联系我补充,谢谢大家。