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

原文前端

随着Phoenix web 框架进入你们的视野, 许多人惊讶于它对 websockets 优秀的支持, 以及用它建立一个"hello world" 聊天应用是多么简单. 鉴于 websockets 在 Phoenix 中的一等公民地位, 我想能够用它来解决一些比简单的聊天应用更难的问题. 在本文中, 咱们将了解如何使用Phoenix 建立一个包含邀请功能的游戏大厅.git

因为Phoenix和Elixir 仍然处于开发中, 本篇中的代码可能会过期. 本文的代码使用 Elixir 1.2.0 和 Phoenix 1.1.3.github

验证

首先咱们须要一些登陆了的用户. 我依照这篇文章设置了用户以及基本的验证. 为了在 websockets 里处理验证, 我将使用 Phoenix.Token.web

咱们须要给用户一个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 文件中:websocket

var token =
$('meta[name=channel_token]').attr('content');
var socket = new Socket('/socket', {params: {token: token}});
socket.connect();

链接到大厅

在咱们的服务器上, 一旦用户链接到了网站, 咱们但愿他们能够看见其余在线的玩家. 让咱们开始建立一个大厅channel, 在这里全部用户能够加入并相互交谈.cookie

channel "game:lobby", MyApp.LobbyChannel
defmodule MyApp.LobbyChannel do
  use MyApp.Web, :channel

  def join("game:lobby", _payload, socket) do
    {:ok, socket}
  end
end

和前端链接:app

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!

相关文章
相关标签/搜索