text-template

Go text/template 総合ガイド — テンプレート語の意味論と周辺エコシステム

本ガイドは、Go標準ライブラリ text/template の全体像を、テンプレート語(テンプレート内で書く言語)の意味論と、このパッケージを土台にした周辺ツール群での実際の使われ方に重点を置いてまとめたものである。

掲載したテンプレート、Goコード、$ から始まる出力とエラーは、すべて Go 1.26.7(darwin/arm64、Apple M4 Max) で実際に実行して確認している。

目次

  1. はじめに
  2. アーキテクチャ
  3. テンプレート語の基礎
  4. ドットとスコープ
  5. 制御構造
  6. パイプラインと関数
  7. テンプレートの合成
  8. Go側のAPI
  9. html/template との違い
  10. 周辺ツールでの使われ方
  11. 落とし穴一覧
  12. まとめ
  13. 参考情報

1. はじめに

1.1 text/template とは

任意のテキストを、データと組み合わせて生成するためのテンプレートエンジンである。 Goの標準ライブラリに含まれ、外部依存がない。

t := template.Must(template.New("t").Parse("Hello, {{ .Name }}!\n"))
t.Execute(os.Stdout, map[string]string{"Name": "Ada"})
// → Hello, Ada!

設計の特徴は3つある。

特徴内容
出力形式に無関心プレーンテキスト。HTMLもYAMLもJSONもただの文字列として扱う
チューリング完全でない再帰的なテンプレート呼び出しはできるが、任意の計算はできない
拡張は関数マップのみ言語を拡張する唯一の手段が Funcs() である

3つ目が本ガイドで最も強調したい点である。 Helm、consul-template、nomad-pack、kubectl、Docker、Prometheus — これらのテンプレート機能はすべて text/template関数を足しただけであり、コア言語は同一である。したがって text/template の意味論を理解すれば、それらすべてに転用できる。

1.2 なぜこの知識が効くのか

インフラ運用の現場で遭遇するテンプレートの大半が text/template である。

ツールテンプレート機能デリミタ
Helmチャートのマニフェスト生成{{ }}
consul-templateConsul/Vault の値を設定ファイルへ反映{{ }}
Nomadジョブ仕様の template スタンザ{{ }}
nomad-packジョブ仕様そのものの生成[[ ]]
kubectl-o go-template{{ }}
Docker--format{{ }}
Prometheus / Alertmanagerアラート通知の整形{{ }}
Grafana Loki / Promtailラベルの加工{{ }}
Terraform(の一部プロバイダ)

