Phoenix Channels: WebSockets en tiempo real con recursos mínimos del servidor

En 2015, Chris McCord demostró Phoenix manejando 2 millones de conexiones WebSocket simultáneas en un solo servidor. No era un truco de marketing: era la demostración de que la BEAM, con sus procesos ligeros y su scheduler de baja latencia, cambia completamente las reglas sobre cuántos usuarios concurrentes puede manejar un servidor web. Phoenix Channels es la abstracción que hace eso accesible.

Qué es un Channel

Un Channel es un módulo que gestiona la comunicación bidireccional entre el cliente (navegador, app móvil, otro servidor) y el servidor, a través de un topic. El topic es como un canal de radio: varios clientes pueden suscribirse al mismo topic y recibir los mismos mensajes.

defmodule MiAppWeb.ChatChannel do
  use Phoenix.Channel

  def join("sala:" <> sala_id, _payload, socket) do
    if autorizado?(socket.assigns.usuario_id, sala_id) do
      send(self(), :after_join)
      {:ok, assign(socket, :sala_id, sala_id)}
    else
      {:error, %{razon: "no autorizado"}}
    end
  end

  def handle_info(:after_join, socket) do
    mensajes = Chat.ultimos_mensajes(socket.assigns.sala_id)
    push(socket, "historial", %{mensajes: mensajes})
    {:noreply, socket}
  end

  def handle_in("nuevo_mensaje", %{"texto" => texto}, socket) do
    usuario_id = socket.assigns.usuario_id
    {:ok, mensaje} = Chat.guardar_mensaje(socket.assigns.sala_id, usuario_id, texto)
    broadcast!(socket, "mensaje_recibido", %{
      id: mensaje.id,
      texto: mensaje.texto,
      usuario: mensaje.usuario.nombre
    })
    {:noreply, socket}
  end

  def handle_in("escribiendo", _payload, socket) do
    broadcast_from!(socket, "usuario_escribiendo", %{usuario_id: socket.assigns.usuario_id})
    {:noreply, socket}
  end
end

El Socket y el Router

Los channels se conectan a través de un Socket, que gestiona la autenticación y el ciclo de vida de la conexión WebSocket:

defmodule MiAppWeb.UserSocket do
  use Phoenix.Socket

  channel "sala:*", MiAppWeb.ChatChannel
  channel "notificaciones:*", MiAppWeb.NotificacionesChannel

  def connect(%{"token" => token}, socket, _connect_info) do
    case Phoenix.Token.verify(MiAppWeb.Endpoint, "user_token", token, max_age: 86_400) do
      {:ok, usuario_id} -> {:ok, assign(socket, :usuario_id, usuario_id)}
      {:error, _} -> :error
    end
  end

  def id(socket), do: "users_socket:#{socket.assigns.usuario_id}"
end

El pattern "sala:*" indica que el ChatChannel maneja cualquier topic que empiece por "sala:". Esta convención de topics jerárquicos es la forma estándar de organizar los namespaces en Phoenix.

PubSub: mensajes entre nodos

Por debajo de los Channels está Phoenix.PubSub, un sistema de publicación/suscripción que funciona tanto en un único nodo como en un cluster distribuido. Cuando haces broadcast! en un Channel, el mensaje llega a todos los procesos suscritos a ese topic, incluso si están en servidores diferentes.

# Suscribirse desde cualquier parte del código
Phoenix.PubSub.subscribe(MiApp.PubSub, "pedidos:nuevos")

# Publicar desde cualquier parte (incluso fuera de un Channel)
Phoenix.PubSub.broadcast(MiApp.PubSub, "pedidos:nuevos", %{
  pedido_id: 123,
  estado: :recibido
})

# El proceso suscrito recibe el mensaje en handle_info
def handle_info(%{pedido_id: id, estado: estado}, state) do
  IO.puts("Nuevo pedido #{id}: #{estado}")
  {:noreply, state}
end

Presence: quién está conectado

Phoenix.Presence rastrea qué usuarios están conectados a qué topics, con sincronización automática entre nodos del cluster:

defmodule MiApp.Presence do
  use Phoenix.Presence,
    otp_app: :mi_app,
    pubsub_server: MiApp.PubSub
end

# En el Channel
def handle_info(:after_join, socket) do
  {:ok, _} = MiApp.Presence.track(socket, socket.assigns.usuario_id, %{
    nombre: socket.assigns.nombre,
    conectado_el: DateTime.utc_now()
  })
  push(socket, "presencia_inicial", MiApp.Presence.list(socket))
  {:noreply, socket}
end

Channels vs LiveView: cuándo usar cada uno

Channels y LiveView usan la misma infraestructura WebSocket pero sirven para cosas distintas. LiveView gestiona interfaces HTML reactivas desde el servidor. Channels son para comunicación bidireccional de propósito general: juegos multijugador, APIs para apps móviles, integraciones entre servicios, o cualquier caso donde el cliente necesite enviar y recibir mensajes arbitrarios sin actualizar el DOM.

Puedes usar ambos en el mismo proyecto sin conflicto: el endpoint de Phoenix gestiona tanto las conexiones LiveView como las conexiones de Channel sobre el mismo puerto.

Consumo de recursos

Cada conexión Channel es un proceso Elixir ligero, unos pocos kilobytes. En comparación, una conexión gestionada con Node.js o con threads del sistema operativo tiene un overhead considerablemente mayor. Eso es lo que permite el número que demostró McCord en 2015 y que sigue siendo válido: la BEAM no comparte el modelo de "un thread por conexión" que limita otros servidores web.

Para ver cómo Phoenix LiveView usa esta misma infraestructura para interfaces reactivas, el artículo sobre LiveView de esta serie es el complemento directo. Y el artículo sobre channels en Go muestra un mecanismo de comunicación entre procesos con un nombre parecido pero una función completamente diferente.

Imagen: Pexels / panumas nikhomkhai

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP