テンプレートエンジンnなGo!?

これの続き。

「コマンドライン引数から取ってるのがダサい」なんだけど、まだまだワタシ Go 初心者の極み、でなぁ、さっと書けないのよ。マニュアルを開きっぱなしにしてずっと読みながらでないと書けないんで、あとで。

うん、見ながらうんうんうなって書けば書ける、てだけのことよ:

tmplexpr2.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8 )
 9 
10 func main() {
11     tmplcont, err := ioutil.ReadFile(os.Args[1])
12     if err != nil { panic(err) }
13     tmpl, err := template.New("test").Parse(string(tmplcont))
14     if err != nil { panic(err) }
15     var i interface{}
16     if len(os.Args) > 2 {
17         jsoncont, err := ioutil.ReadFile(os.Args[2])
18         if err != nil { panic(err) }
19         json.Unmarshal(jsoncont, &i)
20     }
21     tmpl.Execute(os.Stdout, i)
22 }

使ってみた例、は、先日の例でコマンドラインに直接書いていたテンプレート(Argv[1])と json (Argv[2])をファイルに収めたってだけなのでわかるよね? あと、このプログラムを実用プログラムと考える場合はちょっと設計が硬直してるけれど、今回続けたい話の本題ではないので今は問わない。

「Go 初心者の極み」て苦労するのは、ひとつにはそもそもマニュアルの使いにくさと読みにくさがあるんだけれど、その「読みにくさ」とは、サイトデザインや Syntax Highlighting の有無などとは違うところにもある…、てことはちょっと言っといたほうがいいかなと思った。

「オブジェクト指向」ってね、これ、実は「プログラミング作業」の際のメリットばかり強調されがちなんだけれど、本当はそこはあんまり重大でもないと個人的に思ってるんだよね。本当のこれのメリットって、実は「ドキュメントが必然的に階層化される」ことなのではないかと。クラスのメソッド、という「階層化」が強制されるわけよ、それがドキュメントに直接反映される。Go ってオブジェクト指向の恩恵は受けつつも、いわゆる伝統的なオブジェクト指向とは違うんだよね。上の例で言う「ioutil.ReadFile」に辿り着く前の右往左往を自分でやってみると実感出来ると思うんだけれど、「読み込み君が欲しいのぞ」と願い「io.ReadAll」にたどり着くわけでしょ。で「Readerってなにーっ」て叫ぶわけだ。元の java だの(今のは違うよ念の為)のようなシングルパラダイムデザインとしてオブジェクト指向を採用する言語であれば、io.ReadAll のような独立関数はなくて、必ずなんらかのクラスのメソッドになっているので、その場合はそのクラスの初期化関数(コンストラクタ)とかオープンのメソッドがあるんだろう、という探し方が出来る。けどこういうジェネリック関数志向というのかな、こういうのって、何かのクラスに属さずに独立しちゃってるんで、ドキュメントを探り当てるのは面倒になるってわけね。

ワタシはソフトウェアのデザインとしてはこの Go のデザインの方が好きなんだけどね。たとえばこれは、C++ でいう STL の設計とか、あるいはデザインパターンで言うところのビジターパターンかなと思う。そういうわけなんで、人によっては「オブジェクト指向でないなんてクソ」みたいに怒りだしちゃうのもいそうなんだけれど、その怒りはドキュメントのわかりにくさに対してだけにしとけ、って個人的には思う。思うけれど、そのドキュメントへの怒りは完全に同感で、残念だけどワタシもこのタイプの設計のドキュメントって、やっぱ探しにくいと思う。(Go公式ドキュメントに関してはさらに、「Examples」が気が利いてないのも使いにくいと感じる原因の一つ。はっきりいって Examples をあと二歩だけ工夫するだけで全然違うはずなんだよね。)


さて、本日のこのネタの本題は、「プログラム作れました、やったねっ!」っではないくて。そんなもん何の価値もない。誰でも出来ることだ。

そうではなくて、「わっかりにくーい、読みにくいし」を少しだけどうにかしてみようかなと。つまり、テンプレートエンジンとしての仕様について少しは理解が早まるための、やや多めの実例集を試みてみようかと。いや、ここまで読みにくいドキュメントもなかなかお目にかかれんわ、てくらい伝わってこない、良さが。


まずは、空白の扱いとコメント。空白除去は jinja2 と同じかな、マイナス記号で制御出来る。公式の例そのまま:

