Spec-Zone.ru › Phoenix

Каналы

Исходный код

Требование: это руководство предполагает, что вы ознакомились с вводными руководствами и запустили приложение Phoenix (подготовили его к работе).

Каналы — это интересная часть Phoenix, которая обеспечивает обмен данными в режиме, близком к реальному времени, с миллионами подключённых клиентов и между ними.

Вот несколько возможных вариантов использования:

  • Чаты и API для приложений обмена сообщениями
  • Срочные новости, например «забит гол» или «приближается землетрясение»
  • Отслеживание поездов, грузовиков или участников гонки на карте
  • События в многопользовательских играх
  • Мониторинг датчиков и управление освещением
  • Уведомление браузера об изменении CSS или JavaScript страницы (полезно при разработке)

С концептуальной точки зрения каналы довольно просты.

Сначала клиенты подключаются к серверу через транспорт, например WebSocket. После подключения они присоединяются к одной или нескольким темам. Например, чтобы взаимодействовать с публичной комнатой чата, клиенты могут присоединиться к теме с именем public_chat, а для получения обновлений о товаре с ID 7 им может понадобиться присоединиться к теме с именем product_updates:7.

Клиенты могут отправлять сообщения в темы, к которым присоединились, а также получать сообщения из них. И наоборот, серверы каналов получают сообщения от подключённых клиентов и тоже могут отправлять им сообщения.

Серверы могут транслировать сообщения всем клиентам, подписанным на определённую тему. Это показано на следующей диаграмме:

                                                                  +----------------+
                                                     +--Topic X-->| Mobile Client  |
                                                     |            +----------------+
                              +-------------------+  |
+----------------+            |                   |  |            +----------------+
| Browser Client |--Topic X-->| Phoenix Server(s) |--+--Topic X-->| Desktop Client |
+----------------+            |                   |  |            +----------------+
                              +-------------------+  |
                                                     |            +----------------+
                                                     +--Topic X-->|   IoT Client   |
                                                                  +----------------+

Трансляции работают, даже если приложение запущено на нескольких узлах/компьютерах. То есть, если сокеты двух клиентов подключены к разным узлам приложения и подписаны на одну и ту же тему T, оба получат сообщения, транслируемые в T. Это возможно благодаря внутреннему механизму PubSub.

Каналы поддерживают клиентов любого типа: браузер, нативное приложение, умные часы, встроенное устройство и всё, что может подключаться к сети. Клиенту нужна лишь подходящая библиотека; см. раздел Клиентские библиотеки ниже. Каждая клиентская библиотека взаимодействует с помощью одного из «транспортов», поддерживаемых каналами. Сейчас это WebSockets или long polling, но в будущем могут появиться и другие транспорты.

В отличие от не сохраняющих состояние HTTP-соединений, каналы поддерживают долгоживущие подключения. За каждым из них стоит лёгкий процесс виртуальной машины Erlang, работающий параллельно и поддерживающий собственное состояние.

Эта архитектура хорошо масштабируется: каналы Phoenix могут поддерживать миллионы подписчиков с приемлемой задержкой на одном сервере, обрабатывая сотни тысяч сообщений в секунду. Ёмкость можно увеличить, добавив в кластер новые узлы.

Из чего всё состоит

Хотя с точки зрения клиента каналы просты в использовании, маршрутизация сообщений клиентам в кластере серверов задействует ряд компонентов. Давайте рассмотрим их.

Обзор

Чтобы начать обмен данными, клиент подключается к узлу (серверу Phoenix) через транспорт (например, WebSockets или long polling) и использует это единственное сетевое соединение для присоединения к одному или нескольким каналам. Для каждого клиента и каждой темы создаётся отдельный лёгкий процесс сервера канала. Каждый канал хранит %Phoenix.Socket{} и может поддерживать любое необходимое ему состояние в пределах своего socket.assigns.

