🏞️

evp-ts: TypeScriptで環境変数をパースするライブラリ

に公開

クラウド環境でのコンテナの運用が当たり前の昨今、Webサービスのバックエンドの設定は、コマンドライン引数でも、設定ファイルでもなく、環境変数をベースにすることが多い。設定の管理は言うまでもなくセキュリティ(可用性・機密性・完全性)において重要であり、環境変数を安全に扱う手法が求められる。

process.envを直接参照する方法

最も愚直な方法は、環境変数が必要になったタイミングで process.env を参照することだ。
しかし、これは設定ミスを事前に検出できず、予期しないタイミングで不具合が発生するリスクがあるため、実務では避けるべきである。

zodを使う

zodを使い、アプリケーションの起動時に、参照する環境変数をあらかじめパースしておくことで、well-typedにでき、設定漏れにも気づける。

import { z } from 'zod';

const configSchema = z.object({
  API_ENDPOINT: z.string(),
  API_TOKEN: z.string(),
  HTTP_PORT: z.string().transform(Number),
  DEBUG_MODE: z.string().transform(str => str === 'true' || str === '1'),
});

const config = configSchema.parse(process.env);

しかし、zodはあくまで型がついていない値を型がついた状態まで持っていくことが主眼であり、
例えば数値やブール値などが欲しい場合はtransformpreprocessなどを使い自分でパースする必要があり、やや面倒である。

environmen-ts

HERPでは、fp-tsをベースにしたenvironmen-tsという環境変数パーサーライブラリを使っている。ロギングや機密情報のマスク、数値のパーサーなどの便利な機能が揃っている。

import {
  defaultTo,
  Environment,
  EnvironmentError,
  hex,
  keyOf,
  optional,
  port,
  required,
} from '@herp-inc/environmen-ts';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import * as RE from 'fp-ts/ReaderEither';

type Config = {
  apiEndpoint: string;
  httpPort: number;
  apiToken: string;
}

const configD: Decoder<Config> = pipe(
  RE.Do,
  RE.bind('apiEndpoint', () => required('API_ENDPOINT', string())),
  RE.bind('httpPort', () => required('HTTP_PORT', port())),
  RE.bind('apiToken', () => required('API_TOKEN', string(), { sensitive: true })),
  RE.map(({ apiEndpoint, httpPort, apiToken }) => ({ apiEndpoint, httpPort, apiToken }))
);

const env = new Environment(process.env);

const config: E.Either<EnvironmentError, Config> = configD(env);

しかし、 config の項目が増えてきたある日、異変が起きた。fp-tsのpipeの引数が20件までしか対応していないため、今までの書き方ではconfigDを定義できなくなったのだ。そのため、20件単位でDecoderを分割するというぎこちない運用を余儀なくされた。
また、ReaderEitherは、いずれか一つのパースに失敗したタイミングで処理を終了し、後続の値はチェックしない。そのため、複数の項目に誤りがあった場合、何度もパーサーを実行し直す必要が発生する。これがローカル環境ならまだしも、Kubernetesのkustomizeファイルを編集したりして何度もデプロイし直していると時間が溶けてしまう。

ライブラリを利用するために fp-ts をインポートして独特の記法を使わないといけない、型定義とパーサーの両方を記述しないといけないなど、使い心地に不満が残る。しかも、最近の会社の方針としてfp-tsは非推奨にしているため、代替となるライブラリが欲しい。

evp-ts

上記の手法、特にenvironmen-tsが抱える問題を解決するため、新たなライブラリevp-tsを開発した。evp-tsによる記述は以下のようになる。

https://www.npmjs.com/package/evp-ts

import { EVP } from 'evp-ts';

const parser = EVP.object({
    API_ENDPOINT: EVP.string().description('The base URL of the API'),
    API_TOKEN: EVP.string().secret().metavar('TOKEN'),
    HTTP_PORT: EVP.number().description('The port number to listen on'),
    DEBUG_MODE: EVP.boolean().default(false),
});

type Config = EVP.TypeOf<typeof parser>;
const result: Config = parser.parse();

console.log(result);

出力:

[EVP] API_ENDPOINT=https://api.example.com
[EVP] API_TOKEN=<SHA256:12b9377c>
[EVP] HTTP_PORT=3000
[EVP] DEBUG_MODE=false (default)
{
  API_ENDPOINT: "https://api.example.com",
  API_TOKEN: "00000000-0000-0000-0000-000000000000",
  HTTP_PORT: 3000,
  DEBUG_MODE: false,
}