tmpl01
1 {{23 -}} < {{- 45}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl01
2 23<45

コメント:

tmpl02
1 {{23 -}} < {{- 45}}
2 {{/*これはコメント(ブレースと/の間に空白を入れてはいけないことには注意)*/}}
3 侵略
4 {{- /*これはコメントだが空白除去(-と/の間に空白が必要なことには注意)*/ -}}
5 じゃなイカ!?
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl02
2 23<45
3 
4 侵略じゃなイカ!?

ここまではいいんだわ、きっと誰が読んでも「わからーん」とまでは思わない。問題はここから先。少なくともワタシは初見ではさっぱり意味が読み取れなかった。もうちょっとわかりやすく書けないのかなぁ…。


まず、上でやったようにグローバルコンテキストとして json データを入力として、というのではなくて、テンプレート内で変数をゴニョれることに興味を示してみる。これがまぁ伝わりにくい。出来てしまえば簡単なのに:

tmpl03
1 {{$mystrvar := "イカ"}}  {{- /*初期化(変数定義)*/ -}}
2 じゃな{{$mystrvar}}
3 {{$mystrvar = "いよ"}}  {{- /*リアサイン*/ -}}
4 じゃな{{$mystrvar}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl03
2 じゃなイカ
3 じゃないよ

この変数なのだが、まぁ致し方ないのかも知らんけれど、「構造」の定義は出来ないみたいなんだよね。参照は出来るのになんでだ、てことね。Go 言語自身のいわゆる「構造体」はたとえば:

これは上のtemplateとは無関係のGo言語自身の例(匿名structの例)
1     mydicvar := struct {生息域, 語尾 string} {生息域: "深海", 語尾: "イカ"}

みたいに出来るし、先にやったように json から読み込んだ構造はちゃんと構造になっていて、それを取り込めば「構造内のフィールドにドット記法でアクセス出来る」:

data4tmpl04.json
 1 {
 2     "外から侵略": {
 3         "外見": "ネコ型",
 4         "生息域": "宇宙"
 5     },
 6     "地球内": {
 7         "内から侵略": {
 8             "外見": "イカ娘",
 9             "生息域": "深海",
10             "語尾": "イカ"
11         }
12     }
13 }
tmpl04
1 {{$mydicvar := .地球内}}  {{- /*構造の一つ内側をアサインする*/ -}}
2 {{$mydicvar.内から侵略.外見}}じゃな{{$mydicvar.内から侵略.語尾}}  {{/*さらに内側のフィールドにアクセス出来る*/}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl04 data4tmpl04.json
2 イカ娘じゃなイカ

てこと。けどテンプレート内でこうした構造定義は出来ないらしく、おそらく「A boolean, string, character, integer, floating-point, imaginary or complex constant in Go syntax.」がこれで定義可能な全て。仕方ないと受け入れつつも、ちょっと中途半端だなぁとも思う。

ちょっと話戻るんだけれど、「{{.}}」という、初見でかなり謎に感じる記法(かつ公式ドキュメントで唐突に登場するのでやはりわかりづらい)は、ここまでの話の流れで理解出来るよね。まずは現時点での「tmplexpr2.exe」がテンプレートのExecuteに与えるコンテキストとして json からロードした構造全体を渡してるので、この例の場合はこの json のトップレベルが「.」ね。この要領で:

tmpl04-2
1 {{$mydicvar := .}}  {{- /*トップレベル(今の場合jsonオブジェクトそのもの)*/ -}}
2 {{$mydicvar.地球内.内から侵略.外見}}じゃな{{$mydicvar.地球内.内から侵略.語尾}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl04-2 data4tmpl04.json  # データは同じものを
2 イカ娘じゃなイカ

それと、「説明不足」は「シーケンス」に対するアクセスがぱっと見書かれてないこと。どうすんだろう、と思ったが、こういうことみたい:

data4tmpl05.json
1 [
2     {"名前": "カニ男", "語尾": "だカニ"},
3     {"名前": "タコ爺", "語尾": "やんす"},
4     {"名前": "イカ娘", "語尾": "なイカ"}
5 ]
tmpl05
1 じゃ{{(index . 2).語尾}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl05 data4tmpl05.json
2 じゃなイカ
data4tmpl05-2.json
1 {
2     "中はリストだたーり": [
3         {"名前": "カニ男", "語尾": "だカニ"},
4         {"名前": "タコ爺", "語尾": "やんす"},
5         {"名前": "イカ娘", "語尾": "なイカ"}
6     ]
7 }
tmpl05-2
1 じゃ{{(index .中はリストだたーり 1).語尾}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl05-2 data4tmpl05-2.json
2 じゃやんす

いやぁこんなんわからんて。少なくとも初めてこのドキュメントを読んで10分以内にこの正解に辿り着ける人がいるなら見てみたい。どんなに優秀な人でも最速でもきっと30分は悶々とすると思う。ワタシは二時間ほどかな。優秀じゃない人なので。


ふぅ、イヤになるよね、だってさぁ、ここまでの機能ってね、前回の言い方で言うところの、ほぼ「substitution」の部分だけなわけよ、つまり、「Python 標準ライブラリの範囲内だけで出来ること」でしかないわけね、ここで力尽きちゃったら Go の text/template の真の姿は伝わらない、んだけれど、正直このドキュメントでは、その危険性がかなり高いと思う。

そう、Python ユーザとしては羨ましがりたいわけよ、「わぉ、control structure れるのねっ」てね。なので、さっそく頑張って羨ましがってみる。


まずは条件分岐な。「If the value of the pipeline is empty, no output is generated; otherwise, T1 is executed. 」の後に「empty ちゃなんぞね」の説明があるんで、ちゃんと読んどけよ。まぁ普通のことが書いてある。驚くようなことは書いてない。てなわけで:

data4tmpl06.json
 1 {
 2     "漆原るか": {
 3         "ショタ": true,
 4         "性別": "男"
 5     },
 6     "橋田至": {
 7         "ショタ": false,
 8         "性別": "男"
 9     }
10 }
tmpl06
1 漆原るか、性別{{.漆原るか.性別}}{{if .漆原るか.ショタ}}(こんなに可愛い子が女の子であるはずがない){{end}}
2 橋田至、性別{{.橋田至.性別}}{{if .橋田至.ショタ}}(こんなに可愛い子が女の子であるはずがない){{end}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl06 data4tmpl06.json
2 漆原るか、性別男(こんなに可愛い子が女の子であるはずがない)
3 橋田至、性別男

else, else if は例はいらんよね、そのまんま。


うん。この「繰り返し」がまぁこうしたテンプレートエンジンのメインエベント、やね。これが出来てこその「きゃーてんぷれとえんじんすてきぃ」ってなる。出来ないものはわざわざ「テンプレートエンジン」と呼ぶのに躊躇われる。独特な気もするやいね:

data4tmpl07.json
 1 [
 2     {
 3         "名前": "七瀬彩夏",
 4         "愛称": "あやサマー"
 5     },
 6     {
 7         "名前": "田中ちえ美",
 8         "愛称": "ちぇみー"
 9     },
10     {
11         "名前": "小松未可子",
12         "愛称": "みかこし"
13     },
14     {
15         "名前": "安済知佳",
16         "愛称": "ちかぺ"
17     },
18     {
19         "名前": "上田麗奈",
20         "愛称": "うえしゃま"
21     }
22 ]
tmpl07
1 {{range .}}
2 {{.名前}} - {{.愛称}}
3 {{- end}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl07 data4tmpl07.json
2 
3 七瀬彩夏 - あやサマー
4 田中ちえ美 - ちぇみー
5 小松未可子 - みかこし
6 安済知佳 - ちかぺ
7 上田麗奈 - うえしゃま

python の for ループに else があるのと似て、この range にも else ブロックを書ける。ただし意味は python のそれとは違い、単に繰り返すシーケンスが空の場合に実行される、てだけ。どっちがいいって話ではなくて。


「with」は他の言語経験者が想像する通りのものかなと思う:

data4tmpl04.json (再掲)
 1 {
 2     "外から侵略": {
 3         "外見": "ネコ型",
 4         "生息域": "宇宙"
 5     },
 6     "地球内": {
 7         "内から侵略": {
 8             "外見": "イカ娘",
 9             "生息域": "深海",
10             "語尾": "イカ"
11         }
12     }
13 }
tmpl04-3
1 {{- with .地球内.内から侵略 -}}
2 {{.外見}}じゃな{{.語尾}}
3 {{end -}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl04-3 data4tmpl04.json
2 イカ娘じゃなイカ

predefined な global functions が使える、と言っていて、うち一つが上で既に登場させた index。ほか、slice は python のスライスを思い浮かべればその通りの機能だろう、てことだが、使おうとするとなかなか記述が煩雑よのぉと思うのよ(rangeと一緒に使いたかろ?):

tmpl07-2
1 {{range (slice . 1 3)}}  {{- /* x[1:3] */}}
2 {{.名前}} - {{.愛称}}
3 {{- end}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl07-2 data4tmpl07.json
2 
3 田中ちえ美 - ちぇみー
4 小松未可子 - みかこし

記述が煩雑、というと違うか、慣れだもんなこんなん。たださこれ、コードを読む際に読みにくくないかねこれ、と思うんだよね。

printf の場合はこんな感じ:

data4tmpl08.json
1 {
2     "身長": 57,
3     "体重": 550000,
4     "その名は": "太ったデカい人"
5 }
tmpl08
1 {{printf "身長 %.0f メートル" .身長}}
2 {{printf "体重 %.0f キログラム" .体重}}
3 {{printf "その名は%s" .その名は}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl08 data4tmpl08.json
2 身長 57 メートル
3 体重 550000 キログラム
4 その名は太ったデカい人

バグなのか仕様なのかは知らんけど「%d」するとどうなるかは、是非ご自身で。結構泣けるなこれは。


「pipelines」というキーワードが見えてることからも想像出来る通り、アウトプットをパイプラインでフィルタしていける:

tmpl09
 1 {{`
 2 eq
 3 	Returns the boolean truth of arg1 == arg2
 4 ne
 5 	Returns the boolean truth of arg1 != arg2
 6 lt
 7 	Returns the boolean truth of arg1 < arg2
 8 le
 9 	Returns the boolean truth of arg1 <= arg2
10 gt
11 	Returns the boolean truth of arg1 > arg2
12 ge
13 	Returns the boolean truth of arg1 >= arg2
14 ` | html}}
 1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl09
 2 
 3 eq
 4 	Returns the boolean truth of arg1 == arg2
 5 ne
 6 	Returns the boolean truth of arg1 != arg2
 7 lt
 8 	Returns the boolean truth of arg1 &lt; arg2
 9 le
10 	Returns the boolean truth of arg1 &lt;= arg2
11 gt
12 	Returns the boolean truth of arg1 &gt; arg2
13 ge
14 	Returns the boolean truth of arg1 &gt;= arg2

こういうのがまぁ「ちゃんとしたテンプレートエンジンならでは」のもので、「すてききゃー」よな。


あとは、ほかのエンジンだと「マクロ」って呼ぶやつだと思うんだけどね、この Go のはなんかはっきり名前を付けてなさげなの、それが理由で、ここでもドキュメントが読みにくい、と。

「define で定義して template で使う」てことなんだけどさ、ほんっと説明下手よな。:

tmpl07-3
1 {{define "print_row" -}}
2 {{.名前}} - {{.愛称}}
3 {{- end}}
4 {{range .}}
5 {{template "print_row" .}}
6 {{- end}}
1 [me@host: tmplexpr2]$ ./tmplexpr2 tmpl07-3 data4tmpl07.json
2 
3 
4 
5 七瀬彩夏 - あやサマー
6 田中ちえ美 - ちぇみー
7 小松未可子 - みかこし
8 安済知佳 - ちかぺ
9 上田麗奈 - うえしゃま

うん。おそらくこのくらい書いとけば「始められる」と思う。というかまぁこれでほぼ70%くらいは網羅しちゃってはいるけどね。

さっきも言ったけど、この公式ドキュメントを読んですぐに理解できてすぐに実用的に使える人がいたら、それは天才だと思う。なんでこうまでわかりにくいんだか…。うん…、まぁ「説明下手」という文章構成能力の問題が仮にあったとしてもだよ、実際のところ、コード例が適切に書かれてるなら普通はここまで混乱しない。書かれてる例がヒドいんだもの。ドキュメントの構成がダメで、例もダメ、こんな状態でどうやって理解せいちゅうねん、て思う。

けれども、だ。

機能的にはまぁ一応最低限のものはあって、結構実用的なんではないかとは思う。惜しむらくはインラインに計算したりといったことが(たぶん)出来ないことで、これは結構ほかのエンジンに乗り換える動機にもなるかとは思うけれど、そのニーズがないなら、結構耐えると思う。なんにせよ「標準で使える」んだし、ヒドいのはドキュメントだけなんだから、なんにせよありがたいことである。

…てことでいいかな? ひとまず。

で、まだ追いきれていないんだけれど、たぶん「predefined でない」Function を追加したりとかってことも出来るんじゃないかと思うんだよね。そういった「あどばんすど」な使い方は後で探ってみようと思う。(追記にするか別ネタとして起こすかはその時の気分次第。)


2022-01-22 14時追記:
「あどばんすど」の前に、読んで「わっかんねーっ」と思ったけどそうでもなかった「block」について、先に済ましとく。

どう集中して読解しようとするか…、うん、集中力の問題かと思うけれど、これも説明はヒドくわかりにくいと思う、けれど、機能自体は非常に簡単なもの。「shorthand」にもっと真剣に喰らいついてれば、ワタシももっとすぐに理解したかも。

つまり以下2つは同じである、てことね:

1 {{define "hello" -}}
2 こんばんちは, {{.}}
3 {{- end -}}
4 {{template "hello" "世界"}}
5 {{template "hello" "地球"}}
1 {{block "hello" "世界" -}}
2 こんばんちは, {{.}}
3 {{- end}}
4 {{template "hello" "地球"}}

(まぁ厳密な話をするなら「空白取り除き」の手間がわずかばかり変化するけど。)

にしても、ほんっとわかりにくい。こんな簡単な機能を、なんで簡単に説明出来ないの?


2022-01-22 16時追記:
「「predefined でない」Function を追加したり」の例は幸いすぐに見つかるも、例によって「さっぱり意味わからん」。書かれていた例は「strings.Join」を組み込む例だったんだけれけれど、テンプレート内での使い方、というかまたしてもテンプレートそのものの仕様がわからずサンプルとして役立てられなかったのは、当たり前だが無能なワタシのせいである、そうに決まってる…わきゃぁない。大事なので何度でも言うが、このドキュメントはほんとうにヒドい。

扱う問題を小さくして簡単なものから、という意味で「シーケンス」を扱う strings.Join を選んでるのが間違いなのだろう、と、おそらく一発でうまくいくに違いないであろう「strings.Repeat」ならどうだろうかと試みれば案の定:

tmplexpr3.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "strings"
 6     "encoding/json"
 7     "text/template"
 8     "io/ioutil"
 9 )
10 
11 func main() {
12     tmplcont, err := ioutil.ReadFile(os.Args[1])
13     if err != nil { panic(err) }
14     tmpl := template.New("test")
15     var funcs = template.FuncMap{
16         "repeat": strings.Repeat,
17         "contains": strings.Contains,
18     }
19     tmpl.Funcs(funcs)
20     tmpl, err = tmpl.Parse(string(tmplcont))
21     if err != nil { panic(err) }
22     var i interface{}
23     if len(os.Args) > 2 {
24         jsoncont, err := ioutil.ReadFile(os.Args[2])
25         if err != nil { panic(err) }
26         json.Unmarshal(jsoncont, &i)
27     }
28     tmpl.Execute(os.Stdout, i)
29 }
data4tmpl11.json
1 [
2     "七瀬彩夏",
3     "田中ちえ美",
4     "小松未可子",
5     "安済知佳",
6     "上田麗奈"
7 ]
tmpl11
1 {{range .}}
2 {{- if (contains . "田") -}}
3 {{repeat . 4}}
4 {{- else -}}
5 {{repeat . 2}}
6 {{- end}}
7 {{end}}
1 [me@host: tmplexpr3]$ ./tmplexpr3 tmpl11 data4tmpl11.json
2 七瀬彩夏七瀬彩夏
3 田中ちえ美田中ちえ美田中ちえ美田中ちえ美
4 小松未可子小松未可子
5 安済知佳安済知佳
6 上田麗奈上田麗奈上田麗奈上田麗奈

strings.Repeat のシグニチャは「func Repeat(s string, count int) string」、strings.Contains のは「func Contains(s, substr string) bool」だが、これがマッピング時にどうなってんのかね? 色々やってみればわかるか、とやってみるのだが、こういう単純なの以外をやろうとするとやっぱり途端に意味不明になる。というか「定義出来るがテンプレート内での使い方がわからない」に陥る。

うーん…、これはひとまず…。「これに続く実験・検証がどうも難儀な気がする」。ので、今出来ていることの延長だけで出来る有意義なものを一つだけ編みだすことを今回のミッションにしよう」。それは、「テンプレート内から json のロードが出来る」ことね。これが組み込み Function に出来るなら、また一歩「思ったよりずっと割と実用」になるであろう。

「今出来ていることの延長」とは、要するにリストや辞書のようなコンテナでない引数を受け取って、単独の戻り値を返すものであれば、FuncMap・Funcs で組み込んだものの使い方が自分でわからない、ということは起こらないであろう、てこと。てことではあるんだけれど、自信はない。なぜって、うまくいきそうなのにダメだったのがあるから。まぁやってみるしかあるまい。うまくいったらめっけもん、てことにしておく、今回は。ちゃんと理解するのはあとにしよう。(ちなみに「strings.Replace」がダメかけたのだが、これは最後の引数 n の指定間違いでうまくってないように見えてただけだった。回数指定出来る Replace って珍しいからさ、気付かなかったの。)

「strings.Repeat のそのまま延長」なのでまぁ簡単なわけだよ:

tmplexpr4.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8 )
 9 
10 func json_loadb(jsoncont []byte) interface{} {
11     var i interface{}
12     json.Unmarshal(jsoncont, &i)
13     return i
14 }
15 
16 func json_loads(jsoncont string) interface{} {
17     return json_loadb(([]byte)(jsoncont))
18 }
19 
20 func json_load(fn string) interface{} {
21     jsoncont, err := ioutil.ReadFile(fn)
22     if err != nil { panic(err) }
23     return json_loadb(jsoncont)
24 }
25 
26 func main() {
27     tmplcont, err := ioutil.ReadFile(os.Args[1])
28     if err != nil { panic(err) }
29     tmpl := template.New("test")
30     var funcs = template.FuncMap{
31         "json_load": json_load,
32         "json_loads": json_loads,
33     }
34     tmpl.Funcs(funcs)
35     tmpl, err = tmpl.Parse(string(tmplcont))
36     if err != nil { panic(err) }
37     var i interface{}
38     if len(os.Args) > 2 {
39         i = json_load(os.Args[2])
40     }
41     tmpl.Execute(os.Stdout, i)
42 }
tmpl12
 1 {{range (json_load "data4tmpl07.json") -}}
 2 {{.愛称}}
 3 {{end}}
 4 
 5 {{$mystruct := json_loads `
 6 [1.339, 2.225, 4.9911, 10.8812]
 7 `}}
 8 {{range $mystruct -}}
 9 {{printf "%.2f" .}}
10 {{end}}
 1 [me@host: tmplexpr4]$ ./tmplexpr4 tmpl12
 2 あやサマー
 3 ちぇみー
 4 みかこし
 5 ちかぺ
 6 うえしゃま
 7 
 8 
 9 
10 1.34
11 2.23
12 4.99
13 10.88

これだけで一気に出来ることが増えたことになるよね。「テンプレート内で変数をゴニョれる」にまつわる制約(たぶん)である「構造を定義出来ない」は、json を経れば出来る、てことであるし。


2022-01-22 18時追記:
順序が逆になってしまったが、「「strings.Join」だとわからん」も、順番に組み立ててけば何の不可思議もなかった。何悩んでたんだ、とは思う。

こういうことをしたい場合は要は、対になるものと一緒に考え、相互に行き来できるようにすると検証しやすいわけね。なので今の場合、「strings.Join」の逆のことをする「strings.Split」とセットにするとやりやすい、てわけだ:

tmplexpr5.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8     "strings"
 9 )
10 
11 func json_loadb(jsoncont []byte) interface{} {
12     var i interface{}
13     json.Unmarshal(jsoncont, &i)
14     return i
15 }
16 
17 func json_loads(jsoncont string) interface{} {
18     return json_loadb(([]byte)(jsoncont))
19 }
20 
21 func json_load(fn string) interface{} {
22     jsoncont, err := ioutil.ReadFile(fn)
23     if err != nil { panic(err) }
24     return json_loadb(jsoncont)
25 }
26 
27 func main() {
28     tmplcont, err := ioutil.ReadFile(os.Args[1])
29     if err != nil { panic(err) }
30     tmpl := template.New("test")
31     var funcs = template.FuncMap{
32         "json_load": json_load,
33         "json_loads": json_loads,
34         "strsplit": strings.Split,
35         "strjoin": strings.Join,
36     }
37     tmpl.Funcs(funcs)
38     tmpl, err = tmpl.Parse(string(tmplcont))
39     if err != nil { panic(err) }
40     var i interface{}
41     if len(os.Args) &tl; 2 {
42         i = json_load(os.Args[2])
43     }
44     tmpl.Execute(os.Stdout, i)
45 }
tmpl13
1 {{strjoin (strsplit "abc,cde,ghi" ",") "-"}}
2 
3 {{range (strsplit "abc,cde,ghi" ",") -}}
4 Hello, {{.}}!
5 {{end}}
1 [me@host: tmplexpr5]$ ./tmplexpr5 tmpl13
2 abc-cde-ghi
3 
4 Hello, abc!
5 Hello, cde!
6 Hello, ghi!

出来てしまえば簡単なんだよなぁ、いつものことだけれど。

あとは…、今のはグローバル関数の追加なんだけれど、コンテキストとしてクラスのようなものを渡して、その「メソッド」をテンプレートから呼び出すことが出来る、みたいな構成も作れると思う(というかそんなことが書いてある)んで、それをお試そうか、とも思うんだけれど、ただね、「必要最小限で結構実用」というレベルには既に達してるからなぁ…。気が向いたら、だわね、それは。

それよりは、今の例で「strsplit」みたいな命名にしたくなったそれ、つまり、「文字列にしか適用できないとはナニゴトだ」…えっとつまり「型汎用」の方が気になる。Python みたいな動的言語におけるダックタイピングだと考えなくていいことで、まさに静的言語ならではのネタね。これは「テンプレートエンジン」の話ではなくて、Go言語そのものの話だろう。こっちは結構気になるので、気が向いたら、と言わずに、早めに解決したいわね。


2022-01-22 20時追記:
「型汎用」の話ね。

えっと、ちゃんと注意しとかないと誤解されそうなんで強めに言っとく。「すべきだ」の話ではない。ほんとに。これは、今の例で言うところの「文字列用 split、リスト用 split、…のように、別の相手に同じ名前の関数を使いたくなったら困る…のでその術が欲しい」の一つの考え方ではあるものの、静的言語においてこれをするのは大抵の場合、不自然だったり強引だったり、なんなら「非合法」だったりするので、推奨可能とまで言えるかどうかは実装言語次第なの。Go言語の場合は、あんましよろしくない。出来るけれど。

まぁこういうのって、スクリプト言語ばっかりやってるとどうしても忘れがちになるんだけれど、Goはやっぱり基本的には静的タイピングなのでね。

前置きはここまで。一応こんな「不気味な」方法で実現出来る:

tmplexpr6.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8     "strings"
 9     "reflect"
10 )
11 
12 func json_loadb(jsoncont []byte) interface{} {
13     var i interface{}
14     json.Unmarshal(jsoncont, &i)
15     return i
16 }
17 
18 func json_loads(jsoncont string) interface{} {
19     return json_loadb(([]byte)(jsoncont))
20 }
21 
22 func json_load(fn string) interface{} {
23     jsoncont, err := ioutil.ReadFile(fn)
24     if err != nil { panic(err) }
25     return json_loadb(jsoncont)
26 }
27 
28 func split(s, a interface{}) interface{} {
29     t := reflect.ValueOf(s)
30     if t.Kind() == reflect.String {
31         return strings.Split(s.(string), a.(string))
32     }
33     // TODO: implementations for other type here.
34     panic("Unknown type for split!")
35 }
36 
37 func main() {
38     tmplcont, err := ioutil.ReadFile(os.Args[1])
39     if err != nil { panic(err) }
40     tmpl := template.New("test")
41     var funcs = template.FuncMap{
42         "json_load": json_load,
43         "json_loads": json_loads,
44         "split": split,
45         "strjoin": strings.Join,
46     }
47     tmpl.Funcs(funcs)
48     tmpl, err = tmpl.Parse(string(tmplcont))
49     if err != nil { panic(err) }
50     var i interface{}
51     if len(os.Args) > 2 {
52         i = json_load(os.Args[2])
53     }
54     tmpl.Execute(os.Stdout, i)
55 }
tmpl14
1 {{strjoin (split "abc,cde,ghi" ",") "-"}}
2 
3 {{range (split "abc,cde,ghi" ",") -}}
4 Hello, {{.}}!
5 {{end}}
1 [me@host: tmplexpr6]$ ./tmplexpr6 tmpl14
2 abc-cde-ghi
3 
4 Hello, abc!
5 Hello, cde!
6 Hello, ghi!

テンプレートの仕様だけに気を配るならGo言語による実現は関係なかろう、というのも考え方で、その考え方も別に嫌いではないけれど、テンプレートの保守も Go プログラムの保守も自分なのなら、その保守性にはやっぱり意識的であるべきで。なのでこれはまぁ「バランス感覚」の話だわな。


2022-01-23 08時追記:
昨晩の「型汎用」の流れで、「json_loadb」を経る必要がないんではないかと気付いた。

この後の話の流れの都合でちょっと時は戻る(つまり「strsplit/strjoin」をなかったことにする)が:

tmplexpr7.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8 )
 9 
10 func json_loads(jsoncont string) interface{} {
11     var i interface{}
12     json.Unmarshal(([]byte)(jsoncont), &i)
13     return i
14 }
15 
16 func json_load(fn string) interface{} {
17     jsoncont, err := ioutil.ReadFile(fn)
18     if err != nil { panic(err) }
19     return json_loads(string(jsoncont))
20 }
21 
22 func main() {
23     tmplcont, err := ioutil.ReadFile(os.Args[1])
24     if err != nil { panic(err) }
25     tmpl := template.New("test")
26     var funcs = template.FuncMap{
27         "json_load": json_load,
28         "json_loads": json_loads,
29     }
30     tmpl.Funcs(funcs)
31     tmpl, err = tmpl.Parse(string(tmplcont))
32     if err != nil { panic(err) }
33     var i interface{}
34     if len(os.Args) > 2 {
35         i = json_load(os.Args[2])
36     }
37     tmpl.Execute(os.Stdout, i)
38 }
tmpl12 (再掲)
 1 {{range (json_load "data4tmpl07.json") -}}
 2 {{.愛称}}
 3 {{end}}
 4 
 5 {{$mystruct := json_loads `
 6 [1.339, 2.225, 4.9911, 10.8812]
 7 `}}
 8 {{range $mystruct -}}
 9 {{printf "%.2f" .}}
10 {{end}}
 1 [me@host: tmplexpr7]$ ./tmplexpr7 tmpl12
 2 あやサマー
 3 ちぇみー
 4 みかこし
 5 ちかぺ
 6 うえしゃま
 7 
 8 
 9 
10 1.34
11 2.23
12 4.99
13 10.88

まだ Go の型システムの勘所が完全にはピンと来てなくて、特にこうした型変換(キャスト)が実際にどういう振る舞いをしてるのかがあんまり良くわかってないんだよね。「string」だの「[]byte」ちぅことはどこかに Unicode のエンコード/デコードが介在しそうな気がするんだけど、それを誰がいつやってるのか全然把握出来てない。(例えば Python では明示的にも行き来出来るよね。そういうの。)けどまぁとにかく「期待通り動く」。無論 utf-8 に統一する前提ではあるけれど。


2022-01-23 13時追記:
本日としての本題「コンテキストとしてクラスのようなものを渡して、その「メソッド」をテンプレートから呼び出すことが出来る、みたいな構成も作れると思う」は、結果的には非常に簡単だったんだけれど、その「結果的に」に至るまでの迷走たるや、筆舌に尽くし難く。この中でかなり衝撃的な事実に気付いたがそれは後述。

ひとまず、改めて動機の整理。

関係する部分が二つあって、一つがGoプログラムで言うところの「func (t *Template) Execute(wr io.Writer, data interface{}) error」の「data」、もう一つが「func (t *Template) Funcs(funcMap FuncMap) *Template」。ともに何の工夫もしない場合は、テンプレート利用側から見るとどちらも「グローバル」にみえる。これの階層化が出来ないだろうか、ということ。Funcs で延々フラットに追加し続けるアプローチを採り続ければいずれは名前の衝突に悩むことは目に見えている。「data」一個だけでなく複数のコンテキストを駆使したいこともあるだろう。

この道筋を、てことね。本日時点でのワタシのニーズに実際にあるかと言えば、昨日言ったように「既に今日のオレには十分実用になってるから、今日のオレにとっては蛇足よね」ではあるのよ、なので「将来困ることに備えての皮算用」よな。

やり方は想像できてたので「簡単に決まってる」と軽い気持ちで始めたらハマってしまったわけだが、「答え」のコードは実に簡単明快である:

tmplexpr8.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8 )
 9 
10 func json_loads(jsoncont string) interface{} {
11     var i interface{}
12     json.Unmarshal(([]byte)(jsoncont), &i)
13     return i
14 }
15 
16 func json_load(fn string) interface{} {
17     jsoncont, err := ioutil.ReadFile(fn)
18     if err != nil { panic(err) }
19     return json_loads(string(jsoncont))
20 }
21 
22 type Export struct {
23     Data interface{}
24 }
25 func (exp Export) Getenv(key string) string {
26     return os.Getenv(key)
27 }
28 
29 func main() {
30     tmplcont, err := ioutil.ReadFile(os.Args[1])
31     if err != nil { panic(err) }
32     tmpl := template.New("test")
33     var funcs = template.FuncMap{
34         "json_load": json_load,
35         "json_loads": json_loads,
36     }
37     tmpl.Funcs(funcs)
38     tmpl, err = tmpl.Parse(string(tmplcont))
39     if err != nil { panic(err) }
40     exp := Export{}
41     if len(os.Args) > 2 {
42         exp.Data = json_load(os.Args[2])
43     }
44     tmpl.Execute(os.Stdout, exp)
45 }
tmpl13
1 {{- range .Data -}}
2 {{.名前}}
3 {{end -}}
4 {{.Getenv "USERPROFILE"}}  {{/*Windows で使われている環境変数*/}}
1 [me@host: tmplexpr8]$ ./tmplexpr8 tmpl13 data4tmpl07.json
2 七瀬彩夏
3 田中ちえ美
4 小松未可子
5 安済知佳
6 上田麗奈
7 C:\Users\hhsprings

「後述」とした「衝撃的な事実」は言葉にするとヒドく簡単で、「命名規則」の問題。Go言語そのものの規則か text/template の規則なのかはまだ突き止めていないけれど、上のワタシの例を「Data、Getenv」でなく「data、getenv」にすると壊れる。

実際には Go は何の不平も言わないし、「なんか動く、期待していない形で」。具体的に言うと、テンプレート内では「data」という名前でアクセス出来なくなる。データ自体は受け取れているのに。つまり全然実用にならない形で「なんか動く、間違ってるけど」という状態。

こんなん気付かないよ…。地道に fmt.Println だのテンプレートの書き方だのを試行錯誤してて、「データ全体は可視なのに「data」という名前がどうやっても出力出来ない」というところまで辿り着き、ようやく「data ではなく Data としなければならない」と気付いた。要するにフィールド名のルックアップに「先頭が大文字」というルールを持っているわけね。

で、わかってしまってから熟読すると、書いてはあるんだわ、わかりにくい…。(「unlike with field names they do not need to start with an upper case letter.」という風に、直接の説明ではなく間接的な説明、のみ。)

ともあれ、だ。ひとまず、今度こそ、「ここまで書いてあれば」きっと誰でも「始められる」と思う。

こうやってメソッドとか追加できるんだから「計算実行したい」みたいなことも text/template の枠内だけで出来たりするであろう、とも思っているけれど、まぁそれこそ必要になってから考えればいいと思うし、あと、「サードパーティ製のもっとええもん」はあるんではないかと思ってる。興味深いものがあったら紹介しようと思うかもね。でもそれは今日ではない。

散々文句を書いたよ、だけどそれは全部「ドキュメント」の話であって、機能面では今のところ不満はない。

最初辺りで言った通りなんだけど Windows 版のものに関しては、今のところ「実行時に特殊なランタイムライブラリを必要とすることがない、完全に自立した EXE アプリケーションを作れる」ということが非常にオイシイ特徴になってて、それがゆえに「ちょっとしたレスキュー環境の鍋の具」になりうるわけなのよ。そういう「持ち運びに便利」なものであるにも関わらずそれが「テンプレートエンジン」なのは、場合によってはとてつもなく救世主になるかもしれない、って思うわけね。


2022-01-23 21時追記:
こうやって追記にするか別ネタとして新たに起こすかかなり迷った話。ワタシにとっては「蛇足」であり、だけれども人によってはこちらこそが本題となるような話である。

エンジニアによる「認識」が、ときとして本来の意味とはズレて伝わっていると感じることがある。この「テンプレートエンジン」もそうで、まぁ英語ネィティブな人々がそういう認識はせず、英語を母国語としないからこそ起こる「誤解」でもあるんだけれど、「テンプレートエンジン=JSPとか」と解釈している人は結構多い。つまり「WEB サービスを作るフレームワーク」のことをテンプレートエンジンだと思っている、ということ。これは無論英語的な意味でも、IT のテクニカルタームとしても誤った解釈なんだけれど、まぁそう思ってしまう背景に対しては理解する。英語読めよっ、とは思うけどね、でも、誤解してしまうのはわからんでもない。それだけ「テンプレートエンジン」と「HTML 生成子ちゃん」が密に結合しがちなのだ。

「テンプレートエンジン」は、「狭義では」と限定修飾語を付与するまでもなく、テリトリーはワタシがここまでやってきたところまで、である。そりゃそうだ。「テンプレート(雛形)に基づくジェネレータシステム」のことをテンプレートエンジンと呼ぶのだから。だから JSP や PHP (など)は「テンプレートエンジンを用いた WEB サービスフレームワーク」ということ。

で、Go 言語なんだけれども、結構「ネットワーク通信に基づくサービスを作るのに長けていよう」としている節があって、http サーバを立てるのに使えるライブラリが最も充実しているように見えるのよね。特にcompress パッケージのかなり独特な品揃えは、日常のユーティリティ用途のためのものではなく明らかに HTTP 1.1 に対する応答向けのもの。ハッシュ関数群や暗号化パッケージなんかもみればほんとによく分かる。ので、「その例(WEB サービスを作る例)もやってみようかしらね」と思ったわけ。

そうなんだけどさぁ、結局今回のネタから取り立てて大きく発展するようなもんではないんだよね、「テンプレート学習用のネタとしては」。学習用のネタとして http パッケージの使い方、というならわかるけど、今は「テンプレートエンジン」のネタだから。道具としての発展性は非常に面白いものにはなるよ。ずっと言ってる通り Windows 版だと「何の依存物も必要ない EXE」に出来るのだから、「USB メモリにぶっ込んだまま使える WEB サーバ」を作れるってことだもの。だから「オモロイ道具を作ったったぜっ」ネタということなら発展するし面白い。でもそれは今のここでやるネタではないよなと。

てわけで、それを今すぐしたいなら、公式ドキュメントの Get Started からここに至れ。このページの最後の「final.go」を書き換えて遊ぶのが早い。注意点は「//go:build ignore」というコメントを外してビルドすることと、「edit.html」「view.html」を作るのを忘れないこと、のみ。すぐにお試せる。かなり簡単なプログラムなので、すぐに理解出来ると思う。無論、今回のワタシの「実例集」はその「edit.html」「view.html」理解に直結してる。


2022-01-26 17時追記:
今なんとなく漠然と「標準だけで出来ることってどこまでなんだろう」とドキュメントをただ眺めてる時間が結構長いんだけれど、ふと「テンプレートエンジンが標準ライブラリとして組み込み済みである」ことの…、まぁ「メリット」と言っていいのかな、という、だけれどもちょっと存在意義がよくわからない「へんちくりんな標準機能」を見つけた。

Regexp.ExpandRegexp.ExpandStringね。高機能なテンプレートエンジンでないと出来ないこと、というわけではないのだけれど、でもおそらく text/template 機能に依拠しているであろう機能、と思う。けどさぁ、これ、うれしいんかなぁ、と思うのと、あとインターフェイスがひっじょーにわかりにくい、というかまぁ「使いにくい」と思うんだよね。

何をするものかというと、正規表現でキャプチャする際に名前を付けることが出来るよね? これをマッチオブジェクトから「名前で」取り出して、それをデータとしてテンプレートに適用する、という一連は、まぁ普通「世界が破滅するほどに難しい」わけだ。てなわきゃない。普通はちょっとドキュメントを読めば誰でもすぐに出来るようになる。「正規表現そのものについてちゃんと熟達してるならば」。Python だと例えばこんなよね:

 1 >>> import re
 2 >>> PAT = re.compile(r"(?P<key>\w+)\s*=\s*(?P<value>\w+)$")
 3 >>> PAT.match("name= suzuko")
 4 <re.Match object; span=(0, 12), match='name= suzuko'>
 5 >>> m = PAT.match("name= suzuko")
 6 >>> m.group("key")
 7 'name'
 8 >>> m.group("value")
 9 'suzuko'
10 >>> '"{key}": "{value}"'.format(**m.groupdict())
11 '"name": "suzuko"'

Regexp.ExpandRegexp.ExpandString は、この一連を「ちょっとだけスマート(???)に書ける」というもの、のようだね。上の Python で模擬したので言えば、「明にマッチオブジェクトから取り出してテンプレートに明に適用する」ことが隠れるので「凄まじく助かります、なんてスマートではっぴーなんだ、びっちっ!」。誰がこんなん望んだんだかわからんが、なんつーかまずは「いるかぁ、これ?」て思うのが第一印象で、そして、Go 関数としてのインターフェイスが最悪、てのが次に思うこと。実用的な意味での問題を起こすようなものかと言われればそうではないし、必要だと思う人には必要なのかもしれんけれど、きっとアンケートを取ったら、「これがあってくれて極めて助かりました、ふぁっきんっ!」なんてエンジニアはほとんど皆無だと思うぞ。(少なくとも、おそらくこの関数のヘンチクリンなシグニチャが原因で、用途に気付かない、てことの方が多いであろう。)

一つのコア機能が全般に波及して全体がハッピーになる、というのは喜ばしい進化なはずなのだけれど、これは違う、て思った、てハナシでしたとさ。をしまい。


2022-01-31 17時追記:

こうやってメソッドとか追加できるんだから「計算実行したい」みたいなことも text/template の枠内だけで出来たりするであろう、とも思っているけれど、まぁそれこそ必要になってから考えればいいと思うし、あと、「サードパーティ製のもっとええもん」はあるんではないかと思ってる。興味深いものがあったら紹介しようと思うかもね。でもそれは今日ではない。

これね。物語が共通ルートからヒロイン固有ルートへ分岐する瞬間を見逃すなっ、的な。流れとして「計算でけんのでやんの、だったらほかのテンプレートエンジン使うわぁ」となるか、あるいは「既に与えられている拡張の仕組みの枠内で自前拡張するか」の、後者、ね。

いずれにしても「JSP って java をそのまま書けるんですよっ!! だから JSP ぱねーっっす」ということではない、て話はやっぱせねばと思う。「テンプレートエンジンが必要」とされたのは、これは「ロジックとビューをはっきり分離したい」という動機が一番大きい。要するにこういうことをやってるとすぐに保守しにくくなってくるわけだ:

ロジックとビューがごちゃごちゃと
1 if someexpr:
2     print("""
3 Hello {}
4 """.format(val * 3200 if expr2 is None else val * 2800))
5 else:
6     print("こんばんは, {}".format(math.cos(3)))

そもそも「読みにくい」のだが、ということはつまり「書きにくい」。そして「腫れ物」であるかのように誰も触りたくないプログラムとなる。だから「ロジック・データ・ビュー」の境界をはっきりとさせて「ビューの記述に集中する」ためにテンプレートエンジンが必要とされたのだ、てことね。だから「テンプレートエンジンにロジックを書ける」ことは、この暗黒面に逆戻りしかねない、ということをも意味する、てこと。

ということは一応前置きした上で。

すげきよまさるさんより、いくつかちょっとした式評価システムが見つかるが、ひとまずこれは良さげかなぁと思った:

こういうのって、テンプレートエンジンのテリトリーと結構被りがちなところがあって、選択するのにバランスが難しい気がするんだけれど、標準ライブラリの text/template (or html/template) に組み込んで使うのにちょうど良さげな気がする。これだけよ:

tmplexpr9.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "encoding/json"
 6     "text/template"
 7     "io/ioutil"
 8     "github.com/maja42/goval"
 9 )
10 
11 func json_loads(jsoncont string) interface{} {
12     var i interface{}
13     json.Unmarshal(([]byte)(jsoncont), &i)
14     return i
15 }
16 
17 func json_load(fn string) interface{} {
18     jsoncont, err := ioutil.ReadFile(fn)
19     if err != nil { panic(err) }
20     return json_loads(string(jsoncont))
21 }
22 
23 type Export struct {
24     Data interface{}
25 }
26 func (exp Export) Getenv(key string) string {
27     return os.Getenv(key)
28 }
29 func (exp Export) Eval(expression string) interface{} {
30     eval := goval.NewEvaluator()
31     result, err := eval.Evaluate(expression, nil, nil)
32     if err != nil {
33         panic(err)
34     }
35     return result
36 }
37 
38 func main() {
39     tmplcont, err := ioutil.ReadFile(os.Args[1])
40     if err != nil { panic(err) }
41     tmpl := template.New("test")
42     var funcs = template.FuncMap{
43         "json_load": json_load,
44         "json_loads": json_loads,
45     }
46     tmpl.Funcs(funcs)
47     tmpl, err = tmpl.Parse(string(tmplcont))
48     if err != nil { panic(err) }
49     exp := Export{}
50     if len(os.Args) > 2 {
51         exp.Data = json_load(os.Args[2])
52     }
53     tmpl.Execute(os.Stdout, exp)
54 }
tmpl14
 1 {{.Eval `320 * 400`}}
 2 {{.Eval `320 == 400`}}
 3 {{.Eval `320 != 400`}}
 4 
 5 {{range (.Eval `[2, 4, 6]`) -}}
 6 {{.}}
 7 {{end}}
 8 
 9 {{$lst := (.Eval `[1, 3, 5]`) -}}
10 {{- range $lst -}}
11 {{.}}
12 {{end -}}
 1 128000
 2 false
 3 true
 4 
 5 2
 6 4
 7 6
 8 
 9 
10 1
11 3
12 5

ほんとにちょっとしたことにはこれでもまぁいいんだけれど、このままでは text/template としてのコンテキストと無関係に孤立しちゃってる、つまりテンプレート内で使えるほかの変数とかにアクセス出来ないわけで、なのでこれで満足できるかどうかといえば微妙ね。一応 goval にも組み込み変数・関数を追加出来る仕組みがある(Evaluateの第二・第三引数)し、このエバリュエータのインスタンスは、例でやったような「都度」ではなくテンプレートのライフサイクルと同じにすればいいと思うし、ゆえに「無関係に孤立」もなんとか出来るかもしれない、とは思う。けど、そこまでやるなら、別のテンプレートエンジンを使えばいいんではなかろうか、という気分との闘いにはなるわな。

でもこれさ、テンプレートエンジンに組み込む、という用途でない使い方のつもりなら、結構使いみち多いよね。それこそ設定ファイルに計算式を書いてもいい、みたいなことをしたければ、「テンプレートエンジンではなく」こういうエバリュエータだけ用意する手もある、てことだよ。


2022-02-01 12時追記:
「XML を入力に」の話。

テンプレートエンジンnなGo!にて:

当然やりたいのは「template + data で望みのもの」の、この「data」が柔軟なやつ。json にしてみようと。スクリプト言語みたいなダイナミックバインディングとは違うんでちょっと趣は違うんだけれど、違ってもちゃんと出来るのはステキ、「interface{}」で。ちゃんと書いてある

静的タイピングな言語なのに不透明型を割と素直に扱えるのがすげーよまさるさん、てことだったんだけれど、なんかここいらのシカケが Go 言語本体に組み込まれてるのかライブラリ実装によるものなのかが、ドキュメントを眺めてるだけだとかなり曖昧なんだよね…。つまり「json では出来てるのに xml では出来ない」てことなわけだけれど、要するに「これは言語機能として組み込んだんでしょう、だったらなんで」と思うわけよ:

 1 package main
 2 
 3 import (
 4     "encoding/xml"
 5     "fmt"
 6 )
 7 
 8 func main() {
 9     type Result struct {
10         Name    string   `xml:"fullname"`
11         Groups  []string `xml:"group>value"`
12     }
13     v := Result{Name: "none"}
14     //
15     data := `
16         <person>
17             <fullname>Grace R. Emlin</fullname>
18             <group>
19                 <value>Friends</value>
20                 <value>Squash</value>
21             </group>
22         </person>
23     `
24     err := xml.Unmarshal([]byte(data), &v)
25     if err != nil {
26         fmt.Printf("error: %v", err)
27         return
28     }
29     fmt.Printf("Name: %q\n", v.Name)
30     fmt.Printf("Groups: %v\n", v.Groups)
31 }
実行結果
1 Name: "Grace R. Emlin"
2 Groups: [Friends Squash]

バックティックで書いてる指示を Unmarshal が解釈してマッピングを行っている、ということなわけで、これって Go 言語組み込みなわけよね、たぶんこれってまさに「マーシャライズとかシリアライズ」のために追加された言語機能なのだよね? 言語設計の段階からして「json とかから Go 構造体にマップ出来たらはぴーよね」として作ったのではないの? だからこそ json はこれ「も」出来るのだよね?:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "encoding/json"
 6 )
 7 
 8 func main() {
 9     var i interface{}
10     json.Unmarshal([]byte(`{"key": "kagi", "value": "atai"}`), &i)
11     fmt.Println(i)
12 }
実行結果
1 map[key:kagi value:atai]

それなのに、なぜに xml はそうならんの?:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "encoding/xml"
 6 )
 7 
 8 func main() {
 9     var i interface{}
10     xml.Unmarshal([]byte(`<Doc><Key>kagi</Key><Value>atai</Value></Doc>`), &i)
11     fmt.Println(i)
12 }
実行結果
1 <nil>

頭をいまいちど整理しておく。

まず、「透明型」へのマッピングが完備しているのは、これは非常にスゴいことであり、なおかつ「静的タイピング言語の良さを最大限に発揮出来る」可能性を秘めている。「透明型を扱う/扱える」というのはたとえるならば「オレは婚姻届専門職員だからそれ以外は受け取らないし受け取っても処理出来ないが、婚姻届なら任せとけっ」と、極端に専門家することに等しい。そう、「バリデーション」ね。届けのフォーマットは厳密に規定され、それに従ってしか動かない、そうした「硬直」は、合理的でもあり効率的でもある。こういうのは動的タイピングの Python などは苦手とするところである。

一方で、Go でいう「interface{}」だとか、C の「void*」みたいな汎用は、そうした硬直に対する反旗みたいなもの。「柔らかい」という言い方もするが、こうした設計方針がデータ構造に対するものである場合は、その「柔らかさの向く先」は主として「フィールドの増減程度の変更には耐える」ことである。必ずしもなんでもありのアナーキーに耐えうることを目指すわけではない。ともあれ、少なくとも json のアンマーシャライズについては「柔らかさを受け容れる」としたわけさね。それを「xml では出来ない」とした理由は?? うん、よーわからん。「中途半端」と思うだけだわな、末端プログラマとしてはさ。

なわけで、json と同じノリで xml を入力としたければ標準ライブラリの閉じていては無理なんだろうなぁ、と思って、ちょいちょい「awesome go」巡りをしてたんだよね。

んで「Universal JSON, BSON, YAML, XML translator to ANY format using templates.」という説明に導かれて bafi に辿り着いたわけなのだけれど、これね、「これは違う、がこれだ!」だった。

bafi。何かつーと、「コンテナのコンバータ」を目的としている点が違っているものの、実際は「今ワタシがここでやってる「テンプレートエンジンnなGo!?」そのもの」なんだわ。だから「これは違う」なんだけれど、じゃぁワタシが標準ライブラリだけでやろうとして出来ていない「xml to any」をどう解決してるのか? それこそが「答え」の、「mxj – Encode / decode XML as JSON or map[string]interface{}; extract values with dot-notation paths and wildcards. Replaces x2j and j2x packages.」:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     mxj "github.com/clbanning/mxj/v2"
 6 )
 7 
 8 func main() {
 9     var i interface{}
10     i, _ = mxj.NewMapXml([]byte(`<Doc><Key>kagi</Key><Value>atai</Value></Doc>`))
11     fmt.Println(i)
12 }
実行結果
1 map[Doc:map[Key:kagi Value:atai]]

てなわけで:

tmplexpr10.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "path/filepath"
 6     "encoding/json"
 7     mxj "github.com/clbanning/mxj/v2"
 8     "text/template"
 9     "io/ioutil"
10     "github.com/maja42/goval"
11 )
12 
13 func json_loads(jsoncont string) interface{} {
14     var i interface{}
15     json.Unmarshal(([]byte)(jsoncont), &i)
16     return i
17 }
18 
19 func json_load(fn string) interface{} {
20     jsoncont, err := ioutil.ReadFile(fn)
21     if err != nil { panic(err) }
22     return json_loads(string(jsoncont))
23 }
24 
25 func xml_loads(xmlcont string) interface{} {
26     i, err := mxj.NewMapXml([]byte(xmlcont))
27     if err != nil { panic(err) }
28     return i
29 }
30 
31 func xml_load(fn string) interface{} {
32     xmlcont, err := ioutil.ReadFile(fn)
33     if err != nil { panic(err) }
34     return xml_loads(string(xmlcont))
35 }
36 
37 type Export struct {
38     Data interface{}
39 }
40 func (exp Export) Getenv(key string) string {
41     return os.Getenv(key)
42 }
43 func (exp Export) Eval(expression string) interface{} {
44     eval := goval.NewEvaluator()
45     result, err := eval.Evaluate(expression, nil, nil)
46     if err != nil {
47         panic(err)
48     }
49     return result
50 }
51 
52 func main() {
53     tmplcont, err := ioutil.ReadFile(os.Args[1])
54     if err != nil { panic(err) }
55     tmpl := template.New("test")
56     var funcs = template.FuncMap{
57         "json_load": json_load,
58         "json_loads": json_loads,
59         "xml_load": xml_load,
60         "xml_loads": xml_loads,
61     }
62     tmpl.Funcs(funcs)
63     tmpl, err = tmpl.Parse(string(tmplcont))
64     if err != nil { panic(err) }
65     exp := Export{}
66     if len(os.Args) > 2 {
67         if filepath.Ext(os.Args[2]) == ".xml" {
68             exp.Data = xml_load(os.Args[2])
69         } else {
70             exp.Data = json_load(os.Args[2])
71         }
72     }
73     tmpl.Execute(os.Stdout, exp)
74 }
tmpl15
 1 {{$mystruct := xml_loads `
 2 <Doc>
 3 <List>
 4 <Item>あかん</Item>
 5 <Item>いかん</Item>
 6 <Item>やかん</Item>
 7 <Item>おかん</Item>
 8 </List>
 9 </Doc>
10 `}}
11 {{range $mystruct.Doc.List.Item -}}
12 {{.}}
13 {{end}}
1 [me@host: tmplexpr10]$ ./tmplexpr10.exe tmpl15
2 
3 あかん
4 いかん
5 やかん
6 おかん

