はじめに
envsubst コマンドはシェルスクリプトから使える簡易的なテンプレートエンジンであるかのように紹介されていますが、よく調べるとなぜこのような仕様になっているんだ?と疑問になるような仕様の機能を持っています。その理由は envsubst コマンドが本来 gettext.sh による国際化機能(翻訳処理)に伴う変数展開を安全に行うために作られたコマンドだからです。それを知らずに envsubst だけを見ていても使い方や仕様の意味はよくわからないでしょう。この記事では envsubst コマンドの奇妙な仕様はどのように使うもので、なぜこのような仕様になったのかについて説明します。
envsubt コマンドの使い方
基本機能(変数の展開)
envsubt コマンドは実際にはテンプレートエンジンではないのですが、次のような環境変数の値を文字列に埋め込むといった使い方をよく見かけます。
Hello ${NAME}
Your home directory is ${HOME}.
$ export NAME="Koichi"
$ envsubst < template.txt
Hello Koichi
Your home directory is /home/koichi.
埋め込む変数の書式には $VAR と ${VAR} だけが使えます。シェルの ${VAR:-} のようなものは使えません。テンプレートエンジンとしての使い方はこれだけです。条件分岐やループと言った機能はありません。ループがない理由は環境変数を配列にできないので仕方ないのかもしれませんが、条件分岐ぐらいあっても良さそうです。しかしありません。なんて低機能なテンプレートエンジンなんだ!と思うかもしれませんが、それは envsubt コマンドの本来の機能には必要ないものだからです。必要のない機能を追加したりしません。
--variables オプション(変数名の抽出)
これは、引数で指定された文字列の中から、変数名を取り出すための引数です。例えばこのような動作をします。
$ envsubst --variables 'foo ${NAME} bar $HOME baz'
NAME
HOME
どういう動作をするかはわかります。しかしなんのために使うのかさっぱりわかりません。ドキュメントを見ても動作しか書いていないので読んだだけでは使い方はわからないと思います。--variables オプションのもう一つ奇妙なところは、文字列を標準入力ではなく引数で与えるところです。--variables を指定しない場合は標準入力から与えるのですが、こちらは引数で与えます。そして標準入力の内容は無視されます。
envsubst の引数(展開する変数の制限)
envsubst コマンドに渡した引数から、テンプレートで展開する変数を制限することができます。環境変数を展開する時に余計な変数、例えば環境変数に入れているシークレットアクセスキーなどが展開されてしまったら困るので、そういうのを防ぐためだろうなというのは想像できます。
$ export NAME="Koichi"
$ envsubst < template.txt
Hello Koichi
Your home directory is /home/koichi.
$ envsubst < template.txt '$NAME'
Hello Koichi.
Your home directory is ${HOME}.
機能は理解できますが、奇妙なのは引数の形式です。なぜ NAME ではなく $NAME なのでしょうか? 普通に考えたら envsubst NAME VAR1 VAR2 のような引数にすると思います。しかし envsubst の指定の仕方は envsubst '$NAME $VAR1 $VAR2' です。しかも envsubst 'foo $NAME bar ${VAR1} baz $VAR2' のように余計な文字が入っていても無視されて $VAR または ${VAR} という書式になっているところだけを解釈します。なぜこんな面倒な解釈をしているのでしょうか?
envsubst の本来の使い方
gettext と eval_gettext の話
envsubst の奇妙な使い方を理解するには、それが本来どこに使われているかを知らなければなりません。envsubst は GNU の国際化(多言語・翻訳・日本語化)機能である GNU gettext プロジェクトに含まれているツールです。そしてシェルスクリプト用の翻訳ライブラリ gettext.sh の中で使われています。
gettext.sh はバージョン 0.21.1 でコメント・空行込でも 135 行、コメント・空行を除くと 59 行しかなく、本質的な処理をしている関数の処理はそれぞれ 1 行しかありません。簡単なシェルスクリプトなので読むのをおすすめしますと言いたいところですが、別に読まなくてもコードはここに引用してしまうものが全てだったりします。ただコメントは重要なのでやっぱり読むのをおすすめします。GNU gettext がインストールされていれば、おそらく /usr/bin/gettext.sh か /usr/local/bin/gettext.sh にあるでしょう。
まず eval_gettext 関数で envsubst がどのように使われているかコードを見てます。見ての通りたったの一行で、ここで envsubst --variables "$1" と envsubst "$1" が両方とも使われています。
eval_gettext () {
gettext "$1" | (export PATH `envsubst --variables "$1"`; envsubst "$1")
}
話をすすめるには eval_gettext と gettext が何かを説明しなければならないでしょう。ここまでの話で想像がつくかと思いますが、このコマンドは文字列の翻訳を行っています。翻訳機能に関してはこの記事の対象ではないので詳しくは説明しませんが、以下のような処理を行うということを知っていれば十分だと思います。
gettext 'Hello ${name}'
# 日本語の言語ファイルがあれば「こんにちは ${name}」
# なければ元の文字列「Hello ${name}」と出力する
name="Koichi" # 注意 name は export していません
eval_gettext 'Hello ${name}'
# 翻訳文があれば「こんにちは Koichi」
# なければ元の文字列「Hello Koichi」と出力する
# ${name} という文字列を eval_gettext は name 変数の中身に置き換えている
gettext は GNU gettext に含まれている外部コマンドで文字列の翻訳だけを行います。eval_gettext 関数は gettext.sh で定義されたシェル関数で gettext コマンドを使って翻訳した後、変数を展開する処理を追加で行っています。
なぜ eval_gettext は変数を展開できるのか?
次に以下のようなコードがなぜ動作するのか?です。この段階ではシェル変数 name はエクスポートしてないことに注意してください。
name="Koichi"
eval_gettext 'Hello ${name}' # => Hello, Koichi
気づいている人もいるかも知れませんが eval を使えば簡単です。このような感じで動かすことができます。ただしこれは翻訳文にシェルのメタ文字などを含めた場合、色々な問題が発生する危険なコードです。
eval_gettext () {
eval "echo $(gettext "$1")"
}
eval を使った危険性を避けるため、実際のコードは以下のようになっています。
eval_gettext () {
gettext "$1" | (export PATH `envsubst --variables "$1"`; envsubst "$1")
}
'Hello ${name}' はシングルクォートされているので、関数呼び出しの時点では変数は展開されません。つまり gettext 'Hello ${name}' が実行されます。出力結果は(日本語の翻訳文がある場合に)こんにちは ${name} で、この文字列に対して envsubst コマンドは変数を展開します。ここでは翻訳前の文章ではなく翻訳後の文章に対して envsubt で変数展開を行っていることに注意してください。まあ当然と言えば当然ですね。
envsubst コマンドは外部コマンドであるため name 変数の値を参照するには、name が環境変数としてエクスポートされていなければなりません。つまり export name を実行しなければいけないということです。しかし一体どの変数をエクスポートすればよいのでしょうか?それを知るには(翻訳前の)'Hello ${name}' を読み取って name を返す必要があります。envsubst --variables "$1" はその処理をするためのものです。
ここでは翻訳前の 'Hello ${name}' から変数名を取得しなければなりません。なぜなら翻訳文は翻訳ファイルに(おそらく別の人が)書くものなので全く異なる変数名に変えてしまうことが可能だからです。例えば翻訳文を間違えて こんにちは ${namae} にしてしまうことは可能です。したがって確実に name をエクスポートするには翻訳前の文字列をパースしなければいけません。
gettext 'Hello ${name}' # => こんにちは ${name}
envsubst --variables 'Hello ${name}' # => name
# 「export PATH name」を実行する
export PATH `envsubst --variables 'Hello ${name}'`
export の最初の引数 PATH はエクスポートする変数が一つもない場合に exprot が引数なしで実行されないようにするためのダミーの環境変数です。export を引数なしで実行すると環境変数一覧が出力されてします。
--variables はなぜ標準入力ではなく引数を渡すのか? それはこの時点で翻訳前の文字列を変数で持っているからです。すでに変数で持っているのであればいちいち変数の内容を出力するよりも引数で渡したほうが楽です。一方で翻訳処理自体は gettext の出力を入力とするため標準入力を使うのが適しています。
次に envsubst "$1" ですが、これは指定した変数名だけを展開するためのものです。例えば環境変数 LANG などはデフォルトでエクスポートされているため、対策なしでは翻訳ファイルから参照できてしまいます。それは予期せぬ問題を引き起こす可能性があり、場合によっては脆弱性の原因にもなりかねません。
export name="Koichi"
echo 'こんにちは ${name} $LANG' | envsubst # 引数なしだと…
# => こんにちは Koichi ja_JP.UTF-8
# 想定しない変数の展開は、予期せぬ問題を引き起こす可能性がある
echo 'こんにちは ${name} $LANG' | envsubst 'Hello ${name}'
# => こんにちは Koichi $LANG
ここでも envsubt の引数は翻訳前の文字列でなければなりません。理由は同じで翻訳ファイルは他の人が書くものだからです。
コードの意味をわかりやすくまとめるとこのようになります。envsubst の奇妙な仕様は引数として与えられるものが翻訳前の文字列だからです。翻訳前の文字列はソースコード(シェルスクリプト)に書かれている文字列なので安全ですが、翻訳後の文字列は翻訳ファイルに誰かが書いた文字列なので危険である、という前提があります。
eval_gettext () {
gettext "翻訳前の文字列" | # => 翻訳後の文字列
(
# 翻訳前の文字列からエクスポートする変数名を抽出してエクスポート
export PATH `envsubst --variables "翻訳前の文字列"`
# 翻訳前の文字列から展開する変数名を抽出して、それだけを変数展開
envsubst "翻訳前の文字列"
)
}
このように翻訳前の安全な文字列から envsubst --variables ... でエクスポートする変数名を抽出する、envsubst ... で展開する変数名を抽出する、この二つの処理で抽出した情報を使って、翻訳後の文字列を安全な形で変数を置き換えるようになっています。そのために二回 envsubst を実行し、翻訳前の文字列を二回パースしています。gettext による翻訳も加えれば三回も翻訳前の文字列を参照しています。理由はわかりましたがなんとも回りくどい仕組みですね。
さいごに
本来コマンドというのは汎用的に色んな用途で使えるように作るものです。envsubst コマンドもテンプレートエンジンと考えれば汎用的に使うことができます。しかし --variables オプションや引数の仕様は明らかに eval_gettext 関数で使うために設計されています。
envsubst の本質はテンプレートエンジンではなく printf '%s' 相当です。実際、他の言語では翻訳文に対して printf 相当のものを使って値を埋めています。ただシェルの printf には参照する引数の位置を指定する書式が使えなかっため envsubst が必要だったのです。(注意 ksh、zsh の printf にはあります。printf '%2$s %1$s\n' FOO BAR を実行してみてください)
実は私も最近まで envsubst コマンドをテンプレートエンジンとして見ていました。以前テンプレートエンジンであれば持っているはずの機能を使うにはどうすればいいかを、ドキュメントを読んで調べたのですが、そのような機能がまったくなく、しかもよくわからない機能を持っていてどうしてこのような仕様になっているのか理解できませんでした。
つい最近、シェルスクリプトで国際化するための情報を調べていて、その過程で gettext.sh の中で使われていることに気づき、ようやく envsubst コマンドの奇妙な仕様について理解することができました。コミットログを見ると gettext.sh のために envsubst が作られたのは明らかです。
b800ac7 2003-09-17 Don't mention $"..." bash shorthand any more.
c5bed35 2003-09-17 Document how to internationalize shell scripts.
2551fcd 2003-09-17 Documentation of programs of gettext-runtime package.
0b8caec 2003-09-17 Support for internationalized shell scripts.
b2ab0c4 2003-09-17 Support for internationalized shell scripts: Documentation for envsubst.
9d4b651 2003-09-17 Support for internationalized shell scripts: Shell functions.
↑ ここで gettext.sh のコードが追加されている。
a9a59a4 2003-09-17 Support for internationalized shell scripts: Program to substitute env...
↑ ここで envsubst のコードが追加されている。
1bf5e35 2003-09-16 Support only simple access to shell variables, only ASCII names,...
166ecfb 2003-09-16 Document the shell format strings.
正直言って gettext.sh の設計は良いとは言えません。eval_gettext という名前から推測するに、元は eval を使って実装しようと考えていたのではないかと思います。その過程で eval は使い方を誤ると危険だからという理由で envsubst コマンドを作ったのだと思いますが、設計が eval を使った実装に影響されすぎています。翻訳を行うだけなら以下のような仕様のコマンド(argsubst コマンド)で十分なはずです。
eval_gettext () {
# gettext は「Hello #{1}」から「こんにちは #{1}」を返す
# argsubt は #{n} を引数の指定した位置の値に展開する
# 区別しやすく # に変更したが $ のままでもよい
gettext "$1" | ( shift; argsubst "$@" )
}
eval_gettext 'Hello #{1}' "$name"
# 位置での指定は分かりづらいというのであればこのような仕様も考えられる
eval_gettext () {
# gettext は「Hello #{name}」から「こんにちは #{name}」を返す
# argsubt は #{name} を引数で指定した名前の値に展開する
gettext "$1" | ( shift; argsubst "$@" )
}
eval_gettext 'Hello #{name}' name="$name"
# name="$name" は冗長に思えるが、ソースコードと翻訳ファイルを
# 疎結合にできるというメリットが有る(変数名の変更が翻訳文に影響しない)
またテンプレートエンジンとして使うのであれば envsubst は以下のような仕様で十分でしょう。
# 全ての環境変数を展開する場合
envsubst < template.txt
# 展開する変数名を制限する場合
envsubst --allow NAME,VAR1 < template.txt
# 引数で指定する場合(キーのみの場合は環境変数を意味する)
envsubst NAME="$name" VAR1 < template.txt
# --variables オプションは不要
翻訳のために gettext.sh の内部で使うために作られたことに気づくと、envsubst の仕様の意味は理解できますが、それと同時になんとも中途半端な設計だなぁというのが今の私の感想です。簡易的なテンプレートエンジンとして使えるのは、それはそれで便利ですけどね。