Nuxt.js + TypeScript のアプリケーションで環境変数を安全に管理する


はじめに

イマドキの SPA 開発だとアプリケーションの設定を環境変数で取り扱うことが非常に多くあります。

開発環境・本番環境で変えたい API の baseURL、Google Analytics のトラッキング ID や Firebase の認証情報、ビルド後の成果物を上げる CDN の URL まで、ぶっちゃけ「大体の設定が環境変数で行われている」といっても過言ではない状態です。

ただ、割と環境変数は雑に使われます。いたるところから呼び出されます。いつか崩壊します。

なので、この記事では環境変数の利便性を残しながらも、可能な限り安全に環境変数を取り回す方法をご紹介します。

例によって例の如く、サンプル及び実現方法は Nuxt.js ですが、他の技術でも転用できるはずです。

この記事はちょこちょこ手伝わせてもらっている Omnis inc. での治安維持向上の一環で上がった話のまとめだったりもします。

React も Vue もある会社なので、フロントエンドの治安維持に興味のある人はぜひ。


tl;dr


  • やると良いこと


    • TypeScript で型定義を引き回すための定義を用意する

    • Vue の prototype に $environments を inject する

    • Universal Application の場合は不足がある場合は起動時に落とす

    • process.env へのアクセスを人力以外で禁止する



  • サンプル




環境変数を利用するモチベーションと課題感

はじめに、「そもそもなんで環境変数利用するんだっけ?」というのを整理します。


利用するモチベーション


  • アプリケーション全体に関わる根本的な設定を書き換えるのがつらいという課題


    • 外部注入なしでは production / staging を直書きするくらいしかない

    • production / staging / beta / test(unit test) みたいな形で増えるのは絶対辛い



  • フロントエンドでは環境依存の値の、ハードコーディングはまずい


    • ユーザーに見えてしまうため

    • ソースコードに https://api-beta.example.com がいるとかはやめたい


      • アクセス制限掛けていても心理的に厳しい





大体このあたりの理由で使われると思います。


課題感

一方でそのまま process.env をやることによるフロントエンド的な課題感もいくつかあります。


  • デフォルトでは NODE_ENV 程度しか定義されていないため、カスタムの環境変数にヌケモレがあったときに気づきづらい


    • クリティカルなものだとアプリケーションが死ぬ可能性がある

    • 一方でアナリティクス系の SDK の ID などはそれはそれで入れ忘れてもアプリケーションは動くので気づきづらいため事前に検知したい

    • チームメンバーが自明な環境変数を足したときにコミュニケーションが毎回発生するのも minus



  • TypeSafe ではなくどういった値なのかが分かりづらい


    • 型情報が存在しないためどういう値が入ってきたのか分かりづらい

    • 直で使うなら process.env.FOO === '1' ? true : false みたいな string to boolean のキャストの機会も失われる



  • いたるところから process.env が呼ばれて把握しづらい


    • 間にレイヤーが一枚挟まっているとバリデーションなどをする機会がでてくるがない




課題の解決方法

実際に上記の課題を解決してみます。

さっさと結論だけ確認したい人は、これを全部まとめたサンプルのコードをご確認ください。

Star してもらえると記事の更新モチベになります。

https://github.com/potato4d/how-to-safety-env-use-in-nuxt


専用の ~/plugins/environments.ts を用意する

まずは専用の環境変数用の箱を用意します。

これ自体は割と適当で良いので自分も結構適当に書いています。

重要なのは


  • Nuxt.js デフォルトでついてくる環境変数を明示する


    • browser / client / mode / modern / server / static

    • これらは外部向けに export されてないので公式の型定義は使えなさそう :cry:



  • 環境変数の型定義を export する


  • export default で inject を行い this.$environments にマッピングする

の 2 点です。


plugins/environments.ts

export type EnvironmentVariables = {

NODE_ENV: string
browser: boolean
client: boolean
mode: 'spa' | 'universal'
modern: boolean
server: boolean
static: boolean
APP_NAME: string
}

export const environments: EnvironmentVariables = {
NODE_ENV: process.env.NODE_ENV!,
browser: process.browser!,
client: process.client!,
mode: process.mode!,
modern: process.modern!,
server: process.server!,
static: process.static!,
APP_NAME: process.env.APP_NAME!
}

export default (context, inject: (name: string, v: any) => any) => {
inject('environments', environments)
}


これで this.$environments で環境変数が読み取れるようになるので、これを統一的に利用していくと良さそうです。

作ったら nuxt.config.ts の更新も忘れずに。


nuxt.config.ts

import NuxtConfiguration from '@nuxt/config'

const config: NuxtConfiguration = {
// ...
plugins: [
'~/plugins/environments.ts'
]
// ...
}
// ...


ただ、これではまだ TypeScript 側が Vue.prototype.$environments に環境変数が生えていることを検知できていないので、 shims-vue.d.ts を変更します。


~/types/shims-vue.d.ts

import Vue from 'vue'

import { EnvironmentVariables } from '~/plugins/environments'

declare module 'vue/types/vue' {
interface Vue {
$environments: EnvironmentVariables
}
}


Screen Shot 2019-07-02 at 20.47.14.png

これでバッチリ補完も効く上に、TypeSafe な環境変数の取り回しができるようになりました。

