封装一个NestJs&TypeScript中的自动导入模块的包

2,515 阅读15分钟

封装一个NestJs中的自动导入模块的方法

要做什么?

  • 我们先来看几张代码

image.png

image.png

  • 不知道大家看了这两张代码是什么感受呢? 反正我的感受是不太好的😄, 每次添加一个模块都要在app.module中引入并且注册, 这样是很麻烦的, 我们今天就来解决这个问题.

为什么做?

  • 最近姐夫找到我让我做一个xx官网, 我一听那肯定没问题呀, 很爽快就接了下来, 并且拍着胸脯保证 说着: 没问题~没问题~. 正好姐夫也不是太着急并且是我一个人做, 所以这一次就想用一点激进一点的技术或者说是陌生一点的技术, 这其中就有NestJs来作为后端的框架使用, 前端使用的是Quasar CLI 搭建的项目.
  • NestJsQuasar在之前作者都是用过的, 为什么说陌生呢? 因为之前在使用过程中, 也仅仅是使用, 并没有刨根问底的学习, 仅仅为了使用而使用, 没有任何意义, 所以这一次相趁这次机会好好探索一下其中的奥妙 (不知道为什么, 近期非常喜欢看源码).

第一步: 思路

  1. 我们想要在node中引入(导入)一个模块, 一般会经常用到两个方法(require(), import())和一个关键字(import), 因为我们做的是动态导入或者说编程式导入, 所以其中的关键字肯定是用不了了, 所以我们能选择的就只剩下两个方法了, 这里我们选用import()方法来做, 因为我们是要做一个ts版的包, 用require()的话不太好, 而且import()方法返回Promise, 更好控制一点 (最后差点因为Promise被害死).
  2. 我们想写一个导入方法就涉及到文件以及文件夹的操作(文件操作), 不过我们这里只需要读取即可.
  3. 我们需要用户传入一些东西: 文件夹路径、忽略规则、筛选规则、当前的文件路径, 而我们最后给用户返回一个key为路径value为文件内容的Map<path, content>即可.
  4. 上面几点都确认后, 就可以开始写我们自己的导入方法了.

第二步: 粗略实现

  • 我们先来写一点伪代码, 嘿嘿😁, 我在想这个方法的时候脑子中是这样想的.
// 1.定义一个用来导入的方法, 这里暂且就叫他single吧
const single = () => { /* 导入单文件 */ }

// 2.定义一个主方法用来调用single方法来批量导入文件, 这里暂且叫他requireAll
const requireAll = () => { /* 调用single批量导入文件 */ }

requireAll({ /* 配置 */ })
  • 这里的single方法非常的好实现, 我们先把他给实现了
export const requireSingle = (fullpath: string) => {
  return new Promise((resolve, reject) => {
    import(fullpath)
      .then(data => resolve(data))
      .catch(error => reject(error));
  });
}

这里接受一个文件路径返回一个promise, 在promiseresolve方法和reject方法分别对应importthencatch方法.

  • 接着, 我们再来实现一下requireAll方法.
// 合并模块
export const mergeMap = (targetMap: Map<any, any>, subMap: Map<any, any>) => {
  for (let key of subMap.keys()) {
    targetMap.set(key, subMap.get(key));
  }
}

// 忽略以.开头的文件、文件夹、node_modules、.d.ts、.d.js文件
const DEFAULT_IGNORE_RULE = /(^\.|^node_modules|(\.d\..*)$)/;
// 只取.ts或者.js结尾的文件
const DEFULAT_FILTER = /^([^\.].*)\.(ts|js)$/;
// 当前文件路径
const DEFAULT_CURRENT_FILE = __filename;

// 定义requireAll的参数类型
interface IOptions {
  dirname: string;
  ignore: RegExp | Function;
  filter: RegExp | Function;
  currentFile: string;
}

