🕺

NestJSでConfigModuleを使いやすくするtips

2022/03/09に公開

nestjsのConfigModule便利ですね。今回はこの便利なConfigModuleを更に便利にしようと思います。おさらいから入るので、実際の実装が知りたい方は途中まで飛ばしてください。

まずはおさらい

環境変数を登録する側

まずはモジュールをインストールします。

npm i --save @nestjs/config

ConfigServiceを使いたいモジュールでインポートします。forRoot()で細かな設定ができます。デフォルトでは.envファイルを読み込んでくれます。

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot()],
})
export class AppModule {}

.envではないファルを指定したり、

ConfigModule.forRoot({
  envFilePath: '.development.env',
});

グローバルにしたりもできます。

ConfigModule.forRoot({
  isGlobal: true,
});

また別ファイルで環境をjsonにまとめ

configuration.ts
export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST,
    port: parseInt(process.env.DATABASE_PORT, 10) || 5432
  }
});

それを指定して使うことも可能です。

app.module.ts
import configuration from './config/configuration';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [configuration],
    }),
  ],
})
export class AppModule {}

環境変数を使う側

ConfigModuleがグローバルに登録されていない場合は、使いたい側のモジュールにConfigModuleをインポートします。グローバルに登録されている場合は不要です。

feature.module.ts
@Module({
  imports: [ConfigModule],
  // ...
})

あとは、使いたい側のサービスなどに注入します。

feature.service.ts
import { ConfigService } from '@nestjs/config';

// 省略
constructor(private configService: ConfigService) {}

こうすることで、そのサービス内部で環境変数を取得できます。

feature.service.ts
// get an environment variable
const dbUser = this.configService.get<string>('DATABASE_USER');

// get a custom configuration value
const dbHost = this.configService.get<string>('database.host');

少し戻って、ConfigServiceを注入する際に、ジェネリクスを指定できます。ConfigService<EnvironmentVariables>の部分に注目してください。

interface EnvironmentVariables {
  PORT: number;
  TIMEOUT: string;
}

// somewhere in the code
constructor(private configService: ConfigService<EnvironmentVariables>) {
  const port = this.configService.get('PORT');

  // TypeScript Error: this is invalid as the URL property is not defined in EnvironmentVariables
  const url = this.configService.get('URL');
}

こうすると、なんとthis.configService.get()返り値に型がききます。またConfigService<EnvironmentVariables, true>と2つ目のジェネリクスを指定すると、返り値の型からundefinedが取り除かれます。

constructor(private configService: ConfigService<EnvironmentVariables>) {
  // portは number | undefined 型
  const port = this.configService.get('PORT');
}
constructor(private configService: ConfigService<EnvironmentVariables, true>) {
  // portは number型
  const port = this.configService.get('PORT');
}

そしてこれは一番驚いたのですが、this.configService.get('PORT', { infer: true })のように{ infer: true }をつけると、ドットで値にアクセスできるようになります。型推論も効きます。TypeScript v4.1 から可能になったみたいです。stackoverflow

type DBConfig = { database: { host: string } }

constructor(private configService: ConfigService<DBConfig>) {
  const dbHost = this.configService.get('database.host', { infer: true });
}

長くなりましたがおさらいは以上です。使ってて次のことを実現したいなと思いました。

  • 常に{ infer: true }にしたい
  • configurationファイルを作成して、環境変数にIntなどの変換処理をほどこしてからConfigModuleに渡したい。
  • 常にConfigServiceのジェネリクスの第1引数に、先程作ったconfigurationファイルをもとに型を生成して渡したい。
  • 常にConfigServiceのジェネリクスの第2引数をtrueにしたい。
  • 上記を毎回設定するのは大変。ConfigModuleを拡張して、これらがデフォルトで指定されているようにしたい。

独自のConfigModuleを作る

目標

import { AppConfigService } from '../config/config.service.ts';

HogeService {
  constructor(@Inject('AppConfigService') private config: AppConfigService){
    // string型が返ってくる。
    const dbHost = this.config.get('database.host');
  }
}

実装