После установки соединения каждое входящее сообщение клиента направляется в соответствующий сервер канала на основе его темы. Если сервер канала запрашивает трансляцию сообщения, оно отправляется в локальный PubSub, который рассылает его всем клиентам, подключённым к тому же серверу и подписанным на эту тему.

Если в кластере есть другие узлы, локальный PubSub также пересылает сообщение в их PubSub, которые рассылают его своим подписчикам. Поскольку на каждый дополнительный узел требуется отправить лишь одно сообщение, издержки на добавление узлов незначительны, а каждый новый узел позволяет обслуживать гораздо больше подписчиков.

Поток сообщений выглядит примерно так:

                                 Channel   +-------------------------+      +--------+
                                  route    | Sending Client, Topic 1 |      | Local  |
                              +----------->|     Channel.Server      |----->| PubSub |--+
+----------------+            |            +-------------------------+      +--------+  |
| Sending Client |-Transport--+                                                  |      |
+----------------+                         +-------------------------+           |      |
                                           | Sending Client, Topic 2 |           |      |
                                           |     Channel.Server      |           |      |
                                           +-------------------------+           |      |
                                                                                 |      |
                                           +-------------------------+           |      |
+----------------+                         | Browser Client, Topic 1 |           |      |
| Browser Client |<-------Transport--------|     Channel.Server      |<----------+      |
+----------------+                         +-------------------------+                  |
                                                                                        |
                                                                                        |
                                                                                        |
                                           +-------------------------+                  |
+----------------+                         |  Phone Client, Topic 1  |                  |
|  Phone Client  |<-------Transport--------|     Channel.Server      |<-+               |
+----------------+                         +-------------------------+  |   +--------+  |
                                                                        |   | Remote |  |
                                           +-------------------------+  +---| PubSub |<-+
+----------------+                         |  Watch Client, Topic 1  |  |   +--------+  |
|  Watch Client  |<-------Transport--------|     Channel.Server      |<-+               |
+----------------+                         +-------------------------+                  |
                                                                                        |
                                                                                        |
                                           +-------------------------+      +--------+  |
+----------------+                         |   IoT Client, Topic 1   |      | Remote |  |
|   IoT Client   |<-------Transport--------|     Channel.Server      |<-----| PubSub |<-+
+----------------+                         +-------------------------+      +--------+

Endpoint

В модуле Endpoint приложения Phoenix объявление socket указывает, какой обработчик сокета будет принимать подключения по заданному URL.

socket "/socket", HelloWeb.UserSocket,
  websocket: true,
  longpoll: false,
  auth_token: true

Phoenix поставляется с двумя транспортами по умолчанию: websocket и longpoll. Их можно настроить непосредственно в объявлении socket.

Обработчики сокетов

На стороне клиента вы установите соединение с сокетом по указанному выше маршруту:

let socket = new Socket("/socket", {authToken: window.userToken})

На сервере Phoenix вызовет HelloWeb.UserSocket.connect/2, передав ваши параметры и начальное состояние сокета. В сокете можно выполнять аутентификацию, идентифицировать подключение сокета и задавать стандартные значения socket assigns. Здесь же определяются маршруты каналов.

Маршруты каналов

Маршруты каналов сопоставляют строки тем и направляют подходящие запросы указанному модулю Channel.

Символ звёздочки * служит подстановочным знаком, поэтому в следующем примере маршрута запросы для room:lobby и room:123 будут направлены в RoomChannel. В вашем UserSocket будет:

channel "room:*", HelloWeb.RoomChannel

Каналы

Каналы обрабатывают события от клиентов, поэтому они похожи на контроллеры, но между ними есть два ключевых различия. События каналов могут передаваться в обоих направлениях — входящем и исходящем. Кроме того, соединения каналов сохраняются дольше одного цикла запроса/ответа. Каналы — это наиболее высокий уровень абстракции компонентов Phoenix для обмена данными в реальном времени.

В каждом Channel реализуется один или несколько вариантов каждой из четырёх функций обратного вызова — join/3, terminate/2, handle_in/3 и handle_out/3.

Темы

