[elixir! #0021][译] 使用Phoenix和Websockets创建一个游戏大厅系统 by Alex Jensen

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, 通过 jointerminate 方法. 注意我们仍然可以获取 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!

相关推荐