Elixir

Elixir でソースコードジェネレーションする

この記事は Akatsuki Advent Calendar 2018 の16日目の記事です。
前回は e__koma さんの CodeBuildでサーバレスバッチ環境を運用する でした。

はじめに

アプリケーション開発をしていると、データからソースコードを生成したくなる場面があります。
私の場合、「種類」を表す整数(いわゆる enum)でパターンマッチをしたくなり、所属プロジェクトでのソースコードジェネレーションの仕組みを整えました。
今日はこの動機と方法について書こうと思います。

動機

マスタデータによる条件分岐

ゲームサーバーの開発では、クエストやアイテムなどの細かい設定のために膨大なマスタデータが投入されます。
ただでさえ量が多いので、データ作成の面でもコーディングの面でもコンパクトな表現が望ましく、「種類」を表現するようなカラムを追加することがしばしばあります。
例えば、quests テーブルに type カラムを用意して、メインクエストやデイリークエストなどをこの1つのテーブルで管理する場合があります。

id name type ...
1 メインクエスト1 main ...
2 メインクエスト2 main ...
... ... ... ...
101 デイリークエスト1 daily ...
102 デイリークエスト2 daily ...
... ... ... ...

これらのデータに対しては、大体は同じを処理をしつつ、type ごとに特殊な処理が追加されることになります。
例えば、いずれもスタミナを消費するが、メインクエストは何度でもプレイできる一方、デイリークエストは1日1回限定である、などです。

以前別プロジェクトにて Ruby on Rails で書いていたときは、Single Table Inheritance (STI) の機能を使い、Model クラスの継承関係でこれらを表現していました。

class Quest < ApplicationRecord
  class Main < Quest
    def available?(player)
      # ...
    end
  end

  class Daily < Quest
    def available?(player)
      # ...
    end
  end
end

Elixir の場合、パターンマッチングを使うときれいにコードが書けそうです。
type を atom として保持すれば次のように書けます
(定義できる atom の数には限りがあるため、外部から渡ってくる文字列を atom にすることは普通は良くないのですが、マスタデータで指定されうる type は事前にわかっているため、ここでは問題にはなりません)。

defmodule Sample.Quest do
  alias Sample.MasterData

  def available?(%MasterData.Quest{type: :main} = quest, player) do
    # ...
  end

  def available?(%MasterData.Quest{type: :daily} = quest, player) do
    # ...
  end
end

クライアントサイドとの連携

普通はこれで目的達成なのですが、私のプロジェクトの場合、クライアントサイドで C# の enum としてこれらの値を使いたいという要望がありました。
このとき、main や daily などをそれぞれ 1 や 2 などの整数で持つことになります。

defmodule Sample.Quest do
  alias Sample.MasterData

  @main 1
  @daily 2

  def available?(%MasterData.Quest{type: @main} = quest, player) do
    # ...
  end

  def available?(%MasterData.Quest{type: @daily} = quest, player) do
    # ...
  end
end

こうなると今度は、「メインクエストは1、デイリークエストは2」という対応付けが、ソースコードのあらゆる箇所に散乱することになります。
これらの対応付けは一度決めたら変更することがないとはいえ、ミスの原因にならないよう一箇所で定義したいものです。

さらに言うと、これらの値はクライアントとサーバーとの共通の取り決めであるため、まとめて定義するのであればソースコードの外部に配置したくなります。
私のプロジェクトでは、これを他のマスタデータと同じように Excel で定義し JSON 出力するという管理方法を取りました。

id member
1 MAIN
2 DAILY
... ...

この場合、サーバーでの条件分岐は次のようになります。

defmodule Sample.Quest do
  alias Sample.MasterData

  def available?(quest, player) do
    MasterData.EnumQuestType.to_atom(quest.type)
    |> available?(quest, player)
  end

  defp available?(:main, quest, player) do
    # ...
  end

  defp available?(:daily, quest, player) do
    # ...
  end
end

1 や 2 といった直値でパターンマッチングするとマスタデータとの二重管理になってしまうため、いちいち atom に変換する必要が出てきてしまいました。

