Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

penv.macro を使ってハマった話

エンジニアの伊藤です。この記事では babel-plugin-macros を環境変数に応用した penv.macro を使ったときに遭遇した奇妙?な動きとそれを解決した経緯をまとめます。

penv.macro とは

https://github.com/chengjianhua/penv.macro

The penv.macro aims to write configurations of multiple environments in one file simultaneously and remove irrelevant configuration codes from the final bundle.

penv.macroでは、コードの中に複数の環境に対する設定を書いておいて、 babel によるコード変換処理中に環境に合わない設定を出力から一切消すことができます。環境ごとに異なる REST API のエンドポイントはよくあるパターンかと思います。テックタッチでは開発用・production 用ブラウザ拡張の ID 設定等にも用いています。

環境に対する設定の埋め込み方は非常にシンプルです。ある設定項目について、NODE_ENV に対する値をオブジェクトリテラルで記述し、設定にデフォルト値があればそれを続けて記述することができます。

const adminUrlPrefix = penv(
  {
    production: 'https://admin.example.com',
    local: 'http://localhost:3000',
  },
  'https://dev-admin.test.com'  // development ないしはその他の値が渡ったとき
)

この状態でbabelを通すと、コンパイル時の NODE_ENV に対応する値が設定値として残る仕組みです。上記の例だと、NODE_ENV=production としておき babel を通すと

const adminUrlPrefix = 'https://admin.example.com';

が出力されます。

テックタッチでも環境変数に応じて稼働する環境ごとのパラメーターを設定しています。稼働する環境以外の設定が一緒にバンドルされることを防ぐべく、penv.macro を使っています。

penv.macro はまりポイント 😢

関数 penv の定義は以下のようになっています。

export default function<T = any>(
  candidates: Record<string, T>,
  defaultValue: T,
):

定義上第1引数部分が Record<string, T> となりオブジェクトのプロパティ名に文字列が期待されるように見えます。また、ドキュメントにも

https://github.com/chengjianhua/penv.macro/blob/master/DETAILS.md

Due to this, it supports plain object only right now and the key must be a string literal, not a computed expression statement. But the property value can be anything the JavaScript syntax supports.

と記述されており、文字列ならオッケーに見えます。が、これがはまりポイントでした。

遭遇した「?」な事象

ここで、新たな local-devevelopment が加わったとします*1。ハイフンを含んでいるので明示的に文字列リテラルとして扱う必要があります。

const adminUrlPrefix = penv(
  {
    production: 'https://admin.example.com',
    local: 'http://localhost:3000',
    'local-development': 'http://localhost:3000',
  },
  'https://dev-admin.test.com'
)

ここで NODE_ENV を local-development として babel を通すとどうなるでしょう。

local-developmentに対する値ではなくデフォルト値が返ってしまう

adminUrlPrefix側がデフォルト値(!)になってしまいました。期待値は http://localhost:3000 です。

設定ミスを疑うも誤りは見つかりません。調べるうちに、プロパティ名に文字列リテラルを与えたときだけこの症状が発生し、識別子( ‘ でくくっていないもの)を用いたときは発生しないことがわかりました。

Dive into penv.macro

ここから penv を読み解いていきます。50行ほどの javascript からなっています。もとのコードは https://github.com/chengjianhua/penv.macro/blob/master/src/macro.js ですが、後述する通り管理の都合でビルドされた結果を扱っていきます。macro.js がビルドされた結果は以下のようになっています (npm install した中から引っぱってきました):

 1 "use strict";
 2  
 3  const {
 4    createMacro
 5  } = require('babel-plugin-macros');
 6  
 7  function envVariableMacro({
 8    references,
 9    babel: {
10      types: t
11    },
12    config
13  }) {
14    const {
15      targetName = 'NODE_ENV'
16    } = config;
17    const targetEnv = process.env[targetName];
18    const {
19      default: defaultReferences
20    } = references;
21    defaultReferences.forEach(referencePath => {
22      const {
23        parentPath
24      } = referencePath;
25      const argumentPath = parentPath.get('arguments')[0];
26      const defaultValue = parentPath.get('arguments')[1];
27      const matchedPropertyPath = argumentPath.get('properties').find(propertyPath => {
28        const keyName = propertyPath.get('key').node.name;
29        return keyName === targetEnv;
30      });
31      const matchedValueNode = matchedPropertyPath ? matchedPropertyPath.get('value').node : defaultValue ? defaultValue : t.nullLiteral();
32      const wrapperPath = parentPath.get('parentPath').parentPath;
33      const {
34        parent: parentNode
35      } = wrapperPath; // console.log(require('util').inspect(wrapperPath, { colors: true, depth: 4 }));
36  
37      if (t.isExpressionStatement(parentNode)) {
38        wrapperPath.remove();
39      } else {
40        parentPath.replaceWith(matchedValueNode);
41      }
42    });
43  }
44  
45  module.exports = createMacro(envVariableMacro, {
46    configName: 'penv'
47  });