bafi がやってること、つまり「コンテナを自在に行き来する」こと自体がオイシイ話よね。ワタシのサイトには、検索すれば大量に xml に対する文句が出てくると思う。そこでどんなに「正しい主張」をしてようが、本来根幹にあるのは「めんどうくさい」である。そしてその「めんどうくさい」の根幹こそが、「インフラの欠如」なのである、実際には。みたいなことを言うと java プログラマや C# 等 .NET プログラマは反論するだろうが、それこそが我々が感じる反感の最たるもの。「いや、だからさ、それって java と .NET でだけ楽、てことだろ?」。

Python や Ruby に組み込みの「xml プロセッサ」は、これは「expat」のみである。James Clark による、世界でほとんど最初にメジャーになった xml 実装で、つまり xml の処理系としてはほぼ最古にして、もっとも広く使われているものだが、これは「Shift-JIS でないなんて! 迷惑を撒き散らすなよ、この低能がっ!」というよくわからん「自称技術者」どもに立ち向かうインフラにはならない。そう、いまだに「utf-8 しか扱えない」ので。機能面でも本来の剥き身の最下層部分しか実装していないので、「ステキなステキな xml ワールド」のごく一部にしか恩恵に預かれない。

ゆえに、「java と .NET 以外のプログラマ」にとっての xml は、「とっとと別フォーマットに変換しちゃって捨て去りたいもの」である。それに「java と .NET プログラマ」にとっても、いつかは性能問題に悩み、これを捨て去る決断をせざるを得ない日も来よう。xml てのはそういうもんである。