evp-tsには5つの特徴がある。

  • インポートはimport { EVP } from 'evp-ts';のみで完結する。名前空間を圧迫しないので、衝突の心配がなく、認知負荷も低い
  • 全ての値を網羅的にチェックし、エラーがある場合もバリデーションを中断しない
  • zodと同じように、パーサーから型定義を導出できる
  • パースした環境変数をログに残す。機密情報はハッシュ化して隠蔽できる (.secret()で指定)
    • ハッシュ化することによって、想定外の値を設定していないかがわかる
  • dotenvファイルを自動で生成できる

dotenvファイルの生成

parser.describe()を使うことで、環境変数の設定例を含むdotenvファイルを生成できる。
デフォルト値がある場合はデフォルト値、それ以外はmetavar(設定例で表示されるプレースホルダー名)が記述される。
テンプレートや、ドキュメントとして使用できる。describe()した内容をGitリポジトリに含めて、差分を見られるようにしておくのも良いだろう。

console.log(parser.describe());

出力例:

# The base URL of the API
API_ENDPOINT=<string>
API_TOKEN=TOKEN
# The port number to listen on
HTTP_PORT=<number>
DEBUG_MODE=false

未使用の環境変数の検出

環境変数名のタイプミスは時にバグの原因となり、特にデフォルト値が存在する場合はさらに厄介である。恥ずかしいことに、「本番用アダプタとテスト用のダミーアダプタを切り替えるための環境変数があり、設定されていない場合はダミーアダプタを使用する」という仕様にしてしまったせいで、環境変数の設定漏れが原因で本番環境で不具合が出るケースが社内で発生していた。

特にビジネスロジックに影響を与えるような環境変数では、デフォルト値の設定はそもそも避けることが望ましいが、evp-tsはさらに、設定ミスを検出する独自の機能を備えている。

reportUnused()もしくはrejectUnused()を使用することで、未使用の環境変数、つまり存在はするがevp-tsから参照されていない環境変数を検出し、ログに残したりエラーとみなすことができる。
assumePrefix('APP_')を指定すると、「APP_がついているのに参照していない環境変数が存在したら、ミスを疑う」という仮定ができる。
assumePrefix()を使わずに、ignoreUnused("HOME", "USER", ...)のように明示的に無視する環境変数を指定することも一応可能ではあるが、このような環境変数を網羅するのはあまり実用的とは言えない。

import { EVP } from 'evp-ts';

const parser = EVP.object({
    APP_FOO: EVP.string(),
}).assumePrefix('APP_').rejectUnused();

// APP_BAR=123 のような値が設定されている場合、エラーが発生する
const result = parser.parse();

高度な使用例

タグ付きユニオン

データベース接続設定のように、特定の環境変数に依存して、参照する環境変数の形式が動的に変わりうるケースも、union()を使うことで対応できる。

const parser = EVP.object({
    DATABASE_BACKEND: EVP.union({
        mysql: EVP.object({
            host: EVP.string().env('MYSQL_HOST').default('localhost'),
            port: EVP.number().env('MYSQL_PORT').default(3306),
        }).description('MySQL接続設定'),
        sqlite: EVP.object({
            path: EVP.string().env('SQLITE_PATH'),
        }),
    }).tag('backend')
});

// 結果の型は以下のようになる。'backend'プロパティの値に応じて、参照できるプロパティ(mysqlまたはsqlite)が決まる構造になっている。
type Config = {
    DATABASE_BACKEND: {
        backend: 'mysql';
        mysql: {
            host: string;
            port: number;
        };
    } | {
        backend: 'sqlite';
        sqlite: {
            path: string;
        };
    }
}

.describe()した場合はきちんと両方のパターンを記述する。

カスタムロガーの使用

デフォルトでは標準出力にログを出力するが、winston等のロガーを使用することも可能である。

import * as winston from 'winston';

const logger = winston.createLogger({
    transports: [
        new winston.transports.Console({
            format: winston.format.simple()
        })
    ]
});

const parser = EVP.object({
    LOG_LEVEL: EVP.enum(['error', 'warn', 'info', 'debug']).default('info'),
}).logger(logger);

const result = parser.parse();
logger.level = result.LOG_LEVEL;

各手法の比較

機能 process.env zod environmen-ts evp-ts
型安全性
エラーの一括検出
機密情報の保護
ドキュメント生成
未使用変数の検出
依存の少なさ
学習コスト

まとめ

evp-tsは、環境変数の読み込みというニッチな領域に特化し、実務に役立てるための様々な機能を提供する。
まだ社内での採用事例は少ないが、環境変数パーサーといえばevp-ts、という立ち位置を目指していきたい。

Discussion