8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

NestJSAdvent Calendar 2019

Day 20

NestJS用のライブラリ(モジュール)を作る

Last updated at Posted at 2019-12-20

本記事は NestJS Advent Calendar 2019 20日目の記事です。

本記事では、NestJS用のライブラリを作る上で便利な関数(メソッド)や定番な書き方を紹介していきます。私がNestJSを業務でもプライベートでも触っている中で得た知見も含まれています。

私が開発しているライブラリはこちらにあります。
NestJS用に作成したライブラリ一覧 - GitHub

環境や前提条件

  • 執筆時のNestJSのバージョンは 6.10.12 です。
  • ある程度、NestJSを触っている人向けの情報になります。Module・Provider・Inject・Decoratorを知ってる&使ったことがある方だと理解しやすいと思います。知らなくてもNestJSの公式ドキュメントに説明がありますので読んでみることをおすすめします。
  • 本記事では、私が開発している@anchan828/nest-commandsをサンプルコードとして多く掲載します
  • 公式ドキュメントにドキュメント化されてないものが多数出てきます。私も正確に理解しているわけではないので、より詳細が気になる人はソースコードを読んでみてください。

モジュールの初期化

まず、ライブラリ開発において最初に作るのはModuleクラスです。これがなきゃ始まりません。

多くのライブラリでは、初期化時にオプションを渡したいので、staticメソッドをModuleクラスに実装しています。

@Module({
  imports: [CommandModule.register({ scriptName: "app-name" })]
})
class AppModule {}

可能ならDynamicModuleは同期と非同期の2つ用意しよう

次のコードにあるようにCommandModuleに加え、CommandCoreModuleを用意しています。2つのモジュールを作成しているということですね。これは必ず2つ用意する必要はなく、単に可読性を重視したものです。実際に CommandCoreModuleクラスを見てみるとやや複雑なコードが書かれています。利用者がよく見るクラスはシンプルに、裏側では複雑な処理をするという形です。

@Module({})
export class CommandModule {
  public static register(options?: CommandModuleOptions): DynamicModule {
    return {
      imports: [CommandCoreModule.register(options)],
      module: CommandModule,
    };
  }

  public static registerAsync(options?: CommandModuleAsyncOptions): DynamicModule {
    return {
      imports: [CommandCoreModule.registerAsync(options)],
      module: CommandModule,
    };
  }
}

非同期で初期化を行うAsyncメソッドは、可能であれば実装したほうが良いです。アプリケーションによっては必ずしもオプション情報は同期的に取得できるわけではなく、ClassProviderやFactoryProvider経由で取得する可能性があります。

ただ非同期でオプション情報をInjectするので、「register時にオプション情報を使いたいんだけど使えない!」ということや「オプション情報を使って複数のproviderを作成することができない!1」ということが起こります。

public static register(options: CommandModuleOptions): DynamicModule {
  // 同期タイプであればオプション情報はValueProviderとして提供されるため、この段階でオプション情報をもとに様々な処理ができる
 const providers = this.createProviders(options);
  return {
    exports: [...providers],
    module: CommandCoreModule,
    providers: [{ provide: COMMAND_MODULE_OPTIONS, useValue: options }, ...providers],
  };
}
public static registerAsync(options: CommandModuleAsyncOptions): DynamicModule {
    // オプション情報はClassProviderやFactoryProvider経由で取得する必要があるため、この段階では実際のオプション情報が得られない!オプション情報をもとに複数のproviderが作れない!
    const asyncProviders = this.createAsyncProviders(options);
    return {
      exports: asyncProviders,
      imports: [...(options.imports || [])],
      module: CommandCoreModule,
      providers: asyncProviders,
    };
  }

Async用のオプションは、ほぼ定石な実装方法があります。これから乗せるコードをそのままコピペして、クラス名や定数等を自分のライブラリに合わせて変更してください。それだけでAsyncの実装は終わります。

まずはModuleAsyncOptionsModuleOptionsFactoryの2つのinterfaceを作成します。

command.interface.ts

export interface CommandModuleAsyncOptions extends Pick<ModuleMetadata, "imports"> {
  useClass?: Type<CommandModuleOptionsFactory>;
  useExisting?: Type<CommandModuleOptionsFactory>;
  useFactory?: (...args: unknown[]) => Promise<CommandModuleOptions> | CommandModuleOptions;
  inject?: Array<Type<CommandModuleOptionsFactory> | string | any>;
}

export interface CommandModuleOptionsFactory {
  createCommandModuleOptions(): Promise<CommandModuleOptions> | CommandModuleOptions;
}