そんなわけなのでな、「レスキュー環境としての「Go でテンプレートエンジン」」のネタが、そういう「捨て去りたい xml をちゃちゃっと変換しちゃるぜ」のための救世主ともなりました、の巻。をしまい。


2022-02-02 23時追記:
自分自身がユーザとなって、作ったこの道具をいざ使ってやろうと思ってハタと気付いた。あ、かなり大事な機能について検証出来てない…。

何かというと、キーワードとしては「ネスト」なんだけれど、それだけだと色んなものと混同してしまうので、冗長な日本語で説明するならば、「中身をインプットとして外枠な雛形を定義する、という考え方とそのシカケ」の話。すなわち、html なんぞは「ど頭に <html> と書き、末尾が </html> なのは誰がやろうと一緒なんだから、この大枠をテンプレートとして、内側だけに集中するようにしたいんぢゃぁ」つぅことよ、非常に当たり前のニーズでしょ?

相変わらず公式リファレンスは何も説明してない。Stackoverflow でようやっと「今わかっているものだけで実現出来る」ことに気付いた。「Stackoverflow を読んだ」のに「気付いた」とはなんぞや? うん、実は全然中身は読んでない。さらっと数秒流し読みでハタと気付いた。あ、そうか…:

1 {{define "html" -}}
2 <html>{{template "body" .}}</html>
3 {{- end}}
4 {{define "body" -}}
5 <body>hohoy</body>
6 {{- end}}
7 
8 {{template "html"}}

