田舎で並行処理の夢を見る

試したこと、需要がないかもしれないけど細々とアウトプットしてます

【第15回清流elixir+fukuoka.exもくもく勉強会】Elixirでチャットサーバーを作りながらNode間通信を試す

トピック

elixir-sr.connpass.com

Qiitaのアドベントカレンダーの執筆したり、業務が忙しすぎたり、PIDの闇にハマっていたりで、少し間が空いてしまいましたが第15回のレポートをまとめました
今回も前回同様に、kogaさんにお声がけ頂きましてfukuoka.exさんと合同でもくもく勉強会を開催しました
fukuokaex.connpass.com

僕はQiitaのアドベントカレンダーElixirNode間チャットアプリを作るってのを書こうと思って、前からやりたかったNode間通信に挑戦したので当日の内容と後日の作業をプラスでまとめたものを共有します

清流elixir-infomation
開催場所: 丸の内(愛知)
参加人数: 12 -> 4 コミュニティ参加人数 : 36 -> 37 update!
2019/12/07現在

第15回の勉強会について

Nodeの基本的な操作

ElixirでのNode間通信を行う基本的な操作はプログラミングElixirの第15章にまとめられている。しかし、ただまねるだけでは面白くないので以下の記事を参考にNode間でチャットを作る簡易的なアプリケーションを作ってみようと思う

dev.classmethod.jp

その前に基本的な Nodeに関するコマンド処理を覚えておこう。まず複数のNodeを識別する必要があるためNodeに名前を付けてあげよう。名前を付ける際はiexの起動時に--snameというオプションを付与して以下のように起動する。すると、立ち上がったプロンプトに付与したNode名が表示されていることが確認できる

$ iex --sname node1
:
iex(node1@okabe-y)1>

自身のNode名を確認したい時にはNode.self()とすることでアトム形式で自身のNode名を取得することが出来る

iex(node1@okabe-y)1> Node.self()
:"node1@okabe-y"

これでNodeへの名前付けと自身の名前の確認が出来るようになった。次にもう1つターミナルを用意して、別のNodeを立ち上げてみる。こちらにはnode2という名前を付与する

$ iex --sname node2
:
iex(node2@okabe-y)1>

次に、この用意した2つのNodeを実際に繋げてみる。といっても、やり方は凄く簡単で自身のNodeに接続させてやりたいNodeの情報をNode.connect()/1を通して渡してやるだけだ。今回はnode1に対してnode2を接続させてみよう。まずはnode2のターミナルで自身のNode情報を確認する

iex(node2@okabe-y)1> Node.self()
:"node2@okabe-y"

次に、node1のターミナルで先ほど確認したnode2の情報を用いて、実際にNodeを接続させる。接続に無事に成功するとtrueが返ってくる

iex(node1@okabe-y)2> Node.connect(:"node2@okabe-y")
true

ついでに自身のNodeに今、どのNodeが接続しているのかを確認しておこう。Node.list()とすることで接続されているNodeの一覧をリスト形式で取得する事が出来る

iex(node1@okabe-y)3> Node.list()
[:"node2@okabe-y"]

無事に、先ほどの接続が完了していることが確認出来た

チャットサーバーの用意

ベースに使用しているのは先ほど紹介させて頂いた記事だが、せっかくなのでチャットルームの概念をサーバーに用意してみた。少しコードが長くなってしまったので順に解説はしていく

defmodule ClientInfo do
  defstruct name: "GUEST", room: "roomA", pid: nil
end