次に、providerを作成するためのメソッドを用意します。これでAsyncの実装は終わりです。

command.core-module.ts

public static registerAsync(options: CommandModuleAsyncOptions): DynamicModule {
  const asyncProviders = this.createAsyncProviders(options);
  return {
    exports: asyncProviders,
    imports: [...(options.imports || [])],
    module: CommandCoreModule,
    providers: asyncProviders,
  };
}

private static createAsyncProviders(options: CommandModuleAsyncOptions): Provider[] {
  const asyncOptionsProvider = this.createAsyncOptionsProvider(options);
  if (options.useExisting || options.useFactory) {
    return [asyncOptionsProvider];
  }
  return [
    asyncOptionsProvider,
    {
      provide: options.useClass,
      useClass: options.useClass,
    } as ClassProvider,
  ];
}

private static createAsyncOptionsProvider(options: CommandModuleAsyncOptions): FactoryProvider {
  if (options.useFactory) {
    return {
      inject: options.inject || [],
      provide: COMMAND_MODULE_OPTIONS,
      useFactory: options.useFactory,
    };
  }
  return {
    inject: [options.useClass || options.useExisting].filter(
      (x): x is Type<CommandModuleOptionsFactory> => x !== undefined,
    ),
    provide: COMMAND_MODULE_OPTIONS,
    useFactory: async (optionsFactory: CommandModuleOptionsFactory): Promise<CommandModuleOptions> =>
      await optionsFactory.createCommandModuleOptions(),
  };
}

後は必要に応じて、提供するProviderを増やしたりexportするProviderを追加したりカスタマイズすればOKです。

[余談] forRootとregisterの違い

多くのNestJS用のライブラリをみると、DynamicModuleの提供でよくforRootregisterのメソッド名を見ます。結論を先に行ってしまえば、もちろん単なるメソッド名なので動作による違いはありません。作者の好みとか目的に沿って命名する感じなので自由です。

forRootという名は、Angularから引き継がれているお作法だと思います。AngularのRouterModuleでは、forRootとforChildを使っていますね。
registerは、NestJS界隈で最近使われ始めた気がします。「命名に特に理由やこだわりがないんだけど...」という人はregister使っとけばいいかと思います。

デコレーターを作るならSetMetadataを使おう

SetMetadataReflect.defineMetadataをラッパーしたデコレーターです。

このようにクラスデコレーターやメソッドデコレーターとして使用することができます。

@SetMetadata('roles', ['user'])
class TestClass {
  @SetMetadata('roles', ['admin'])
  public update(){

  }
}

さらにSetMetadataをラップして、独自のデコレーターを作ることもできます。

const Roles = (...roles: string[]) => SetMetadata('roles', roles);

@Roles('user')
class TestClass {}

ライブラリ作るなら型は的確に指定しよう

ただ、欠点があります。SetMetadataはクラスデコレーターとメソッドデコレーター両方に対応しているため、「クラスデコレーターとして作ったデコレーターを、メソッドデコレーターとして使ってしまう」ことができてしまいます。

const ClassDecorator = SetMetadata('class', 'value');
const MethodDecorator = SetMetadata('method', 'value');

// 逆!意図しない使い方をされてしまうことになる
@MethodDecorator
class TestClass {
  @ClassDecorator
  public update(){

  }
}

一般公開するライブラリを作成するのであれば、デコレーターの型を必ず指定してあげてください。

const ClassDecorator: ClassDecorator = SetMetadata('class', 'value');
const MethodDecorator: MethodDecorator = SetMetadata('method', 'value');

// コンパイルエラーが発生。意図しない仕様を防ぐことができる
@MethodDecorator
class TestClass {
  @ClassDecorator
  public update(){

  }
}

また、SetMetadataは、プロパティーデコレーターとパラメーターデコレーターには対応していません。なので自分でReflect.defineMetadataを使って対応することになります。

各種のデコレーターのサンプルを書いておきます。私の開発しているライブラリの一部ですが、すべてのコードはcommand.decorator.tsで見れます。

command.decorator.ts
// クラスデコレーター
export function Commander(options?: CommanderOptions): ClassDecorator {
  return SetMetadata(COMMAND_MODULE_COMMANDER_DECORATOR, options || {});
}

// メソッドデコレーター
export function Command(options?: CommandOptions): MethodDecorator {
  return SetMetadata(COMMAND_MODULE_COMMAND_DECORATOR, options);
}