Темы — это строковые идентификаторы, то есть имена, которые используют различные уровни приложения, чтобы сообщения попадали в нужное место. Как мы видели выше, в темах можно использовать подстановочные знаки. Это позволяет применять удобное соглашение "topic:subtopic". Часто темы составляют из идентификаторов записей уровня приложения, например "users:123".

Сообщения

Модуль Phoenix.Socket.Message определяет структуру со следующими ключами, которые обозначают допустимое сообщение. Из документации Phoenix.Socket.Message.

  • topic — строковая тема или пространство имён пары "topic:subtopic", например "messages" или "messages:123"
  • event — строковое имя события, например "phx_join"
  • payload — полезная нагрузка сообщения
  • ref — уникальная строковая ссылка

PubSub

Функциональность PubSub предоставляет модуль Phoenix.PubSub. Заинтересованные процессы могут получать события, подписавшись на темы. Другие процессы могут транслировать события в определённые темы.

Это удобно для трансляции сообщений в каналах, а также для разработки приложений в целом. Например, так все подключённые live views узнают о добавлении нового комментария к публикации.

Система PubSub отвечает за передачу сообщений между узлами, чтобы их можно было отправить всем подписчикам в кластере. По умолчанию для этого используется Phoenix.PubSub.PG2, который задействует встроенный механизм обмена сообщениями виртуальной машины Erlang.

Если ваша среда развёртывания не поддерживает распределённый Elixir или прямое взаимодействие между серверами, Phoenix также включает адаптер Redis, использующий Redis для обмена данными PubSub. Дополнительные сведения см. в документации Phoenix.PubSub.

Клиентские библиотеки

Любое устройство с сетевым подключением может взаимодействовать с каналами Phoenix, если для него есть клиентская библиотека. Ниже перечислены существующие библиотеки; мы всегда рады новым. Инструкции по созданию собственной библиотеки см. в руководстве Создание клиента для каналов.

Официальная

Phoenix поставляется с клиентом JavaScript, который доступен при создании нового проекта Phoenix. Документация модуля JavaScript находится по адресу https://phoenix.hexdocs.pm/js/; код размещён в нескольких файлах js.

Сторонние

  • Swift (iOS)
    • SwiftPhoenix
  • Java (Android)
    • JavaPhoenixChannels
  • Kotlin (Android)
    • JavaPhoenixClient
  • C#
    • PhoenixSharp
  • Elixir
    • phoenix_gen_socket_client
    • slipstream
  • GDScript (игровой движок Godot)
    • GodotPhoenixChannels

Собираем всё вместе

Давайте объединим все эти идеи и создадим простое приложение чата. Убедитесь, что создали новое приложение Phoenix. Теперь можно сгенерировать UserSocket.

Генерация сокета

Для начала запустим генератор сокета:

$ mix phx.gen.socket User

Он создаст два файла: клиентский код в assets/js/user_socket.js и соответствующую серверную часть в lib/hello_web/channels/user_socket.ex. После выполнения генератор также предложит добавить следующую строку в lib/hello_web/endpoint.ex:

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  socket "/socket", HelloWeb.UserSocket,
    websocket: true,
    longpoll: false

  ...
end

Генератор также предлагает импортировать клиентский код; мы сделаем это позже.

Затем настроим сокет, чтобы сообщения направлялись в нужный канал. Для этого раскомментируем определение канала "room:*":

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", HelloWeb.RoomChannel
  ...

Теперь все сообщения клиентов, темы которых начинаются с "room:", будут направляться в наш RoomChannel. Далее определим модуль HelloWeb.RoomChannel для управления сообщениями в комнате чата.

Присоединение к каналам

Первоочередная задача каналов — разрешать клиентам присоединяться к заданной теме. Для проверки разрешения нужно реализовать join/3 в lib/hello_web/channels/room_channel.ex.

defmodule HelloWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end

  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end
end