defmodule ChatServer do
  def start() do
    pid = spawn(__MODULE__, :receiver, [[]])
    :global.register_name(:server, pid)
  end
  def receiver(clients) do
    receive do
      # 全体通知のメッセージ
      {sender, {:announce, body}} ->
        IO.puts("[ANNOUNCE] #{body}")
        send(sender, {:ok})
        receiver(clients)
      # ルーム単位のメッセージ
      {sender, {:message, room, body}} ->
        Enum.filter(clients, fn client ->
          client.room == room
        end)
        |> Enum.map(fn client ->
          IO.puts("[MESSAGE]: #{client.room}@#{client.name} #{body}")
        end)
        send(sender, {:ok})
        receiver(clients)
      # ルームへの新規参加
      {sender, {:join, room, name}} ->
        client = %ClientInfo{name: name, room: room, pid: sender}
        add = clients ++ [client]
        IO.puts("[JOIN]: #{name} joined the #{room}")
        send(sender, {:ok})
        receiver(add)
      # ルームから退室
      {sender, {:leave, room, name}} ->
        IO.puts("[LEAVE]: #{name} left the #{room}")
        send(sender, {:ok})
        receiver(Enum.filter(clients, fn client ->
          !(client.room == room and client.name == name)
        end))
      {sender, {:status}} ->
        IO.puts("[STATUS] send server status to client")
        send(sender, {:ok, :status, clients})
        receiver(clients)
    end
  end
end

defmodule Client do
  def server_pid(), do: :global.whereis_name(:server)
  def announce(body) do
    send(server_pid(), {self(), {:announce, body}})
    receiver()
  end
  def message(room, body) do
    send(server_pid(), {self(), {:message, room, body}})
    receiver()
  end
  def join(room, name) do
    send(server_pid(), {self(), {:join, room, name}})
    receiver()
  end
  def leave(room, name) do
    send(server_pid(), {self(), {:leave, room, name}})
    receiver()
  end
  def status() do
    send(server_pid(), {self(), {:status}})
    receiver()
  end
  def receiver() do
    receive do
      {:ok} -> IO.puts("success send message to server")
      {:ok, :status, data} ->
        Enum.map(data, fn d ->
          IO.puts("[INFO]: #{d.name} joined #{d.room}")
        end)
      _ -> IO.puts("invalid message")
      after 3000 ->
        IO.puts("not reply...")
    end
  end
end

まずはチャットサーバーで扱うクライアントのデータの構造をstructを用いて定義している。今回のチャットサーバーは凄くシンプルなもので、それぞれ以下を参照している

  • name -> クライアントの名前
  • room -> クライアントが入室している部屋(今回は入室は1室のみ)
  • pid -> クライアントのPID
defmodule ClientInfo do
  defstruct name: "GUEST", room: "roomA", pid: nil
end

次にメインとなるチャットサーバーの実装がChatServerモジュールに記述してある。基本的にやっていることは凄くシンプルでElixirのメッセージパッシングの構文receiveをパターンマッチングを使って複数種類のメッセージを受け取るようにして、それぞれのメッセージを受け取った際の処理を順に記述しているだけになる

  • announce -> 接続しているクライアント全員にメッセージを通知
  • message -> 指定したルームにいるクライアント全員にメッセージを通知
  • join -> 指定したルームに新規にクライアントを参加させる
  • leave -> 指定したルームからクライアントを退室(削除)させる
  • status(デバッグ用) -> 現在チャットサーバーに接続しているクライアントの情報(誰がどこのルームに参加しているか)を返す

また、start()を呼び出すことで、メッセージ受信用のプロセスを新規で立ち上げて、:global.register_nameを使って、接続しているNode間で立ち上げた新規のプロセス情報を共有する。これはメッセージをメッセージ受信用のプロセスに送信するために行なっている(sendPIDを指定する必要があるため)

そして、最後にクライアントがメッセージを送信するためのモジュールがClientになる。それぞれの関数はsend構文をwrapして、関数単位で処理が出来るようにしているだけなので、実装しなくても同様の処理は行えるが、楽にメッセージを送信するために実装した

メッセージを送ってみる

先ほど実装したコードを適当なファイル名を付けてiexを実行したパスに配置しよう。僕は適当にmessage_server.exという名前を付けた。このファイルをそれぞれのiex上からコンパイルしてモジュールを実行可能にする