// プロパティーデコレーター
export function CommanderOption(options?: CommandOptionOptions): PropertyDecorator {
  return (target: Record<string, any>, key: string | symbol): void => {
    ...
  };
}
// パラメーターデコレーター
export function CommandPositional(options?: CommandPositionalOptions): ParameterDecorator {
  return (target: any, key: string | symbol, parameterIndex: number): void => {
    ...
  };
}


// 使い方

// クラスデコレーター
@Commander()
class TestCommander {
  // メソッドデコレーター
  @Command()
  public serve(
    // パラメーターデコレーター
    @CommandPositional()
    port: number,
  ): void {}

  // プロパティーデコレーター
  @CommanderOption()
  public token!: string;
}

Providerをかき集める!

ライブラリによっては、ある処理のコールバック先として、ClassProviderのメソッドを指定したい場合があります。

@Commander()
class TestCommander {
  @Command()
  public serve () {
   // ライブラリによってここを呼び出したい
  }
}

実現するためには、どうにかしてクラスのインスタンスを取得して、指定するメソッドを呼び出さなくてはいけません。 (必要によって.bind() を使う必要があります)

このときに、まずはすべてのモジュールからProviderをかき集めることになります。どのProviderを集めるかは、指定したデコレーターがあるかを判断材料とすると良いです。

// これは対象のProviderだ!
@Commander()
class TestCommander1 {
}

// これは指定のデコレーターがないので違う!
@Injectable()
class TestCommander2 {
}

実際にかき集める際に便利な機能が存在します。

ModulesContainer

ModulesContainerクラスは、単純なMap<string, Module>オブジェクトです。NestContainerがモジュールのコンパイル時にModulesContainerに各モジュールを追加して保持します。

ModulesContainerの使い方は簡単で、使用したいProvider内でinjectすればいいだけです。

@Injectable()
export class ExplorerService {
  constructor(private readonly modulesContainer: ModulesContainer) {}
}

Mapオブジェクトからvalueのみ取り出してみましょう。

const modules = [...this.modulesContainer.values()];

InstanceWrapper

ProviderのインスタンスはInstanceWrapperというオブジェクトにラップされています。InstanceWrapperにはScopeなどのProvider設定や、モジュールのコンパイル時に必要な情報がありますが、デコレーターを判断材料としてProviderをかき集める場合は不要な情報です。インスタンスさえあればいいです。

// providerのすべてのInstanceWrapperを取得する
const instanceWrappers = modules.map(module => [...module.providers.values()]).reduce((a, b) => a.concat(b), []);

また、 6.10.11 から @nestjs/core に追加された DiscoveryService を使えばよりシンプルになります。DiscoveryModuleをインポートする必要があるので注意してください。実装例はこちら explorer.service.ts

const instanceWrappers = this.discoveryService.getProviders()

InstanceWrapperのインスタンスには、Functionではないもの(ValueProviderで文字列や数値を設定しているもの)があります。なのできちんとfilterなどでフィルタリングするといいです。

const classInstanceWrappers = instanceWrappers
      .filter(instanceWrapper => instanceWrapper.instance?.constructor);

上記と同じ意味としてmetatypeも使用できます。

const classInstanceWrappers = instanceWrappers
      .filter(instanceWrapper => instanceWrapper.metatype);

Reflect.getMetadataによりメタデータが存在すれば、デコレーターを設定しているインスタンスということが判断できます。

for (const classInstanceWrapper of classInstanceWrappers) {
   const metadata = Reflect.getMetadata(
     COMMAND_MODULE_COMMANDER_DECORATOR,
     classInstanceWrapper.metatype,
   );

   if (metadata) {
     // クラスデコレーターがあるProviderだ!
   }
}

MetadataScanner

MetadataScannerは、Objectのprototypeからメソッド名の一覧を返すヘルパークラスです。

MetadataScannerにはscanFromPrototypegetAllFilteredMethodNamesの2つのメソッドがありますが、結果的にどちらもやることは同じです。私はgetAllFilteredMethodNamesでメソッド名のみを取得するのを好んでます。実装例はこちら explorer.service.ts

const instance = /* クラスのインスタンス */
const prototype = Object.getPrototypeOf(instance);

for (const methodName of this.metadataScanner.getAllFilteredMethodNames(prototype)) {
  const metadata = Reflect.getMetadata(COMMAND_MODULE_COMMAND_DECORATOR, prototype[methodName]);
  if (metadata) {
    // メソッドデコレーターがあるメソッドだ!
  }
}

プロジェクト構成どうしてる?

「NestJS用のライブラリを作ろう!」と思ったとき、まずはプロジェクトの構成を考えます。このとき大まかに3つのやり方があります。

  1. @nestjs/cliのnest newで、基本的なNestJSプロジェクトを作成する
  2. @nestjs/cliで、monorepoモードのNestJSプロジェクトを作成する
  3. 独自のプロジェクト構成を作成する