↑これはわかるだろう。特に何の変哲もないというか、「define 内で template を使用出来る」ことにだけは気付く必要はあるものの、ここまでの理解の範囲を何も超えちゃぁいない。ゆえに「ど頭に <html> と書き、末尾が </html> なのは誰がやろうと一緒なんだから、この大枠をテンプレートとして、内側だけに集中するようにしたいんぢゃぁ」に必要な仕組みは、この例でいう「template html」がどこか「オレが今集中したい記述、の外」で定義出来ること、だけなのではないのか、と。「、の外」は、無論「共通化・共有化」のためにも不可欠である。末尾に自分で「{{template "html"}}」と書かなければならないのはやや煩わしいのかもしれんけれど、でも別にそのくらいは許容範囲であろうと思う。

つまり、今ここまででワタシが書いてきた Go プロラグムで欠けているのは「オレが今集中したい記述、の外」、つまり「ベーステンプレートの取り込み機能」ということだ。上の例で言うところの「{{define "html" -}}」が別テンプレートファイルに定義されていて、「{{define "body" -}} の記述だけに集中したい」ということだ。どうだろうね、ひとまずプリアンブル・ポストアンブルを取り込めるようにすればひとまずいいのかしらね?:

tmplexpr11.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "path/filepath"
 6     "encoding/json"
 7     mxj "github.com/clbanning/mxj/v2"
 8     "text/template"
 9     "io/ioutil"
