原文
随着Phoenix web 框架进入大家的视野, 许多人惊讶于它对 websockets 优秀的支持, 以及用它创建一个"hello world" 聊天应用是多么简单. 鉴于 websockets 在 Phoenix 中的一等公民地位, 我想可以用它来解决一些比简单的聊天应用更难的问题. 在本文中, 我们将了解如何使用Phoenix 创建一个包含邀请功能的游戏大厅.
由于Phoenix和Elixir 仍然处于开发中, 本篇中的代码可能会过时. 本文的代码使用 Elixir 1.2.0 和 Phoenix 1.1.3.
验证
首先我们需要一些登录了的用户. 我依照这篇文章设置了用户以及基本的验证. 为了在 websockets 里处理验证, 我将使用 Phoenix.Token
.
我们需要给用户一个token使他们能够证明自己的身份. 我使用 标签来存放token. 在你的应用的layout中, 添加:
...
<%= if @current_user do %>
<%= tag :meta, name: "channel_token", content: Phoenix.Token.sign(@conn, "user", @current_user.id) %>
<% end %>
...
为验证用户连接时传来的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!