学习目标

  • 构建一个灵活的配置系统
  • 写一个数据库配置工具

预装依赖

  • chalk:用于在命令行输出有颜色的消息
  • dotenv:读取环境变量文件到一个对象
  • find-up:向上查找一个文件并返回其路径

文件结构

本节课目录和文件结构的更改只聚焦于core包,编写好之后的目录结构如下

src/core
├── common
│   ├── configure
│   │   ├── base.util.ts
│   │   ├── configure.ts
│   │   ├── env.ts
│   │   ├── index.ts
│   │   └── utiler.ts
│   ├── constants.ts
│   ├── constraints
│   │   ├── index.ts
│   │   ├── match.constraint.ts
│   │   ├── match.phone.constraint.ts
│   │   └── password.constraint.ts
│   ├── core.module.ts
│   ├── decorators
│   │   ├── dto-validation.decorator.ts
│   │   └── index.ts
│   ├── helpers.ts
│   ├── index.ts
│   ├── providers
│   │   ├── app.filter.ts
│   │   ├── app.interceptor.ts
│   │   ├── app.pipe.ts
│   │   └── index.ts
│   └── types.ts
└── database
    ├── base
    │   ├── data.service.ts
    │   ├── index.ts
    │   ├── repository.ts
    │   ├── subscriber.ts
    │   └── tree.repository.ts
    ├── constants.ts
    ├── constraints
    │   ├── index.ts
    │   ├── model.exist.constraint.ts
    │   ├── tree.unique.constraint.ts
    │   ├── tree.unique.exist.constraint.ts
    │   ├── unique.constraint.ts
    │   └── unique.exist.constraint.ts
    ├── db.util.ts
    ├── index.ts
    ├── parse-uuid-entity.pipe.ts
    └── types.ts

应用编码

在开始编码之后创建supports/database目录,并把原来的一些数据库相关的类型,常量,函数等放入此目录中的文件,目录结构如上面所示,然后更改一下应用中的导入地址

配置系统

由于官方提供的@nestjs/config是通过系统内部的模块方式注入服务来实现的,很多场景下无法满足灵活的需求,比如自动化的多数据配置等,所以本节实现一个简单灵活的配置系统

类型及常量

配置中并非所有元素都是静态,一些属性可能通过函数获取,比如通过env获取环境变量,而此函数在调用loadEnvs加载环境文件前是无法获取所有我们自定义的环境变量的,所以必须把配置包含在一个回调函数中,读取的时候去执行通过返回值获取,因此我们定义一个ConfigRegister作为获取配置的回调函数的类型

// src/core/common/types.ts
export interface AppConfig {
    debug: boolean; // 是否debug
    timezone: string; // 时区
    locale: string; // 语言
    port: number; // 服务器端口
    https: boolean; // 是否通过https访问
    host: string; // 服务器地址
}

export interface BaseConfig {
    app: AppConfig;
    [key: string]: any;
}

// 配置注册器函数
export type ConfigRegister = () => T;
// 多个配置注册器集合
export type ConfigRegCollection = {
    [P in keyof T]?: () => T[P];
};

定义一个专门用于配置当前环境值的枚举常量

// src/core/common/constants.ts
export enum EnviromentType {
    DEV = 'development',
    PROD = 'production',
    TEST = 'test',
}

环境变量

代码: src/core/supports/configure/env.ts

首先编写loadEnvs函数,加载环境变量,步骤如下

当前环境通过启动命令中的NODE_ENV赋值,其它的process.env环境变量都可以通过启动命令赋值

cross-env NODE_ENV=development nest start

  • 使用find-up向上层查找.env文件,直到找到为止,如果没有则undefined
  • 使用find-up向上层查找.env.当前环境文件,直到找到为止,如果没有则undefined
  • 把前面查找的两个文件地址放入一个数组并过滤掉undefined
  • 使用dotenv循环读取上面过滤后的文件数组中的环境变量,后者覆盖前者的变量,最终赋值给一个对象
  • 把以上读取的自定义环境变量对象与process.env合并后赋值给一个对象,前者覆盖后者
  • 把这个对象中的环境变量重新全部赋值给process.env

编写env函数来读取环境变量,env函数通过重载实现多参数模式,其参数如下

  • key: 需要获取的环境变量的名称
  • parseTo: 转义函数(由于环境变量读取后其值都是字符串类型,所以对于number,boolean等类型的值需要传入转义函数进行转义)
  • defaultValue: 默认值
// src/core/common/configure/env.ts