// Partial接口的作用就是把传递的泛型类型中的所有属性定义为可选属性
export const requireAll = (options: Partial<IOptions>) => {
  // (必传)拿到文件夹路径
  const dirname = options.dirname;
  // 拿到忽略规则, 如果没有就用默认的
  const ignore = options.ignore || DEFAULT_IGNORE_RULE;
  // 用来保存导入的模块
  const modules: Map<any, any> = new Map();
  // 拿到筛选规则, 如果没有就用默认的
  const filter = options.filter || DEFULAT_FILTER;
  // (必传)执行此方法的文件路径
  const currentFile = options.currentFile || DEFAULT_CURRENT_FILE;
  // 拿到文件夹下的文件夹名称或者文件名称
  const files = readdirSync(dirname);

  // 定义一个判断文件是否为忽略文件的方法, 这里暂且叫ignoreFile, true: 为忽略文件, false: 不是忽略文件
  const ignoreFile: (f: string) => boolean = (fullpath: string) => {
    if (typeof ignore === 'function') {
      return ignore(fullpath);
    } else {
      return fullpath === currentFile || !!fullpath.match(ignore as RegExp);
    }
  }

  // 定义一个判断文件是否符合筛选条件的方法, 这里暂且叫filterFile, true: 符合筛选条件, false: 不符合筛选条件
  const filterFile = (fullpath: string) => {
    if (typeof filter === 'function') {
      return filter(fullpath);
    } else {
      return filter.test(fullpath);
    }
  }
  
  files.forEach(async filename => {
     // 因为readdirSync方法拿到的都是文件名或者文件夹名, 并非是完整的路径, 所以这里做一下完整路径拼接
     const fullpath = join(dirname, filename);
     // 如果为true, 则为忽略文件
     if (ignoreFile(fullpath)) return;

     // 如果是文件夹, 重新调用requireAll方法即可
     if (statSync(fullpath).isDirectory()) {
       const subModules = requireAll({
         dirname: fullpath,
         ignore: ignore
       });
       // 合并现有的模块和子模块
       mergeMap(modules, subModules);
      } else {
       // 如果是文件
       try {
         // 如果符合筛选条件, 则读取该文件, 并添加到modules中
         if (filterFile(fullpath)) {
           // requireSingle方法加载文件, 上面已经实现过
           const data = await requireSingle(fullpath);
           modules.set(fullpath, data);
         }
       } catch (error) {
         throw error;
       }
     }
  });

  return modules;
}

上面的代码中, 有几个重点需要强调:

  1. 我们可以看到files.forEach中的回调方法用的是async修饰, 这代表我面里面可能会用到await, 事实也是这样, 我们确实在执行requireSingle方法的时候用到了await.
  2. 这样每一个forEach的回调方法都变成了异步方法, 其中的requireSingle方法确实可以得到执行了, 但是我们来看最后一句代码return modules, 当我们把forEach的回调置为异步后, return modules是拿不到返回值的.

那我们该怎么解决呢?

这里我们把forEach方法来改写一下:

 await Promise.all(
   files.forEach(async filename => {
   // 因为readdirSync方法拿到的都是文件名或者文件夹名, 并非是完整的路径, 所以这里做一下完整路径拼接
   const fullpath = join(dirname, filename);
   // 如果为true, 则为忽略文件
   if (ignoreFile(fullpath)) return;

    // 如果是文件夹, 重新调用requireAll方法即可
    if (statSync(fullpath).isDirectory()) {
      const subModules = requireAll({
        dirname: fullpath,
        ignore: ignore
      });
      // 合并现有的模块和子模块
      mergeMap(modules, subModules);
     } else {
      // 如果是文件
      try {
        // 如果符合筛选条件, 则读取该文件, 并添加到modules中
        if (filterFile(fullpath)) {
          // requireSingle方法加载文件, 上面已经实现过
          const data = await requireSingle(fullpath);
          modules.set(fullpath, data);
        }
      } catch (error) {
        throw error;
      }
    }
 })
);
  1. forEach方法改为map方法.
  2. map方法外面套上一层await Promise.all 这样就解决了我们上面因为异步, return modules获取不到数据的问题.

但是这里要注意, requireAll方法也要加上一个async修饰符哟

requireAll完整代码

// 合并模块
export const mergeMap = (targetMap: Map<any, any>, subMap: Map<any, any>) => {
  for (let key of subMap.keys()) {
    targetMap.set(key, subMap.get(key));
  }
}

// 忽略以.开头的文件、文件夹、node_modules、.d.ts、.d.js文件
const DEFAULT_IGNORE_RULE = /(^\.|^node_modules|(\.d\..*)$)/;
// 只取.ts或者.js结尾的文件
const DEFULAT_FILTER = /^([^\.].*)\.(ts|js)$/;
// 当前文件路径
const DEFAULT_CURRENT_FILE = __filename;

// 定义requireAll的参数类型
interface IOptions {
  dirname: string;
  ignore: RegExp | Function;
  filter: RegExp | Function;
  currentFile: string;
}

