[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!

相關文章
相關標籤/搜索