В нашем приложении чата мы разрешим всем присоединяться к теме "room:lobby", но все остальные комнаты будут считаться закрытыми, и для доступа к ним потребуется специальная авторизация, например через базу данных. (В рамках этого упражнения мы не будем заниматься закрытыми комнатами чата, но после его завершения вы можете изучить эту тему самостоятельно.)

Теперь, когда канал готов, наладим обмен данными между клиентом и сервером.

Сгенерированный assets/js/user_socket.js определяет простой клиент на основе реализации сокета, входящей в Phoenix.

Эту библиотеку можно использовать для подключения к сокету и присоединения к каналу; для этого достаточно задать в файле имя комнаты "room:lobby".

// assets/js/user_socket.js
// ...
socket.connect()

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("room:lobby", {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

Затем нужно убедиться, что assets/js/user_socket.js импортирован в файл JavaScript приложения. Для этого раскомментируйте эту строку в assets/js/app.js.

// ...
import "./user_socket.js"

Сохраните файл. Благодаря модулю автоматической перезагрузки Phoenix страница в браузере должна обновиться сама. Если всё прошло успешно, в консоли JavaScript браузера появится сообщение «Joined successfully». Теперь клиент и сервер обмениваются данными через постоянное соединение. Давайте сделаем его полезным и добавим чат.

В lib/hello_web/controllers/page_html/home.html.heex заменим существующий код контейнером для сообщений чата и полем ввода для их отправки:

<div id="messages" role="log" aria-live="polite"></div>
<input id="chat-input" type="text">

Теперь добавим пару обработчиков событий в assets/js/user_socket.js:

// ...
let channel           = socket.channel("room:lobby", {})
let chatInput         = document.querySelector("#chat-input")
let messagesContainer = document.querySelector("#messages")

chatInput.addEventListener("keypress", event => {
  if(event.key === 'Enter'){
    channel.push("new_msg", {body: chatInput.value})
    chatInput.value = ""
  }
})

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

Нам нужно было лишь обнаружить нажатие клавиши Enter и отправить push событие по каналу вместе с текстом сообщения. Мы назвали событие "new_msg". Теперь обработаем другую часть приложения чата: будем прослушивать новые сообщения и добавлять их в контейнер сообщений.

// ...
let channel           = socket.channel("room:lobby", {})
let chatInput         = document.querySelector("#chat-input")
let messagesContainer = document.querySelector("#messages")

chatInput.addEventListener("keypress", event => {
  if(event.key === 'Enter'){
    channel.push("new_msg", {body: chatInput.value})
    chatInput.value = ""
  }
})

channel.on("new_msg", payload => {
  let messageItem = document.createElement("p")
  messageItem.innerText = `[${Date()}] ${payload.body}`
  messagesContainer.appendChild(messageItem)
})

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

Мы прослушиваем событие "new_msg" с помощью channel.on, а затем добавляем текст сообщения в DOM. Теперь обработаем входящие и исходящие события на сервере, чтобы завершить настройку.

Входящие события

Входящие события обрабатываются с помощью handle_in/3. Можно сопоставлять имена событий с шаблонами, например "new_msg", а затем извлекать полезную нагрузку, переданную клиентом по каналу. В нашем приложении чата достаточно уведомить всех остальных подписчиков room:lobby о новом сообщении с помощью broadcast!/3.

defmodule HelloWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end

  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body})
    {:noreply, socket}
  end
end

broadcast!/3 уведомит всех подключённых клиентов в теме этого socket и вызовет их функции обратного вызова handle_out/3. handle_out/3 — необязательная функция обратного вызова, но с её помощью можно настраивать и фильтровать трансляции до того, как они попадут к каждому клиенту. По умолчанию за нас реализована handle_out/3, которая просто отправляет сообщение клиенту. Обработка исходящих событий позволяет гибко настраивать и фильтровать сообщения. Давайте посмотрим как.

Перехват исходящих событий