// Partial接口的作用就是把传递的泛型类型中的所有属性定义为可选属性
export const requireAll = (options: Partial<IOptions>) => {
  // (必传)拿到文件夹路径
  const dirname = options.dirname;
  // 拿到忽略规则, 如果没有就用默认的
  const ignore = options.ignore || DEFAULT_IGNORE_RULE;
  // 用来保存导入的模块
  const modules: Map<any, any> = new Map();
  // 拿到筛选规则, 如果没有就用默认的
  const filter = options.filter || DEFULAT_FILTER;
  // (必传)执行此方法的文件路径
  const currentFile = options.currentFile || DEFAULT_CURRENT_FILE;
  // 拿到文件夹下的文件夹名称或者文件名称
  const files = readdirSync(dirname);

  // 定义一个判断文件是否为忽略文件的方法, 这里暂且叫ignoreFile, true: 为忽略文件, false: 不是忽略文件
  const ignoreFile: (f: string) => boolean = (fullpath: string) => {
    if (typeof ignore === 'function') {
      return ignore(fullpath);
    } else {
      return fullpath === currentFile || !!fullpath.match(ignore as RegExp);
    }
  }

  // 定义一个判断文件是否符合筛选条件的方法, 这里暂且叫filterFile, true: 符合筛选条件, false: 不符合筛选条件
  const filterFile = (fullpath: string) => {
    if (typeof filter === 'function') {
      return filter(fullpath);
    } else {
      return filter.test(fullpath);
    }
  }
  
   await Promise.all(
       files.forEach(async filename => {
       // 因为readdirSync方法拿到的都是文件名或者文件夹名, 并非是完整的路径, 所以这里做一下完整路径拼接
       const fullpath = join(dirname, filename);
       // 如果为true, 则为忽略文件
       if (ignoreFile(fullpath)) return;

        // 如果是文件夹, 重新调用requireAll方法即可
        if (statSync(fullpath).isDirectory()) {
          const subModules = requireAll({
            dirname: fullpath,
            ignore: ignore
          });
          // 合并现有的模块和子模块
          mergeMap(modules, subModules);
         } else {
          // 如果是文件
          try {
            // 如果符合筛选条件, 则读取该文件, 并添加到modules中
            if (filterFile(fullpath)) {
              // requireSingle方法加载文件, 上面已经实现过
              const data = await requireSingle(fullpath);
              modules.set(fullpath, data);
            }
          } catch (error) {
            throw error;
          }
        }
     })
    );

  return modules;
}

到这里我们的requireAll方法就算是完成了, 但是此时的requireAll只能用于加载普通的ts文件, 还不足以达到给NestJs应用的程度.

应用示例

我们先来看一下目录结构
├── a.ts
├── b.ts
└── children
    └── c.ts

image.png

文件内容

image.png

调用方式

image.png

让我们来看一下输出结果

image.png

嘿嘿😁, 是不是很cool?

第三步: 精益求精

  • 我们刚刚已经说过了, 现在的requireAll方法还远远达不到给NestJs使用的程度, 我们后面应该怎么做呢? 这里可能需要一点ts装饰器和Reflect-metadata的基础, 如果大家没有了解过的话, 欢迎看一下作者的这几篇文章:
  • TypeScript装饰器官网笔记: juejin.cn/post/697242…
  • Reflect Metadata(元数据)学习笔记: juejin.cn/post/697242…
  • Proxy&Reflect官网笔记: juejin.cn/post/697390…
  • vue3中的元编程&代码抽离: juejin.cn/post/697457…
  • 这里要强调一下, 如果确实没有了解过这部分知识的话, 需要按作者给出的顺序阅读(从上到下)

我们先来打开一个NestJs的项目看一下(新创建的项目即可)

这里附上NestJs中文网的地址, 按照官网操作即可docs.nestjs.cn/

我们先来看一下目录结构, 这里作者把目录结构稍稍改变了一下, 不过这个并不重要:

├── assets
├── main.ts
├── modules
│   ├── app
│   │   ├── app.controller.ts
│   │   ├── app.module.ts
│   │   └── app.service.ts
│   ├── config
│   │   ├── config.controller.ts
│   │   ├── config.module.ts
│   │   └── config.service.ts
│   └── user
│       ├── user.controller.ts
│       ├── user.module.ts
│       └── user.service.ts
├── types
└── utils
    ├── requreAll.ts
    └── utils.ts

image.png

再来看一下最开始说过的app.module.ts文件: image.png 我们可以看到, 所有的模块都是要在app.module中的imports中以数组项的形式注册的, 这里只是2个模块, 我们操作起来尚可接受, 如果我们在做一个大项目, 有几十个甚至上百个模块的时候, 我们需要把每一个模块都引入一遍就比较麻烦了.