「マスタデータをサーバーに取り込む際に atom に変換する」という選択肢もありますが、クライアントへのレスポンスは整数を期待されているので、
同じ意味を持った変数の型が整数なのか atom なのかを気に掛けなければならず、コーディングの負担になってしまいます。

defmodule Sample.Handler.Quest do
  alias Sample.MasterData

  @behaviour :cowboy_handler

  def init(req, %{player_id: player_id}) do
    req =
      player_id
      |> Sample.Quest.available()
      |> Enum.map(&%{&1 | type: MasterData.EnumQuestType.to_integer(&1.type)})
      |> Sample.Misc.Cowboy.reply_success(req, player_id)

    {:ok, req, nil}
  end
end

解決策

上記のように色々と思案した結果、マスタデータから次のようなコードを自動生成することにしました。

defmodule Sample.EnumQuestType do
  defmacro __using__(_) do
    quote do
      @enum_main 1
      @enum_daily 2
    end
  end
end

利用側は use するだけでよく、シンプルになりました。

defmodule Sample.Quest do
  use Sample.EnumQuestType
  alias Sample.MasterData

  def available?(%MasterData.Quest{type: @enum_main} = quest, player) do
    # ...
  end

  def available?(%MasterData.Quest{type: @enum_daily} = quest, player) do
    # ...
  end
end

これによって、以下の2つの要求を同時に達成することができました。

  • サーバーとクライアントで共通の enum 定義を参照したい
  • コンパイル時点で値を確定させ、そのままパターンマッチングに使いたい

方法

上記のようなソースコードジェネレーションの方法を説明していきます。
といっても実は簡単で、読み込んだデータの値を、用意しておいたテンプレートに埋め込んでファイルに書き出すだけです。
Mix task として実装していきます。

テンプレート

まずはテンプレートを用意します。

# このファイルは自動生成されたものなので、直接編集しないこと
defmodule Sample.<%= model_name %> do
  defmacro __using__(_) do
    quote do
      <%= for %{id: id, member: member} <- records do %>
        @enum_<%= member %> <%= id %><% end %>
    end
  end
end

model_name, records は生成時に渡す変数です。
これを、template.eex などの名前で保存します。拡張子の eex は Embedded Elixir の意味です。

この際、いくつ注意する点があります。

  • 生成されたソースコードを手書きのものと勘違いして編集されてしまわないように、コメントを添えておく
  • あとでフォーマットするため、変な空行が入らないように詰めて書いたほうがよい

今回は、開発の途中でこの仕組みを導入した(以前は都度型変換をしていた)ので、移行しやすいように変換用の関数も定義しました。

# このファイルは自動生成されたものなので、直接編集しないこと
defmodule Sample.<%= model_name %> do
  defmacro __using__(_) do
    quote do
      <%= for %{id: id, member: member} <- records do %>
        @enum_<%= member %> <%= id %><% end %>
    end
  end

  <%= for %{id: id, member: member} <- records do %>
    def <%= member %>(), do: <%= id %><% end %>

  <%= for %{id: id, member: member} <- records do %>
    def to_atom(<%= id %>), do: :<%= member %><% end %>

  <%= for %{id: id, member: member} <- records do %>
    def to_integer(:<%= member %>), do: <%= id %><% end %>
end

先程のクエストの例だと生成結果は次のようになります。

# このファイルは自動生成されたものなので、直接編集しないこと
defmodule Sample.EnumQuestType do
  defmacro __using__(_) do
    quote do
      @enum_main 1
      @enum_daily 2
    end
  end

  def main(), do: 1
  def daily(), do: 2

  def to_atom(1), do: :main
  def to_atom(2), do: :daily

  def to_integer(:main), do: 1
  def to_integer(:daily), do: 1
end

データ読み込み

次に、マスタデータを読み込みます。
ここのやり方はもちろんプロジェクト依存ですが、私の場合は、enum_ というプレフィックスの JSON ファイルを読み込むため、以下のようになりました。

