🥮

NestJSのConfigurationプラクティス:.envと環境変数, バリデーション, 独自の環境変数読み出しサービス

2021/12/19に公開

NestJS Configuration は開発初期の関門

アプリケーションフレームワークを使って開発するとき、最初に固めたい部分のひとつにコンフィグレーションがあります。コンフィグレーションとは以下のようなことです:

  • ローカル、ステージ、本番など実行環境により異なる環境変数をアプリケーションで扱えるようにする
  • アプリケーション全体で使うクライアントライブラリをセットアップする

NestJS でもコンフィグレーション方法がもちろん提供されています。

https://docs.nestjs.com/techniques/configuration

いろいろな設定方法がある中、私は.envファイルと環境変数を利用する方法がシンプルでわかりやすいと考えています。しかしテスト時の環境変数上書きやバリデーションなど追加で考えたいこともあります。この記事ではローカル、ステージ、本番など実行環境により異なる環境変数をNestJSアプリケーションで扱えるようにしつつテスト時の上書きやバリデーションを行う方法を紹介します。プロジェクトを作成した直後を想定して具体的に見ていきしょう。

Configuration Module で初期化

NestJSはModuleという単位で機能を取り込んだり、または作成することを推奨しています。

https://docs.nestjs.com/modules

Configurationも例にもれずにModuleとして提供されていて、利用するには@nestjs/configパッケージをインストールします。

yarn add @nestjs/config

これで Configuration を NestJS のモジュールとして扱えるようになります。モジュールとして扱えると DI の対象となるため、環境変数や外部ファイルを NestJS の世界のインスタンスとして引き回せるようになります。

使い方はシンプルで、他のモジュールと同じようにapp.module.ts にて imports へ指定すればOKです。

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

@Module({
  imports: [ConfigModule.forRoot({
      envFilePath: ['.env.development.local'],
  })],
})
export class AppModule {}

envFilePath をみてください。ここでdotenvのファイルを指定しています。どうやらこのファイルから設定値を読み出しているようです。なるほど、そしたらこのファイルに環境変数をすべて書いてしまえば、データベースはじめいろいろな外部サービスと接続して使えそうです。しかし現実ではチームの方針や外部サービスなどのキー管理方針によってさまざまな要件があると思います。例として、以下のような要件を満たしたいとしましょう。

  1. できるだけ .env.development.local に環境変数を記載してバージョン管理したい
  2. バージョン管理できない機密情報はローカルマシンの環境変数に展開する
  3. デプロイ先は Cloud Run を想定しており、Secrets Manger との連携によりすべて環境変数に展開されているものを使いたい
  4. Jest でテストを実行するときはテスト用の環境変数で上書きしたい
  5. 読み込む環境変数をバリデーションしたい
  6. 読み込む環境変数はisProduction()などより直感的に呼び出したい

こういう話になってくると公式ドキュメントだけでは手詰まりになってきます。NestJSのConfigurationでできることを眺めながら、それぞれどう実現していくか考えてみます。

1,2,3: NestJS が .env ファイルと環境変数を合体してくれるのでそれに乗っかる

ありがたいことに 1,2,3 はほぼ Configuration Module がカバーしてくれています。envFilePath で指定した dotenv のファイルと環境変数がマージされ、アプリケーションから呼び出せるようになります。以下のような状況を想定しましょう。

.env.development.local
NODE_ENV=development
DATABASE_URL="postgresql://postgres:password@127.0.0.1:54321/hello_db_development?schema=public"
MICROCMS_KEY=use-environment-variables
shell
export MICROCMS_KEY=1234567890

このとき冒頭で示したようにenvFilePath.env.development.localを指定しModuleを初期化します。

app.module.ts(再掲)
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot({
      envFilePath: ['.env.development.local'],
  })],
})
export class AppModule {}

これでアプリケーションを起動すると次のようにして値を得ることができます。

src/components/posts/posts.resolver.ts
import { ConfigService } from '@nestjs/config';
import { Query, Resolver } from '@nestjs/graphql';

@Resolver()
export class PostsResolver {
  constructor(
    private configService: ConfigService,
  ) {}
  @Query(() => String)
  helloConfiguration(): string {
    const nodeEnv = this.configService.get<string>('NODE_ENV'); // development
    const databaseUrl = this.configService.get<string>('DATABASE_URL'); // postgresql:/...
    const microCmsKey = this.configService.get<string>('MICRO_CMS_KEY'); // 1234567890
  }
}

.env.development.local と環境変数がマージされ、同じキー名で指定されたものはdotenvのファイルよりも環境変数が優先されます。