10     "github.com/maja42/goval"
11     "github.com/ogier/pflag"
12 )
13 
14 func json_loads(jsoncont string) interface{} {
15     var i interface{}
16     json.Unmarshal(([]byte)(jsoncont), &i)
17     return i
18 }
19 
20 func json_load(fn string) interface{} {
21     jsoncont, err := ioutil.ReadFile(fn)
22     if err != nil { panic(err) }
23     return json_loads(string(jsoncont))
24 }
25 
26 func xml_loads(xmlcont string) interface{} {
27     i, err := mxj.NewMapXml([]byte(xmlcont))
28     if err != nil { panic(err) }
29     return i
30 }
31 
32 func xml_load(fn string) interface{} {
33     xmlcont, err := ioutil.ReadFile(fn)
34     if err != nil { panic(err) }
35     return xml_loads(string(xmlcont))
36 }
37 
38 type Export struct {
39     Data interface{}
40 }
41 func (exp Export) Getenv(key string) string {
42     return os.Getenv(key)
43 }
44 func (exp Export) Eval(expression string) interface{} {
45     eval := goval.NewEvaluator()
46     result, err := eval.Evaluate(expression, nil, nil)
47     if err != nil {
48         panic(err)
49     }
50     return result
51 }
52 
53 func main() {
54     var (
55         preample = pflag.StringP("pre", "p", "", "specify filename of template as preamble")
56         postample = pflag.StringP("post", "P", "", "specify filename of template as postamble")
57     )
58     ousage := pflag.Usage
59     pflag.Usage = func() {
60         ousage()
61         os.Exit(1)
62     }
63     pflag.Parse()
64     tmplcont, err := ioutil.ReadFile(pflag.Arg(0))
65     if err != nil { panic(err) }
66     if *preample != "" {
67         pra, err := ioutil.ReadFile(*preample)
68         if err != nil { panic(err) }
69         tmplcont = []byte(string(pra) + string(tmplcont))
70     }
71     if *postample != "" {
72         poa, err := ioutil.ReadFile(*postample)
73         if err != nil { panic(err) }
74         tmplcont = []byte(string(tmplcont) + string(poa))
75     }
76     tmpl := template.New("test")
77     var funcs = template.FuncMap{
78         "json_load": json_load,
79         "json_loads": json_loads,
80         "xml_load": xml_load,
81         "xml_loads": xml_loads,
82     }
83     tmpl.Funcs(funcs)
84     tmpl, err = tmpl.Parse(string(tmplcont))
85     if err != nil { panic(err) }
86     exp := Export{}
87     if len(pflag.Args()) > 1 {
88         if filepath.Ext(pflag.Arg(1)) == ".xml" {
89             exp.Data = xml_load(pflag.Arg(1))
90         } else {
91             exp.Data = json_load(pflag.Arg(1))
92         }
93     }
94     tmpl.Execute(os.Stdout, exp)
95 }
tmpl16-1pre
1 {{define "html" -}}
2 <html>{{template "body" .}}</html>
3 {{- end}}
tmpl16-2body
1 {{define "body" -}}
2 <body>hey, heeey!</body>
3 {{- end}}
tmpl16-3post
1 {{template "html" .}}
1 [me@host: tmplexpr11]$ ./tmplexpr11 --pre=tmpl16-1pre tmpl16-2body --post=tmpl16-3post
2 
3 
4 <html><body>hey, heeey!</body></html>

