読者です 読者をやめる 読者になる 読者になる

Jinja2サブセット的な型付きテンプレートエンジンhaiji

年末年始なので何か作ろうかなと思いhaiji*1というライブラリを作ってみてる.


まぁ,なんというか最近ansible*2よく使ってたこともあってイロイロ思うトコロあったりなかったり.

大概のテンプレートエンジンにおいて,個人的にイマイチ気に入らないトコロは未定義変数の扱いだ.ある変数の値を展開しようとして,「テンプレートを展開しようとした環境でその変数の値が未定義なとき」を考えるなんてハッキリ言って正気とは思えないし,未定義なときは空文字列と同じになるとかもお前ホントそれでいいのかと..

具体的には,テンプレートの展開を行っている環境からある変数を消す,あるいは,変数名を変更するといった変更に伴い,展開結果が変わってしまうかどうかが実際に動かすまでは簡単にはわからなくなるという点が嫌.これは,erbのrender partialやJinja2のincludeといったテンプレート自体の部品化を促進する程,メンテナンス性にダメージもまた与えることに繋がっていく.

プロダクトが大きくなり「実際にいろんな箇所で展開されているテンプレート部品」があるときに,先程のような変数名やら何やらの変更を行っても本当に望み通りの展開結果を保っているか簡単に確認できるだろうか?変更によってある変数が未定義になっても「未定義時の動作」が定義されていたり空文字になったりであれば,未定義であること自体がプログラマの意図していない事態だとしても,テンプレートは正常に展開されて意図しない結果を無批判に作り出してしまう.

結果として

  • 変数名がなんとなく不適切なものになってきちゃったけどテンプレートの展開結果を壊すかもしれないから変更したくない
  • 最早不要かもしれないけど実際にテンプレートのどこかで使われているかもしれないからコードを消したいけど消せない
  • テンプレート側に変数の参照を追加したときに,ある箇所からは定義されて渡されてくるけどある処理からはそうでない.具体的にどれが実際ソレなのか

など後ろ向きな力がかかることになる.メンテナンスのハードルが高くなり,またひとつ地獄の釜の蓋が開く.


無自覚に何かを余計なことを引き起こしてしまいかねない要因は極力排除したい.


ひとつの解決策として,テンプレートが要求する環境に対し厳密に型を付けるという策が考えられる.テンプレートの展開を行う環境に「ある変数が定義されているべき」という要求を型で表現する.テンプレート展開時には,その要求を満たすような環境を提供すればいい.そして適合していなければ型検査に失敗してもらうのだ.

haijiはこの考えを基本にして実験的に作ってみたJinja2のサブセット的テンプレートエンジンだ.雰囲気はリポジトリのexample.hsとexapmle.tmplを見てもらうのがよいだろう.実際にJinja2を入れてexample.pyと比べてみればいい.

中身はどうしてもTemplateHaskellと依存型で伊東ライフにはなる.

たとえば,以下のようなhaijiテンプレートは,

{{ foo }}

レンダリングするときに次のような型の値を要求する.これがレンダリングのために最低限必要な環境の型ということだ.

TLDict '[ "foo" :-> TL.Text ]

実際には,型レベルリストに余計な型が入っていても,実際に使っているものが入っていればOKで,次のような型の値でもレンダリングはできる.使われないだけだ.

TLDict '[ "foo" :-> TL.Text, "hoge" :-> Int ]

展開が複数ある以下のようなhaijiテンプレートなら,

{{ foo }}{{ bar }}

レンダリングするときに次のような型の値を要求する.

TLDict '[ "foo" :-> TL.Text, "bar" :-> Int ]

さらに,以下のようなhaijiテンプレートであれば,

{{ foo.bar }}

レンダリングするときに次のような型の値を要求する.

TLDict '[ "foo" :-> TLDict '[ "bar" :-> TL.Text ] ]

他にも,以下のようなhaijiテンプレートであれば,

{% for item in items %}
  <li>{{ item.foo }}</li>
{% endfor %}

レンダリングするときに次のような型の値を要求する.

TLDict '[ "item" :-> [ TLDict '[ "foo" :-> TL.Text ] ] ]

もし,次のように,同じ変数itemsがテキストにもリストにも判断されるような箇所がある場合,このテンプレートはコンパイルエラーになる.

{{ items }}
{% for item in items %}
  <li>{{ item.foo }}</li>
{% endfor %}

他にもif〜(else)〜endifや,includeに対応している.

もし,テンプレート内で使われている変数を変更したり追加したりした場合,そのテンプレートをレンダリングしている箇所で変更後に要求されるようになった変数を与えていなければ,次のように型検査に失敗してコンパイルエラーになる*3.逆に,実際にテンプレートで使っている変数を消してしまってレンダリングしても同様のコンパイルエラーになる*4

example.hs:14:39:
    No instance for (Retrieve (TLDict '[]) "navigation" [x0])
      arising from a use of ‘retrieve’
    In the second argument of ‘map’, namely
      ‘retrieve dict_a7nS (Key :: Key "navigation")’
    In the second argument of ‘($)’, namely
      ‘(map
          (\ x_a7nT
             -> \ esc_a7nU dict_a7nV -> LT.concat ["    <li><a href=\"", ....]
                  esc_a7nR (Text.Haiji.TH.add x_a7nT (Key :: Key "item") dict_a7nS))
          (retrieve dict_a7nS (Key :: Key "navigation")))’
    In the expression:
      (LT.concat
       $ (map
            (\ x_a7nT
               -> \ esc_a7nU dict_a7nV -> LT.concat ["    <li><a href=\"", ....]
                    esc_a7nR (Text.Haiji.TH.add x_a7nT (Key :: Key "item") dict_a7nS))
            (retrieve dict_a7nS (Key :: Key "navigation"))))
Failed, modules loaded: Text.Haiji, Text.Haiji.Types, Text.Haiji.Parse, Text.Haiji.TH.

Proof of Concept的にザックリ作り始めたばかりなので,まだ機能的に十分ではないし,コレどうしようかと思っている箇所もいくつかあるし,インターフェースも決定し兼ねてる.hackageにも上げてない.そもそもLLやフロントの人からは「こんなゲンミツにしてどうすんだなんとなくでも動いてるのが確認できるからいいんじゃねーか」という意見もあるかもしれない.なお速度は最初から考えないようにしてる模様.

*1:廃寺

*2:テンプレートエンジンはJinja2ね

*3:ので,何かが足りないことなどがわかる

*4:ので,コンパイルエラーにならなければ消しても展開結果には影響が無いものだとわかる