やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

Elixirでモジュール内の関数を動的に呼び出す

パイセンの実装に感動

僕が感動したのはElixirでの実装ではありませんが、Rubyで特定のディレクトリ以下に_____.rbというファイルを作成し、命名規則に従ったモジュールとinterfaceを満たす関数を実装すると、自動でモジュール内の関数が読み込まれて動的に実行されるという設計に感動しました。
このような設計の方法はデザインパターンに通ずることがあるんじゃないかと思いましたが、僕自身、オブジェクト指向をやんちゃにしか取り組んだことしかないので詳細は不明。
とはいえ、この実装方法に感動したのでElixirを使って実装してみます。

Elixirでの実装方法

先に全体のコードを貼っておきます。

github.com

interface

Elixirinterfaceを指定することは出来ませんが、behaviorを使用することで指定の関数を満たすモジュールを定義させることが出来るようです。

elixir-lang.org

なるほど。たまに見かけてた@implってこんな感じで使うんですね。
とはいえ、behaviorを使うのはちょっとリッチだなと思ったので、今回は共通認識として「この関数はモジュール内に実装必須ね」と開発メンバーに事前に共有をしたという前提で、通常のモジュールを用いて実装を行います。

モジュールの読み込み

以下の順で実装をしてみようと思います。

  • 指定のディレクトリ階層に格納されている____.exの一覧を文字列のリストで取得
  • 指定のディレクトリ階層までのモジュール名を取得したListの要素、それぞれに結合

コードに落とすとこうなりました。思っていたよりも長くなってしまいました。staticな文字列にしたらもっと、短くなると思いますが自分のスタイルを貫きました。

github.com

def module_names do
    processes_path() # 指定のディレクトリのpathを文字列で取得
    |> File.ls! # ディレクトリ内のファイル一覧を取得
    |> Enum.filter(fn fname -> ex_extension_slice(fname) == ".ex" end) # .exファイル以外を除去
    |> Enum.map(fn fname -> merge_module_name(fname) end) # この階層までのモジュール名を付与(eg: Project.Sample <> FileModule)
end

これで指定モジュール内に格納されている.exファイルを読み込む準備が出来ました。あとは読み込んだモジュールへのpathを良い感じに使ってあげます。今回は、モジュール内に共通で実装されているstartという関数を実行させます(startは新たなプロセスを起動させるための関数)。

文字列から動的にモジュール内の関数を実行する方法は以下を参考にしました。ノリはspawnでモジュール名と関数のアトムを渡すのに似ていますね。

dynamic - Elixir - Call method on module by String-name - Stack Overflowstackoverflow.com

ハマったのは新規で自分が作成したモジュールを読み込む場合にはElixirをモジュール名に付与する必要があるということです。気付かなかった。

defp call_start_func(module_name, p_pid) do
    String.to_existing_atom("Elixir." <> module_name) # eg: Elixir. + Project.Sample.FileModule
    |> apply(:start, [p_pid])
end

モジュールに実装する動作について

これで指定ディレクトリ内のモジュールに実装されたstart関数を実行することが出来る様になりました。 今回の場合にはlib/processes/childrenに新たな.exファイルを追加して、モジュール内にstartという関数を実行して、新たなプロセスを生成して、特定のプロセスにmessage passingを行う(メッセージの送信)という実装にしてあります。

defmodule Processes.Children.Apple do
  def start(p_pid) do
    pid = spawn(fn -> run(p_pid) end)
    pid
  end

  defp run(p_pid) do
    # 特定のプロセスに「🍎」というメッセージを送信
    Processes.Job.run(p_pid, "🍎")
  end
end

モジュール名はProcesses.Children以降は何でも良いです。分かりやすさ重視のため、絵文字を送っています。メッセージの送信処理はProcesses.Jobに共通化してありますが、以下のように新たに任意で作成することも当然可能です。

# original job
defp run(p_pid) do
  IO.puts('hello world')
  run(p_pid)
end

動作確認

メッセージを受け取る側のプロセスにloggerを仕込んであるので、実際にプロセスが動的に起動して、メッセージを送って来ているのかを確認してみます。

iex(1)> Main.execute

ログが流れ始めました。立ち上がったプロセス情報(PID)を返すようにしています。

%{
  children: [#PID<0.141.0>, #PID<0.142.0>, #PID<0.143.0>, #PID<0.144.0>,
   #PID<0.145.0>],
  parent: #PID<0.140.0>
}
iex(2)> 
10:08:07.574 [info]  🐓⏰ => 2020/11/09 10:08:07 from #PID<0.145.0> as 🍍
 
10:08:08.698 [info]  (′・ω・`) Received message from #PID<0.142.0> as 🍑
 
10:08:08.836 [info]  (′・ω・`) Received message from #PID<0.141.0> as 🍊
 
10:08:09.789 [info]  (′・ω・`) Received message from #PID<0.144.0> as 🍌
 
10:08:09.906 [info]  (′・ω・`) Received message from #PID<0.143.0> as 🍎
 
10:08:10.041 [info]  🐓⏰ => 2020/11/09 10:08:10 from #PID<0.145.0> as 🍍
 
10:08:10.679 [info]  (′・ω・`) Received message from #PID<0.141.0> as 🍊
:
:

f:id:takamizawa46:20201109233442g:plain

絵文字とモジュール名を連動させるようにしてあります。このログをみる限り以下のモジュールからメッセージを受けていることが分かります。

  • pineapple(🍍)
  • apple(🍎)
  • peach(🍊)
  • banana(🍌)

pineappleからメッセージが送られて来た場合には現在時刻を表示するようにさせてあります。独自の処理を組み込むのも簡単だねってElixirの宣伝です。

参考文献