В нашем приложении мы не будем это реализовывать, но представьте, что пользователи нашего чата могут игнорировать сообщения о присоединении новых пользователей к комнате. Такое поведение можно реализовать следующим образом: явно указать Phoenix, какое исходящее событие нужно перехватить, а затем определить функцию обратного вызова handle_out/3 для этих событий. (Разумеется, предполагается, что у нас есть контекст Accounts с функцией ignoring_user?/2 и что мы передаём пользователя через карту assigns.) Важно отметить, что функция обратного вызова handle_out/3 будет вызываться для каждого получателя сообщения, поэтому перед включением более затратных операций, например обращения к базе данных, в handle_out/3 следует тщательно оценить их необходимость.

intercept ["user_joined"]

def handle_out("user_joined", msg, socket) do
  if Accounts.ignoring_user?(socket.assigns[:user], msg.user_id) do
    {:noreply, socket}
  else
    push(socket, "user_joined", msg)
    {:noreply, socket}
  end
end

Вот и всё, что нужно для простого приложения чата. Откройте несколько вкладок браузера — и увидите, как сообщения отправляются и транслируются во все окна!

Использование аутентификации по токену

При подключении часто требуется аутентифицировать клиента. К счастью, с помощью Phoenix.Token это можно сделать за 5 шагов.

Шаг 1 — включение функциональности auth_token в сокете

Phoenix поддерживает независимый от транспорта способ передачи токена аутентификации на сервер. Чтобы включить его, нужно передать параметр :auth_token в объявление сокета в модуле Endpoint.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  socket "/socket", HelloWeb.UserSocket,
    websocket: true,
    longpoll: false,
    auth_token: true

  ...
end

Шаг 2 — назначение токена в соединении

Предположим, в нашем приложении есть модуль аутентификации plug под названием OurAuth. Когда OurAuth аутентифицирует пользователя, он задаёт значение для ключа :current_user в conn.assigns. Поскольку current_user существует, можно просто назначить токен пользователя в соединении, чтобы использовать его в шаблоне. Это поведение можно оформить в виде приватной функции plug — put_user_token/2. Её также можно вынести в отдельный модуль. Чтобы всё заработало, добавим OurAuth и put_user_token/2 в конвейер браузера.

pipeline :browser do
  ...
  plug OurAuth
  plug :put_user_token
end

defp put_user_token(conn, _) do
  if current_user = conn.assigns[:current_user] do
    token = Phoenix.Token.sign(conn, "user socket", current_user.id)
    assign(conn, :user_token, token)
  else
    conn
  end
end

Теперь наш conn.assigns содержит current_user и user_token.

Шаг 3 — передача токена в JavaScript

Затем нужно передать токен в JavaScript. Это можно сделать внутри тега script в lib/hello_web/components/layouts/root.html.heex, непосредственно перед скриптом app.js:

<script>window.userToken = "<%= assigns[:user_token] %>";</script>
<script src={~p"/assets/js/app.js"}></script>

Шаг 4 — передача токена конструктору сокета и его проверка

Нужно также передать :auth_token конструктору сокета и проверить токен пользователя в функции connect/3. Для этого отредактируйте lib/hello_web/channels/user_socket.ex следующим образом:

def connect(_params_, socket, connect_info) do
  # max_age: 1209600 is equivalent to two weeks in seconds
  case Phoenix.Token.verify(socket, "user socket", connect_info[:auth_token], max_age: 1209600) do
    {:ok, user_id} ->
      {:ok, assign(socket, :current_user, user_id)}
    {:error, reason} ->
      :error
  end
end

В JavaScript можно использовать токен, заданный ранее, при создании Socket:

let socket = new Socket("/socket", {authToken: window.userToken})

Для проверки токена пользователя, переданного клиентом, мы использовали Phoenix.Token.verify/4. Функция Phoenix.Token.verify/4 возвращает либо {:ok, user_id}, либо {:error, reason}. Возвращаемое значение можно сопоставить с шаблоном в выражении case. Если токен подтверждён, мы задаём идентификатор пользователя в сокете как значение :current_user. В противном случае возвращаем :error.

