🐈

Phoenix1.6ではWebSocketの準備にmix phx.gen.socketを使う

2021/09/09に公開

Phoenixの時期バージョンである1.6系で、プロジェクト開始時のWebSocket周りについて変更があったので、メモしておきます。

結論、Phoenix1.6からは、mix phx.newタスクでWebSocket周りの設定とファイルが生成されなくなったので、別途mix phx.gen.socketを実行する必要があります。

本記事の背景

本記事を書いている時点では、Phoenix 1.6.0-rc.0 Released - Phoenix Blogにある通り、Phoenixの時期バージョンである1.6系の本リリースに向けての取り組みが進んでいます。このバージョンに関する変更はphoenix/CHANGELOG.md at master · phoenixframework/phoenixにまとまっています。

1.6.0-rc.0 (2021-08-26)の変更リストの中に、

[mix phx.new] No longer generate a socket file by default, instead one can run mix phx.gen.socket

とあるのが、今回の話題です。

Phoenixプロジェクトを新規作成する

まずは、現在リリースされている、RC版のPhoenixをインストールします。

$ mix archive.install hex phx_new 1.6.0-rc.0

そして、いつものようにmix phx.newタスクで、プロジェクトを作成します。

$ mix phx.new phx_16 --no-ecto

WebSocketを準備する

1.5系まででは、WebSocket周りのファイルがあれこれ作られていましたが、1.6系からはmix phx.gen.socketタスクを実行する必要があります。

mix phx.gen.socketタスクを、モジュール名を引数に渡して実行します。

$ mix phx.gen.socket User
* creating lib/phx_16_web/channels/user_socket.ex
* creating assets/js/user_socket.js

Add the socket handler to your `lib/phx_16_web/endpoint.ex`, for example:

    socket "/socket", Phx16Web.UserSocket,
      websocket: true,
      longpoll: false

For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:

    import "./user_socket.js"

1.5系まででは、上記のファイルや設定は、mix phx.newした時点で、UserSocketというモジュール込みで生成されていたのですが、上記のタスク実行によって生成されるようになりました。ただし、assets/js/app.jsでWebSocketを扱うJavaScriptのファイルのimportを追加する必要があるのは同じですね。

生成されたファイルの差分を見てみると、こんな感じ。