When a key exists both in the runtime environment as an environment variable (e.g., via OS shell exports like export DATABASE_USER=test) and in a .env file, the runtime environment variable takes precedence.
https://docs.nestjs.com/techniques/configuration#getting-started

ですのでローカル開発においては、バージョン管理できる情報は積極的に .env.development.local などに切り出し、機密情報は direnv で管理するのが良いでしょう。実行環境においては必要な情報をすべて環境変数へ展開すれば、.env.development.localは完全に無視できます。ここはデプロイ先の機能をうまく使っていくのが良さそうです。例えば Cloud Run では、Secrets Manager の値を自動で環境変数へ展開してくれます。

https://cloud.google.com/run/docs/configuring/secrets

バージョン管理したい設定値はdotenvで管理し、機密情報や実行環境の設定はすべて環境変数上書きする。この方針を基本にすればシンプルに設定できそうです。

4. Jest でテストするときはモック用の環境変数で上書きしたい

ここまでとは別の環境変数を考えるシーンとえいばテストがあります。例えば開発時は外部サービスのサンドボックス環境を利用しているが、テストでは完全にモックにするので外部サービスにつなぎたくない、ということがあります。

Jestの場合、テストを実行する前に任意の設定ファイルを実行できるため、そこで環境変数を上書きする処理を書いてあげればOKです。

src/config/dotenv/unit-test-config.ts
import * as dotenv from 'dotenv';
import * as path from 'path';

// できれば環境変数を上書きするオプションがほしいところ
// https://github.com/motdotla/dotenv/issues/517
const testEnv = dotenv.config({
  path: path.join(process.cwd(), '.env.test.local'),
});

// 明示的に環境変数を上書き
Object.assign(process.env, {
  ...testEnv.parsed,
});
.env.test.local
MICROCMS_KEY=test-dummy
package.json
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node",
+    "setupFiles": [
+      "<rootDir>/config/dotenv/unit-test-config.ts"
+    ]
  }

テスト用の .env.test.localを用意して、Jest実行時に設定ファイルとして呼び出します。単純にdotenv.config()で読み込むだけでは環境変数へは設定しれくれないので読み込んだ後明示的に Object.assign(process.env,dotenv.config()) として環境変数を上書きしています。[1]

上の例はユニットテストの設定例です。E2Eテストを行うような場合はE2Eテスト用の環境変数セットが必要になるでしょう。Jestは実行時に任意のjest.config.jsを読み込めるため、E2Eテストはそれ専用のsetUpFiles: jest.e2e.jsonを使えばよさそうですね。これでテストについてもJestの仕組みをうまく使って環境変数をセットできました。

5. 環境変数をバリデーションしたい

状況に応じて環境変数を読み込めるようになりました。あとはアプリケーションからガンガン使っていきます。え?…その前に本番環境で環境変数がちゃんとセットされるか不安ですか? わかります。そうですね、では Configuration Module の初期化でひと手間追加し、環境変数をバリデーションしましょう。

app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
+import { validate } from './env-validator';

@Module({
  imports: [ConfigModule.forRoot({
      envFilePath: ['.env.development.local'],
+     validate,
  })],
})
export class AppModule {}

なにやらvalidateというものがConfigModule.forRootに追加されていますね。Configuration Module ではさまざまなバリデーション方法が用意されていて、ここで利用しているのはそのひとつです。validate関数を開発者自身で定義して、それを渡すやり方です。バリデーションに失敗した場合は例外をスローすれば、アプリケーションが起動することはありません。早速実装してみましょう。