penv.macro のベースである babel-plugin-macros はアプリケーション内でインポートした関数(今回の例で言うと関数 penv)で渡された引数部分を AST に展開し、そこに追加処理を行うことができるインターフェースを提供してくれます。上記関数 envVariableMacro がその処理の起点となっています。

  • 17 行目:環境変数(デフォルトで NODE_ENV)の値を取得
  • 25, 26 行目:関数 penv に渡したオブジェクトとデフォルト値に対する AST ノードを取得
  • 27 - 30 行目:オブジェクトのプロパティの中で環境変数の値と一致するプロパティ(key, valueの組)の AST ノードを取得
  • 31 行目:取得したプロパティがあればその値のノードを、なければデフォルト値のノードを取得。デフォルト値もなければ null リテラル のノードを取得
  • 以下 22 行目で決まった値で関数 penv 部分を置き換える処理

になります。

何が悪いの?

local-development プロパティの値が用いられずデフォルト値になってしまった事象を探るため、上記27 - 30 行目付近で関数 penv に渡したオブジェクトの AST ノードをのぞいてみます。

const matchedPropertyPath = argumentPath.get('properties').find(propertyPath => {
  console.log('===\n', propertyPath.get('key').node, '\n===') // 追加
  const keyName = propertyPath.get('key').node.name;
  return keyName === targetEnv;
});

そして babel を通すと、以下のような出力を得ます。

hiroki@hiroki-desktop:~/projects/penv-test$ NODE_ENV=local-development yarn babel index.js 
yarn run v1.22.4
$ /home/hiroki/projects/penv-test/node_modules/.bin/babel index.js
===
 Node {
  type: 'Identifier',
  start: 68,
  end: 78,
  loc: SourceLocation {
    start: Position { line: 5, column: 4, index: 68 },
    end: Position { line: 5, column: 14, index: 78 },
    filename: undefined,
    identifierName: 'production'
  },
  name: 'production',
  leadingComments: undefined,
  innerComments: undefined,
  trailingComments: undefined
} 
===
===
 Node {
  type: 'Identifier',
  start: 113,
  end: 118,
  loc: SourceLocation {
    start: Position { line: 6, column: 4, index: 113 },
    end: Position { line: 6, column: 9, index: 118 },
    filename: undefined,
    identifierName: 'local'
  },
  name: 'local',
  leadingComments: undefined,
  innerComments: undefined,
  trailingComments: undefined
} 
===
===
 Node {
  type: 'StringLiteral',
  start: 147,
  end: 166,
  loc: SourceLocation {
    start: Position { line: 7, column: 2, index: 147 },
    end: Position { line: 7, column: 21, index: 166 },
    filename: undefined,
    identifierName: undefined
  },
  extra: { rawValue: 'local-development', raw: "'local-development'" },
  value: 'local-development',
  leadingComments: undefined,
  innerComments: undefined,
  trailingComments: undefined
} 
===
const adminUrlPrefix = 'https://dev-admin.test.com';

関数 penv.macro に渡したオブジェクトのプロパティ名部分は、production, local は Identifier ですが、’local-development’ は StringLiteral でした。そして StringLiteral には name がないため、先の条件判定で環境変数の値に対応するプロパティがないと判定されます。結果、デフォルト値となってしまったのでした。