diff --git a/assets/js/user_socket.js b/assets/js/user_socket.js
new file mode 100644
index 0000000..7a22631
--- /dev/null
+++ b/assets/js/user_socket.js
@@ -0,0 +1,64 @@
+// NOTE: The contents of this file will only be executed if
+// you uncomment its entry in "assets/js/app.js".
+
+// Bring in Phoenix channels client library:
+import {Socket} from "phoenix"
+
+// And connect to the path in "lib/phx_16_web/endpoint.ex". We pass the
+// token for authentication. Read below how it should be used.
+let socket = new Socket("/socket", {params: {token: window.userToken}})
+
+// When you connect, you'll often need to authenticate the client.
+// For example, imagine you have an authentication plug, `MyAuth`,
+// which authenticates the session and assigns a `:current_user`.
+// If the current user exists you can assign the user's token in
+// the connection for use in the layout.
+//
+// In your "lib/phx_16_web/router.ex":
+//
+//     pipeline :browser do
+//       ...
+//       plug MyAuth
+//       plug :put_user_token
+//     end
+//
+//     defp put_user_token(conn, _) do
+//       if current_user = conn.assigns[:current_user] do
+//         token = Phoenix.Token.sign(conn, "user socket", current_user.id)
+//         assign(conn, :user_token, token)
+//       else
+//         conn
+//       end
+//     end
+//
+// Now you need to pass this token to JavaScript. You can do so
+// inside a script tag in "lib/phx_16_web/templates/layout/app.html.heex":
+//
+//     <script>window.userToken = "<%= assigns[:user_token] %>";</script>
+//
+// You will need to verify the user token in the "connect/3" function
+// in "lib/phx_16_web/channels/user_socket.ex":
+//
+//     def connect(%{"token" => token}, socket, _connect_info) do
+//       # max_age: 1209600 is equivalent to two weeks in seconds
+//       case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
+//         {:ok, user_id} ->
+//           {:ok, assign(socket, :user, user_id)}
+//
+//         {:error, reason} ->
+//           :error
+//       end
+//     end
+//
+// Finally, connect to the socket:
+socket.connect()
+
+// Now that you are connected, you can join channels with a topic.
+// Let's assume you have a channel with a topic named `room` and the
+// subtopic is its id - in this case 42:
+let channel = socket.channel("room:42", {})
+channel.join()
+  .receive("ok", resp => { console.log("Joined successfully", resp) })
+  .receive("error", resp => { console.log("Unable to join", resp) })
+
+export default socket
diff --git a/lib/phx_16_web/channels/user_socket.ex b/lib/phx_16_web/channels/user_socket.ex
new file mode 100644
index 0000000..354337e
--- /dev/null
+++ b/lib/phx_16_web/channels/user_socket.ex
@@ -0,0 +1,51 @@
+defmodule Phx16Web.UserSocket do
+  use Phoenix.Socket
+
+  # A Socket handler
+  #
+  # It's possible to control the websocket connection and
+  # assign values that can be accessed by your channel topics.
+
+  ## Channels
+  # Uncomment the following line to define a "room:*" topic
+  # pointing to the `Phx16Web.RoomChannel`:
+  #
+  # channel "room:*", Phx16Web.RoomChannel
+  #
+  # To create a channel file, use the mix task:
+  #
+  #     mix phx.gen.channel Room
+  #
+  # See the [`Channels guide`](https://hexdocs.pm/phoenix/channels.html)
+  # for futher details.
+
+
+  # Socket params are passed from the client and can
+  # be used to verify and authenticate a user. After
+  # verification, you can put default assigns into
+  # the socket that will be set for all channels, ie
+  #
+  #     {:ok, assign(socket, :user_id, verified_user_id)}
+  #
+  # To deny connection, return `:error`.
+  #
+  # See `Phoenix.Token` documentation for examples in
+  # performing token verification on connect.
+  @impl true
+  def connect(_params, socket, _connect_info) do
+    {:ok, socket}
+  end
+
+  # Socket id's are topics that allow you to identify all sockets for a given user:
+  #
+  #     def id(socket), do: "user_socket:#{socket.assigns.user_id}"
+  #
+  # Would allow you to broadcast a "disconnect" event and terminate
+  # all active sockets and channels for a given user:
+  #
+  #     Elixir.Phx16Web.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+  #
+  # Returning `nil` makes this socket anonymous.
+  @impl true
+  def id(_socket), do: nil
+end

WebSocketを動かしてみる

設定の追加

まずは、mix phx.gen.socketを実行した後に出てきた以下の指示をそれぞれ実行します。

Add the socket handler to your `lib/phx_16_web/endpoint.ex`, for example:

    socket "/socket", Phx16Web.UserSocket,
      websocket: true,
      longpoll: false

For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:

    import "./user_socket.js"

変更後のdiffは以下の通りです。

diff --git a/assets/js/app.js b/assets/js/app.js
index 9eabcff..0e163db 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -4,7 +4,7 @@ import "../css/app.css"
 
 // If you want to use Phoenix channels, run `mix help phx.gen.channel`
 // to get started and then uncomment the line below.
-// import "./user_socket.js"
+import "./user_socket.js"
 
 // You can include dependencies in two ways.
 //
diff --git a/lib/phx_16_web/endpoint.ex b/lib/phx_16_web/endpoint.ex
index 03ae366..966bd7c 100644
--- a/lib/phx_16_web/endpoint.ex
+++ b/lib/phx_16_web/endpoint.ex
@@ -10,7 +10,12 @@ defmodule Phx16Web.Endpoint do
     signing_salt: "tB4UeyNi"
   ]
 
-  socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
+  socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
+
+  socket("/socket", Phx16Web.UserSocket,
+    websocket: true,
+    longpoll: false
+  )
 
   # Serve at "/" the static files from "priv/static" directory.
   #
@@ -25,7 +30,7 @@ defmodule Phx16Web.Endpoint do
   # Code reloading can be explicitly enabled under the
   # :code_reloader configuration of your endpoint.
   if code_reloading? do
-    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+    socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
     plug Phoenix.LiveReloader
     plug Phoenix.CodeReloader
   end

チャネルの作成