src/config/environments/env-validator.ts
import { IsEnum, IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { plainToClass } from 'class-transformer';
import { validateSync } from 'class-validator';

enum NodeEnvEnum {
  Development = 'development',
  Production = 'production',
  Test = 'test',
}

/**
 * ①
 * バリデーションしたい環境変数がある場合はここに記載してください。
 * バリデーションに失敗するとアプリケーションは起動しません。
 */
export class EnvValidator {
  @IsEnum(NodeEnvEnum)
  NODE_ENV: NodeEnvEnum;

  @IsNumber()
  PORT = 3333; // デフォルト値も指定できる

  @IsNotEmpty()
  @IsString()
  DATABASE_URL: string;
}

/**
 * ②
 * @param config バリデーション対象の Record<string, any>。今回は .env.development.local と 環境変数が合体したもの
 * @returns バリデーション済の Record<string, any>
 */
export function validate(config: Record<string, unknown>) {
  const validatedConfig = plainToClass(EnvValidator, config, {
    enableImplicitConversion: true,
  });

  const errors = validateSync(validatedConfig, {
    skipMissingProperties: false,
  });

  if (errors.length > 0) {
    throw new Error(errors.toString());
  }
  return validatedConfig;
}

①は、class-validatorという仕組みを使うためのクラス定義です。各フィールドに対して@IsNumber()などのデコレータを指定することで、plainToClass()にてバリデーションが走ります。なぜclass-validatorを使うかというと、NestJSが提供するリクエストのバリデーション方法のひとつにこのclass-validatorを使う方法があるためです。Configでも同じ仕組みを使うことでコンテキストをそろえられます。

https://docs.nestjs.com/techniques/validation

そして②がNestJSから呼び出されるバリデーション関数です。ここでplainToClass(EnvValidator)を呼び出しいてるのでバリデーションが走ります。エラーがある場合は例外をthrowすることにより、意図しない環境変数でアプリケーションが起動することを防げます。試しに変な文字列をPORTに入れて起動してみます。

.env.development.local
PORT=88888invalid-port
> yarn start:dev
[10:07:33 AM] Starting compilation in watch mode...

[10:07:37 AM] Found 0 errors. Watching for file changes.


/backend/src/config/environments/env-validator.ts:47
    throw new Error(errors.toString());
          ^
Error: An instance of EnvValidator has failed the validation:
 - property PORT has failed the following constraints: isNumber

意図どおりバリデーションエラーになり起動が中断されました。

ここからはバリデーションを通過した後の話です。環境変数は基本的に文字列で入りますが、validatedConfigを返すことでEnvValidatorで定義した型に変換されます。つまり PORT はnumber型としてアプリケーションで使えるようになります。ただし、EnvValidatorはあくまでバリデーションと変換のためのクラスであり、この処理を通過したからと言ってEnvValidator.DATABASE_URLのように呼び出せるようになるわけではない、という点に注意してください。

src/components/posts/posts.resolver.ts
import { ConfigService } from '@nestjs/config';
import { Query, Resolver } from '@nestjs/graphql';

@Resolver()
export class PostsResolver {
  constructor(
    private configService: ConfigService,
  ) {}
  @Query(() => String)
  hello(): string {
+    return this.configService.get<number>('PORT'); // 3333 (number型になります)
  }
}

6. 環境変数はisProduction()みたいな直感的な関数で呼び出したい

バリデーションも通過し、アプリケーションで呼び出せるConfigurationは安全なものばかり。これで安心ですね。え?...どんな環境変数がセットされているのか見に行くのが大変?便利な関数でラップしたい? まぁそうですね、たしかに私もそう思います。this.configService.get<number>('PORT');は正直読む立場としてはあまり頻出してほしくない書き方です。

では、Configuration Module が提供するConfigServiceをさらにラップして、そこでisProduction()などの便利関数を定義する作戦はどうでしょうか。いわゆるコンポジションというやつですね。こうなってくると独自モジュールを作成する機運です。データベースのクライアントライブラリを使ってブログ記事の便利な読み出し口を作るのと同様、便利なコンフィグの読み出し口を作りましょう。なんでもいいのですが Pb(ペタビット)という名前のアプリケーションを作ると仮定し[2]、便利な読み出し口PbEnvサービスを用意します。

src/config/environmentsにモジュールを作ります。このパスは例であり、任意のsrc配下で構いません。

src/config/environments
├── env-validator.ts # さっき作ったバリデーションファイルも一緒に持ってきます
├── pb-env.module.ts
├── pb-env.service.ts
└── pb-env.spec.ts
src/config/environments/pb-env.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

/**
 * アプリケーションモジュールで利用する設定値は、ここから取得します。
 */
@Injectable()
export class PbEnv {
  constructor(private configService: ConfigService) {}

  isProduction(): boolean {
    return this.configService.get('NODE_ENV') === 'production';
  }

  get service() {
    return this.configService;
  }

  get NodeEnv(): string {
    return this.configService.get('NODE_ENV');
  }

  get Port(): number {
    return this.configService.get('PORT');
  }

  get DbHost(): string {
    return this.configService.get('DB_HOST');
  }
}

getter や 関数を使ってconfigServiceを隠蔽しています。このファイルをアプリケーションから呼び出すことができれば、使い勝手がよくなりそうです。モジュールもみてみます。

src/config/environments/pb-env.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { validate } from './env-validator';
import { PbEnv } from './pb-env.service';

@Global()
@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['.env.development.local'],
      validate,
      isGlobal: true,
    }),
  ],
  providers: [PbEnv],
  exports: [PbEnv],
})
export class PbEnvModule {}