defmodule Mix.Tasks.Sample.EnumGenerator
  @masterdata_dir "priv/masterdata/json"
  @json_suffix ".json"

  # ...

  defp load(table) do
    @masterdata_dir
    |> Path.join(table <> @json_suffix)
    |> File.read!()
    |> Jason.decode!(keys: :atoms)
    |> Enum.map(&downcase_member/1)
  end

  defp downcase_member(%{member: member} = map), do: %{map | member: String.downcase(member)}
end

値の埋め込み

テンプレートへの値の埋め込みは、EEx.eval_file/2 に、テンプレートとキーワードリストを渡すだけです。

  @template_path "lib/mix/tasks/sample/enum_template.eex"

  # ...

  defp render(table, records) do
    model_name = Macro.camelize(table)

    File.cwd!()
    |> Path.join(@template_path)
    |> EEx.eval_file(model_name: model_name, records: records)
  end

フォーマット

そのままだと後の mix format の変更対象になってしまうので、Code.format_string!/2 します。
ファイル末尾の改行が無くなってしまったので、フォーマット後に追加しています。

  # /.formatter.exs と同じ値
  @line_length 120

  # ...

  defp format(content) do
    content
    |> Code.format_string!(line_length: @line_length)
    |> Kernel.++(["\n"])
  end

書き出し

最後に、.ex ファイルとして、コンパイル対象のディレクトリに出力します。

  @target_dir "lib/sample/"

  # ...

  defp export(basename, content) do
    File.cwd!()
    |> Path.join(@target_dir <> basename <> ".ex")
    |> Mix.Generator.create_file(content, force: true)
  end

注意点

実際に試したところ、コードがかなり書きやすくなり、やって良かったと感じています。
一方で、デメリットもあるため、それらを踏まえた上で使う必要がありました。

  • 開発途中でこの仕組みに乗り換えるのは手作業での置換が多くて大変。
  • マスタデータに入っている値そのものが読まれるわけではないので、データ作成者への仕組みの説明が若干難しくなる。
  • (ソースコードジェネレーションではなく use の話ですが) module attribute の重複が定義した際、エラーどころか警告もなく上書きされてデバッグが困難になる。長くはなるがテーブル名をプレフィックスとして付けたほうが良さそう。

おわりに

Elixir でソースコードを生成する方法を紹介しました。
今回解決したかった課題そのものは特殊ではありましたが、データからコードを生成したい場面は他にもあるかもしれません。

最後に、ジェネレーターのソースコード全体を貼っておきます。

defmodule Mix.Tasks.Sample.EnumGenerator do
  use Mix.Task

  # /.formatter.exs と同じ値
  @line_length 120

  @masterdata_dir "priv/masterdata/json"
  @target_dir "lib/sample/"
  @template_path "lib/mix/tasks/sample/enum_template.eex"
  @json_prefix "enum_"
  @json_suffix ".json"

  def run(_options) do
    for table <- tables() do
      records = load(table)
      content = render(table, records) |> format()
      export(table, content)
    end
  end

  defp tables() do
    @masterdata_dir
    |> File.ls!()
    |> Enum.filter(&String.starts_with?(&1, @json_prefix))
    |> Enum.map(&String.replace_suffix(&1, @json_suffix, ""))
  end

  defp load(table) do
    @masterdata_dir
    |> Path.join(table <> @json_suffix)
    |> File.read!()
    |> Jason.decode!(keys: :atoms)
    |> Enum.map(&downcase_member/1)
  end

  defp downcase_member(%{member: member} = map), do: %{map | member: String.downcase(member)}

  defp render(table, records) do
    model_name = Macro.camelize(table)

    File.cwd!()
    |> Path.join(@template_path)
    |> EEx.eval_file(model_name: model_name, records: records)
  end

  defp format(content) do
    content
    |> Code.format_string!(line_length: @line_length)
    |> Kernel.++(["\n"])
  end

  defp export(basename, content) do
    File.cwd!()
    |> Path.join(@target_dir <> basename <> ".ex")
    |> Mix.Generator.create_file(content, force: true)
  end
end