Elixir 2019-06-21
随着Phoenix web 框架进入大家的视野, 许多人惊讶于它对 websockets 优秀的支持, 以及用它创建一个"hello world" 聊天应用是多么简单. 鉴于 websockets 在 Phoenix 中的一等公民地位, 我想可以用它来解决一些比简单的聊天应用更难的问题. 在本文中, 我们将了解如何使用Phoenix 创建一个包含邀请功能的游戏大厅.
由于Phoenix和Elixir 仍然处于开发中, 本篇中的代码可能会过时. 本文的代码使用 Elixir 1.2.0 和 Phoenix 1.1.3.
首先我们需要一些登录了的用户. 我依照这篇文章设置了用户以及基本的验证. 为了在 websockets 里处理验证, 我将使用 Phoenix.Token
.
我们需要给用户一个token使他们能够证明自己的身份. 我使用<meta> 标签来存放token. 在你的应用的layout中, 添加:
<head> ... <%= if @current_user do %> <%= tag :meta, name: "channel_token", content: Phoenix.Token.sign(@conn, "user", @current_user.id) %> <% end %> ... </head>
为验证用户连接时传来的token是否合法, 我们需要修改 web/channels/user_socket.ex
文件中的 connect
方法:
alias MyApp.{Repo, User} def connect(%{"token" => token}, socket) do case Phoenix.Token.verify(socket, "user", token, max_age: 86400) do {:ok, user_id} -> socket = assign(socket, :current_user, Repo.get!(User, user_id)) {:error, _} -> :error end end
这可以从token中解析出用户的ID, 并将用户赋值到socket中, 使得我们之后可以调用它.
为了从前端发起websocket连接, 我们需要从meta 标签中获取token, 并使用它在连接到后端时建立一个Socket连接. 将以下代码添加到 socket.js
文件中:
var token = $('meta[name=channel_token]').attr('content'); var socket = new Socket('/socket', {params: {token: token}}); socket.connect();
在我们的服务器上, 一旦用户连接到了网站, 我们希望他们可以看见其他在线的玩家. 让我们开始创建一个大厅channel, 在这里所有用户可以加入并相互交谈.
channel "game:lobby", MyApp.LobbyChannel
defmodule MyApp.LobbyChannel do use MyApp.Web, :channel def join("game:lobby", _payload, socket) do {:ok, socket} end end
和前端连接:
var lobby = socket.channel('game:lobby'); lobby.join().receive('ok', function() { console.log('Connected to lobby!'); });
现在用户已经登录了, 并通过websockt 连接到了大厅, 他们应当能够查看其他在线的用户并邀请他们. 由于Elixir是一门函数式语言且不能保存state, 所以实现起来会很有挑战性. 我们将在一个独立的进程中使用 GenServer 来模拟保存state. 未来Phoenix可能会实现类似的功能, 但是现在我们需要自己实现它. (译者注: 现在已经有了 Phoenix.Presence) 感谢Phoenix Trello project 的作者, 我从他那里学到了这些.
这里是让我们的大厅运作所需的代码. 我不会深入探讨GenServer 是如何运作的, 从高级层面上来看我们可以将他当成是一个持久的映射.
defmodule MyApp.ChannelMonitor do use GenServer def start_link(initial_state) do GenServer.start_link(__MODULE__, initial_state, name: __MODULE__) end def user_joined(channel, user) do GenServer.call(__MODULE__, {:user_joined, channel, user}) end def user_left(channel, user_id) do GenServer.call(__MODULE__, {:user_left, channel, user_id}) end def handle_call({:user_joined, channel, user}, _from, state) do new_state = case Map.get(state, channel) do nil -> Map.put(state, channel, [user]) users -> Map.put(state, channel, Enum.uniq([user | users])) end {:reply, new_state, new_state} end def handle_call({:users_in_channel, channel}, _from, state) do new_users = state |> Map.get(channel) |> Enum.reject(&(&1.id == user_id)) new_state = Map.update!(state, channel, fn(_) -> new_users end) {:reply, new_state, new_state} end end
然后我们需要将 ChannelMonitor 添加到 start 函数中, 这样Phoenix启动时就会自动启动它. 修改好之后, 重启你的服务器.
def start(_type, _args) do ... children = [ ... worker(MyApp.ChannelMonitor, [%{}]), ] end
现在, 我们可以在channels 里使用 ChannelMonitor 了. 在LobbyChannel里, 作如下修改:
defmodule MyApp.LobbyChannel do use MyApp.Web, :channel alias MyApp.ChannelMonitor def join("game:lobby", current_user) do current_user = socket.assigns.current_user users = ChannelMonitor.user_joined("game:lobby", current_user)["game:lobby"] send self, {:after_join, users} {:ok, socket} end def terminate(_reason, socket) do user_id = socket.assigns.current_user.id users = ChannelMonitor.user_left("game:lobby", user_id)["game:lobby"] lobby_update(socket, users) :ok end def handle_info({:after_join, users}, socket) do lobby_update(socket, users) {:noreply, socket} end defp lobby_update(socket, users) do broadcast! socket, "lobby_update", %{users: get_usernames(users)} end defp get_usernames(nil), do: [] defp get_usernames(users) do Enum.map users, &(&1.username) end end
这段代码做了什么? ChannelMonitor 是一个映射, 以 channel 名作为key, 以用户列表作为value. 每次我们更新 ChannelMonitor 时都会返回那个映射, 我们可以在其中查找对应channel 的用户. 由于value 是用户列表, 我们需要提取每个用户的用户名, 再发送到前端. 我们需要在连接开始和终止时更新 ChannelMonitor, 通过 join
和 terminate
方法. 注意我们仍然可以获取 socket.assigns.current_user
.
当我们想要从服务器通过channel 发送消息给每个用户, 我们使用 broadcast! socket, name_of_event, data
. 这里我们发送了一个 "lobby_update" 事件, 并将新的列表发送给每个在线的用户. 如果你试图在join 函数中使用broadcast! , Phoenix会报错, 因为在socket中join还没有完成. 使用send self, {args}
可以让你在join过程中发送消息, 然后我们可以在 handle_info
中进行模式匹配, 再广播给所有用户.
前端的接收非常简单. 修改大厅的代码, 监听"lobby_update" 事件, 并获取我们从后端发来的数据:
var lobby = socket.channel('game:lobby'); lobby.on('lobby_update', function(response) { console.log(JSON.stringfy(response.users)); }); lobby.join().receive('ok', function() { console.log('Connected to lobby!'); });
现在当用户连接/断线时所有用户都能看到. 你可以在两个浏览器标签登录不同的账号, 因为新标签会有不同的cookies.
我们已经得到了在线玩家列表. 现在我们想要和他们中的一个进行游戏. 我们的游戏大厅将会实现一个 邀请/接收 的流来从大厅里开始游戏. 我们需要在后端监听邀请事件, 并将其分发到正确的人. 我们可以这样实现:
def handle_in("game_invite", %{"username" => username}, socket) do data = %{"username" => username, "sender" => socket.assigns.current_user.username} broadcast! socket, "game_invite", data {:noreply, socket} end
你会发现到这里有些错误. 我们想要将邀请发送给特定的用户, 而不是广播出去. 这里的问题在于发送消息给前端的方法只有 send 和 broadcast. send
方法需要目标socket, 然而我们只有发送者的 socket. 所以, 我们使用 broadcast 并定义了一个 handle_out
使得消息只发送给我们想要发给的人.
intercept ["game_invite"] def handle_out("game_invite", %{"username" => username, "sender" => sender}, socket) do if socket.assigns.current_user.username == username do push socket, "game_invite", %{username: sender} end {:noreply, socket} end
intercept 告诉Phoenix为特定的事件的广播使用我们定义的 handle_out. 在这里我们是和连接到channel里的每个玩家对话, 并执行我们想要的操作. 直到找到那个被邀请的玩家, 我们发送一个谁邀请了他的信号给他. 要从前端邀请, 我们要添加如下代码:
lobby.on('game_invite', function(response) { console.log('You were invited to join a game by', response.username); }); window.invitePlayer = function(username) { lobby.push('game_invite', {username: username}); };
现在你可以使用 invitePlayer('other_user')
来试着给在线的玩家发送邀请. 消息应当只发送给目标.
本文中, 我们创建了一个大厅, 可以看到当前在线的玩家, 并向他们发送开始游戏的邀请. 我们借助Phoenix 对websockets方便的操控搭建了这个系统, 并将 state 保存在了一个单独的进程. 之后, 你可以创建额外的channel 给用户, 让他们在邀请玩家之后可以进行游戏. Happy coding!