これでアプリケーション上で書くコードについては、 TypeScript ファイルでは environments.ts を import してきて、 Vue コンポーネントでは this.$environments を利用すれば良いので非常にわかりやすく安全になりました。


nuxt.config.ts でのバリデーション処理の追加

ですが、そもそもただしく環境変数が渡ってきているかどうかはわかりません。

起動時に必須であるのに存在しない環境変数があった場合などは、コード上は表現できていても実際は動作していないということになりかねません。

それらを解消するために、 nuxt.config.ts で事前のバリデーションをやってやることをおすすめします。

具体的にはこのようなコードを追加してやります。

中身としては


  • 環境変数を全て走査

  • null or undefined のものがあったらエラーとしてプロセスを異常終了

を行っているだけです。


nuxt.config.ts

import NuxtConfiguration from '@nuxt/config'

import consola from 'consola'
import { environments } from './src/plugins/environments'

if (!process.env.CI) {
Object.entries(environments).forEach(([key, value]) => {
if (['browser', 'client', 'mode', 'modern', 'server', 'static'].includes(key)) {
return
}
if (environments[key] === undefined || environments[key] === null) {
consola.error(`Missing environment variable: '${key}'`)
process.exit(1)
}
})
}


実際に動かすとこんな感じになります。

Screen Shot 2019-07-02 at 20.32.54.png

consola は Nuxt.js チームが開発して Nuxt.js 本体でも使われているだけあって、エラー時の見た目にも統一感がでて良い感じですね。

ちゃんと設定してやるとそのまま起動します。

Screen Shot 2019-07-02 at 20.33.00.png

こうすることで、


  1. 本番ビルド時

  2. 本番サーバー実行時

  3. プロセスが死に続けるから移行が終わらない事によるローリングアップデート時

の 3 つのタイミングで環境変数の設定ミスに気づくことができます。

設定ミスをした場合アプリケーションが死ぬ要因にもなるので、できるだけ設定ミスはコケさせておきましょう。

これで起動時も安心です。


process.env へのアクセスを禁止する Lint rule を書く

とはいえ鬱陶しくなって抜け道的に process.env を使われるとどうしようもないので、このあたりは ESLint のルールを書いて縛ります。

基本的に性善説をベースとしたコーディング規約は破綻するのが常なので、ルールを制定するなら Lint ルールを書くという感じで運用すると便利です。ESLint のルールはローカルで自作して使い回せるので、サンプルレポジトリを参考に試してみてください。

幸いにも eslint-plugin-vue に相乗りする形で実装されている eslint-plugin-nuxt がこれまた相乗りしやすい作りになっているので、これを使います。

コード解説は割愛します。

const utils = require('eslint-plugin-nuxt/lib/utils')

module.exports = {
meta: {
docs: {
description: 'disallow directly access to `process` in Vue component'
},
messages: {
noEnv: 'Unexpected {{name}} in Vue Component.'
}
},

create(context) {
const forbiddenNodes = []

return {
MemberExpression(node) {
const objectName = node.object.name
if (objectName === 'process') {
const propertyName = node.computed
? node.property.value
: node.property.name
if (propertyName) {
forbiddenNodes.push({ name: 'process.' + propertyName, node })
}
}
},
...utils.executeOnVue(context, (rootNode) => {
const fileName = context.getFilename()

// 環境設定用ファイルについては許可する
if (fileName.includes('plugins/environments')) {
return
}

// Get all methods
const computed = rootNode.properties.find((p) => p.key.name === 'computed')

const componentMethods = [
...rootNode.properties.filter((property) => {
return (
property.value.type === 'ArrowFunctionExpression' ||
property.value.type === 'FunctionExpression'
)
}),
...(computed ? computed.value.properties : [])
]

componentMethods.forEach((method) => {
forbiddenNodes.forEach((forbiddenNode) => {
if (utils.isInFunction(method, forbiddenNode.node)) {
context.report({
node: forbiddenNode.node,
messageId: 'noEnv',
data: {
name: forbiddenNode.name
}
})
}
})
})
})
}
}
}

実行するとこんな感じです。便利ですね。

Screen Shot 2019-07-02 at 21.19.16.png

これで最後の「最終手段に process.env の直利用をされるとどうしようもない」を解決できました。

これで基本的な治安は保たれるのではないでしょうか。


最終的なサンプルについて

最後に、これを全部まとめたサンプルを作りました。よかったら参考にしてみてください。

https://github.com/potato4d/how-to-safety-env-use-in-nuxt


おわりに

簡単にですが、Nuxt.js + TypeScript のアプリケーションを例に、 SSR/SPA で簡単に環境変数を管理できる方法をご紹介しました。

ここまでやる必要がないケースもそれなりにありそうですが、一例として。

環境変数自体は非常に柔軟で便利なシステムなので、それを活かしつつ間に一つレイヤーを増やすと幸せになれそうです。

もし Nuxt.js + TypeScript での治安のフィードバックを送りたい人は、 twitter.com/potato4d に送ってくれると私が、 https://www.wantedly.com/companies/omnisinc に連絡をすると実運用を始めつつある人達がレスポンスできると思うので、改善案などがあるかたはぜひご連絡ください。


ちなみに

余談ですが、フロントエンドの開発環境で環境変数を扱う時は、ディレクトリベースで自動で読み込んでくれる direnv を使うのがおすすめです。

https://github.com/direnv/direnv