AdventCalendar
Elixir
ElixirDay 12

EExでconfigファイルのテンプレート化

10月からPerlの国から越してきました@toku_bassです。
この記事はAdventCalender 2017の12日目の記事になります。

テンプレート化の動機

my_project/config/配下のファイルが環境(dev,stg,prod)毎に手書きでメンテしている状態を解決したかったのでtemplateファイルから生成することにしました。
いくらCIでテストを回しても、prod.exsはテストできている訳ではないので手書きは怖いです。

EEx

まずEExの解説から。

https://elixirschool.com/jp/lessons/specifics/eex/

RubyにERBが、そしてJavaにJSPがあるようにElixirにもEEx即ち埋め込みElixirがあります。EExを使って文字列の中にElixirを埋め込んで評価することができます。

iex> EEx.eval_string("<%= foo %>", foo: 1)
"1"

eval_stringの中を追って行くと、
まずtemplateをtokenに分解して、それぞれのtokenに対応したhandlerがtokenをElixirのコードに置き換えていきます。
handlerはEEx.Engineに定義されており、自作Engineに差し替えて挙動を変えることができます。

# イメージ
def eval_string do
 compiled = compile(string)
 do_eval(compiled, bindings) #値のbinding
end

def compile do
   tokens = tokenize()
   token_handler(tokens)
end

ここまでがtemplateのcompileになり、この後evalでbindingをします
挙動としては以下のようになっています。

iex > compiled = quote do: unquote({:foo, [line: 1]), nil}
{:foo, [line: 1], nil}
iex > Code.eval_quoted(compiled, [foo: 100])
{100, [foo: 100]}

tokenizeの詳細も書きたかったのですが、長くなるので割愛します。

configテンプレートとして使う

さて、ではこのEExがConfigテンプレートとして使えるのか試してみます。

単純なデータ

iex> EEx.eval_string("<%= foo %>", foo: 1)
"1"

はい

iex> EEx.eval_string("<%= foo %>", foo: "a")
"a"
iex> EEx.eval_string("<%= foo %>", foo: 'a')
"a"
iex> EEx.eval_string("<%= foo %>", foo: :a)
"a"

あれ、、、

EEx.eval_string("<%= foo %>", foo: ["a","b","c"])
"abc"

あらら

config/*.exsファイルを生成したいので異なるデータにされるのは困ります。
EEx.eval_string("<%= foo %>", foo: ":a")
とすれば:aが表示されますが、全部文字列で持つのはデータファイルの管理上うれしくありません。
Hexで他のテンプレートエンジンも探してみましたが、stringに変換する挙動は同じでした。

条件分岐

条件分岐の使い勝手はどうでしょうか

iex> EEx.eval_string("<%= if false do %><%= hoge %><% end %>", [])
** (CompileError) nofile:1: undefined function hoge/0

if falseなのにhogeを要求されてしまいました。
これは(説明は割愛しますが)assignsを使えば大丈夫です。

EEx.eval_string("<%= if false do %><%= @hoge %> <% end %>", assigns: [])

templateの改行削除

比較的新しいバージョンのElixirではtrimオプションがあります

iex> EEx.eval_string("start
...> <%= if false do %>
...>  <%= foo %>
...> <% end %>
...> end", [foo: 1], trim: true)

"start\nend"

想定通り

しかしifを1行にすると、、、

iex> EEx.eval_string("start
...> <%= if false do %><%= foo %><% end %>
...> end", [foo: 1], trim: true)

"start\n\nend"

これはバグっぽい、、

Engineを差し替えて対処する

示した例のように、to_stringで変換されると上手くいかないのでinspectで出力して期待通り表示されるようにします。

defmodule MyEngine do
  use EEx.Engine

  def handle_expr(buf, "=", expr) do
    expr = Macro.prewalk(expr, &EEx.Engine.handle_assign/1) # assigns用
    quote do
      unquote(buf) <> inspect(unquote(expr))
    end
  end
end


IO.puts EEx.eval_string("<%= foo %>", [ foo: :a ], engine: MyEngine)
# :a
IO.puts EEx.eval_string("<%= foo %>", [ foo: "a" ], engine: MyEngine)
# "a"
IO.puts EEx.eval_string("<%= foo %>", [ foo: 'a' ], engine: MyEngine)
# 'a'
IO.puts EEx.eval_string("<%= foo %>", [ foo: ["a","b", "c"] ], engine: MyEngine)
# ["a","b","c"]

成果物

準備が整ったので、configを作ります。

defmodule Mix.MyProject.Config.DevConstant do

  @config [
    logger: %{
      level: :debug,
      truncate: 1024
    },
  ]

  def get_all do
    @config
  end

end
# config/template.eex
use Mix.Config

config :logger,
 level: <%= @logger.level %>,
 truncate: <%= @logger.truncate %>
# 実際は mix task にしています
template = File.read!("config/template.eex")
params   = Mix.MyProject.Config.DevConstant.get_all()
config   = EEx.eval_string(template, [assigns: params], engine: MyEngine)
File.write!("config/dev.exs",config)

以上で12日のAdventCalendarの記事を終わります。
ありがとうございました。

明日は

@rinosamakanataさんの「erlyberly について」です