那我们怎么通过requireAll方法来解决这个问题呢?

  • 首先我们知道, app.module中的imports目前只接受module(模块), 并不接受controllerservice, 而现在的requireAll方法不管是module还是controller还是service都会一股脑的导入到imports里面, 这样即使不报错也势必不合乎情理.
  • 那我们怎样来区分导入的文件是module还是controller还是service呢? 我们先来看一下这三者他们的区别.
config.controller.ts

image.png

config.service.ts

image.png

config.module.ts

image.png

  • 我们可以看到除了类名是不一样的以外, 还有一个明显的差别, 那就是每个类的装饰器不同, 例如ConfigController的装饰器是@Controller(), ConfigServeice的装饰器是@Injectable(), 而ConfigModule的装饰器是@Module(), 我们所要做的就是根据装饰器不同来区分这个类到底是module还是controller还是service
  • 但我们知道, 我们通过类本身是没有办法知道该类都被哪些装饰器所修饰的(至少目前没有找到), 所以我们只能采用一个折中的办法, 我们自己定义一个装饰器, 来记录当前的类被哪些装饰器所修饰, 我们来看一下代码实现.
export function defineDecorator (metadata: string[]) {
  return function (target: any) {
    Reflect.defineMetadata('decorator', metadata, target);
  }
}

我们定义了一个defineDecorator方法, 意为定义装饰器, 该装饰器是一个工厂装饰器 (如果不明白什么是工厂装饰器的话, 可以看一下作者上面提到的装饰器的文章), 接受一个元数据, 该数据是一个字符串类型的数组, 返回一个装饰器, 该装饰器是一个类装饰器, 接受target参数, 然后给target定义一个keydecorator的元数据, 元数据的值就是传进来的metadata参数.

我们来看一下怎么应用: image.png 以上代码引用了defineDecorator方法给ConfigModule类进行装饰, metadata['Module'](这里为什么是从v-require-all中导入的方法呢? 因为作者已经将封装好的方法发布到了npm上面, 后面大家想用或者查看源码的话, 可以直接npm i v-required-all --save 来使用), 这样一来有什么作用呢? 大家来看一下这一段代码: image.png 这段代码使用Reflect.getMetadata('decorator', ConfigModule)来获取到了我们所定义的元数据, 有了这个, 我们就能区分当前导入的文件是module还是controller还是service了. 具体是怎么做的呢? 我们继续往下看.

第四步: 尘埃落定

我们刚刚说到, 通过defineDecorator装饰类之后, 我们就能区分这个类是否是module了, 那么到底是怎么区分的呢?

  1. 首先, 我们需要在requireAll方法的基础上再封装一个方法, 该方法单独给NestJs使用, 暂且命名为requireAllInNest.
// 读取属性
export const readKeys = (map: Object, callback: (key: any, value: any) => void) => {
  const results = [];
  if (map instanceof Map) {
    for (let key of map.keys()) {
      const returns = callback(key, map.get(key));
      if (returns !== undefined) {
        results.push(returns);
      }
    }
  } else {
    for (let key of Reflect.ownKeys(map)) {
      // @ts-ignore
      const returns = callback(key, map[key]);
      if (returns !== undefined) {
        results.push(returns);
      }
    }
  }
  if (results.length > 0) {
    return results;
  }
}

export const requireAllInNest = (options: Partial<IOptions>, type: 'Controller' | 'Injectable' | 'Module' = 'Module') => {
  return new Promise((resolve, reject) => {
    if (type === 'Controller') {
      options.filter = /^([^\.].*)\.controller\.ts$/;
    } else if (type === 'Injectable') {
      options.filter = /^([^\.].*)\.service\.ts$/;
    } else {
      options.filter = /^([^\.].*)\.module\.ts$/;
    }
    requireAll(options).then(modules => {
      try {
        const importsModule: any[] = readKeys(modules, (key, value) => {
          return readKeys(value, (vKey, target) => {
            if (typeof target === 'function') {
              const metadata = Reflect.getMetadata('decorator', target);
              if (Array.isArray(metadata) && metadata.length > 0 && metadata.indexOf(type) !== -1) {
                return target;
              }
            }
          });
        });
        const results: any[] = [];
        importsModule.forEach((chunk: any[]) => results.push(...chunk));
        resolve(results);
      } catch (error) {
        reject(error);
      }
    }).catch(error => reject(error));
  })
}

