はじめに
Elixir の Mix は、プロジェクトの作成・コンパイル・テスト・依存関係管理などを担う標準のビルドツールです。
その内部では、さまざまな「状態」を扱う必要があります。
その一端を担っているのが、Mix.State という内部モジュールです。
Mix.State は公開APIではありませんが、ソースコードを読むと自分のアプリでも使い回せそうな技がいくつも詰まっていました。
ここではざっくりした役割とそこから学べる状態管理の技を、自分用のメモとして整理しておきます。
※ 写真はイメージです
Mix.State の概要
Mix.State は、次のような責務を持つモジュールです。
-
GenServerとして起動される - 起動時に ETS の名前付きテーブル を作り、
shell・env・targetなどの設定を格納 - 一部の情報は GenServer の状態として保持し、必要になるまで初期化せず、最初の呼び出し時にだけ変換処理を行ってメモ化
- 読み取り主体の情報は
:persistent_termを用いたキャッシュとして保持
大まかには以下のように整理できます:
- ETS を使った「変更しやすいグローバル状態」
-
persistent_termを使った「読み出し回数が多い値の蓄え」 -
GenServer内部での「一度だけ計算して後は再利用する値」
この小さなモジュールの中に、状態管理のパターンがコンパクトにまとまっていて、とても参考になります。
Mix.State から学べる技
この記事では、次の 4 つの技に絞って見ていきます。
- 名前付き
GenServerで ETS テーブルの寿命を管理する - ETS を「グローバル設定用の辞書」として包む
-
persistent_termで読み出し主体の値を蓄える -
GenServerの状態で「遅延初期化+メモ化」を行う
名前付き GenServer で ETS の寿命を管理する
Mix.State はまず GenServer として起動され、その中で ETS テーブルを作成します。
defmodule Mix.State do
@name __MODULE__
use GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok, name: @name)
end
@impl true
def init(:ok) do
table = :ets.new(@name, [:public, :set, :named_table, read_concurrency: true])
:ets.insert(table,
shell: Mix.Shell.IO,
env: from_env("MIX_ENV", :dev),
target: from_env("MIX_TARGET", :host),
scm: [Mix.SCM.Git, Mix.SCM.Path]
)
{:ok, %{}}
end
end
この構成はシンプルですが、いろんな用途で使えるパターンのような氣がします。
- ETS テーブルは
GenServer.init/1内で生成 - テーブル名はモジュール名(
__MODULE__) -
named_tableにすることで外部から参照可能 - 初期値の挿入を
init/1に集約
このようにしておくことで、次のような利点があります。
-
ETS の寿命が GenServer にひもづく
プロセスが落ちればテーブルも一緒に消え、再起動すれば自動的に再生成されます。 -
ETS の生成と初期化が一か所に集約される
状態の立ち上がり方が明確になり、見通しの良い構成になります。 -
supervision tree に統合しやすい
アプリ全体のライフサイクルに自然に組み込めるため、再起動時の振る舞いも把握しやすくなります。
状態の所有をプロセスに集約しておくことで、「気軽にアクセスできる状態」と「制御された初期化・再構築」の両立が実現できます。
ETS をグローバル設定用の辞書として包む
Mix.State では、ETS を直接操作せず、専用のラッパ関数を通じて状態にアクセスできるようにしています。
def get(key, default \\ nil) do
case :ets.lookup(@name, key) do
[{^key, value}] -> value
[] -> default
end
end
def put(key, value) do
:ets.insert(@name, {key, value})
:ok
end
def fetch(key) do
case :ets.lookup(@name, key) do
[{^key, value}] -> {:ok, value}
[] -> :error
end
end
def update(key, fun) do
:ets.insert(@name, {key, fun.(:ets.lookup_element(@name, key, 2))})
:ok
end
こうすることで、呼び出し側では Mix.State.get(:env) のように、ETS の存在を意識せず扱えます。
persistent_term で読み出し主体の値を蓄える
Mix.State は、頻繁に読み出されるがほとんど更新されない値を :persistent_term で保持しています。
def read_cache(key) do
:persistent_term.get({__MODULE__, key}, nil)
end
def write_cache(key, value) do
:persistent_term.put({__MODULE__, key}, value)
value
end
def delete_cache(key) do
:persistent_term.erase({__MODULE__, key})
end
def clear_cache do
for {{__MODULE__, _} = key, _value} <- :persistent_term.get() do
:persistent_term.erase(key)
end
end
このように設計すれば、persistent_term を高速な読み取り専用キャッシュとして使えます。
初期化時に一度だけ設定し、その後ほとんど更新されない値に向いています。
GenServer 状態での遅延初期化とメモ化
Mix.State では、必要になるまで処理を遅らせ、最初の呼び出し時に一度だけ実行・保存するパターンが使われています。
def handle_call(:builtin_apps, _from, %{builtin_apps: apps} = state) do
if is_map(apps) do
{:reply, apps, state}
else
result =
for path <- apps,
app = app_from_code_path(path),
do: {app, path},
into: %{}
{:reply, result, %{state | builtin_apps: result}}
end
end
初期状態では「変換前の値」を持っておき、
最初の問い合わせ時だけ変換処理を行い、その結果を state にメモ化しています。
このような遅延初期化は、「重い処理だけど毎回は不要」なケースに効果的です。初回の呼び出しでのみ処理を実行し、それ以降はキャッシュされた値を返す設計になっており、並行アクセス時も GenServer が直列化を保証してくれるため、安全に一度だけ実行されます。状態変換のタイミングが明確になることで、コード全体の見通しも良くなります。
おわりに
Mix.State はごく小さな内部モジュールですが、その中には汎用性の高い状態管理の技がいくつも詰まっていました。
普段何気なく使っている標準ライブラリでも、ソースコードを読むことで自分のコードにも取り入れられるアイデアが見つかることがあり、あらためて学びの多さを実感しています。
