NestJSのConfigurationプラクティス:.envと環境変数, バリデーション, 独自の環境変数読み出しサービス
NestJS Configuration は開発初期の関門
アプリケーションフレームワークを使って開発するとき、最初に固めたい部分のひとつにコンフィグレーションがあります。コンフィグレーションとは以下のようなことです:
- ローカル、ステージ、本番など実行環境により異なる環境変数をアプリケーションで扱えるようにする
- アプリケーション全体で使うクライアントライブラリをセットアップする
NestJS でもコンフィグレーション方法がもちろん提供されています。
https://docs.nestjs.com/techniques/configuration
いろいろな設定方法がある中、私は.env
ファイルと環境変数を利用する方法がシンプルでわかりやすいと考えています。しかしテスト時の環境変数上書きやバリデーションなど追加で考えたいこともあります。この記事ではローカル、ステージ、本番など実行環境により異なる環境変数をNestJSアプリケーションで扱えるようにしつつテスト時の上書きやバリデーションを行う方法を紹介します。プロジェクトを作成した直後を想定して具体的に見ていきしょう。
Configuration Module で初期化
NestJSはModuleという単位で機能を取り込んだり、または作成することを推奨しています。
Configurationも例にもれずにModuleとして提供されていて、利用するには@nestjs/config
パッケージをインストールします。
yarn add @nestjs/config
これで Configuration を NestJS のモジュールとして扱えるようになります。モジュールとして扱えると DI の対象となるため、環境変数や外部ファイルを NestJS の世界のインスタンスとして引き回せるようになります。
使い方はシンプルで、他のモジュールと同じようにapp.module.ts
にて imports へ指定すればOKです。
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot({
envFilePath: ['.env.development.local'],
})],
})
export class AppModule {}
envFilePath
をみてください。ここでdotenvのファイルを指定しています。どうやらこのファイルから設定値を読み出しているようです。なるほど、そしたらこのファイルに環境変数をすべて書いてしまえば、データベースはじめいろいろな外部サービスと接続して使えそうです。しかし現実ではチームの方針や外部サービスなどのキー管理方針によってさまざまな要件があると思います。例として、以下のような要件を満たしたいとしましょう。
- できるだけ
.env.development.local
に環境変数を記載してバージョン管理したい - バージョン管理できない機密情報はローカルマシンの環境変数に展開する
- デプロイ先は Cloud Run を想定しており、Secrets Manger との連携によりすべて環境変数に展開されているものを使いたい
- Jest でテストを実行するときはテスト用の環境変数で上書きしたい
- 読み込む環境変数をバリデーションしたい
- 読み込む環境変数は
isProduction()
などより直感的に呼び出したい
こういう話になってくると公式ドキュメントだけでは手詰まりになってきます。NestJSのConfigurationでできることを眺めながら、それぞれどう実現していくか考えてみます。
.env
ファイルと環境変数を合体してくれるのでそれに乗っかる
1,2,3: NestJS が ありがたいことに 1,2,3 はほぼ Configuration Module がカバーしてくれています。envFilePath
で指定した dotenv のファイルと環境変数がマージされ、アプリケーションから呼び出せるようになります。以下のような状況を想定しましょう。
NODE_ENV=development
DATABASE_URL="postgresql://postgres:password@127.0.0.1:54321/hello_db_development?schema=public"
MICROCMS_KEY=use-environment-variables
export MICROCMS_KEY=1234567890
このとき冒頭で示したようにenvFilePath
で.env.development.local
を指定しModuleを初期化します。
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot({
envFilePath: ['.env.development.local'],
})],
})
export class AppModule {}
これでアプリケーションを起動すると次のようにして値を得ることができます。
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 の値を自動で環境変数へ展開してくれます。
バージョン管理したい設定値はdotenvで管理し、機密情報や実行環境の設定はすべて環境変数上書きする。この方針を基本にすればシンプルに設定できそうです。
4. Jest でテストするときはモック用の環境変数で上書きしたい
ここまでとは別の環境変数を考えるシーンとえいばテストがあります。例えば開発時は外部サービスのサンドボックス環境を利用しているが、テストでは完全にモックにするので外部サービスにつなぎたくない、ということがあります。
Jestの場合、テストを実行する前に任意の設定ファイルを実行できるため、そこで環境変数を上書きする処理を書いてあげればOKです。
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,
});
MICROCMS_KEY=test-dummy
"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 の初期化でひと手間追加し、環境変数をバリデーションしましょう。
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
関数を開発者自身で定義して、それを渡すやり方です。バリデーションに失敗した場合は例外をスローすれば、アプリケーションが起動することはありません。早速実装してみましょう。
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でも同じ仕組みを使うことでコンテキストをそろえられます。
そして②がNestJSから呼び出されるバリデーション関数です。ここでplainToClass(EnvValidator)
を呼び出しいてるのでバリデーションが走ります。エラーがある場合は例外をthrowすることにより、意図しない環境変数でアプリケーションが起動することを防げます。試しに変な文字列をPORT
に入れて起動してみます。
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
のように呼び出せるようになるわけではない、という点に注意してください。
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型になります)
}
}
isProduction()
みたいな直感的な関数で呼び出したい
6. 環境変数はバリデーションも通過し、アプリケーションで呼び出せる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
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
を隠蔽しています。このファイルをアプリケーションから呼び出すことができれば、使い勝手がよくなりそうです。モジュールもみてみます。
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するだけになりました。
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解決は、次のようになります。
- NestJS がアプリケーション起動時にModulesの依存関係を解決しようとする
-
PbEnvModule
を発見 -
PbEnvModule
にはvalidate
が指定されており、PbEnvValidator
を使って環境変数をバリデーション - バリデーションOKな場合はモジュールのインスタンスが生成される
-
PbEnvModule
がexportsしているのはPbEnv
というサービスであり、このサービスをアプリケーション全体で使えるようになる。PbEnv
はConfigService
をラップしたもので、環境変数へアクセスするための便利な関数を定義する場所
PbEnv
はアプリケーションからは次のようにして使います。
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でこんなふうに書けます。
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 は最初の関門ですが、うまく突破できるとアプリケーション開発がさらに加速します。ぜひお試しください!
ソースコード
Discussion