app.module.tsで設定していた内容をこちらに移しました。逆にapp.module.tsは以下のようにただPbEnvModuleをimportsするだけになりました。

src/app.module.ts
import { Module } from '@nestjs/common';
import { PbEnvModule } from '@pb-config/environments/pb-env.module';
import { PbEnv } from '@pb-config/environments/pb-env.service';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostsModule } from './components/posts/posts.module';
import { PostsResolver } from './components/posts/posts.resolver';

@Module({
  imports: [
+    PbEnvModule,
    PostsModule,
  ],
  controllers: [AppController],
  providers: [AppService, PostsResolver],
})
export class AppModule {}

アプリケーションが起動するときのModules解決は、次のようになります。

  1. NestJS がアプリケーション起動時にModulesの依存関係を解決しようとする
  2. PbEnvModuleを発見
  3. PbEnvModuleにはvalidateが指定されており、PbEnvValidatorを使って環境変数をバリデーション
  4. バリデーションOKな場合はモジュールのインスタンスが生成される
  5. PbEnvModuleがexportsしているのはPbEnvというサービスであり、このサービスをアプリケーション全体で使えるようになる。PbEnvConfigServiceをラップしたもので、環境変数へアクセスするための便利な関数を定義する場所

PbEnvはアプリケーションからは次のようにして使います。

src/components/posts/posts.resolver.ts
import { PbEnv } from '@pb-config/environments/pb-env.service';
import { ConfigService } from '@nestjs/config';
import { Query, Resolver } from '@nestjs/graphql';
import { PrismaService } from '@pb-components/prisma/prisma.service';

@Resolver()
export class PostsResolver {
  constructor(
    private prisma: PrismaService,
    private configService: ConfigService,
    private pbEnv: PbEnv,
  ) {}
  
  @Query(() => String)
  hello(): string {
    return this.configService.get<number>('DATABASE_URL'); // こっちと比べて
  }
  
+  @Query(() => String)
+  helloEnv(): string {
+    return this.pbEnv.DatabaseUrl; // かなり直感的になりました。ミスも減りそう
+  }
}

ずいぶん読みやすくなりましたね。GraphQLのQueryで定義していて、実行すると以下のようになります。


実行結果。環境変数にセットしたデータベースURLが取得できていることがわかります

まとめ

この記事では NestJS Configuration のプラクティスを紹介しました。

  • NestJSが元来dotenvと環境変数を併用する想定なので、それに従えば開発環境と実行環境の環境変数をシンプルに設定できる
  • ただし、Jestでテストを実行するときはsetupFiles:で明示的に環境変数を上書きする
  • ConfigModuleは初期化するときに任意のvalidate関数を渡すことができる。class-validatorと併用することで記述量をおさえつつ安全にアプリケーションを起動できる
  • ConfigServiceをさらに別のサービスでラップすることで、isProduction()など環境変数をより直感的に呼び出せる

次:ライブラリの初期化

環境変数がInjectableなサービスになったことで、いろいろなところから直感的に呼び出せるようになりました。次の記事ではこのConfigを使って、アプリケーション全体で使うクライアントライブラリを初期化する方法を試してみます。PbEnvは環境変数を直感的に読み出せるようになっただけではなく、環境変数をまとめられる点も魅力です。例えばGraphQLの初期化ではいくつかオプションを渡すことになるのですが、NODE_ENVによって挙動を変えたい場合もあるでしょう。getterでこんなふうに書けます。

pv-env.service.ts
  get GqlModuleOptionsFactory(): GqlModuleOptions {
    // 開発:コードからスキーマを生成し、Playgroundも利用する。
    // バックエンドのコードが正なのでコードファーストアプローチを使う
    const devOptions: GqlModuleOptions = {
      autoSchemaFile: path.join(
        process.cwd(),
        'src/generated/graphql/schema.gql',
      ),
      sortSchema: true,
      debug: true,
      playground: true,
    };

    // 本番環境:実行だけ
    const prdOptions: GqlModuleOptions = {
      autoSchemaFile: true,
      debug: false,
      playground: false,
    };
    if (this.isProduction()) {
      return prdOptions;
    } else {
      return devOptions;
    }
  }

定義してしまえば、GqlModuleOptionsを返しているのでそのまま GraphQL Module に渡してあげれば終わりです。Config は最初の関門ですが、うまく突破できるとアプリケーション開発がさらに加速します。ぜひお試しください!

ソースコード

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/nestjs-config

脚注
  1. なお、Jestを実行するときは、自動的にNODE_ENV=testがセットされるためこちらで指定する必要はありません。 ↩︎

  2. 筆者のセンスには触れないでください。 ↩︎

Discussion