次は、WebSocketのチャネルを作りましょう。

lib/phx_16_web/channels/user_socket.exにこんなコメントが生成されています。

  ## Channels
  # Uncomment the following line to define a "room:*" topic
  # pointing to the `Phx16Web.RoomChannel`:
  #
  # channel "room:*", Phx16Web.RoomChannel
  #
  # To create a channel file, use the mix task:
  #
  #     mix phx.gen.channel Room
  #
  # See the [`Channels guide`](https://hexdocs.pm/phoenix/channels.html)
  # for futher details.

というわけで、channel "room:*", Phx16Web.RoomChannelの部分をコメントアウトして、mix phx.gen.channel Roomを実行します。

$ mix phx.gen.channel Room
* creating lib/phx_16_web/channels/room_channel.ex
* creating test/phx_16_web/channels/room_channel_test.exs

Add the channel to your `lib/phx_16_web/channels/user_socket.ex` handler, for example:

    channel "room:lobby", Phx16Web.RoomChannel

上記の最後に指示のあるハンドラは、すでに前述の通り設定してあるのでOKです。チャネルはlib/phx_16_web/channels/room_channel.exに、以下の通り生成されます。

diff --git a/lib/phx_16_web/channels/room_channel.ex b/lib/phx_16_web/channels/room_channel.ex
new file mode 100644
index 0000000..99427ce
--- /dev/null
+++ b/lib/phx_16_web/channels/room_channel.ex
@@ -0,0 +1,32 @@
+defmodule Phx16Web.RoomChannel do
+  use Phx16Web, :channel
+
+  @impl true
+  def join("room:lobby", payload, socket) do
+    if authorized?(payload) do
+      {:ok, socket}
+    else
+      {:error, %{reason: "unauthorized"}}
+    end
+  end
+
+  # Channels can be used in a request/response fashion
+  # by sending replies to requests from the client
+  @impl true
+  def handle_in("ping", payload, socket) do
+    {:reply, {:ok, payload}, socket}
+  end
+
+  # It is also common to receive messages from the client and
+  # broadcast to everyone in the current topic (room:lobby).
+  @impl true
+  def handle_in("shout", payload, socket) do
+    broadcast socket, "shout", payload
+    {:noreply, socket}
+  end
+
+  # Add authorization logic here as required.
+  defp authorized?(_payload) do
+    true
+  end
+end

WebSocketクライアントによる接続

準備ができたので、CLIからWebSocketに接続します。wscatを使いましょう。

まずは、WebSocketサーバーを立ち上げます。

$ iex -S mix phx.server

そして、別のターミナルでwscatを起動します。

$ wscat -c ws://localhost:4000/socket/websocket
Connected (press CTRL+C to quit)
>

まずはトピックにjoinして見ましょう。

> {"topic":"room:lobby","ref":1, "payload":{},"event":"phx_join"}
< {"event":"phx_reply","payload":{"response":{},"status":"ok"},"ref":1,"topic":"room:lobby"}

WebSocketサーバ側では、こんなログが出ています。

[info] JOINED room:lobby in 32µs
  Parameters: %{}

では、メッセージを送ってみましょう。ちなみに、Roomチャンネルのpingイベントのハンドラは以下のような実装になっています。

def handle_in("ping", payload, socket) do
  {:reply, {:ok, payload}, socket}
end

以下のようにして、pingイベントに適当なペイロードをわたして送ってやります。

> {"topic":"room:lobby","ref":1, "payload":{"body": {"message": "Hello, WebSocket!"}},"event":"ping"}
< {"event":"phx_reply","payload":{"response":{"body":{"message":"Hello, WebSocket!"}},"status":"ok"},"ref":1,"topic":"room:lobby"}

うまく動いているようですね。WebSocketサーバ側では、こんなログが出ています。

[debug] HANDLED ping INCOMING ON room:lobby (Phx16Web.RoomChannel) in 50µs
  Parameters: %{"body" => %{"message" => "Hello, WebSocket!"}}

おわりに

Phoenix1.6系からは、mix phx.newタスクではWebSocket関連の設定やファイルが生成されないようになりました。しかし、mix phx.gen.socketによって1.5系同様のことはすぐにできるということが確認できました。

Discussion