なおしてみる

StringLiteral が現れても大丈夫なように、また文法的に NumericLiteral も出現しうるので以下のように修正しました*2。また、設定ミスったときに備え警告も出せるようにしておきます。

const matchedPropertyPath = argumentPath.get('properties').find(propertyPath => {
  const keyNode = propertyPath.get('key').node;
  const key = t.isIdentifier(keyNode) ? keyNode.name :    // Identifier なら name をみる
    t.isStringLiteral(keyNode) ? keyNode.value :          // StringLiteral なら value をみる
    t.isNumericLiteral(keyNode) ? String(keyNode.value) : // NumericLiteral なら value をみつつ文字列に変える
    undefined;
  if (key === undefined) {
    console.warn('[penv.macro] Unsupported key type:', keyNode.type);
  }
  return key === targetEnv;
});

これで ‘local-development’ がきても期待する ‘http://localhost:3000’ が展開されるようになりました!

'local-development' に対する値が返ってきた

penv.macro はシンプルなモジュールであり、すでにメンテは継続されていないようなので、社内では patch-package モジュールを用いてパッチ管理しています:

$ cat patches/penv.macro+0.4.0.patch 
diff --git a/node_modules/penv.macro/dist/macro.js b/node_modules/penv.macro/dist/macro.js
index a350a27..c64e7a5 100644
--- a/node_modules/penv.macro/dist/macro.js
+++ b/node_modules/penv.macro/dist/macro.js
@@ -25,8 +25,15 @@ function envVariableMacro({
     const argumentPath = parentPath.get('arguments')[0];
     const defaultValue = parentPath.get('arguments')[1];
     const matchedPropertyPath = argumentPath.get('properties').find(propertyPath => {
-      const keyName = propertyPath.get('key').node.name;
-      return keyName === targetEnv;
+      const keyNode = propertyPath.get('key').node;
+      const key = t.isIdentifier(keyNode) ? keyNode.name :
+        t.isStringLiteral(keyNode) ? keyNode.value :
+        t.isNumericLiteral(keyNode) ? String(keyNode.value) :
+        undefined;
+      if (key === undefined) {
+        console.warn('[penv.macro] Unsupported key type:', keyNode.type);
+      }
+      return key === targetEnv;
     });
     const matchedValueNode = matchedPropertyPath ? matchedPropertyPath.get('value').node : defaultValue ? defaultValue : t.nullLiteral();
     const wrapperPath = parentPath.get('parentPath').parentPath;

小回りの効くモジュールなのでうまく使いたいところです。

落ち穂拾い

オブジェクトリテラル

オブジェクトを記述する方法としてオブジェクトリテラルを用いていますが、オブジェクトリテラルの定義を確認すると以下のようになっています。https://www.ecma-international.org/wp-content/uploads/ECMA-262_6th_edition_june_2015.pdf からの抜粋です:

オブジェクトリテラルの定義

オブジェクトリテラルで、プロパティを定義するときに PropertyName: AssignmentExpression の形式を取る場合の PropertyName は IdentifierName, StringLiteral, NumericLiteral の3つがすべてで、babel を介すると IdentifierName は Identifier として参照できるようになっているということです。逆にこの PropertyName: AssignmentExpression 形式以外のプロパティ定義が現れたときは penv.macro では未想定で、Error が投げられます 😇。

また、@babel/types に Identifier, StringLiteral, NumberLiteral 等、ノードの判定に必要な t.is〜 という関数が用意されています。各ノードにどのようなプロパティがあるのかも含め下記ドキュメントとしてまとまっています。

@babel/types · Babel

*1:別途定義されている API にアクセスする URL は development 環境向きだけど管理画面 URL はローカル環境的な意味合いです。また、あとから環境を追加する場合はこの表記を避ければはまらずに済みました。ただ実際には、このような表記がもともと使われていた上で penv を用いることにしたため避けられませんでした

*2:penvの用途でここに数値入れるひとはまあいないとは思いますが文法が許すので