Phoenix.Channel поведение
ИсточникОпределяет канал Phoenix.
Каналы обеспечивают двунаправленную связь с клиентами и интегрируются со слоем Phoenix.PubSub для функциональности с мягким реальным временем.
Обзор концепции см. в руководстве по каналам.
Темы и колбэки
Каждый раз при подключении к каналу нужно выбрать конкретную тему, которую вы хотите прослушивать. Тема — это просто идентификатор, но по соглашению она часто состоит из двух частей: "topic:subtopic". Подход "topic:subtopic" хорошо сочетается с Phoenix.Socket.channel/3, позволяя сопоставлять все темы, начинающиеся с заданного префикса, с помощью звёздочки (символа *) в конце шаблона темы:
channel "room:*", MyAppWeb.RoomChannel
Любая тема с префиксом "room:", поступающая в маршрутизатор, будет направлена в MyAppWeb.RoomChannel в приведённом выше примере. В колбэке join/3 каналов также можно сопоставлять темы с шаблоном, чтобы извлечь шаблон с заданной областью:
# handles the special `"lobby"` subtopic
def join("room:lobby", _payload, socket) do
{:ok, socket}
end
# handles any other subtopic as the room ID, for example `"room:12"`, `"room:34"`
def join("room:" <> room_id, _payload, socket) do
{:ok, socket}
end
Первый аргумент — тема, второй аргумент — карта данных, переданная клиентом, а третий аргумент — экземпляр Phoenix.Socket. socket передаётся всем колбэкам канала, поэтому изучите его модуль и документацию, чтобы узнать о его полях и способах взаимодействия с ним.
Авторизация
Чтобы отправлять и получать события PubSub в канале, клиенты должны подключиться к нему. В каналах необходимо реализовать колбэк join/3, который авторизует сокет для заданной темы. Например, можно проверить, разрешено ли пользователю подключаться к этой комнате.
Чтобы авторизовать сокет в join/3, верните {:ok, socket}. Чтобы отказать в авторизации в join/3, верните {:error, reply}.
Входящие события
После успешного подключения клиента к каналу входящие от него события обрабатываются колбэками handle_in/3 канала. В этих колбэках можно выполнить любое действие. Входящие колбэки должны возвращать socket, чтобы сохранять эфемерное состояние.
Обычно вы либо пересылаете сообщение всем слушателям с помощью broadcast!/3, либо напрямую отвечаете на событие клиента в стиле «запрос/ответ».
Данные обычных сообщений могут иметь любой сериализуемый тип и часто представляют собой карты:
def handle_in("new_msg", %{"uid" => uid, "body" => body}, socket) do
...
{:reply, :ok, socket}
end
Двоичные данные передаются в виде кортежа {:binary, data}:
def handle_in("file_chunk", {:binary, chunk}, socket) do
...
{:reply, :ok, socket}
end
Трансляции
События можно транслировать в тему из любой части приложения с помощью функции broadcast в endpoint:
MyAppWeb.Endpoint.broadcast!("room:13", "new_message", %{content: "hello"})
Также можно выполнять трансляцию непосредственно из каналов. Ниже приведён пример получения входящего события "new_msg" от одного клиента и трансляции сообщения всем подписчикам темы для этого сокета.
def handle_in("new_msg", %{"uid" => uid, "body" => body}, socket) do
broadcast!(socket, "new_msg", %{uid: uid, body: body})
{:noreply, socket}
end
Ответы
Ответы полезны, чтобы подтвердить получение сообщения клиента или вернуть результаты операции. Ответ отправляется только клиенту, подключённому к текущему процессу канала. Внутри ответа содержится ref сообщения клиента, позволяющий клиенту сопоставить полученный ответ с отправленным сообщением.
Например, представьте, что вы создаёте ресурс и отвечаете созданной записью:
def handle_in("create:post", attrs, socket) do
changeset = Post.changeset(%Post{}, attrs)
if changeset.valid? do
post = Repo.insert!(changeset)
response = MyAppWeb.PostView.render("show.json", %{post: post})
{:reply, {:ok, response}, socket}
else
response = MyAppWeb.ChangesetView.render("errors.json", %{changeset: changeset})
{:reply, {:error, response}, socket}
end
end
Или можно просто подтвердить успешное выполнение операции:
def handle_in("create:post", attrs, socket) do
changeset = Post.changeset(%Post{}, attrs)
if changeset.valid? do
Repo.insert!(changeset)
{:reply, :ok, socket}
else
{:reply, :error, socket}
end
end
В ответах также поддерживаются двоичные данные в виде кортежа {:binary, data}:
{:reply, {:ok, {:binary, bin}}, socket}
Если не нужно отправлять ответ клиенту, можно вернуть:
{:noreply, socket}
Так можно поступить, если необходимо ответить позднее; см. reply/2.
Отправка сообщений
Вызов push/3 позволяет отправить клиенту сообщение, которое не является ответом на конкретное сообщение клиента. Поскольку это не ответ, отправленное сообщение не содержит ref сообщения клиента: ему не предшествовало сообщение клиента, с которым его можно было бы связать.
Возможные сценарии использования:
- Вы автоматически сохранили документ пользователя
- Игра пользователя скоро завершится
- Настройки IoT-устройства нужно обновить
Например, можно push/3 сообщение клиенту в handle_info/3 после получения относящегося к нему сообщения PubSub.
alias Phoenix.Socket.Broadcast
def handle_info(%Broadcast{topic: _, event: event, payload: payload}, socket) do
push(socket, event, payload)
{:noreply, socket}
end
Данные для отправки можно передать в виде карты или помеченного кортежа {:binary, data}:
# client asks for their current rank. reply contains it, and client
# is also pushed a leader board and a badge image
def handle_in("current_rank", _, socket) do
push(socket, "leaders", %{leaders: Game.get_leaders(socket.assigns.game_id)})
push(socket, "badge", {:binary, File.read!(socket.assigns.badge_path)})
{:reply, %{val: Game.get_rank(socket.assigns[:user])}, socket}
end
Обратите внимание, что в этом примере push/3 вызывается из handle_in/3; таким образом, можно, по сути, ответить на одно сообщение клиента N раз. О причинах, по которым это может быть полезно, см. reply/2.
Перехват исходящих событий
Когда событие транслируется с помощью broadcast/3, каждый подписчик канала может перехватить его и вызвать свой колбэк handle_out/3. Это позволяет настраивать данные события отдельно для каждого сокета, добавляя дополнительную информацию или условно отфильтровывая сообщение. Если событие не перехвачено с помощью Phoenix.Channel.intercept/1, сообщение отправляется непосредственно клиенту:
intercept ["new_msg", "user_joined"]
# for every socket subscribing to this topic, append an `is_editable`
# value for client metadata.
def handle_out("new_msg", msg, socket) do
push(socket, "new_msg", Map.merge(msg,
%{is_editable: User.can_edit_message?(socket.assigns[:user], msg)}
))
{:noreply, socket}
end
# do not send broadcasted `"user_joined"` events if this socket's user
# is ignoring the user who joined.
def handle_out("user_joined", msg, socket) do
unless User.ignoring?(socket.assigns[:user], msg.user_id) do
push(socket, "user_joined", msg)
end
{:noreply, socket}
end
Завершение
При завершении вызывается колбэк канала terminate/2, которому передаются причина ошибки и сокет.
Если завершение вызвано отключением клиента, причиной будет {:shutdown, :left}. Аналогично, если завершение вызвано закрытием соединения с клиентом, причиной будет {:shutdown, :closed}.
Если какой-либо из колбэков возвращает кортеж :stop, это также вызовет завершение с причиной, указанной в кортеже.
Однако terminate/2 не вызывается в случае ошибок или завершения процесса. Такое же поведение наблюдается в абстракциях Elixir, например GenServer и других. Подобно GenServer, можно также использовать :trap_exit, чтобы гарантировать вызов terminate/2. Однако такой подход не рекомендуется.
Как правило, если нужно что-либо очистить, лучше отслеживать процесс канала и выполнять очистку из другого процесса. Все колбэки канала, включая join/3, вызываются внутри процесса канала. Поэтому self() в любом из них возвращает PID для отслеживания.
Причины выхода при остановке канала
Когда колбэки канала возвращают кортеж :stop, например:
{:stop, :shutdown, socket}
{:stop, {:error, :enoent}, socket}
второй аргумент — причина выхода, которая соответствует стандартному поведению при выходе из GenServer.
При остановке канала можно выбрать один из трёх вариантов:
:normal— в этом случае выход не будет записан в журнал, а связанные процессы не завершатся:shutdownили{:shutdown, term}— в этом случае выход не будет записан в журнал, а связанные процессы завершатся с той же причиной, если только они не перехватывают сигналы выходалюбое другое значение — в этом случае выход будет записан в журнал, а связанные процессы завершатся с той же причиной, если только они не перехватывают сигналы выхода
Подписка на внешние темы
Иногда может потребоваться программно подписать сокет на внешние темы в дополнение к внутренней socket.topic. Например, представьте систему торгов, в которой удалённый клиент динамически задаёт предпочтения для товаров, о торгах по которым он хочет получать уведомления. Вместо создания отдельного процесса канала и темы для каждого предпочтения эффективнее и проще подписать один канал на соответствующие уведомления через endpoint. Например:
defmodule MyAppWeb.Endpoint.NotificationChannel do
use Phoenix.Channel
def join("notification:" <> user_id, %{"ids" => ids}, socket) do
topics = for product_id <- ids, do: "product:#{product_id}"
{:ok, socket
|> assign(:topics, [])
|> put_new_topics(topics)}
end
def handle_in("watch", %{"product_id" => id}, socket) do
{:reply, :ok, put_new_topics(socket, ["product:#{id}"])}
end
def handle_in("unwatch", %{"product_id" => id}, socket) do
{:reply, :ok, MyAppWeb.Endpoint.unsubscribe("product:#{id}")}
end
defp put_new_topics(socket, topics) do
Enum.reduce(topics, socket, fn topic, acc ->
topics = acc.assigns.topics
if topic in topics do
acc
else
:ok = MyAppWeb.Endpoint.subscribe(topic)
assign(acc, :topics, [topic | topics])
end
end)
end
end
Примечание: вызывающая сторона должна предотвращать повторные подписки. После вызова subscribe/1 из endpoint обработка обычных сообщений Elixir в канале выполняется так же, как и обычно. Чаще всего достаточно просто переслать событие %Phoenix.Socket.Broadcast{} и его данные:
alias Phoenix.Socket.Broadcast
def handle_info(%Broadcast{topic: _, event: event, payload: payload}, socket) do
push(socket, event, payload)
{:noreply, socket}
end
Гибернация
Начиная с Erlang/OTP 20, каналы автоматически переходят в режим гибернации после 15_000 миллисекунд бездействия, чтобы экономить память. Это можно настроить, передав параметр :hibernate_after в use Phoenix.Channel:
use Phoenix.Channel, hibernate_after: 60_000
Также можно задать значение :infinity, чтобы полностью отключить эту функцию.
Завершение работы
Можно настроить поведение при завершении работы каждого канала, используемого приложением, задав значение :shutdown при использовании:
use Phoenix.Channel, shutdown: 5_000
По умолчанию используется значение 5_000. Поддерживаемые значения описаны в документации модуля Supervisor.
Журналирование
По умолчанию события "join" и "handle_in" каналов записываются в журнал с уровнями :info и :debug соответственно. Уровень журналирования для каждого события можно изменить или отключить журналирование событий определённого типа, задав параметры :log_join и :log_handle_in при использовании Phoenix.Channel. Например, следующая конфигурация записывает события подключения с уровнем :info, но отключает журналирование входящих событий:
use Phoenix.Channel, log_join: :info, log_handle_in: false
Обратите внимание: изменение уровня для типа события не влияет на то, что записывается в журнал, если только не задать значение false; в этом случае изменится соответствующий уровень.
Сводка
Типы
- handle_call(msg, from, socket)
Обрабатывает обычные сообщения вызова GenServer.
- handle_cast(msg, socket)
Обрабатывает обычные сообщения передачи GenServer.
- handle_in(event, payload, socket)
Обрабатывает входящие
event.- handle_info(msg, socket)
Обрабатывает обычные сообщения процесса Elixir.
- handle_out(event, payload, socket)
Перехватывает исходящие
event.- join(topic, payload, socket)
Обрабатывает подключение к каналу посредством
topic.- terminate(reason, t)
Вызывается перед завершением процесса канала.
Колбэки
- broadcast(socket, event, message)
Транслирует событие всем подписчикам темы сокета.
- broadcast!(socket, event, message)
То же, что и
broadcast/3, но вызывает исключение, если трансляция завершается неудачей.- broadcast_from(socket, event, message)
Транслирует событие от PID всем подписчикам темы сокета.
- broadcast_from!(socket, event, message)
То же, что и
broadcast_from/3, но вызывает исключение, если трансляция завершается неудачей.- intercept(events)
Определяет, какие события Channel перехватывать для колбэков
handle_out/3.- push(socket, event, message)
Отправляет событие непосредственно подключённому клиенту, не требуя предварительного сообщения от клиента.
- reply(socket_ref, status)
Асинхронно отвечает на отправленное через сокет сообщение.
- socket_ref(socket)
Создаёт
socket_refдля асинхронного ответа.
Функции
Типы
payload()Источник
@type payload() :: map() | term() | {:binary, binary()} reply()Источник
@type reply() :: status :: atom() | {status :: atom(), response :: payload()} socket_ref()Источник
@type socket_ref() ::
{transport_pid :: Pid, serializer :: module(), topic :: binary(),
ref :: binary(), join_ref :: binary()} Колбэки
handle_call(msg, from, socket)Источник
@callback handle_call(
msg :: term(),
from :: {pid(), tag :: term()},
socket :: Phoenix.Socket.t()
) ::
{:reply, response :: term(), Phoenix.Socket.t()}
| {:noreply, Phoenix.Socket.t()}
| {:stop, reason :: term(), Phoenix.Socket.t()} Обрабатывает обычные сообщения вызова GenServer.
handle_cast(msg, socket)Источник
@callback handle_cast(msg :: term(), socket :: Phoenix.Socket.t()) ::
{:noreply, Phoenix.Socket.t()} | {:stop, reason :: term(), Phoenix.Socket.t()} Обрабатывает обычные сообщения передачи GenServer.
handle_in(event, payload, socket)Источник
@callback handle_in(
event :: String.t(),
payload :: payload(),
socket :: Phoenix.Socket.t()
) ::
{:noreply, Phoenix.Socket.t()}
| {:noreply, Phoenix.Socket.t(), timeout() | :hibernate}
| {:reply, reply(), Phoenix.Socket.t()}
| {:stop, reason :: term(), Phoenix.Socket.t()}
| {:stop, reason :: term(), reply(), Phoenix.Socket.t()} Обрабатывает входящие event.
Перед отправкой данные сериализуются с помощью настроенного сериализатора.
Пример
def handle_in("ping", payload, socket) do
{:reply, {:ok, payload}, socket}
end handle_info(msg, socket)Источник
@callback handle_info(msg :: term(), socket :: Phoenix.Socket.t()) ::
{:noreply, Phoenix.Socket.t()} | {:stop, reason :: term(), Phoenix.Socket.t()} Обрабатывает обычные сообщения процесса Elixir.
handle_out(event, payload, socket)Источник
@callback handle_out(
event :: String.t(),
payload :: payload(),
socket :: Phoenix.Socket.t()
) ::
{:noreply, Phoenix.Socket.t()}
| {:noreply, Phoenix.Socket.t(), timeout() | :hibernate}
| {:stop, reason :: term(), Phoenix.Socket.t()} Перехватывает исходящие event.
См. intercept/1.
join(topic, payload, socket)Источник
@callback join(topic :: binary(), payload :: payload(), socket :: Phoenix.Socket.t()) ::
{:ok, Phoenix.Socket.t()}
| {:ok, reply :: payload(), Phoenix.Socket.t()}
| {:error, reason :: map()} Обрабатывает подключение к каналу посредством topic.
Чтобы авторизовать сокет, верните {:ok, socket} или {:ok, reply, socket}. Чтобы отказать в авторизации, верните {:error, reason}.
Перед отправкой данные сериализуются с помощью настроенного сериализатора.
Пример
def join("room:lobby", payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end terminate(reason, t)Источник
@callback terminate(
reason :: :normal | :shutdown | {:shutdown, :left | :closed | term()},
Phoenix.Socket.t()
) :: term() Вызывается перед завершением процесса канала.
Функции
broadcast(socket, event, message)Источник
Транслирует событие всем подписчикам темы сокета.
Сообщение события должно быть сериализуемой картой или помеченным кортежем {:binary, data}, в котором data — двоичные данные.
Примеры
iex> broadcast(socket, "new_message", %{id: 1, content: "hello"})
:ok
iex> broadcast(socket, "new_message", {:binary, "hello"})
:ok broadcast!(socket, event, message)Источник
То же, что и broadcast/3, но вызывает исключение, если трансляция завершается неудачей.
broadcast_from(socket, event, message)Источник
Транслирует событие от PID всем подписчикам темы сокета.
Канал, которому принадлежит сокет, не получит опубликованное сообщение. Сообщение события должно быть сериализуемой картой или помеченным кортежем {:binary, data}, в котором data — двоичные данные.
Примеры
iex> broadcast_from(socket, "new_message", %{id: 1, content: "hello"})
:ok
iex> broadcast_from(socket, "new_message", {:binary, "hello"})
:ok broadcast_from!(socket, event, message)Источник
То же, что и broadcast_from/3, но вызывает исключение, если трансляция завершается неудачей.
intercept(events)Источник
Определяет, какие события Channel перехватывать для колбэков handle_out/3.
По умолчанию транслируемые события отправляются непосредственно клиенту, но перехват событий позволяет каналу настраивать событие для клиента, добавляя дополнительную информацию или отфильтровывая сообщение.
Примечание: перехват событий может значительно увеличить накладные расходы, если многим подписчикам требуется настраивать сообщение, поскольку трансляция будет кодироваться N раз вместо единого общего кодирования для всех подписчиков.
Примеры
intercept ["new_msg"]
def handle_out("new_msg", payload, socket) do
push(socket, "new_msg", Map.merge(payload,
is_editable: User.can_edit_message?(socket.assigns[:user], payload)
))
{:noreply, socket}
end
Колбэки handle_out/3 должны возвращать одно из следующих значений:
{:noreply, Socket.t} |
{:noreply, Socket.t, timeout | :hibernate} |
{:stop, reason :: term, Socket.t} push(socket, event, message)Источник
Отправляет событие непосредственно подключённому клиенту, не требуя предварительного сообщения от клиента.
Сообщение события должно быть сериализуемой картой или помеченным кортежем {:binary, data}, в котором data — двоичные данные.
Обратите внимание: в отличие от некоторых клиентских библиотек, этот серверный вызов push/3 не возвращает ссылку. Если нужно получить ответ клиента и сопоставить его с отправленным сообщением, необходимо включить в сообщение уникальный идентификатор, отслеживать его в состоянии канала, предусмотреть передачу этого идентификатора клиентом в ответе и проверять ref при поступлении ответа в handle_in/3.
Примеры
iex> push(socket, "new_message", %{id: 1, content: "hello"})
:ok
iex> push(socket, "new_message", {:binary, "hello"})
:ok reply(socket_ref, status)Источник
@spec reply(socket_ref(), reply()) :: :ok
Асинхронно отвечает на отправленное через сокет сообщение.
Обычно на сообщение клиента отвечают, возвращая кортеж из handle_in/3, например:
{:reply, {status, payload}, socket}
Но иногда нужно асинхронно ответить на отправленное сообщение — то есть после завершения колбэка handle_in/3. Например, может потребоваться выполнить работу в другом процессе и ответить после её завершения.
Для этого создайте ссылку на сокет с помощью socket_ref/1 и вызовите reply/2 с этой ссылкой, когда будете готовы ответить.
Примечание: требуется socket_ref, чтобы сам объект socket не утёк за пределы канала. socket содержит такую информацию, как assigns и конфигурация транспорта, поэтому важно не копировать эти данные за пределы канала, которому они принадлежат.
Технически reply/2 позволяет несколько раз ответить на одно и то же сообщение клиента, и каждый ответ будет содержать ref сообщения клиента. Однако клиент может ожидать только один ответ; в этом случае для дополнительных сообщений предпочтительнее использовать push/3.
Перед отправкой данные сериализуются с помощью настроенного сериализатора.
Примеры
def handle_in("work", payload, socket) do
Worker.perform(payload, socket_ref(socket))
{:noreply, socket}
end
def handle_info({:work_complete, result, ref}, socket) do
reply(ref, {:ok, result})
{:noreply, socket}
end socket_ref(socket)Источник
@spec socket_ref(Phoenix.Socket.t()) :: socket_ref()
Создаёт socket_ref для асинхронного ответа.
Пример использования см. в reply/2.
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/Phoenix.Channel.html