うん、良いね。ちなみに、特にポストアンブルの方に着目するとわかりやすいんだけれど、「ドット」を忘れないようにね、でないとデータが渡らない。


2022-02-03 06時追記:
本格的に使ってやろうかと思い始めた頃合いにして、既に諦めムード大満開の章、である。

もう断言して良さそうなんだよな、「Go 組み込みのテンプレートエンジンは、めっちゃ使いにくい」。一つ前の追記の「テンプレートの入れ子」についても結局のところ「必要最小限は出来る=必要最小限しか出来ない」てこと。どのエンジンか忘れたんだけど、こういうことが出来るのがあったはずなのよ:

架空のテンプレートエンジンの疑似テンプレート
1 {{defmacro "outer"}}
2 # --- BEGIN body
3 {{CONTENT}}
4 # --- END body
5 {{enddefmacro}}
6 
7 {{execmacro "outer"}}
8 中身
9 {{endexecmacro}}
架空のテンプレートエンジンの疑似テンプレートの実行結果
1 # --- BEGIN body
2 中身
3 # --- END body

これはデータは引数の形で受け取り、「{{CONTENT}}」は「execmacro」時にボディを与えることで、みたいなノリだったはず。「NESTED」とかだったような気が。で、これに類する機能がありさえすれば、「便利マクロをたくさん書けば書くほどオイシイ」はずなのだけれど、Go のこれはそうじゃない、全然。データの授受こそ出来るけれど、「{{CONTENT}}」に相当する部分は「{{template “name” .}}」の形で固定するしかないので、あまり柔軟なテンプレートにはならない。(同じものを内側だけ変えて何度も使う、のようなことが「出来ない」。出来ないというか「データを変える」ことでしか出来ない。)