configurationファイル作成

こちらのベストプラクティスに従って、環境変数をまとめていきます。
https://zenn.dev/dove/articles/5fd7926e7da949

config/configuration.ts
const convertOrDefault = (
  raw: string | undefined,
  defaultValue: string,
): string => {
  if (!raw) return defaultValue;

  return raw;
};

const convertIntOrDefault = (
  raw: string | undefined,
  defaultValue: number,
): number => {
  if (!raw) return defaultValue;
  const parsed = Number.parseInt(raw, 10);
  if (Number.isNaN(parsed)) return defaultValue;

  return parsed;
};

const convertBooleanOrDefault = (
  raw: string | undefined,
  defaultValue: boolean,
): boolean => {
  if (!raw) return defaultValue;
  if (raw !== 'true' && raw !== 'false') return defaultValue;
  if (raw === 'true') return true;
  if (raw === 'false') return false;

  return false;
};

export const generateAppConfig = () => {
  if (
    !process.env.DB_USER_NAME ||
    !process.env.DB_PASSWORD ||
    !process.env.DB_NAME ||
    !process.env.DB_HOST
  ) {
    console.error(
      'DB_USER_NAME or DB_PASSWORD or DB_NAME or DB_HOST environmental variable is missing!',
    );
    process.exit();
  }
  
  return {
    database: {
      // [必須] DBユーザー名
      username: process.env.DB_USER_NAME,
      // [必須] DBパスワード
      password: process.env.DB_PASSWORD,
      // [必須] データベース名
      database: process.env.DB_NAME,
      // [必須] DBのホスト名
      host: process.env.DB_HOST,
      // データーベースの種類。
      dialect: convertOrDefault(process.env.DB_KIND, 'postgres'),
      // SQLを標準ログ出力するかどうか
      logging: convertBooleanOrDefault(process.env.DB_SEQUELIZE_LOGGING, false),
    },
  };
};

export type AppConfig = ReturnType<typeof generateAppConfig>;

やっていることは、

  • 必須の環境変数がなければ、サーバーを停止
  • デフォルトの環境変数を指定
  • 数字や真偽値は変換

です。
最後にこの関数の返り値から型情報を抽出しています。これをConfigSevice<型情報>に渡してあげます。

Configモジュール作成

.envファイルの読み込みを停止し、configurationファイルを指定しています。.envを使わなくても良い開発方法に関してはこちらを参照してください。
https://zenn.dev/dove/articles/5fd7926e7da949

config/config.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

import { AppConfigService } from './config.service';
import { generateAppConfig } from './configuration';

// グローバルにする
@Global()
@Module({
  imports: [
    ConfigModule.forRoot({
      // .envよみこまない
      ignoreEnvFile: true,
      // 設定ファイル指定
      load: [generateAppConfig],
    }),
  ],
  providers: [
    {
      provide: 'AppConfigService',
      useClass: AppConfigService,
    },
  ],
  exports: ['AppConfigService'],
})
export class AppConfigModule {}

configサービスの作成

これが一番試行錯誤しました。extendsしたメソッドを呼ぶときの引数の型が難しかったorz。
しかし、これで最初に思い描いていたことがすべて達成できました!!

config/config.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService, Path, PathValue } from '@nestjs/config';

import { AppConfig } from './configuration';

// 参考: https://stackoverflow.com/questions/68411508/typescript-type-safe-string-with-dot-notation-for-query-nested-object

@Injectable()
export class AppConfigService extends ConfigService<AppConfig, true> {
  // eslint-disable-next-line no-use-before-define
  get<P extends Path<T>, T = AppConfig>(arg: P): PathValue<T, P> {
    return super.get<T, P, PathValue<T, P>>(arg, { infer: true });
  }
}

あとは環境変数を使いたいサービスからこのAppConfigServiceを呼び出してあげるだけです。

import { AppConfigService } from '../config/config.service.ts';

HogeService {
  constructor(@Inject('AppConfigService') private config: AppConfigService){
    // string型が返ってくる。
    const dbHost = this.config.get('database.host');
  }
}

Discussion