Шаг 5 — подключение к сокету в JavaScript

После настройки аутентификации можно подключаться к сокетам и каналам из JavaScript.

let socket = new Socket("/socket", {authToken: window.userToken})
socket.connect()

После подключения можно присоединяться к каналам, указав тему:

let channel = socket.channel("topic:subtopic", {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

Обратите внимание: аутентификация по токену предпочтительна, поскольку она не зависит от транспорта и хорошо подходит для долгих соединений, например каналов, в отличие от использования сессий и других способов аутентификации.

Отказоустойчивость и гарантии надёжности

Серверы перезапускаются, сети разделяются, а клиенты теряют подключение. Чтобы проектировать надёжные системы, нужно понимать, как Phoenix реагирует на такие события и какие гарантии предоставляет.

Обработка повторного подключения

Клиенты подписываются на темы, а Phoenix хранит эти подписки в таблице ETS в памяти. Если канал аварийно завершится, клиентам потребуется повторно подключиться к темам, на которые они были подписаны. К счастью, клиент JavaScript Phoenix умеет делать это самостоятельно. Сервер уведомит всех клиентов об аварийном завершении, что вызовет функцию обратного вызова Channel.onError у каждого клиента. Клиенты попытаются подключиться к серверу снова, используя стратегию экспоненциальной задержки. После подключения они попытаются повторно присоединиться к темам, на которые подписывались ранее. Если это удастся, они снова начнут получать сообщения из этих тем.

Повторная отправка сообщений клиента

Клиенты каналов помещают исходящие сообщения в PushBuffer и отправляют их серверу при наличии соединения. Если соединение недоступно, клиент сохраняет сообщения до тех пор, пока не сможет установить новое соединение. Пока соединения нет, клиент хранит сообщения в памяти до установления соединения или получения события timeout. Тайм-аут по умолчанию составляет 5000 миллисекунд. Клиент не сохраняет сообщения в локальном хранилище браузера, поэтому при закрытии вкладки браузера сообщения будут потеряны.

Повторная отправка сообщений сервера

Phoenix использует стратегию отправки сообщений клиентам «не более одного раза». Если клиент отключён и пропустит сообщение, Phoenix не отправит его повторно. Phoenix не сохраняет сообщения на сервере. При перезапуске сервера неотправленные сообщения будут потеряны. Если приложению нужны более надёжные гарантии доставки сообщений, такой код придётся написать самостоятельно. Обычно для этого сообщения сохраняют на сервере, а клиенты запрашивают пропущенные сообщения.

Например, на клиенте можно отслеживать last_seen_id (или last_updated_at). При подключении клиент передаст last_seen_id через параметры канала, указав последнее полученное значение. Так сервер поймёт, как отправить клиенту данные об эффектах, произошедших после этого идентификатора. На клиенте при получении события "new_message" значение last_seen_id увеличивается, чтобы обеспечить надёжное восстановление после отключения и повторного подключения. Код может выглядеть примерно так:

// on the client
let socket = new Socket(...)

let lastSeenMsg = {}
let roomParams = () => lastSeenMsg.id ? {last_seen_id: lastSeenMsg.id} : {}
let roomChannel = socket.channel("rooms:123", roomParams)

roomChannel.on("new_message", msg => {
  lastSeenMsg = msg
  renderNewMessage(msg)
})

roomChannel.join().receive("ok", ({messages}) => {
  lastSeenMsg = messages[messages.length - 1]
  renderMessages(messages)
})
# room_channel.ex on the server
def join("rooms:" <> id, params, socket) do
  ...
  messages = fetch_messages_since(params["last_seen_id"])
  {:ok, %{messages: messages}, socket}
end

Пример приложения

Пример только что созданного приложения можно посмотреть в проекте phoenix_chat_example.

← Предыдущая страница Аутентификация API
Следующая страница → Присутствие

Пакет Hex Предварительный просмотр Hex (текущий файл) Скачать версию ePub

Создано с помощью ExDoc (v0.40.3) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/channels.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API