我们来看一下这个方法做了什么事情:

  1. 首先该方法有两个参数, 第一个参数options等同于requireAlloptions参数, 而第二个参数用来指定需要获取module还是controller还是service.
  2. 通过type参数来改写filter属性(文件的筛选条件)
  3. 执行requireAll方法, 获取到返回的modules, 我们返回的modules是一个Map<string, Object>的形式, 所以我们这里定义了一个readKeys方法来处理一下(该方法类似forEach方法), 这里为什么要用两层readKeys方法呢? 因为我们获取到modulesvalue是一个对象形式的{ a: 'xxx', default: xxx }, 第一层readKeys用来获取到modulesvalue, 第二层用来获取到value中的内容.
  4. 通过判断value的内容是否为function来进行下一步操作, 如果是function并且该方法含有decorator元数据key, 并且元数据中包含传进来的type('Controller' | 'Injectable' | 'Module'), 则断定该内容就是我们需要的module(例如我们这里需要的是module), 将该内容返回即可.
  5. 我们最后获取到的modules会是一个多维的数组嵌套, 我们还需要把它结构一下, 所以有了这两句代码.
const results: any[] = [];
importsModule.forEach((chunk: any[]) => results.push(...chunk));

至此, requireAllInNest方法就算是封装完了, 好了, 我们来看一下怎样使用这个方法.

image.png 我们可以看到我们的想法是非常好的, 但是无奈NestJs@Module装饰器中的imports不接受Promise类型的参数, 这里就有了我们之前差点被Promise害死的伏笔. 那我们该怎么解决呢? 其实很简单, 我们可以重写或者说自定义一个Module装饰器, 这个装饰器的作用就是给被装饰的类添加imports, controllers, providers这三个元数据, 那我们也可以自己来实现一下.

type IDepend = Promise<any> | Promise<any>[] | any[];
type IPrototypeClass<T = any> = new (...args: any[]) => T;
// 约束一下Module的参数
interface IMetadata {
  imports: IDepend;
  controllers: IDepend;
  providers: IDepend;
}
const metadataHandler = {
  setImports: (imports: any, target: IPrototypeClass) => Reflect.defineMetadata('imports', toArray(imports), target),
  setControllers: (controllers: any, target: IPrototypeClass) => Reflect.defineMetadata('controllers', toArray(controllers), target),
  setProviders: (providers: any, target: IPrototypeClass) => Reflect.defineMetadata('providers', toArray(providers), target),
  handler(data: any, target: IPrototypeClass, method: (v:any, t:IPrototypeClass) => void) {
    const single = (value: any) => (value && isPromise(value)) ? (value as Promise<any>).then(d => method(d, target)) : method(value, target);
    isArray(data) ? (data as any[]).forEach(v => single(v)) : single(data);
  },
  imports(imports: IDepend, target: IPrototypeClass) {
    this.handler(imports, target, this.setImports);
  },
  controllers(controllers: IDepend, target: IPrototypeClass) {
    this.handler(controllers, target, this.setControllers);
  },
  providers(providers: IDepend, target: IPrototypeClass) {
    this.handler(providers, target, this.setProviders);
  }
}
// 基本算是重写@Module
export function Module (metadata: Partial<IMetadata>) {
  return function (target: IPrototypeClass) {

    metadataHandler.imports(metadata.imports, target);
    metadataHandler.controllers(metadata.controllers, target);
    metadataHandler.providers(metadata.providers, target);

  }
}

我们可以看到Module方法本身也是一个工厂装饰器, 其作用就是调用metadataHandler对象给target添加元数据, 这里metadataHandler就不解释了, 就是判断是否为数组、是否为promise、是否为普通数据然后给target添加元数据的一个方法集合.

我们来看一下使用方法: image.png 其实可以看到, 非常非常的简单, 只要把原来的Module方法改为自己定义的Module方法即可.

效果:

image.png image.png image.png

嘿嘿😁, 是不是很cool!

最后一步: 发布到npm

发布到npm, 大家参考一下这两篇文章即可, 其中有一些注意的地方会在下面说到.

  • 怎样发布一个npm包?: cloud.tencent.com/developer/a…
  • 将ts编写的代码上传到npm: blog.csdn.net/Dilomen/art…
  • 这里我们要强调一下, 在做以上的操作之前, 如果本地的npm源是淘宝源或者其他源的话, 要把他们改成默认的, 或者直接删除 .npmrc文件即可, 作者就是直接删除了 .npmrc文件.

总结

到这里该方法就已经封装完成了, 如要使用此方法, 只需在npm搜索v-require-all即可.
有的朋友问我为什么要取这个名字, 为什么要有一个v-的前缀呢? 因为本来想取require-all和@require-all的, 但是都用不了了, 所以就用了v-为前缀, v为作者网名veloma的首字母.