// 获取全部环境变量
export function env(): { [key: string]: string };
// 直接获取环境变量
export function env(key: string): T;
// 获取类型转义后的环境变量
export function env(
    key: string,
    parseTo: ParseType,
): T;
// 获取环境变量,不存在则获取默认值
export function env(
    key: string,
    defaultValue: T,
): T;
// 获取类型转义后的环境变量,不存在则获取默认值
export function env(
    key: string,
    parseTo: ParseType,
    defaultValue: T,
): T;
// 获取环境变量的具体实现
export function env(
    key?: string,
    parseTo?: ParseType | T,
    defaultValue?: T,
)

为了方便,写一个environment函数用于获取当前环境NODE_ENV

配置类

配置类的实现比较简单,其属性和方法如下

_created属性的作用是避免重复加载配置

// src/core/common/configure/configure.ts
export class Configure {
    // 配置加载状态
    protected _created = false;
    // 根据配置注册器生成的配置
    protected _config!: { [key: string]: any };
    // 根据传入的配置构造器对象集生成所有配置
    create(_config: ConfigRegCollection)
    // 配置是否已加载
    get created()
    // 获取一个配置,不存在则返回defaultValue
    get(key: string, defaultValue?: CT)
   // 判断一个配置是否存在
    has(key: string): boolean
    // 获取所有配置
    all()
    // 加载环境变量并重置所有配置
    protected reset(_config: ConfigRegCollection)
    // 传入配置注册器集合并执行每个配置注册器来加载所有配置
    protected loadConfig(_config: ConfigRegCollection)
}

Util模式

为了便捷的使用各种第三方服务和类库(如数据库,Redis,Socket,云存储等),我们构建一个简单的Util机制,每种服务使用Util类自动化配置以及在Util中编写各种API方法

BaseUtil

添加一个用于配置映射的类型

// src/core/common/types.ts
export interface UtilConfigMaps {
    required?: boolean | string[];
    maps?: { [key: string]: string } | string;
}

这个所有Util了基类,运行流程如下

  • 通过mapConfigconfigure(前面创建的Configure类的对象)中需要的配置映射到configMaps属性
  • 在映射的时候使用checkAndGetConfig,如果是requiredtrue或是个包含此字段的数组,但是配置中又没有就抛出错误
  • 最终在factory方法中调用mapConfig获取映射后的配置并作为参数赋值给子类的create方法,子类可以选择把配置赋值给config属性或其它用途
// src/core/common/configure/base.util.ts
export abstract class BaseUtil {
    protected _created = false;

    protected configure!: Configure;
    // 子类配置
    protected config!: CT;

    // 配置映射
    protected abstract configMaps?: UtilConfigMaps;

    /**
     * 检测是否已被初始化
     */
    created()
    /**
     * 始化Util类
     * 将映射后的配置放入子类的factory进行进一步操作
     * 比如赋值给this.config
     */
    factory(configure: Configure)
    /**
     * 由子类根据配置初始化
     */
    protected abstract create(config: any): void;
    /**
     * 根据configMaps获取映射后的配置
     * 如果configs是一个string则直接在获取其在配置池中的值
     * 如果configs是一个对象则获取后再一一映射
     */
    private mapConfig()
    /**
     * 检测并获取配置
     * 如果required为true则检测每个配置在配置池中是否存在
     * 如果required为数组则只把数组中的值作为key去检测它们在配置池中是否存在
     * 其它情况不检测
     */
    protected checkAndGetConfig(
        name: string,
        key: string,
        required?: UtilConfigMaps['required'],
    )
}

DbUtil

添加用于数据库配置的类型

需要注意的是与nestjs默认的配置方式不同,为了让配置更加清晰,在自定义的配置方式中我们让每个连接必须带有连接名称

// src/core/database/types.ts
export interface DatabaseConfig {
    // 数据库默认配置
    default?: string;
    // 启用的连接名称
    enabled: string[];
    // 数据库连接配置
    connections: DbOption[];
    // 所有连接的公共配置,最终会合并到每个连接中
    common: Record;
}
// 数据库连接配置
export type DbOption = TypeOrmModuleOptions & {
    name: string;
};

DbUtil继承自BaseUtil用于配置数据库连接,后面的教程会做更多处理,比如数据迁移和填充都要用到,目前比较简单,方法列表如下

getNestOptions