iex(node1@okabe-y)4> c("message_server.ex")
[Client, ChatServer, ClientInfo]
iex(node2@okabe-y)2> c("message_server.ex")
[Client, ChatServer, ClientInfo]

node1をメッセージ受信に用いて、node2からメッセージを送ってみる。まず、node1でチャットサーバーを起動する。その前にNode.list()を確認して、node1にnode2が接続していることを確認しておこう。Nodeが接続されていないと、:global.whereis_nameが値(チャットサーバーのPID)が取得出来ないので、メッセージを送る事が出来ない
無事に、チャットサーバーが立ち上がると:yesというアトムが返ってくる

iex(node1@okabe-y)5> Node.list()
[:"node2@okabe-y"]


iex(node1@okabe-y)6> ChatServer.start()
:yes

次に、node2の方からメッセージを送ってみる

iex(node2@okabe-y)3> Client.join("Elixirを朝まで語る部屋", "okb")
success send message to server
:ok

node1のターミナルを確認すると受信したメッセージを元に作成したログを確認することが出来るだろう

[JOIN]: okb joined the Elixirを朝まで語る部屋

せっかくなのでもう1つNodeを追加して、よりチャットっぽさを出してみよう。新たにターミナルを立ち上げてnode3という名前でiexを用意して、node1に接続させる

$ iex --sname node3
:
iex(node3@okabe-y)1> c("message_server.ex")
[Client, ChatServer, ClientInfo]

node1のターミナル

iex(node1@okabe-y)7> Node.connect(:"node3@okabe-y")
true

無事に成功したようなので、node3からnode2と同じチャットルームにjoinさせて、適当にチャットしてみる

iex(node3@okabe-y)3> Client.join("Elixirを朝まで語る部屋", "にゃーん")
success send message to server
:ok

node1

[JOIN]: にゃーん joined the Elixirを朝まで語る部屋

チャットサーバーに接続しているクライアントの確認
node2

iex(node2@okabe-y)5> Client.status()
[INFO]: okb joined Elixirを朝まで語る部屋
[INFO]: にゃーん joined Elixirを朝まで語る部屋
[:ok, :ok]

では、メッセージを送ってみよう node2

iex(node2@okabe-y)6> Client.message("Elixirを朝まで語る部屋", "こんにちは、初めまして!")
success send message to server
:ok

node1

# 送信されたというログ(フロントを実装していないため、サーバーログで確認)
[MESSAGE]: Elixirを朝まで語る部屋@okb こんにちは、初めまして!
[MESSAGE]: Elixirを朝まで語る部屋@にゃーん こんにちは、初めまして!

node3

iex(node3@okabe-y)5> Client.message("Elixirを朝まで語る部屋", "こんにちは、okbさん")
success send message to server
:ok

node1

[MESSAGE]: Elixirを朝まで語る部屋@okb こんにちは、okbさん
[MESSAGE]: Elixirを朝まで語る部屋@にゃーん こんにちは、okbさん

nod2

iex(node2@okabe-y)12> Client.leave("Elixirを朝まで語る部屋", "okb")
success send message to server
:ok

node1

[LEAVE]: okb left the Elixirを朝まで語る部屋

node3

iex(node3@okabe-y)6> Client.message("Elixirを朝まで語る部屋", "まだ朝じゃないのにいなくなったww")
success send message to server
:ok

node1

[MESSAGE]: Elixirを朝まで語る部屋@にゃーん まだ朝じゃないのにいなくなったww

無事にやり取りが出来ていることが確認出来た

今後の発展

今回のNode間通信は同一のホストで行なっただけで、まだNode間通信の真髄を味わったわけではない。それにチャットサーバーもクライアント管理が適当であったり、認証がなかったり、データ構造がいまいちだったりと改善の余地が山ほどある。しかしながら、ElixirでのNode間通信の基礎を楽しく抑えるには十分だろう

次はDockerを使って別ホストとのNode間通信を行なったり、cloudのVMと接続したりということをやってみようと思う

参考文献