「Helmのテンプレートが読めない」という悩みの大半は、Helm固有の話ではなく text/template の話である。 {{- が何をするのか、. がいつ変わるのか、and が何を返すのか — これらはすべて標準ライブラリの仕様である。

1.3 検証環境

$ go version
go version go1.26.7 darwin/arm64

本ガイドの検証には、次の形のハーネスを使った。

package main

import (
	"os"
	"text/template"
)

func main() {
	src, _ := os.ReadFile(os.Args[1])
	t, err := template.New("t").Parse(string(src))
	if err != nil {
		println("PARSE ERROR:", err.Error())
		return
	}
	if err := t.Execute(os.Stdout, data); err != nil {
		println("\nEXEC ERROR:", err.Error())
	}
}

パースエラーと実行エラーを区別して表示している点が重要である。 §2.3 で述べるとおり、この区別が text/template を理解する鍵になる。


2. アーキテクチャ

2.1 4段のパイプライン

テンプレート文字列
      │
      ↓  ① 字句解析(lexer)
   トークン列   {{ }} の内外を切り分け、アクションを字句に分解
      │
      ↓  ② 構文解析(parser)
    AST        text/template/parse パッケージのノード木
      │
      ↓  ③ 名前解決・関数存在確認
  検証済みAST   この段で「関数が存在しない」等を検出する
      │
      ↓  ④ 実行(exec)— データを与えて走らせる
   出力テキスト   reflect でデータを辿りながら Writer へ書く

①〜③が Parse、④が Execute である。 この境界がエラーの出方を決める。

2.2 AST を実際に覗く

text/template/parse は公開パッケージなので、構文木を直接観察できる。

tr, err := parse.Parse("demo", `A{{ if .X }}{{ .Y | upper }}{{ end }}B`,
	"{{", "}}", map[string]any{"upper": strings.ToUpper})
dump(tr["demo"].Root, 0)
*parse.ListNode `A{{if .X}}{{.Y | upper}}{{end}}B`
  *parse.TextNode `A`
  *parse.IfNode `{{if .X}}{{.Y | upper}}{{end}}`
    *parse.PipeNode `.X`
      *parse.CommandNode `.X`
    *parse.ListNode `{{.Y | upper}}`
      *parse.ActionNode `{{.Y | upper}}`
        *parse.PipeNode `.Y | upper`
          *parse.CommandNode `.Y`
          *parse.CommandNode `upper`
  *parse.TextNode `B`

この木からテンプレート語の構造がそのまま読み取れる。

ノード対応する構文
ListNodeテキストとアクションの列
TextNode{{ }} の外側の生テキスト
ActionNode{{ ... }} 1つ
PipeNodeパイプライン(| で連結されたコマンド列)
CommandNodeパイプラインの1段(関数呼び出しまたは値)
IfNode / RangeNode / WithNode制御構造
TemplateNode{{ template "x" }}

PipeNode が複数の CommandNode を持つという構造が、パイプラインの実体である。.Y | upper は「.Y を評価し、その結果を upper の最後の引数として渡す」となる。§6.1 で改めて触れる。

2.3 パース時エラーと実行時エラーの境界

この区別を誤ると、デバッグの方向を間違える。

パース時に判明するもの:

--- min / max 組み込み
PARSE ERROR: template: t:1: function "min" not defined
パース時に検出
構文エラー{{ if }} の閉じ忘れ、括弧の不一致
未定義の関数{{ min 1 2 }}min は組み込みではない)
テンプレート名の重複定義(同一Parse内)§7.4

実行時に判明するもの:

--- 存在しない struct フィールド
EXEC ERROR: template: t:1:4: executing "t" at <.Nope>: can't evaluate field Nope in type main.User

--- nil ポインタの参照
EXEC ERROR: template: t:1:12: executing "t" at <.Missing.City>: nil pointer evaluating *main.Address.City

--- 未定義テンプレートの呼び出し
EXEC ERROR: template: t:1:12: executing "t" at <{{template "nosuch"}}>: template "nosuch" not defined
実行時に検出
フィールド・メソッドが存在しない.Nope
nil ポインタの参照.Missing.City
型の不一致eq 1 "1"
関数が error を返した{{ fail }}
未定義テンプレートの呼び出し{{ template "nosuch" }}
引数の個数違いcall upper(引数不足)

**「関数名の綴り間違いはパース時に落ちるが、テンプレート名の綴り間違いは実行時まで落ちない」**という非対称がある。これはHelmチャートで {{ include "mychart.labelz" . }} のような誤りが、そのコードパスを通るまで発覚しない理由である。

エラーメッセージの構造も覚えておく価値がある。

template: t:1:12: executing "t" at <.Missing.City>: nil pointer evaluating *main.Address.City
          │ │ │              │       │                │
          │ │ │              │       │                └─ 具体的な原因
          │ │ │              │       └─ 問題のあった式
          │ │ │              └─ 実行中のテンプレート名
          │ │ └─ 列
          │ └─ 行
          └─ テンプレート名

行と列、式、原因がすべて出る。 巨大なHelmテンプレートでも位置が特定できる。

2.4 パースは1回、実行は何度でも

const src = `{{ range $i, $v := .Items }}{{ $i }}:{{ $v.Name }} {{ end }}`

func BenchmarkParseEachTime(b *testing.B) {
	for i := 0; i < b.N; i++ {
		t := template.Must(template.New("t").Parse(src))
		t.Execute(io.Discard, data)
	}
}
func BenchmarkParseOnce(b *testing.B) {
	for i := 0; i < b.N; i++ {
		pre.Execute(io.Discard, data)     // pre はパッケージ変数
	}
}
$ go test -bench=. -benchmem -run=XXX
goos: darwin
goarch: arm64
cpu: Apple M4 Max
BenchmarkParseEachTime-16        	  392786	      3066 ns/op	    4667 B/op	      75 allocs/op
BenchmarkParseOnce-16            	 1969609	       611.4 ns/op	     528 B/op	      13 allocs/op
BenchmarkConcurrentExecute-16    	 5412448	       231.8 ns/op	     528 B/op	      13 allocs/op

パースを毎回やると約5倍遅く、アロケーションは6倍近くになる。 テンプレートはパッケージ変数か sync.Once で1回だけパースし、Execute を繰り返すのが定石である。

var tmpl = template.Must(template.New("t").Parse(src))    // 起動時に1回

template.Must は「パースに失敗したら panic する」ヘルパで、起動時に落としてしまうための道具である。テンプレートの構文エラーは実行時ではなくデプロイ時に発見したい、という思想である。

2.5 並行安全性

上のベンチマークの3つ目が b.RunParallel である。

BenchmarkConcurrentExecute-16    	 5412448	       231.8 ns/op

パース済みのテンプレートに対する Execute は並行安全である。 16並列で問題なく動き、スループットも上がっている。

ただし安全なのは Execute だけである。

メソッド並行安全
Execute / ExecuteTemplate(パース完了後)
Parse
Funcs
Option / Delims
Clone✗(元テンプレートを読む)

「起動時に構築を完了させ、以後は Execute だけ」という使い方が前提になっている。 リクエストごとに Funcs を足すような設計は競合する。リクエスト固有の関数が必要なら Clone してから Funcs するが、Clone 自体のコストがかかる。


3. テンプレート語の基礎

3.1 アクションとテキスト

{{ }} の外側はそのまま出力され、内側は「アクション」として評価される。

A{{ .Name }}B
│  └─ アクション
└─ 生テキスト

アクションの中の空白は自由である。 {{.Name}}{{ .Name }} は同一である。慣習としては空白を入れる(Helmの公式チャートもそうしている)。

3.2 コメント

--- コメント
入力: A{{/* これはコメント */}}B{{- /* トリム付きコメント */ -}}C
出力: ABC

{{/* ... */}} がコメントで、出力に現れない。 トリム記法(§5.4)と併用できる。

コメントはアクションの一種なので、テキストの途中に置くと「アクションがあった」痕跡が残らない(前後のテキストがそのまま連結される)。

3.3 データの参照

type Address struct{ City, Zip string }
type User struct {
	Name  string
	Age   int
	Tags  []string
	Addr  *Address
	Meta  map[string]string
}
func (u User) Greet() string         { return "Hello, " + u.Name }
func (u User) Label(p string) string { return p + ":" + u.Name }
Name:   {{ .Name }}
Age:    {{ .Age }}
Nested: {{ .Addr.City }} / {{ .Addr.Zip }}
Map:    {{ .Meta.team }} / {{ index .Meta "tier" }}
Slice:  {{ index .Tags 0 }}
Method: {{ .Greet }}
Arg:    {{ .Label "user" }}
Len:    {{ len .Tags }}
Name:   Ada
Age:    36
Nested: London / NW1
Map:    core / 1
Slice:  math
Method: Hello, Ada
Arg:    user:Ada
Len:    2

読みどころが4つある。

① ポインタは自動的にデリファレンスされる。 .Addr*Address だが .Addr.City と書ける。

--- ポインタの自動デリファレンス
{{ .Addr.City }} / {{ (.Addr).Zip }}
→ London / NW1

② マップのキーは .Key でも index でも引ける。 ただし挙動が違う(§3.4)。

③ メソッドは引数なしなら .Greet だけで呼ばれる。 括弧は不要というより、書けない。

④ 引数つきメソッドは .Label "user" と空白区切りで書く。 これは関数呼び出しの構文と同じである。

インデックスの多段も可能である。

{{ index (dict "k" (dict "j" "deep")) "k" "j" }}
→ deep

3.4 参照の失敗はどうなるか — 3つの非対称

ここが実務で最も混乱を招く部分である。 「存在しないものを参照したとき」の挙動が、対象によって3通りに分かれる。

① struct の存在しないフィールド → 実行時エラー

入力: [{{ .Nope }}]
出力: [
EXEC ERROR: template: t:1:4: executing "t" at <.Nope>: can't evaluate field Nope in type main.User

② map の存在しないキー → エラーにならない。ただし記法で結果が違う

入力: [{{ .Meta.nosuch }}] [{{ index .Meta "nosuch" }}]
出力: [<no value>] []

.Meta.nosuch<no value> という文字列を出力し、index .Meta "nosuch" は空文字列を出力する。 同じ「キーがない」状態なのに出力が違う。

これは実害のある差である。 設定ファイルを生成しているときに <no value> という文字列が混入すると、下流のパーサが失敗する。あるいは失敗せずに <no value> という値で動いてしまう。

対策は3つある。

① index を使う                    → 空文字列になる
② Option("missingkey=zero") を設定 → 型のゼロ値になる(§8.3)
③ Option("missingkey=error")      → 実行時エラーにする(§8.3)

③が最も安全である。 設定生成では「静かに空になる」より「落ちる」ほうがよい。

③ nil ポインタ → 参照するとエラー、そのまま出力すると <nil>

入力: [{{ .Missing.City }}]
出力: [
EXEC ERROR: template: t:1:12: executing "t" at <.Missing.City>: nil pointer evaluating *main.Address.City

入力: [{{ .Missing }}]
出力: [<nil>]

まとめると次のようになる。

参照対象が無いとき危険度
.Field(struct)実行時エラー低(すぐ気づく)
.Key(map)<no value> を出力高(静かに壊れる)
index .Map "k"(map)空文字列
.Ptr.Field(nil ポインタ)実行時エラー
.Ptr(nil ポインタ)<nil> を出力

map と nil ポインタが静かに文字列を出力するのが要注意である。<no value><nil> を生成物にgrepするのは、実務的に有効な検査である。

3.5 メソッド呼び出しの規則

呼べるメソッドには条件がある。

--- 値レシーバに対するポインタメソッド
入力: {{ .PtrOnly }}         // func (u *User) PtrOnly() string
出力: EXEC ERROR: can't evaluate field PtrOnly in type main.User

--- 未公開メソッド
入力: {{ .unexported }}
出力: EXEC ERROR: can't evaluate field unexported in type main.User

--- error を返すメソッド
入力: {{ .Fail }}            // func (u User) Fail() (string, error)
出力: EXEC ERROR: template: t:1:3: executing "t" at <.Fail>: error calling Fail: method failed
条件呼べるか
公開メソッド(大文字始まり)
未公開メソッド
値を渡していて、ポインタレシーバのメソッド
ポインタを渡していて、値レシーバのメソッド
戻り値が1つ
戻り値が (T, error)✓(error が非nilなら実行中断)
戻り値が3つ以上

「ポインタを渡すか値を渡すか」でメソッドの可用性が変わる点が実装者側の注意点である。テンプレートに渡すデータは、メソッドを持たせるならポインタで渡すほうが安全である。

(T, error) を返せるのは強力である。 関数やメソッドがエラーを返すと Execute 全体が失敗するため、「必須項目が空なら生成を止める」という制御ができる。Helmの required がこの仕組みである(§10.4)。


4. ドットとスコープ

4.1 . は「現在の値」

. はテンプレート実行中の「カーソル」であり、固定ではない。 rangewithtemplate の3つが . を差し替える。

Execute(w, data)          → . = data
  {{ range .Items }}      → . = Items の各要素
  {{ with .Addr }}        → . = Addr
  {{ template "x" .Foo }} → 呼ばれた側の . = .Foo

Helmテンプレートで {{ include "x" . }} の末尾に . を書く理由がこれである。 引数を渡さないと、呼ばれた側の . が nil になる。

--- 引数なしの template 呼び出しは . が nil になる
入力: {{ define "d" }}[dot={{ . }}]{{ end }}{{ template "d" }}|{{ template "d" . }}
出力: [dot=<no value>]|[dot=map[Fn:0x1002ae080 M:map[...] Name:Ada]]

4.2 $ はルート

$Execute に渡された最上位の値を常に指す。

入力: {{ range .Tags }}{{ . }}@{{ $.Name }} {{ end }}
出力: math@Ada eng@Ada

range の中で . は要素になっているが、$.Name でルートの Name に届く。range の中からグローバルな設定値を参照する定型がこれである。 Helmでいえば {{ $.Values.global.registry }} のような書き方になる。

4.3 変数の宣言と代入

--- 変数の宣言と再代入(1.11+)
入力: {{ $x := 1 }}{{ $x }} {{ $x = 2 }}{{ $x }}
      {{ $s := "" }}{{ range .Tags }}{{ $s = print $s . "," }}{{ end }}accum={{ $s }}
出力: 1 2
      accum=math,eng,
記法意味導入
$x := 値宣言(新しい変数)当初から
$x = 値代入(既存変数の更新)Go 1.11

= による代入は Go 1.11 で追加された。 それ以前は「ループ内で値を積み上げる」ことができなかった。上の accum の例が典型的な用途である。

宣言アクションは何も出力しない。 {{ $x := 1 }} は空文字列を出力する。

4.4 スコープ規則

--- スコープ: range 内の := は外に漏れない
入力: {{ $n := 0 }}{{ range .Tags }}{{ $n := 99 }}{{ end }}n={{ $n }}
出力: n=0

変数のスコープは、宣言されたブロックの終わりまでである。 range / if / with の内側で := すると、そのブロック内だけの新しい変数になる。

ループの外に結果を持ち出したいなら = を使う。

{{ $n := 0 }}
{{ range .Items }}{{ $n = add $n 1 }}{{ end }}    ← = なら外の $n を更新
{{ $n }}

この規則を知らないと「ループでカウントしたのに0のまま」という定番のバグを踏む。


5. 制御構造

5.1 if と真偽判定

{{ if 条件 }}...{{ else if 条件 }}...{{ else }}...{{ end }}

真偽判定は「空かどうか」で決まる。 Goの bool だけでなく、あらゆる型が判定対象になる。

入力: zero-int:[{{ if 0 }}T{{ else }}F{{ end }}] empty-str:[{{ if "" }}T{{ else }}F{{ end }}]
出力: zero-int:[F] empty-str:[F]

「空」の定義は次の通りである。

空とみなされる値
boolfalse
数値0
文字列""
スライス / マップ / 配列長さ0
ポインタ / インターフェースnil
構造体常に非空(フィールドが全てゼロでも真)

構造体が常に真になる点に注意が必要である。 {{ if .Config }}Config が構造体なら常に通る。ポインタにしておけば nil 判定ができる。

5.2 range

range は最も機能が多い制御構造である。

--- index と value
入力: {{ range $i, $v := .Tags }}{{ $i }}={{ $v }} {{ end }}
出力: 0=math 1=eng

--- else(対象が空のとき)
入力: {{ range .Empty }}{{.}}{{ else }}(no items){{ end }}
出力: (no items)

break / continue は Go 1.18 で追加された。

--- break / continue(1.18で追加)
入力: {{ range $i, $v := .Tags }}{{ if eq $i 1 }}{{ break }}{{ end }}{{ $v }} {{ end }}|
      {{ range $i, $v := .Tags }}{{ if eq $i 0 }}{{ continue }}{{ end }}{{ $v }} {{ end }}
出力: math |
      eng

整数を直接 range できる。

入力: {{ range 3 }}x{{ end }}
出力: xxx

入力: {{ range $i := 3 }}{{ $i }},{{ end }}
出力: 0,1,2,

マップを range するとキーでソートされる。 これはGoの言語仕様(マップの反復順序は不定)とは逆の挙動で、テンプレートの出力を決定的にするための意図的な設計である。

--- range over map はキーでソートされる(3回実行)
入力: {{ range $k, $v := .M }}{{ $k }}={{ $v }} {{ end }}    // M は zebra/apple/mango/banana
出力: apple=2 banana=4 mango=3 zebra=1
      apple=2 banana=4 mango=3 zebra=1
      apple=2 banana=4 mango=3 zebra=1

printf "%v" でもマップはソートされるfmt 側の挙動、Go 1.12以降)。

入力: {{ printf "%v" .M }}
出力: map[apple:2 banana:4 mango:3 zebra:1]

この性質のおかげで、生成した設定ファイルをGitにコミットして差分を見る運用が成立する。 ソートされなければ、実行するたびに順序が変わって差分だらけになる。

range できる型をまとめる。

1変数2変数
スライス / 配列要素インデックス, 要素
マップキー(ソート済), 値
チャネル受信値(不可)
整数0..n-1インデックス(1変数と同じ)

5.3 with

with は「値が空でなければ . を差し替えて実行する」構文である。

入力: {{ with .Addr }}{{ .City }}{{ else }}no addr{{ end }}
      {{ with .Missing }}{{ .City }}{{ else }}no addr{{ end }}
出力: London
      no addr

else 節は Go 1.11 で追加された。 それ以前は withelse が書けなかった。

変数束縛もできる。

入力: {{ with $a := .Addr }}{{ $a.City }}/{{ .Zip }}{{ end }}
出力: London/NW1

$a. の両方が使える。深いパスを繰り返し書くのを避けるのが主な用途である。

{{ with .Values.image }}
  image: {{ .repository }}:{{ .tag }}
{{ end }}

with は nil ガードも兼ねる。 上の例で .Values.image が nil なら中身はスキップされ、.repository の nil 参照エラーが起きない。consul-template の {{ with secret "..." }} はこの性質を使っている(§10.5)。

5.4 空白制御

生成物の見た目を制御する、実務で最も使う機能である。

--- 空白制御なし(· は空白、¶ は行末)
入力: A
      {{ if .Admin }}
      X
      {{ end }}
      B
出力: A¶
      ¶
      X¶
      ¶
      B¶

アクションが書かれていた行に、空行が残る。 {{ if }} 自体は何も出力しないが、その前後の改行はテキストとして残るためである。

{{- は直前の空白(改行を含む)を、-}} は直後の空白を削除する。

--- 左トリムのみ
入力: A
      {{- if .Admin }}
      X
      {{- end }}
      B
出力: A¶
      X¶
      B¶

--- 両側トリム
入力: A
      {{- if .Admin -}}
      X
      {{- end -}}
      B
出力: AXB¶

行内の空白も削除される。

入力: A   {{- "X" -}}   B
出力: AXB
記法効果
{{- アクションの直前の連続する空白・タブ・改行を削除
-}}アクションの直後の連続する空白・タブ・改行を削除

注意点は2つある。

- とアクションの間に空白が必要である。 {{-if}} は不正で、{{- if}} と書く。逆に {{ - 1 }} は「マイナス1」であってトリムではない。

② トリムは「連続する空白すべて」を消す。 1つだけ消すのではない。A {{- "X" }} は空白3つすべてが消える。

YAMLを生成する場合、この制御が生死を分ける。

# 悪い例 — 空行が入って YAML が壊れる可能性
metadata:
  labels:
{{ range $k, $v := .Labels }}
    {{ $k }}: {{ $v }}
{{ end }}

# 良い例
metadata:
  labels:
{{- range $k, $v := .Labels }}
    {{ $k }}: {{ $v }}
{{- end }}

Helmチャートが {{- を多用しているのは、YAMLのインデントと空行が意味を持つためである。


6. パイプラインと関数

6.1 パイプライン

| は「左の結果を、右の関数の最後の引数として渡す」演算子である。

--- パイプライン
入力: {{ "hello" | upper }} / {{ upper "hello" }} / {{ repeat "ab" 3 | upper }}
出力: HELLO / HELLO / ABABAB
{{ "hello" | upper }}     ≡  {{ upper "hello" }}
{{ .X | f "a" }}          ≡  {{ f "a" .X }}          ← 最後の引数になる
{{ .X | f "a" | g "b" }}  ≡  {{ g "b" (f "a" .X) }}

「最後の引数」であることが重要である。 sprig の関数群がこの規約に合わせて引数順を設計しているため、{{ .Value | default "fallback" }} のような自然な書き方ができる(§10.3)。

括弧で優先順位を明示できる。

入力: {{ (repeat "-" 5) }} / {{ printf "%s" (upper .name) }}
出力: ----- / ADA

引数の中に関数呼び出しを書くには括弧が必須である。 {{ printf "%s" upper .name }} は「printf に3つの引数を渡す」と解釈されてしまう。

6.2 組み込み関数は19個しかない

text/template の組み込み関数は、ソース上で以下の19個のみである。

// go1.26.7/src/text/template/funcs.go
return FuncMap{
	"and":      and,
	"call":     emptyCall,
	"html":     HTMLEscaper,
	"index":    index,
	"slice":    slice,
	"js":       JSEscaper,
	"len":      length,
	"not":      not,
	"or":       or,
	"print":    fmt.Sprint,
	"printf":   fmt.Sprintf,
	"println":  fmt.Sprintln,
	"urlquery": URLQueryEscaper,

	// Comparisons
	"eq": eq, // ==
	"ge": ge, // >=
	"gt": gt, // >
	"le": le, // <=
	"lt": lt, // <
	"ne": ne, // !=
}
分類関数
論理and, or, not
比較eq, ne, lt, le, gt, ge
出力整形print, printf, println
データ操作index, slice, len
呼び出しcall
エスケープhtml, js, urlquery

文字列操作関数が1つも無い。 uppertrimreplacesplitjoin — すべて存在しない。算術関数も無い。addsubmul も無い。

この極端な最小主義が、Funcs() による拡張を必須にしている。 そして「みんなが同じ関数を実装する」ことになり、その共通実装として sprig が事実上の標準になった(§10.3)。

min / max も組み込みではない。

入力: min={{ min 3 1 2 }} max={{ max 3 1 2 }}
出力: PARSE ERROR: template: t:1: function "min" not defined

Go言語には 1.21 で min/max 組み込みが入ったが、テンプレートには入っていない。 混同しやすい点である。

6.3 and / or は真偽値を返さない

これは最も誤解されている挙動である。

入力: [{{ and "a" "b" }}] [{{ or "" "fallback" }}] [{{ not "" }}]
出力: [b] [fallback] [true]
関数戻り値
and a b c最初の空の引数、すべて非空なら最後の引数
or a b c最初の非空の引数、すべて空なら最後の引数
not a真偽値bool

and / or は Go の && / \|\| ではなく、Python の and / or に近い。 値を返す。

この性質は実用的である。

{{ or .Values.override .Values.default "hardcoded" }}
   → 最初に見つかった非空の値を採用する

しかし出力に使うと事故になる。

{{ if and .A .B }}...{{ end }}      ← 条件として使うなら問題ない
{{ and .A .B }}                     ← 出力すると .B の値が出る(true ではない)

真偽値が欲しいなら not (not x) を使うというイディオムがある。

6.4 短絡評価

Go 1.18 で and / or は短絡評価になった。

入力: and short-circuit: {{ and false .Missing.City }}
      or short-circuit: {{ or true .Missing.City }}
出力: and short-circuit: false
      or short-circuit: true

.Missing.City は nil ポインタ参照なので、評価されればエラーになる。 それが起きていないので、短絡していることが確認できる。

Go 1.18 より前は両辺が必ず評価された。 つまり次のような nil ガードが動かなかった

{{ if and .Addr .Addr.City }}...{{ end }}

古いGoで書かれたテンプレートには、この制約を回避するための入れ子の ifwith が残っていることがある。

{{ with .Addr }}{{ if .City }}...{{ end }}{{ end }}    ← 旧来の書き方
{{ if and .Addr .Addr.City }}...{{ end }}              ← 1.18 以降はこれで動く

6.5 print / printf / println の細部

--- printf の verb
入力: {{ printf "%s|%q|%d|%05.2f|%v|%T" "a" "b" 42 3.14159 .n .n }}
出力: a|"b"|42|03.14|3|int

printffmt.Sprintf そのものである。 %v%q%T、幅・精度指定がすべて使える。

--- print / println
入力: [{{ print "a" "b" 1 2 }}] [{{ print "a" 1 "b" }}] [{{ println "x" }}]
出力: [ab1 2] [a1b] [x
]

printfmt.Sprint であり、「両隣が文字列でないときだけ空白を入れる」という規則を持つ。

print "a" "b" 1 2   → "ab1 2"     ← 1 と 2 の間だけ空白
print "a" 1 "b"     → "a1b"       ← 空白なし

この規則を知らないと、文字列連結で意図しない空白が入る。 確実に連結したいなら printf "%s%s" を使う。

println は末尾に改行を付ける。 テンプレート内で改行を出すのに使えるが、{{ "\n" }} のほうが意図が明確である。

6.6 比較関数と型

入力: eq={{ eq 1 1 }} ne={{ ne 1 2 }} lt={{ lt 1 2 }} ge={{ ge 2 2 }} eq-multi={{ eq 3 1 2 3 }}
出力: eq=true ne=true lt=true ge=true eq-multi=true

eq は複数引数を取れる。 eq x a b c は「xabc のいずれかに等しい」を意味する。in 演算子の代用になる。

{{ if eq .Env "dev" "stg" "test" }}非本番{{ end }}

型が違うと比較できない。

入力: {{ eq 1 "1" }}
出力: EXEC ERROR: template: t:1:3: executing "t" at <eq 1 "1">: error calling eq: incompatible types for comparison: int and string

JSONやYAML由来のデータでこれを踏みやすい。 1 として読まれた値と "1" を比較しようとして落ちる。printf "%v" で文字列化してから比較するのが回避策になる。

{{ if eq (printf "%v" .Version) "1" }}...{{ end }}

6.7 call と関数値

call は「関数値」を呼ぶための関数である。

--- call には関数値が必要
入力: {{ call .Fn "x" }}       // .Fn は strings.ToUpper(データとして渡した関数値)
出力: X

FuncMap に登録した関数名は call に渡せない。

入力: {{ call upper "x" }}
出力: EXEC ERROR: template: t:1:33: executing "t" at <upper>: wrong number of args for upper: want 1 got 0

理由は、テンプレート内で upper と書いた時点で「引数0個の呼び出し」として評価されるためである。call の用途は「データ構造の中に関数が入っている場合」に限られる。

6.8 indexslice

入力: {{ index (dict "k" (dict "j" "deep")) "k" "j" }}
出力: deep

入力: {{ slice "abcdef" 1 3 }}
出力: bc

入力: {{ slice (dict) }}
出力: EXEC ERROR: error calling slice: can't slice item of type map[string]interface {}
関数対象意味
index x k1 k2 ...マップ / スライス / 配列 / 文字列多段のインデックス参照
slice x lo hiスライス / 配列 / 文字列部分列(x[lo:hi]
len x文字列 / スライス / マップ / 配列 / チャネル長さ

index は「マップのキーが識別子として不正な場合」に必須である。 .Meta.my-key は書けない(- が演算子と解釈される)ので index .Meta "my-key" とする。Kubernetesのアノテーション(prometheus.io/scrape など)を参照するときに必ず使う。

{{ index .metadata.annotations "prometheus.io/scrape" }}

6.9 エスケープ関数

入力: {{ html "<b>&</b>" }} | {{ js "a'b" }} | {{ urlquery "a b&c" }}
出力: &lt;b&gt;&amp;&lt;/b&gt; | a\'b | a+b%26c

これらは手動で呼ぶものである。 text/template は自動エスケープしない(§9)。


7. テンプレートの合成

7.1 define / template / block

{{- define "row" -}}
  - {{ .Name }} x{{ .Qty }}
{{ end -}}

{{- block "header" . -}}
== {{ .Title }} ==
{{ end -}}

{{ range .Items }}{{ template "row" . }}{{ end -}}
{{ range .Empty }}{{ template "row" . }}{{ else }}(no items)
{{ end -}}
[defined templates]
  "row"
  "header"
  "t"
[output]
== Report ==
- alpha x2
- beta x0
(no items)
構文意味
{{ define "name" }}...{{ end }}名前付きテンプレートを定義する(その場では実行しない)
{{ template "name" arg }}定義済みテンプレートを呼び出す
{{ block "name" arg }}...{{ end }}定義と呼び出しを同時に行う(既定の実装つき)

blockdefine + template の糖衣である。

{{ block "x" . }}default{{ end }}
    ≡
{{ define "x" }}default{{ end }}{{ template "x" . }}

t.Templates() で定義済みのテンプレート一覧が取れる。 上の例では rowheader、そして本体の t が返っている。block も名前付きテンプレートとして登録される点が確認できる。

7.2 継承パターン

block で既定を用意し、define で上書きするのが Go 流のテンプレート継承である。

const base = `{{ block "title" . }}DEFAULT TITLE{{ end }}
body: {{ .Body }}
{{ block "footer" . }}-- default footer --{{ end }}`

const child = `{{ define "title" }}CHILD TITLE{{ end }}`
=== base only
DEFAULT TITLE
body: hello
-- default footer --

=== base + child override
CHILD TITLE
body: hello
-- default footer --

title だけが差し替わり、footer は既定のまま残る。 これがレイアウト継承の実装である。

7.3 Clone の役割

Parse は受け側のテンプレートを破壊的に変更する。 したがって1つのベースから複数の子を作るには Clone が必要になる。

--- Clone で2つの子を作る
[child1][child2] base=[base]

--- Clone せず2つの子を作ろうとすると
base after two overrides=[child2]
base := template.Must(template.New("b").Parse(`[{{ block "slot" . }}base{{ end }}]`))

// Clone すれば base は無傷、子は独立
c1 := template.Must(template.Must(base.Clone()).Parse(`{{ define "slot" }}child1{{ end }}`))
c2 := template.Must(template.Must(base.Clone()).Parse(`{{ define "slot" }}child2{{ end }}`))
// → [child1] [child2]、base は [base] のまま

// Clone しないと base 自身が上書きされ、2つ目が勝つ
template.Must(base.Parse(`{{ define "slot" }}child1{{ end }}`))
template.Must(base.Parse(`{{ define "slot" }}child2{{ end }}`))
// → base は [child2]

Webアプリでレイアウト+ページの組み合わせを作るとき、Clone を忘れると全ページが最後にパースしたページになる。 典型的なバグである。

7.4 再定義の規則 — 3つの非対称

同名テンプレートの再定義は、状況によって「エラー」「後勝ち」「無視」に分かれる。

① 同一の Parse 呼び出し内で2回 → パースエラー

入力: {{ define "d" }}one{{ end }}{{ define "d" }}two{{ end }}
出力: ERROR: template: x:1: template: multiple definition of template "d"

Parse を分けて2回 → 後勝ち

t.Parse(`{{ define "d" }}one{{ end }}{{ template "d" }}`)
t.Parse(`{{ define "d" }}two{{ end }}`)
→ two

③ 空の define での上書き → 無視される

base := `[{{ block "slot" . }}base{{ end }}]`
base.Parse(`{{ define "slot" }}{{ end }}`)
→ [base]        ← 上書きされない

③は意図的な仕様である。 公式ドキュメントに次の記述がある。

a template definition with a body containing only white space and comments is considered empty and will not replace an existing template's body (本体が空白とコメントのみのテンプレート定義は空とみなされ、既存のテンプレート本体を置き換えない)

「ブロックを空にして消す」ことはできない。 空にしたいなら空白以外の何か(例えばコメントではなく {{ "" }})を置く必要がある。

この3つの非対称が、Helmのようなテンプレート群を扱うときの混乱源になる。 「上書きしたはずなのに効かない」の原因が③であることは少なくない。

7.5 ParseFiles / ParseGlob / ParseFS と命名の罠

=== ParseFiles: テンプレート名はベースファイル名になる
t.Name() = base.tmpl
  "base.tmpl"
  "child.tmpl"
  "page.tmpl"
  "body"

ファイルから読むと、テンプレート名は「ディレクトリを除いたベースファイル名」になる。 tpl/base.tmplbase.tmpl という名前になる。

そして最大の罠がこれである。

=== template.New(name).ParseFiles の罠
  t.Name() = myname
  Execute → [EXEC ERROR: template: myname: "myname" is an incomplete or empty template]
// 動かない
t := template.Must(template.New("myname").ParseFiles("tpl/page.tmpl"))
t.Execute(os.Stdout, data)      // → "myname" is an incomplete or empty template

// 動く(名前を指定しない)
t := template.Must(template.ParseFiles("tpl/page.tmpl"))
t.Execute(os.Stdout, data)

// 動く(名前を明示して実行する)
t := template.Must(template.New("myname").ParseFiles("tpl/page.tmpl"))
t.ExecuteTemplate(os.Stdout, "page.tmpl", data)

template.New("myname") で作った時点で「myname という空のテンプレート」が本体になり、Execute はそれを実行しようとする。 ファイルから読んだ内容は page.tmpl という別名で登録されているため、本体は空のままである。

ParseFSembed と組み合わせて使う。

//go:embed tpl/*.tmpl
var fsys embed.FS

e := template.Must(template.ParseFS(fsys, "tpl/*.tmpl"))
e.ExecuteTemplate(os.Stdout, "page.tmpl", data)

バイナリにテンプレートを埋め込めるので、単一バイナリで配布するツールでは標準的な手法である。

Templates() の順序は保証されない。

=== Templates() の順序は安定か(3回)
body,base.tmpl,child.tmpl,page.tmpl
page.tmpl,body,base.tmpl,child.tmpl
base.tmpl,body,child.tmpl,page.tmpl

実行ごとに順序が変わる(内部がマップのため)。テンプレート一覧を表示する用途では自分でソートする必要がある。


8. Go側のAPI

8.1 主要な型とメソッド

type Template struct { /* ... */ }

// 生成
func New(name string) *Template
func Must(t *Template, err error) *Template

// パース
func (t *Template) Parse(text string) (*Template, error)
func ParseFiles(filenames ...string) (*Template, error)
func ParseGlob(pattern string) (*Template, error)
func ParseFS(fs fs.FS, patterns ...string) (*Template, error)

// 設定
func (t *Template) Funcs(funcMap FuncMap) *Template
func (t *Template) Option(opt ...string) *Template
func (t *Template) Delims(left, right string) *Template

// 複製・検査
func (t *Template) Clone() (*Template, error)
func (t *Template) Lookup(name string) *Template
func (t *Template) Templates() []*Template
func (t *Template) Name() string
func (t *Template) DefinedTemplates() string

// 実行
func (t *Template) Execute(wr io.Writer, data any) error
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error

メソッドチェーンを前提にした設計である。

tmpl := template.Must(
	template.New("t").
		Funcs(myFuncs).
		Option("missingkey=error").
		Delims("[[", "]]").
		Parse(src),
)

Funcs / Option / DelimsParse より前に呼ぶ必要がある。 Funcs は関数の存在確認がパース時に行われるため、後から足しても遅い。Delims も当然パース前でなければ意味がない。

8.2 Funcs — 拡張の唯一の入口

t.Funcs(template.FuncMap{
	"upper":  strings.ToUpper,
	"repeat": strings.Repeat,
	"fail":   func() (string, error) { return "", fmt.Errorf("boom") },
})

関数の要件は次の通りである。

条件内容
戻り値1個、または (T, error) の2個
戻り値が0個
戻り値が3個以上
引数任意個。可変長も可
名前有効なGo識別子でなければ panic

error を返すと Execute 全体が失敗する。

入力: {{ fail }}
出力: EXEC ERROR: template: t:1:3: executing "t" at <fail>: error calling fail: boom

この仕組みが「検証を関数として書く」パターンを可能にしている。 Helm の required、独自の mustBeOneOf のような関数がこれで実装される。

8.3 Optionmissingkey の3モード

--- missingkey=default(既定)
map[string]string  [<no value>]
map[string]int     [<no value>]

--- missingkey=zero
map[string]string  []
map[string]int     [0]

--- missingkey=error
map[string]string  [EXEC ERROR: ... map has no entry for key "nosuch"
map[string]int     [EXEC ERROR: ... map has no entry for key "nosuch"
挙動
missingkey=default(= invalid、既定)<no value> を出力
missingkey=zeroマップの要素型のゼロ値を出力
missingkey=error実行時エラーにする

missingkey=zero の注意点 — マップが map[string]any の場合、any のゼロ値は nil なので <no value> が出る。

--- missingkey=zero、data が map[string]any の場合
[<no value>]        ← zero を指定しても <no value> のまま

JSONを map[string]any に読んで渡す構成では missingkey=zero が効かない。 この構成は極めて一般的なので、実質的な選択肢は defaulterror になる。

設定ファイルを生成する用途では missingkey=error を強く推奨する。 <no value> が生成物に混入するより、生成が失敗するほうが安全である。

8.4 Delims — デリミタの変更

--- Delims を [[ ]] に変える
入力: [[ .Name ]] and {{ .Name }}
出力: Ada and {{ .Name }}

{{ }} は単なる既定値であり、変更できる。 変更すると元のデリミタは生テキストとして扱われる。

用途は「二重テンプレート」である。 テンプレートを生成するテンプレートを書くとき、外側と内側でデリミタを分ける。§10.6 と §10.10 で実例を扱う。

8.5 エラーの扱い

// 起動時に落とす(推奨)
var tmpl = template.Must(template.New("t").Parse(src))

// 実行時のエラーは必ず確認する
if err := tmpl.Execute(w, data); err != nil {
	// 注意: この時点で w には途中まで書き込まれている
	log.Printf("template execution failed: %v", err)
}

重要な注意点Execute がエラーを返しても、Writer には途中までの出力が書き込まれている。

入力: [{{ .Nope }}]
出力: [                    ← "[" は既に書かれている
EXEC ERROR: ...

HTTPレスポンスに直接書くと、壊れた出力が送られる。 対策は bytes.Buffer に一度書いてから転送することである。

var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
	http.Error(w, "internal error", 500)
	return
}
buf.WriteTo(w)

設定ファイル生成でも同じ配慮が必要である。 途中まで書かれたファイルが残ると、次の起動でそれを読んでしまう。テンポラリファイルに書いて rename するのが定石である。


9. html/template との違い

9.1 文脈依存エスケープ

html/templatetext/template と同じAPIを持ちながら、出力先の文脈を解析して自動エスケープする。

data := map[string]any{
	"Name": `<script>alert('xss')</script>`,
	"URL":  "javascript:alert(1)",
	"Raw":  template.HTML("<b>trusted</b>"),
}
src := `<p>{{ .Name }}</p>
<a href="{{ .URL }}">link</a>
<script>var n = {{ .Name }};</script>
raw: {{ .Raw }}`
=== text/template(エスケープなし)
<p><script>alert('xss')</script></p>
<a href="javascript:alert(1)">link</a>
<script>var n = <script>alert('xss')</script>;</script>
raw: <b>trusted</b>

=== html/template(文脈依存エスケープ)
<p>&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;</p>
<a href="#ZgotmplZ">link</a>
<script>var n = "<script>alert('xss')</script>";</script>
raw: <b>trusted</b>

同じテンプレート、同じデータで結果が全く違う。

文脈html/template の処理
HTML本文HTMLエンティティへエスケープ
属性値属性用のエスケープ
href の値で危険なスキーム#ZgotmplZ に置換
<script>JavaScript文字列リテラルとしてエスケープ(クォートも付与)
template.HTMLエスケープしない(明示的に安全と宣言した値)

#ZgotmplZhtml/template 固有のマーカーである。 「Go template zero-gotcha」の意で、危険なURLを無害化した痕跡である。出力にこれが現れたら、テンプレートに javascript:data: のURLが渡っている。

<script> 内でクォートが自動的に付く点にも注目したい。var n = {{ .Name }}; と書けば、値がJSON文字列リテラルになる。var n = "{{ .Name }}"; と自分でクォートを書くと二重になる。

9.2 text/template を HTML に使う危険

上の1行目がXSSそのものである。 text/template<script> タグをそのまま出力する。

判断は単純である。

出力が HTML / ブラウザに解釈されるもの  → html/template(例外なし)
それ以外(設定ファイル、ログ、SQL以外のテキスト) → text/template

「HTMLの一部だけを生成する」場合も html/template を使う。 部分だけなら安全という理屈は成立しない。

逆に、設定ファイル生成に html/template を使ってはいけない。 YAMLの中の &< が勝手にエスケープされて壊れる。

9.3 使い分けの表

用途パッケージ
HTMLページ、HTMLメールhtml/template
YAML / JSON / TOML / INItext/template
シェルスクリプトtext/template(ただしシェルエスケープは自前で)
SQLどちらも不可(プレースホルダを使う)
ログ、レポート、CLI出力text/template
Kubernetesマニフェストtext/template

SQLについて補足する。 テンプレートでSQLを組み立てるのはSQLインジェクションの温床である。database/sql のプレースホルダ(? / $1)を使う。テンプレートで組み立てるのは、どうしても動的にせざるを得ない識別子(テーブル名など)に限り、その場合もホワイトリスト検証を併用する。


10. 周辺ツールでの使われ方

10.1 なぜ text/template がデファクトになったのか

理由は3つある。

理由内容
標準ライブラリである外部依存なし。Goで書かれたツールは無料で手に入る
チューリング完全でない設定生成に無限ループや任意コード実行を持ち込まない
拡張が容易FuncMap に足すだけでドメイン固有機能が入る

2つ目が特に重要である。 設定ファイル生成にJavaScriptやLuaを埋め込むと、テンプレートがプログラムになってしまう。text/template は「変数展開・条件・反復・関数呼び出し」に限定されているため、レビュー可能な範囲に留まる。

10.2 拡張の唯一の機構は FuncMap

周辺ツールがやっていることは、本質的に1つだけである。

t := template.New("x").Funcs(hugeFuncMap).Parse(src)

したがって「そのツール固有の関数リファレンス」を読めば、あとは text/template の知識で足りる。 逆に、コア言語の挙動({{- のトリム、and の戻り値、. の推移)はどのツールでも同一である。

この構造を実感するため、標準ライブラリだけで周辺ツールの代表的な関数を再実装して動かす。

var funcs = template.FuncMap{
	// sprig: default — 空値なら既定値を返す(引数順が Go と逆)
	"default": func(def, given any) any {
		if isEmpty(given) { return def }
		return given
	},
	// sprig: dig — ネストしたマップを安全に辿る。最後の引数が既定値
	"dig": func(args ...any) any { /* ... */ },
	"quote":  func(s any) string { return fmt.Sprintf("%q", fmt.Sprint(s)) },
	"toJSON": func(v any) string { b, _ := json.Marshal(v); return string(b) },
	"base64Decode": func(s string) string { b, _ := base64.StdEncoding.DecodeString(s); return string(b) },
	// Helm: nindent — 改行してから各行をインデント
	"nindent": func(n int, s string) string { /* ... */ },
	"indent":  func(n int, s string) string { /* ... */ },
	// Helm: required — 空なら実行を失敗させる
	"required": func(msg string, v any) (any, error) {
		if isEmpty(v) { return nil, fmt.Errorf("%s", msg) }
		return v, nil
	},
	// consul-template: env / envOrDefault
	"env": os.Getenv,
	"envOrDefault": func(k, def string) string { /* ... */ },
}
--- default(引数順が逆)
{{ .Values.empty | default "fallback" }} / {{ default "fallback" .Values.image.repository }}
→ fallback / nginx

--- dig(安全なネスト参照)
{{ dig "image" "repository" "none" .Values }} / {{ dig "image" "tag" "latest" .Values }}
→ nginx / latest

--- quote / toJSON
{{ quote .Values.image.repository }} / {{ toJSON .Values.image }}
→ "nginx" / {"repository":"nginx"}

--- env / envOrDefault
{{ env "REGION" }} / {{ envOrDefault "NOPE" "us-west-2" }}
→ us-east-1 / us-west-2

--- Helm の nindent
spec:
  data: {{ .block | nindent 4 }}
→ spec:
    data: 
      line1
      line2

--- Helm の indent
spec:
{{ .block | indent 2 }}
→ spec:
    line1
    line2

--- required で実行を止める
{{ required "image.tag is required" .Values.nosuch }}
→ EXEC ERROR: template: t:1:3: executing "t" at <required ...>: error calling required: image.tag is required

default の引数順が「既定値が先」である理由が、これで分かる。 パイプラインで | default "x" と書くと、パイプの左辺が最後の引数になる(§6.1)。だから既定値を第1引数に置く設計になっている。

required が実行を止められるのは、テンプレート関数が error を返せるからである(§8.2)。標準ライブラリの仕様をそのまま利用している。

10.3 sprig — 事実上の標準関数ライブラリ

組み込み関数が19個しかない(§6.2)ため、実用的なテンプレートには関数ライブラリが必要になる。 その共通実装が spriggithub.com/Masterminds/sprig)である。

sprig を採用しているツール:

Helm / Nomad(一部)/ nomad-pack / consul-template(sprig_ 接頭辞つき)
Kubernetes の各種オペレータ / Argo Workflows / kustomize プラグイン など

sprig の関数群は分野別に整理されている。

分野代表的な関数
文字列upper, lower, trim, trimPrefix, replace, split, join, contains, hasPrefix, repeat, substr, nospace, title, camelcase, snakecase
既定値default, empty, coalesce, ternary, required(Helm拡張)
型変換toString, toJson, toPrettyJson, toYaml(Helm拡張), atoi, float64, int
リストlist, first, last, rest, initial, append, prepend, concat, uniq, without, has, compact, sortAlpha, reverse
辞書dict, get, set, unset, hasKey, keys, values, pick, omit, merge, mergeOverwrite, deepCopy, dig
算術add, sub, mul, div, mod, max, min, ceil, floor, round
暗号sha256sum, sha1sum, adler32sum, bcrypt, htpasswd, genPrivateKey, genCA, genSelfSignedCert
エンコードb64enc, b64dec, b32enc, b32dec
日時now, date, dateModify, toDate, unixEpoch
フローfail, regexMatch, regexReplaceAll, regexFind
その他uuidv4, semver, semverCompare, env, expandenv

注意すべき点が2つある。

① sprig の一部の関数は非決定的である。 nowuuidv4randAlphaNum を使うと、実行するたびに出力が変わる。生成物をGitにコミットする運用(GitOps)では避けるべきである。 Helm も helm template の冪等性が壊れるため、これらの使用を推奨していない。

env / expandenv は環境変数を読む。 Helm では意図的に無効化されている(チャートが実行環境の環境変数に依存すると再現性が失われるため)。ツールによって有効・無効が違う。

10.4 Helm

Helm のテンプレートは text/template + sprig + Helm独自関数である。

# templates/deployment.yaml(典型的な形)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
  labels:
    {{- include "mychart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount | default 1 }}
  selector:
    matchLabels:
      {{- include "mychart.selectorLabels" . | nindent 6 }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}

Helm独自の関数・値:

名前内容
include "name" .template の代替。 結果を文字列として返すのでパイプできる
tpl "文字列" .文字列をテンプレートとして評価する(動的テンプレート)
required "msg" .Values.x空なら失敗させる
toYaml / fromYamlYAML変換
lookupクラスタの既存リソースを参照(helm template では nil)
.Valuesvalues.yaml の内容
.ChartChart.yaml の内容
.Releaseリリース名、namespace など
.CapabilitiesクラスタのバージョンやAPI一覧
.Filesチャート内のファイル

includetemplate の違いが最重要である。

{{ template "x" . }}          → 出力に直接書く。パイプできない
{{ include "x" . }}           → 文字列を返す。パイプできる
{{ include "x" . | nindent 4 }}  → だからインデントを調整できる

templatetext/template の組み込みアクションであり、値を返さない。 そのため Helm は「文字列を返す include」を関数として追加した。YAMLのインデント調整に必須であるため、Helmチャートでは template よりも include が使われる。

nindent が Helm チャートに頻出する理由も同じである。

    {{- include "mychart.labels" . | nindent 4 }}
{{- で直前の改行を消す
include で部分テンプレートを文字列として取得
nindent 4 で「改行 + 各行を4スペースインデント」する

この3点セットが Helm チャートの定型句である。 {{- を書かないと空行が入り、nindent を使わないとインデントが崩れる。

10.5 consul-template

consul-template は Consul / Vault / ファイル / 環境変数を参照する関数を追加している。 HashiCorp のツール群(Nomad、Consul、Vault)の設定配布の中核である。

実際のテンプレートを見る。 次は Vault のシークレットを .properties 形式へ展開するテンプレートの構造である。

# rendered by serenity
# template used: {{ env "TEMPLATE_FILE" }}
# data file: {{ env "CONFIG_FILE" }}
# config version: {{ env "CONFIG_VERSION" }}
# generated at: {{ timestamp "Mon Jan 2 15:04:05 -0700 MST 2006" }}

{{ $config_file := env "CONFIG_FILE" -}}
{{- $mountBaseDir := envOrDefault "MOUNT_BASE_DIR" "" -}}
{{- with $config := file $config_file | parseJSON -}}
{{- range $key, $value := $config.properties }}
{{ $key }}={{ $value | toJSON | trim "\"" }}
{{- end }}
{{- range $key, $value := $config.secrets }}
{{- if $value.mountPath }}
{{- with secret (printf "%s/%s" $value.path (sprig_default $value.version -1)) }}
{{- .Data.data.secret | base64Decode | writeToFile (printf "%s%s" $mountBaseDir $value.mountPath) "" "" "0644" }}
{{- end }}
{{- else }}
{{ $key }}={{ with secret $value.path }}{{ .Data.data.secret }}{{ end }}
{{- end }}
{{- end }}
{{- end }}

この1つのテンプレートに、本ガイドで扱った要素がほぼすべて現れている。

使われている機能
{{- / -}} によるトリム(全行)§5.4
$config_file := env "..." 変数宣言§4.3
with $config := ... 変数束縛つき with§5.3
range $key, $value := $config.properties マップの反復§5.2
if $value.mountPath — マップのキー有無で分岐§5.1, §3.4
$value | toJSON | trim "\"" パイプの連鎖§6.1
printf "%s/%s" ... 引数の組み立て§6.5
with secret ... — 取得できたときだけ実行(nilガード)§5.3

consul-template が追加している関数:

関数内容
secret "path"Vault のシークレットを取得.Data.data.x で値)
secrets "path"Vault のパス一覧
key "path"Consul KV の値
keyOrDefault同、既定値つき
ls / treeConsul KV の一覧・木
service "name"Consul のサービス一覧(ヘルスチェック通過分)
nodes / datacentersConsul のノード・DC
file "path"ファイルの内容
env / envOrDefault環境変数
parseJSON / parseYAML / parseTOML文字列をパース
toJSON / toYAML / toTOML構造をシリアライズ
writeToFileファイルへ書き出す(副作用つき関数)
timestamp現在時刻
base64Decode / base64EncodeBase64
sprig_*sprig の全関数(接頭辞つき)

注目点が3つある。

sprig_ 接頭辞。 consul-template は sprig を取り込むが、自前の関数名と衝突しないよう sprig_defaultsprig_dig のように接頭辞を付けている。同じ default がツールによって defaultsprig_default かに分かれるため、テンプレートの移植時に注意が必要である。

writeToFile は副作用を持つ。 テンプレート関数がファイルを書くのは text/template の設計思想からは外れるが、「複数のファイルを1回のレンダリングで生成する」ために使われている。上のテンプレートでは、証明書のような大きなバイナリを本文に出さず別ファイルへ書き出している。

with secret の nil ガードが本質的である。 シークレットが取得できなかった場合、with の中身がスキップされる。これにより「Vault が一時的に応答しない」ときに空の値を書き込む事故を避けている。

10.6 nomad-pack — デリミタを変える

nomad-pack は Nomad のジョブ仕様そのものをテンプレートから生成するツールである。 そしてデリミタを [[ ]] に変えている。

[[ define "serenityTask" ]]
    task "serenity" {
      driver = "docker"
      lifecycle {
        hook    = "prestart"
        sidecar = false
      }
      config {
        image      = "[[ (var "serenity_image" $) ]]:[[ (var "serenity_tag" $) ]]"
        force_pull = [[ (var "serenity_force_pull" $) ]]
        command    = "[[ (var "serenity_command" $) ]]"
      }
      env {
        VAULT_ADDR  = [[ (var "serenity_vault_addr" $) | quote]]
        CONFIG_FILE = "/alloc/data/[[ (var "serenity_config_environment" $) ]].json"
      }
    }
[[ end -]]

なぜ [[ ]} にするのか。 生成先である Nomad のジョブ仕様には template スタンザがあり、その中身は Nomad 自身が consul-template({{ }})で評価する。もし nomad-pack が {{ }} を使うと、Nomad へ渡す前に展開されてしまう。

[[ ]]  → nomad-pack がレンダリング時に評価する
{{ }}  → Nomad が実行時に評価する

両者が同じファイルに共存する。

[[ if (var "splunk_hec" $) ]]
template {
  data = <<EOF
SPLUNK_HEC_TOKEN="{{ with secret "secrets/hse/d2p/[[ (var "vault_region" $) ]]/splunk/secrets/hec-token" }}{{ .Data.data.secret }}{{ end }}"
EOF
  destination = "secrets/splunk.env"
  env         = true
}
[[- end ]]

[[ (var "vault_region" $) ]] が Vault のパスの一部を組み立て、外側の {{ with secret ... }} はジョブ仕様として残る。 二重テンプレートを、デリミタの分離だけで成立させている。§10.10 で改めて論じる。

nomad-pack が使っている sprig 関数の例:

deepCopy        マップを破壊せず複製する
mergeOverwrite  深いマージ(後が勝つ)
dig             ネストしたキーを安全に取得
default         空値なら既定値
quote           HCL の文字列リテラル化
toStringList    HCL のリストリテラル化
contains        部分文字列判定
upper / replace / get

deepCopy + mergeOverwrite の組み合わせで「既定値の継承」を実装している。

[[- $default := deepCopy (var "task_group_defaults" $) | mergeOverwrite (var "task_group" $.defaults) -]]

deepCopy が必要な理由は、mergeOverwrite が第1引数を破壊するためである。 ループの各周回で $default が汚染されると、1つ目のポッドの設定が2つ目に漏れる。

そして値の解決には dig を使う。

resources {
  cpu    = [[ dig "resources" "cpu"    $default.tasks.resources.cpu    $task ]]
  memory = [[ dig "resources" "memory" $default.tasks.resources.memory $task ]]
}

「$task にあればそれ、無ければ $default」という4段の優先順位を、この1関数で表現している。

10.7 Nomad の template スタンザ

Nomad のジョブ仕様に直接テンプレートを書ける。 中身は consul-template である。

task "app" {
  template {
    data = <<EOF
{{ with secret "secret/data/myapp/config" }}
DB_PASSWORD={{ .Data.data.password }}
API_KEY={{ .Data.data.api_key }}
{{ end }}
{{ range service "redis" }}
REDIS_ADDR={{ .Address }}:{{ .Port }}
{{ end }}
EOF
    destination = "secrets/app.env"
    env         = true
    change_mode = "restart"
  }
}
属性内容
dataテンプレート本文(インライン)
sourceテンプレートファイルのパス(data の代替)
destination出力先
env真なら生成物を環境変数として読み込む
change_mode値が変わったときの動作(restart / signal / noop
perms出力ファイルのパーミッション

change_mode が consul-template の常駐モードと連動している。 Vault のシークレットがローテートされると、テンプレートが再レンダリングされ、restart ならタスクが再起動する。「設定が変わったらプロセスを入れ替える」という運用がテンプレートエンジンの機能として提供されている。

10.8 kubectl -o go-template

kubectl は API レスポンスに対して text/template を適用できる。

$ kubectl create -f pod.yaml --dry-run=client \
    -o go-template='{{ .metadata.name }}: {{ range .spec.containers }}{{ .name }}={{ .image }} {{ end }}{{ "\n" }}'
demo: c1=nginx:1.27 c2=redis:7

$ kubectl create -f pod.yaml --dry-run=client \
    -o go-template='{{ range $i, $c := .spec.containers }}{{ $i }} {{ $c.image }}{{ "\n" }}{{ end }}'
0 nginx:1.27
1 redis:7

$ kubectl create -f pod.yaml --dry-run=client \
    -o go-template='{{ range $k, $v := .metadata.labels }}{{ $k }}={{ $v }};{{ end }}{{ "\n" }}'
app=web;tier=front;

注意点が3つある。

① 改行は {{ "\n" }} で出す。 シェルの引数として1行で書くため、テンプレート内に実際の改行を入れづらい。

② フィールド名は API の JSON 名(小文字始まり)である。 Go の構造体フィールド名ではない。.metadata.name であって .Metadata.Name ではない。

③ ラベルの map は自動的にキーでソートされる(§5.2)。上の出力が apptier の順になっているのはそのためである。kubectl の出力が安定するのはこの性質のおかげである。

-o jsonpath との使い分け:

-o go-template-o jsonpath
表現力高い(条件分岐・関数・変数)低い(パス指定のみ)
簡潔さ低い高い
学習コストtext/template の知識が必要JSONPath の知識
適する場面整形が必要、条件で出し分ける単に値を1つ抜く
# 単に値を抜くなら jsonpath が短い
$ kubectl get pods -o jsonpath='{.items[*].spec.containers[*].image}'

# 条件や整形が要るなら go-template
$ kubectl get pods -o go-template='{{ range .items }}{{ if eq .status.phase "Running" }}{{ .metadata.name }}{{ "\n" }}{{ end }}{{ end }}'

-o go-template-file=x.tmpl でファイルから読める。 複雑なものはファイルにする。

10.9 Docker、Prometheus、その他

Docker の --format:

$ docker version --format '{{ .Client.Version }} / {{ .Client.Os }}/{{ .Client.Arch }}'
29.2.0 / darwin/arm64

$ docker version --format '{{ json .Client.Version }}'
"29.2.0"

$ docker version --format '{{ .Nope }}'
template: version:1:3: executing "version" at <.Nope>: can't evaluate field Nope in type system.versionInfo

Docker は json 関数を追加している。 エラーの形は素の text/template とまったく同じである(can't evaluate field ... in type ...)。

# よく使う形
$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}'
$ docker inspect --format '{{ range .Mounts }}{{ .Source }}→{{ .Destination }}{{ "\n" }}{{ end }}' <container>

table 接頭辞は Docker 独自の拡張である(ヘッダ行を付けて列を揃える)。

Prometheus / Alertmanager:

{{ define "slack.title" }}[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}{{ end }}

{{ define "slack.text" }}
{{ range .Alerts }}
*Alert:* {{ .Annotations.summary }}
*Severity:* {{ .Labels.severity }}
*Started:* {{ .StartsAt.Format "2006-01-02 15:04:05" }}
{{ end }}
{{ end }}

Alertmanager は toUppertitlematchreReplaceAllsafeHtml などを追加している。 .StartsAt.Format のようにメソッドを直接呼べる点は text/template の標準機能である(§3.5)。

Prometheus のコンソールテンプレートも同じ仕組みで、queryfirstvaluelabel などの関数が追加されている。

その他の主なツール:

ツール用途
Grafana Loki / Promtailtemplate ステージでラベル加工
Argo Workflows / Argo CDマニフェストの生成
Terraform(provider 側)一部プロバイダのテンプレート機能
VaultACLポリシーテンプレート、レスポンスラッピング
Cosign / Notary出力整形
gomplate汎用テンプレートCLI(sprig + 多数のデータソース)
Hugo静的サイト生成(大幅に拡張された独自関数群)

gomplate は「text/template をシェルから使う」ためのツールである。 CIで設定ファイルを生成する用途に便利で、envsubst の高機能な代替として使える。

10.10 二重テンプレートの問題

周辺ツールを扱うときに最も厄介なのが「テンプレートを生成するテンプレート」である。

① nomad-pack が [[ ]] でジョブ仕様を生成する
② そのジョブ仕様には {{ }} の consul-template が含まれる
③ Nomad が実行時に {{ }} を評価する

この3段構成では、どのデリミタがいつ評価されるかを常に意識する必要がある。

対処法は3つある。

① デリミタを分ける(推奨)

t.Delims("[[", "]]")

nomad-pack がこれを採用している。最も明快で、混乱が起きない。

② エスケープする

デリミタを変えられない場合、リテラルとして出力する。

{{ "{{" }} .Value {{ "}}" }}
$ cat > esc.tmpl <<'X'
{{ "{{" }} .Inner {{ "}}" }}
X
出力: {{ .Inner }}

{{ "{{" }} は「文字列リテラル {{ を出力する」という意味である。 読みにくいが確実に動く。

printf を使う

{{ printf "{{ .Inner }}" }}

より読みやすい。 ただし printf の書式指定子(%)が含まれる場合はエスケープが必要になる。

デバッグの原則:

生成の各段で「その時点の中間出力」を確認する。

nomad-pack render → 生成されたジョブ仕様を目で見る
                  → {{ }} が残っていることを確認する
Nomad へ投入      → alloc のログで最終的な設定を見る

中間出力を確認せずに最終結果だけを見ると、どの段で壊れたか分からない。 Helm でも helm template で中間出力を見るのが基本である。


11. 落とし穴一覧

本節の挙動はすべて Go 1.26.7 で再現したものである。

#落とし穴症状対処
1map の欠損キー<no value> が生成物に混入するindex を使う / missingkey=error3.4
2nil ポインタの出力<nil> が混入するwith でガードする3.4
3struct の欠損フィールド実行時エラー綴りを直す3.4
4and / or が値を返す{{ and .A .B }}.B の値を出す条件としてのみ使う。not (not x) で真偽値化6.3
5min / max が無いfunction "min" not definedsprig を使う / 自前で追加6.2
6文字列関数が1つも無いfunction "upper" not definedsprig を使う6.2
7eq の型不一致incompatible types for comparisonprintf "%v" で揃える6.6
8print の空白規則意図しない空白が入るprintf "%s%s" を使う6.5
9{{- が空白を全部食うインデントが崩れる消す範囲を意識する5.4
10{{-if}} は不正bad number syntax: "-if"{{- if}} と空白を入れる5.4
11ループ内 := が外に漏れないカウンタが0のまま= で代入する4.4
12template に引数を渡し忘れる. が nil になる末尾に . を書く4.1
13template は値を返さないパイプできないHelm なら include を使う10.4
14空の define は上書きしないブロックを消せない空白以外を置く7.4
15同一 Parse 内の重複 definemultiple definition of template分けて書く7.4
16Clone 忘れ全ページが最後のテンプレートになるClone() してから Parse7.3
17New(name).ParseFiles()"name" is an incomplete or empty templateNew せず ParseFiles / ExecuteTemplate7.5
18未定義テンプレート呼び出し実行時まで気づかないテスト網羅 / Lookup で事前確認2.3
19Templates() の順序が不定一覧表示が毎回変わる自分でソートする7.5
20エラー時に途中まで出力される壊れた出力が送られるbytes.Buffer 経由にする8.5
21パースを毎回やる約5倍遅いパッケージ変数に持つ2.4
22text/template で HTMLXSShtml/template を使う9.2
23html/template で YAML& < が壊れるtext/template を使う9.3
24missingkey=zero が効かないmap[string]any では <no value>error にする8.3
25ポインタメソッドが呼べないcan't evaluate field Xデータをポインタで渡す3.5
26call に関数名を渡すwrong number of argsデータ内の関数値に使う6.7
27二重テンプレートの混同早すぎる展開Delims で分ける10.10
28sprig の非決定的関数GitOps で毎回差分now / uuidv4 を避ける10.3

11.1 とくに危険な3つ

この3つは「静かに壊れる」ため、優先度が高い。

<no value> / <nil> の混入(#1, #2)

エラーにならないため、生成物が壊れたまま下流へ流れる。

$ cue export ... > out.yaml     # 例: 設定生成
$ grep -n '<no value>\|<nil>' out.yaml    # ← CI に入れる価値がある

生成物に対する grep をCIに入れるだけで防げる。

and / or を出力に使う(#4)

{{ and .A .B }}     → true ではなく .B の値が出る

条件文以外で使わないという規律で防ぐ。

③ エラー時の部分出力(#20)

入力: [{{ .Nope }}]
出力: [                       ← ここまで書かれている
EXEC ERROR: ...

bytes.Buffer に書いてから転送するという定型で防ぐ。設定ファイルなら一時ファイル + rename にする。


12. まとめ

12.1 1枚にまとめる

text/template の本質 = 「テキスト + アクション」+「reflect でデータを辿る」

  {{ }} の外はそのまま出力、内はアクション
  . は「現在の値」で、range / with / template が差し替える
  $ は常にルート

  組み込み関数は19個だけ
     and or not / eq ne lt le gt ge / print printf println
     index slice len / call / html js urlquery
  → 文字列操作も算術も無い
  → だから FuncMap による拡張が必須で、sprig が事実上の標準になった

  and / or は真偽値ではなく「値」を返す(1.18 以降は短絡評価)

  参照失敗の挙動が3通りに分かれる
     struct の欠損フィールド  → 実行時エラー
     map の欠損キー          → <no value> を静かに出力  ★危険
     nil ポインタ            → 参照でエラー、出力で <nil>  ★危険

  パース時に落ちるもの: 構文、関数名
  実行時に落ちるもの:   フィールド、テンプレート名、型

  {{- と -}} は「連続する空白すべて」を消す
  → YAML 生成では生死を分ける

12.2 学習の順序

順序学ぶこと理由
1. の推移range / with / template誤解の最大の源
2空白制御 {{- -}}生成物の見た目を左右する
3$ と変数のスコープループ内の値の持ち出しに必要
4パイプラインと「最後の引数」規則sprig の引数順の理解に必要
5参照失敗の3通りの挙動静かな障害を防ぐ
6define / template / blockテンプレートの再利用
7パース時 vs 実行時エラーデバッグの方向を決める
8Go 側の API(Funcs / Option / Clone自分でツールを作るとき
9周辺ツールの独自関数必要になった時点で

1〜5を理解すれば、Helm や consul-template のテンプレートはほぼ読める。 残りはツール固有の関数リファレンスを引くだけになる。

12.3 実務での使いどころ

適している:

用途理由
設定ファイルの生成標準ライブラリで完結し、チューリング完全でない
CLIツールの出力整形ユーザーが書式を指定できる(--format
コード生成go:generate と組み合わせやすい
通知メッセージの整形Alertmanager 方式
単一バイナリへの埋め込みembed + ParseFS

適していない:

用途代替
複雑な変換ロジックGoのコードで組み立ててからテンプレートに渡す
構造の妥当性が重要な設定CUE / Jsonnet(構造を理解する言語)
HTMLhtml/template(同じAPIなので移行は容易)
SQLプレースホルダ

最後の「構造の妥当性」について補足する。 text/template は文字列を組み立てているだけで、出力がYAMLとして妥当かを知らない。インデント1つで壊れる。構造そのものを扱いたいなら、CUE や Jsonnet のように「値」を組み立てる言語のほうが適している。 一方で、既存のエコシステム(Helmチャートの豊富さ)を捨てられないという現実もある。

折衷案としては、values.yaml を構造を理解する言語で生成・検証し、チャート自体は Helm のまま使う構成がある。

$ cue export -e 'helmValues' --out yaml > values.yaml
$ helm upgrade --install myapp ./chart -f values.yaml

13. 参考情報

項目場所
text/template パッケージドキュメントhttps://pkg.go.dev/text/template
html/templatehttps://pkg.go.dev/html/template
text/template/parse(AST)https://pkg.go.dev/text/template/parse
ソース(組み込み関数の定義)$(go env GOROOT)/src/text/template/funcs.go
sprighttps://masterminds.github.io/sprig/
Helm テンプレートガイドhttps://helm.sh/docs/chart_template_guide/
Helm 独自関数https://helm.sh/docs/chart_template_guide/function_list/
consul-templatehttps://github.com/hashicorp/consul-template
nomad-packhttps://github.com/hashicorp/nomad-pack
Nomad の template スタンザhttps://developer.hashicorp.com/nomad/docs/job-specification/template
kubectl の出力形式https://kubernetes.io/docs/reference/kubectl/#output-options
Docker の formathttps://docs.docker.com/engine/cli/formatting/
Alertmanager の通知テンプレートhttps://prometheus.io/docs/alerting/latest/notifications/
gomplatehttps://docs.gomplate.ca/

検証環境と検証方法について

$ go version
go version go1.26.7 darwin/arm64

$ sw_vers -productVersion
15.x        # Apple M4 Max

本ガイドに掲載した内容のうち、検証の度合いは次の通りである。

対象検証方法
§2〜§9 のテンプレート・Goコード・出力・エラーすべて Go 1.26.7 で実行した実測値
§6.2 の組み込み関数一覧funcs.go のソースから抽出
§2.4 のベンチマークgo test -bench の実測値
§10.2 の関数再実装のデモ標準ライブラリのみで実装し実行した実測値
§10.8 の kubectl 出力kubectl で実行した実測値
§10.9 の docker 出力docker で実行した実測値
§10.3 の sprig 関数一覧未実行proxy.golang.org に到達できない環境のため、公式ドキュメントに基づく記載)
§10.4〜§10.7 のツール固有テンプレート実在するテンプレートの構造に基づく記載(当該ツールを動かした出力ではない)

sprig と各ツールの関数一覧については実行検証をしていない。 関数名や引数順はバージョンによって変わりうるため、実際に使う際は当該ツールのバージョンに対応するドキュメントを確認することを推奨する。一方で、§10.2 で示した「拡張の機構」そのものは標準ライブラリだけで再現・実行して確認している。