获取所有用于TypeOrmModule的数据库连接的配置,设置autoLoadEntities为“true,使entityautoLoadEntities后自动加载
由于
entityautoLoadEntities后自动加载,subscriber`由提供者方式注册 所以在配置中去除这两者

后续教程我们会写一个自定义的模块创建器来处理subscriber等问题

getNestOption

根据名称获取一个用于Nestjs默认方式的TypeOrmModule的数据库连接配置

setOptions

根据配置设置连接

  • 如果有设置默认连接则启用默认连接,否则选择enabled中的第一个连接为默认连接
  • 如果enabled中没有添加默认连接的自动,则自动添加
  • 检查所有enabled中的连接已配置
  • 合并common配置到每个连接

getMeta

使用getNestOptions获取所有配置,并提供给CoreModule用于TypeOrmModule.forRoot注册每个连接

Utiler管理器

Utiler用于管理所有的Util类,并为它们创建[类名,对象]的映射

此类有一个mergeMeta专门用于合并Util中通过getMeta方法提供ModuleDataMeta数据

// src/core/common/configure/utiler.ts
export class Utiler {
    // 根据传入的configure对象和需要启用的utils进行初始化
    create(configure: Configure, utils: Array>>)
    // Util是否存在
    has, C extends any>(name: Type): boolean
    // 根据Util类获取其对象
    get, C extends any>(name: Type): U
    // 合并ModuleMetaData数据
    mergeMeta(meta: ModuleMetadata): ModuleMetadata {
        const utilMetas: ModuleMetadata = this.utils
            .map((u) => {
                const v = u.value as any;
                return typeof v.getMeta === 'function' ? v.getModuleMeta() : {};
            })
            .reduce(
                (o, n) =>
                    merge(o, n, {
                        arrayMerge: (_d, _s, _o) => [..._d, ..._s],
                    }),
                {},
            );
        return merge(meta, utilMetas, {
            arrayMerge: (_d, _s, _o) => [..._d, ..._s],
        });
    }
}

核心模块

为了可以传入动态配置,需要把原来的CoreModule改成动态模块

  1. 根据传入的配置初始化Configure
  2. 将初始化后的configure对象用于传入utiler用于创建各个util对象
  3. 使用utilermergeMeta合并各个Util中提供给核心模块的元元素和默认的元元素(如果一些Util中没有getMeta方法则略过),对于DbUtil将提供使用TypeOrmModule.forRoot注册每个启用的连接的imports
// src/core/common/core.module.ts
@Module({})
export class CoreModule {
    static forRoot(
        configs: ConfigRegCollection,
        utils: Array>>,
    ): DynamicModule {
        const configure = new Configure();
        configure.create(configs);
        const utiler = new Utiler();
        utiler.create(configure, utils);
        const defaultMeta: ModuleMetadata = {
          ...
        };
        return {
            module: CoreModule,
            global: true,
            ...utiler.mergeMeta(defaultMeta),
        };
    }
}

配置文件

分别创建config/app.config.tsconfig/database.config.ts对应用和typeorm数据库连接进行配置

然后在AppModule中注册CoreModule,通过forRoot方法传入配置和需要启用的DbUtil

// src/app.module.ts
@Module({
    imports: [CoreModule.forRoot(config, [DbUtil]), ContentModule, CoreModule],
    controllers: [AppController],
    providers: [AppService],
})
export class AppModule {}

启动文件

最后美化一下输出的日志,去除原来的普通日志,只输出debug,错误信息和ip+端口的格式

// src/main.ts
async function bootstrap() {
    const app = await NestFactory.create(
        AppModule,
        new FastifyAdapter(),
        { logger: ['error', 'warn', 'debug'] },
    );
    useContainer(app.select(AppModule), { fallbackOnErrors: true });
    const configure = app.get(Configure, { strict: false });
    const appConfig = configure.get('app')!;
    await app.listen(appConfig.port, appConfig.host, () => {
        console.log();
        console.log('Server has started:');
        const listens: string[] = [];
        const nets = networkInterfaces();
        Object.entries(nets).forEach(([_, net]) => {
            if (net) {
                for (const item of net) {
                    if (item.family === 'IPv4') listens.push(item.address);
                }
            }
        });
        const urls = listens.map(
            (l) =>
                `{appConfig.https ? 'https' : 'http'}://{l}:{
                    appConfig.port
                }`,
        );
        if (urls.length > 0) {
            console.log(`- Local:{green.underline(urls[0])}`);
        }
        if (urls.length > 1) {
            console.log(`- Network: ${green.underline(urls[1])}`);
        }
    });
}
bootstrap();