NestJSに関連するプロジェクトを作成するなら@nestjs/cliを使用して作成するのが手っ取り早くおすすめです。なのでシンプルなNestJS用のライブラリを作成するなら1の方法が適しています。

ですがmonorepoで複数パッケージに分けて公開したい!というときに2を選択することができません。@nestjs/cliのmonorepoモードはあくまで1つのアプリケーションを開発する上で細かくモジュールを切り分けてmonorepoの構成で管理するのを目的としているからです。パッケージを公開する機能は備わっていません。

理由は後述2しますが、私はmonorepoでNestJS用のライブラリを作成することが多いです。なので私は、@nestjs/cliは使わずlernaを使ったmonorepo構成にしています。
GitHubにテンプレートリポジトリとして、このmonorepo構成を公開しています。
nest-lerna-template - GitHub

nest-lerna-template の紹介

nest-lerna-templateプロジェクトの中には様々なツールが使用されています。GitHub Actionsから始まりcommitlint、compodoc、husky、prettier、lint-staged、renovate...というようにNestJS界隈でも主要なツールばかりを設定していますので是非参考にしてみてください。3

tslintはサポートがもうすぐ終了するよ!

NestJSの公式がtslintを使っていますが、早めにeslintに乗り換えておくのをおすすめします。少なくとも今後作成するNestJSプロジェクトではeslintのほうが良いです。tslintは今月いっぱいで開発が終わります4。正確には2020年11月まではアップデートは行われますがセキュリティ周りの修正くらいです。

私の扱うNestJSプロジェクトはeslintを使っていますが、不具合等はありません。使って問題ないと思います

lernaを使ったmonorepo構成にするメリット

monorepoでNestJS用のライブラリを作成する理由は単純で、複数パッケージを公開する可能性があるからです。NestJSは多くの機能を有するフレームワークです。公開するライブラリを各機能に対応させた細かなモジュールを作成する必要性が出てきます。これらをmonorepoとして管理し、複数パッケージとして公開するわけです。

わかりにくいですが、例えば@anchan828/nest-bull@anchan828/nest-bull-terminusの2つのパッケージがあります。@anchan828/nest-bull-terminusは、@nestjs/terminusを使った@anchan828/nest-bullのヘルスチェックを行うためのライブラリです。

NestJSという大きなフレームワークでは、様々な機能に自分のライブラリを対応させることになります。1つの大きなライブラリを作るのではなく、目的(express用、terminus用、graphql用、など...)に合わせて細かなパッケージを提供すると管理コストも下がると思います。

また、サンプルプロジェクトも同リポジトリに作成しておけるので、必要なinterfaceやclassなどのexport漏れがないかチェックできたり、Moduleが正しくインポートできるかチェックするのが楽になります。
サンプルプロジェクトをmonorepoで作成し公開している例はこちらのリポジトリです
nest-commands-example - GitHub

パッケージは1つしか作らないだろうと思っていても、途中でmonorepo構成に変更するのは面倒です。なので最近はとりあえずmonorepo構成にしてます。

ファイル名とかクラス名どうする?

明確な答えはありませんが、多くのライブラリを見てると次のファイルがあることが多いです。

example.constants.ts
example.core-module.ts
example.decorators.ts
example.interfaces.ts または example.types.ts
example.module.ts
example.providers.ts
example.service.ts
example.explorer.ts

公式でサポートしているライブラリを参考にしよう

なんだかんだ言って、公式のお作法を真似たほうがいいです。公式でさえ、設計方法がどんどん変わっていきます。
現段階(2019-12-20)だと、nestjs/bullのリポジトリを参考にするといいです。NestJS作者のKamilがプロジェクト構成から手を付けています。最終更新日が6日前で最も新しいのでおすすめです。

おわりに

NestJS用のライブラリを作る上で、得た知見を紹介していきました。NestJSはアップデートが早いので、もしかしたらここで紹介したことも陳腐化してしまうかもしれません。なにぶん、ドキュメント化されていないですからね...

少しでもみなさんにとって、ライブラリを作る際の手助けになれば幸いです。

明日は @naporin24690 さんの NestJSをwebpackでビルドしてTypeORMを使う です。

  1. できる方法があるなら教えてほしい...
    command.core-module.ts

  2. 「lernaを使ったmonorepo構成にするメリット」で理由を書いています。

  3. 他にもおすすめツールとかあれば教えてほしい...

  4. palantir/tslint - Roadmap: TSLint -> ESLint - GitHub

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?