Elixir

Elixirで遠隔PCに侵入#2風「GenServerでサーバ/クライアントを気軽に作る」

2ヶ月ぶりのご無沙汰です:bow_tone1:

前回の fukuoka.ex#1 の後くらいから、福岡と東京を行ったり来たりしつつ、以下登壇/勉強会をこなし、最新技術をプロダクション採用している種々企業のスーパーエンジニアの方々にお話を聞きに行く、というハードな2ヶ月で、このElixirコラムもすっかり止まってしまいました…:sweat:

その一方で、こうした活動の中で出会った方々と、Elixirの良さを共有する機会もあり、次に覚えるプログラミング言語としてElixirを多くの方に選んでいただけた、良質な布教活動(笑)の2ヶ月でもありました :wine_glass:

少しでもElixirがプロダクション採用されるよう、より本番運用やデータ分析みたいな領域にアプローチする内容にてリスタートしたいと思いますので、どうぞよろしく :blush:

さて今回の連載は、Elixirで遠隔PCに侵入ハッカー気分 の続きをやると見せかけ、途中でOTPを使ったリファクタリングをしたり、「耐障害性」に分岐するようなパラレルワールド的展開をしてみようかな、と :ocean:

image.png

前準備

スライドP6にある通り、mixで「Pass」というElixirプロジェクトを作成済みのところからスタートします

iexを起動しておいてください

# iex -S mix
iex>

また、スライドでは、PC間での通信をする例としていますが、説明を分かりやすくするため、サーバーもクライアントも1台のPC内で行うこととします

サーバとクライアントをGenServerで1つにまとめる

P25で定義している「pwdサーバ」は、以下の通り、File.cwd()でカレントフォルダを取得して返すだけのサーバーです

lib/pass.ex
defmodule Pass do
 …
    def start_pwd_server() do
        pid = spawn( Pass, :pwd_server, [] )
        :global.register_name( :pwd, pid )
    end
    def pwd_server() do
        receive do
            { sender_pid, _ } -> 
                { :ok, result } = File.cwd()
                send( sender_pid, { true, result } )
        end
        pwd_server()
    end
 …

P28で定義している「pwdクライアント」は、以下の通り、pwdサーバプロセスにメッセージを投げて、フォルダパスを待ち受けるだけのクライアントです

lib/pass.ex
defmodule Pass do
 …
    def pwd() do
        send( :global.whereis_name( :pwd ), { self(), "" } )
        listen()
    end
 …

まず、pwdサーバを起動します

iex> Pass.start_pwd_server

このサーバに問合せをすると、カレントフォルダが取得できます

iex> Pass.pwd
/code/pass

こういったサーバ/クライアントのアプリは、機能の違いはあれど、構成やインタフェースはほぼ同じため、Elixir(やErlang)では、「GenServer」という名前で汎用モジュール化されています :aerial_tramway:

以下のように「use GenServer」をモジュール内に記述し、handle_call()でサーバ側処理を書けば終わりです

lib/pass_genserver.ex
defmodule PassGenServer do
    use GenServer

    def handle_call( :pwd, _from, state ) do
        { :ok, result } = File.cwd()
        { :reply, result, state }
    end
end

GenServerでのpwdを試してみましょう

GenServer.start_link()でサーバ起動し、GenServer.call()でhandle_call()に定義した処理が呼び出せます

iex> recompile()
iex> { :ok, pid } = GenServer.start_link( PassGenServer, "" )
{:ok, #PID<0.297.0>}
iex> GenServer.call( pid, :pwd )
"/code/pass"

うまくいきました :thumbsup:

GenServerで書く効果

まず、GenServer版と、そうで無い版のコードを見比べて欲しいのですが、コード量が1/3程度にスリム化されています :dancer_tone1:

次に、GenServer版のコードには、本質的な処理(ここではpwd実行)しか書いておらず、プロセス起動やメッセージパッシングのような処理は一切書かなくて良くなっています

サーバとクライアントを別々に定義する必要が無くなり、1つにまとめられるので、メンテナンス性も向上しています

そして、ここからが凄いのですが、関数名と引数/返却を少し変えるだけで、非同期化対応できてしまうのです! :flushed:

GenServerだと非同期化もラクラク

ここまで作ったものは、同期処理のため、スリープを入れると返ってこなくなることを、まず確認しておきます

lib/pass_genserver.ex
defmodule PassGenServer do
    use GenServer

    def handle_call( :pwd, _from, state ) do
        { :ok, result } = File.cwd()
        Process.sleep( 3000 )
        { :reply, result, state }
    end
end

実行すると、処理呼び出し後、スリープ分、待たされます :hourglass_flowing_sand:

iex> recompile()
iex> { :ok, pid } = GenServer.start_link( PassGenServer, "" )
{:ok, #PID<0.297.0>}
iex> GenServer.call( pid, :pwd )
 …
 (3秒、待たされる)
 …
"/code/pass"

非同期化するには、handle_call() を handle_cast() に変更して、引数_fromを削除し、返却を:noreplyに変える… たったこれだけ :thinking:

lib/pass_genserver.ex
defmodule PassGenServer do
    use GenServer

    def handle_cast( :pwd, state ) do
        { :ok, result } = File.cwd()
        Process.sleep( 3000 )
        IO.puts "on handle_cast(): " <> result
        { :noreply, state }
    end
end

呼出も、GenServer.cast() に変え、試してみると...

iex> recompile()
iex> { :ok, pid } = GenServer.start_link( PassGenServer, "" )
{:ok, #PID<0.406.0>}
iex> GenServer.cast( pid, :pwd )
:ok                   【←呼出後、即座に返ってくる】
iex> on handle_cast(): /code/pass  【←3秒後に表示される】

バッチリ非同期になりました :laughing:

元々のコードもそんなに複雑では無かったのですが、これはそもそもElixirが、サーバ/クライアントのような、マルチプロセスを書きやすい言語だからな訳ですが、GenServerを使うと、もはや本質的な処理以外は、書かなくて良いレベルまで昇華されます、OTP恐るべし :scream:

次回に続く)


p.s.

:stars: :stars: 【お知らせ】「fukuoka.ex #2」、今月末8/24(金)に開催です :stars: :stars:

ElixirとPhoenix(高速Web・APIフレームワーク)のマルチプロセスと耐障害性をテーマに、セッションを行います

ちょっと難しそうに聞こえるテーマですが、分かりやすく噛み砕いてセッションしますし、懇親会でのご質問もドシドシ受け付けますので、ご安心ください。

https://techjin.connpass.com/event/63493/
image.png