まぁこれだけでも存分にストレスかなぁと思うんだけれど、本当のところは「あと2つの不満」の方が遥かにデカい。一つは「template 呼び出し記述が冗長」なこと。もう一つが、「データを一つしか渡せないこと」。後者も「template 呼び出し記述が冗長」の原因となるのだが、これはやってみせないとなかなか伝わらんかなぁと思う。

ひとつ上の追記時点でのワタシの Go プログラムを前提として「プリアンブル、ポストアンブルを渡せて、json_loads や .Eval が使える」ことをまず理解して欲しい。とした場合、たとえば:

テンプレート(プリアンブル)
 1 {{- define "speak" -}}
 2 <speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='ja-JP'>
 3 {{- template "speakbody" . -}}
 4 </speak>
 5 {{- end -}}
 6 {{- define "s_voice"}}
 7 <voice name='Microsoft Server Speech Text to Speech Voice ({{.}})'>
 8 {{end -}}
 9 {{- define "e_voice"}}
10 </voice>
11 {{end -}}
12 
13 {{- define "s_pitch" -}}
14 <prosody pitch="{{.}}">
15 {{- end -}}
16 {{- define "s_rate" -}}
17 <prosody rate="{{.}}">
18 {{- end -}}
19 
20 {{- define "s_prosody" -}}
21 {{- if and .pitch .rate -}}
22 <prosody pitch="{{.pitch}}" rate="{{.rate}}">
23 {{- else if .pitch -}}
24 <prosody pitch="{{.pitch}}">
25 {{- else -}}
26 <prosody rate="{{.rate}}">
27 {{- end -}}
28 {{- end -}}
29 {{- define "e_prosody" -}}
30 </prosody>
31 {{- end -}}
32 
33 {{- define "pause" -}}
34 <break time="{{.}}"/>
35 {{- end -}}
テンプレート(ポストアンブル)
1 {{template "speak" .}}
テンプレート
 1 {{- define "speakbody" -}}
 2 {{- template "s_voice" "ja-JP, KeitaNeural" -}}
 3 
 4 <p>
 5 <s>こんばんは。{{template "pause" "500ms"}}またお会いできましたね。</s>
 6 {{template "pause" "4s"}}
 7 <s>{{template "s_prosody" (.Eval `{"rate":"-10%"}`)}}どうしました?{{template "e_prosody"}}</s>
 8 {{template "pause" "3s"}}
 9 </p>
10 {{- template "e_voice" -}}
11 {{- end -}}
実行結果
 1 <speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='ja-JP'>
 2 <voice name='Microsoft Server Speech Text to Speech Voice (ja-JP, KeitaNeural)'>
 3 <p>
 4 <s>こんばんは。<break time="500ms"/>またお会いできましたね。</s>
 5 <break time="4s"/>
 6 <s><prosody rate="-10%">どうしました?</prosody></s>
 7 <break time="3s"/>
 8 </p>
 9 </voice>
10 </speak>

実際に自分で記述してみればわかると思うんだけど、「{{template “…” .}}」だけでもう「なげーよ」と。はっきりいって「楽したくてマクロを定義したつもりなのに、余計に記述量が多い」となりかねず、そして「データを一つしか渡せない」がために「{{template "s_prosody" (.Eval `{"rate":"-10%"}`)}}」のようなことをしなければならない、のに、ここまで読んでくれた方はわかるかと思うが「構造の定義をテンプレート内で行うことは出来ない」のだ、素の Go text/template では。ワタシが「json_loads や .Eval を追加したから初めて出来るようになったこと」なのである。そしてまたしても「なげーよ、なげーよーー」。もうね、「こんなんだったら本物の XML を直接書いたほうが楽だっ」てことになりかねん、てことで、事実今そうなりつつある。

そういうわけで、ここまで頑張って実用になる使い方を探ってきたけれど、これはもう限界。これで出来ることもそれなりにはあるけれど、どんなにお世辞を重ねようが「使いにくいという事実は揺るがない」。ので、うん、ワタシとしてはもう標準ライブラリの text/template には未練はないかな。ほかのエンジンを探そうっと…。