Trilha Phoenix · Channels
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"},
{:swoosh, "~> 1.16"},
{:pbkdf2_elixir, "~> 2.0"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
swoosh: [api_client: false],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
pubsub_server: Hello.PubSub
]},
{Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: um cliente de Channels que fala o protocolo do Phoenix
por um WebSocket de verdade (como o JavaScript do navegador) e mostra as
mensagens que passam por ele, encontra os processos dos channels no servidor,
verifica a conexão com o PostgreSQL, confere os exercícios e guarda as
perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
esquecer_visitantes()
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"
corpo =
case requisicao(metodo, caminho, parametros) do
{:ok, conn} ->
tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()
"**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))
{:erro, erro} ->
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
Kino.Markdown.new(titulo <> "\n\n" <> corpo)
end
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
end
defp resumo_final({_requisicao, {:erro, erro}}) do
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
@doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
def texto(html) do
conteudo =
case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
[_, dentro] -> dentro
nil -> html
end
conteudo
|> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
|> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
|> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
|> String.replace(~r{<option[^>]*>}, "[ ] ")
|> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
|> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
|> String.replace(~r{</t[dh]>}, " ")
|> String.replace(~r{<[^>]+>}, "")
|> String.replace(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
end)
|> String.split("\n")
|> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- Channels pelo fio --------------------------------------------------------------
@doc """
Abre uma conexão WebSocket de verdade com o socket da aplicação, em
/socket/websocket, como o cliente JavaScript faz. Opções:
* `token:` o token de autenticação (o `authToken` do cliente JS);
* `params:` parâmetros da conexão, que chegam em `connect/3`.
Devolve `{:ok, conexao}` ou `{:erro, linha_de_status}` quando o servidor recusa.
"""
def conectar(opcoes \\ []) do
porta = apply(@endpoint, :config, [:http])[:port]
params = Map.merge(%{"vsn" => "2.0.0"}, Keyword.get(opcoes, :params, %{}))
chave = Base.encode64(:crypto.strong_rand_bytes(16))
# O token viaja no cabeçalho Sec-WebSocket-Protocol, como o phoenix.js o envia.
protocolo =
case opcoes[:token] do
nil -> ""
token -> "Sec-WebSocket-Protocol: phoenix, base64url.bearer.phx.#{Base.encode64(token, padding: false)}\r\n"
end
pedido =
"GET /socket/websocket?#{URI.encode_query(params)} HTTP/1.1\r\n" <>
"Host: localhost:#{porta}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" <>
"Sec-WebSocket-Key: #{chave}\r\nSec-WebSocket-Version: 13\r\n" <> protocolo <> "\r\n"
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false])
:ok = :gen_tcp.send(socket, pedido)
{:ok, resposta} = :gen_tcp.recv(socket, 0, 5000)
[cabeca | resto] = String.split(resposta, "\r\n\r\n", parts: 2)
status = cabeca |> String.split("\r\n") |> hd()
if status =~ "101" do
{:ok, %{socket: socket, buffer: Enum.join(resto), ref: 1, joins: %{}, fio: [], guardadas: []}}
else
:gen_tcp.close(socket)
{:erro, status}
end
end
@doc "Como `conectar/1`, mas devolve só a conexão e levanta erro se o servidor recusar."
def conectar!(opcoes \\ []) do
case conectar(opcoes) do
{:ok, conexao} -> conexao
{:erro, status} -> raise "o servidor recusou a conexão: #{status}"
end
end
@doc """
Entra num tópico (evento "phx_join"). Devolve `{conexao, {:ok, resposta}}` ou
`{conexao, {:error, resposta}}`, conforme o `join/3` do channel.
"""
def entrar(conexao, topico, payload \\ %{}) do
ref = Integer.to_string(conexao.ref)
conexao = %{conexao | ref: conexao.ref + 1, joins: Map.put(conexao.joins, topico, ref)}
conexao = enviar_quadro(conexao, [ref, ref, topico, "phx_join", payload])
esperar_resposta(conexao, ref)
end
@doc "Sai de um tópico (evento \"phx_leave\")."
def sair(conexao, topico) do
{conexao, resposta} = pedir(conexao, topico, "phx_leave", %{})
{%{conexao | joins: Map.delete(conexao.joins, topico)}, resposta}
end
@doc "Manda um evento para um tópico e não espera resposta, como `channel.push(...)`."
def empurrar(conexao, topico, evento, payload \\ %{}) do
ref = Integer.to_string(conexao.ref)
enviar_quadro(%{conexao | ref: conexao.ref + 1}, [conexao.joins[topico], ref, topico, evento, payload])
end
@doc """
Manda um evento e espera o "phx_reply" dele. Devolve `{conexao, {status, resposta}}`,
ou `{conexao, :sem_resposta}` se o channel não responder (um `{:noreply, socket}`).
As outras mensagens que chegarem enquanto espera ficam guardadas para `receber/2`.
"""
def pedir(conexao, topico, evento, payload \\ %{}) do
ref = Integer.to_string(conexao.ref)
conexao = enviar_quadro(%{conexao | ref: conexao.ref + 1}, [conexao.joins[topico], ref, topico, evento, payload])
esperar_resposta(conexao, ref)
end
@doc """
A próxima mensagem que o servidor mandou, como `[join_ref, ref, tópico, evento, payload]`.
Devolve `{conexao, mensagem}`, `{conexao, :nada}` se nada chegar no tempo dado, ou
`{conexao, :fechada}` se o servidor fechou a conexão.
"""
def receber(conexao, tempo \\ 500) do
case conexao.guardadas do
[mensagem | resto] -> {%{conexao | guardadas: resto}, mensagem}
[] -> ler_mensagem(conexao, tempo)
end
end
@doc "Todas as mensagens que chegarem até o servidor ficar `tempo` ms em silêncio."
def receber_todas(conexao, tempo \\ 300, juntadas \\ []) do
case receber(conexao, tempo) do
{conexao, mensagem} when is_list(mensagem) -> receber_todas(conexao, tempo, [mensagem | juntadas])
{conexao, _nada_ou_fechada} -> {conexao, Enum.reverse(juntadas)}
end
end
@doc "O heartbeat: o cliente JS manda um a cada 30 segundos, no tópico \"phoenix\"."
def heartbeat(conexao) do
ref = Integer.to_string(conexao.ref)
conexao = enviar_quadro(%{conexao | ref: conexao.ref + 1}, [nil, ref, "phoenix", "heartbeat", %{}])
esperar_resposta(conexao, ref)
end
@doc "Fecha a conexão. Os processos dos channels dela terminam no servidor."
def fechar(conexao) do
:gen_tcp.close(conexao.socket)
:ok
end
@doc """
Mostra o que passou pelo fio nesta conexão, na ordem: `→` o que o cliente mandou,
`←` o que o servidor mandou. Cada mensagem é `[join_ref, ref, tópico, evento, payload]`.
"""
def fio(conexao) do
linhas =
conexao.fio
|> Enum.reverse()
|> Enum.map_join("\n", fn {sentido, mensagem} ->
if(sentido == :enviada, do: "→ ", else: "← ") <> Jason.encode!(mensagem)
end)
Kino.Markdown.new(cerca(if(linhas == "", do: "(nada ainda)", else: linhas), ""))
end
@doc "Esquece o que já foi mostrado: o próximo `fio/1` começa daqui."
def limpar_fio(conexao), do: %{conexao | fio: []}
defp esperar_resposta(conexao, ref, tempo \\ 1500) do
case ler_mensagem(conexao, tempo) do
{conexao, [_, ^ref, _, "phx_reply", %{"status" => status, "response" => resposta}]} ->
{conexao, {String.to_atom(status), resposta}}
{conexao, mensagem} when is_list(mensagem) ->
esperar_resposta(%{conexao | guardadas: conexao.guardadas ++ [mensagem]}, ref, tempo)
{conexao, _nada_ou_fechada} ->
{conexao, :sem_resposta}
end
end
defp enviar_quadro(conexao, mensagem) do
dados = Jason.encode!(mensagem)
mascara = :crypto.strong_rand_bytes(4)
tamanho = byte_size(dados)
cabeca =
cond do
tamanho < 126 -> <<1::1, 0::3, 1::4, 1::1, tamanho::7>>
tamanho < 65_536 -> <<1::1, 0::3, 1::4, 1::1, 126::7, tamanho::16>>
true -> <<1::1, 0::3, 1::4, 1::1, 127::7, tamanho::64>>
end
:ok = :gen_tcp.send(conexao.socket, [cabeca, mascara, mascarar(dados, mascara)])
%{conexao | fio: [{:enviada, mensagem} | conexao.fio]}
end
# O cliente mascara cada byte com uma chave de 4 bytes (exigência do protocolo WebSocket).
defp mascarar(dados, <<a, b, c, d>>) do
chave = {a, b, c, d}
for {byte, i} <- Enum.with_index(:binary.bin_to_list(dados)), into: <<>> do
<<Bitwise.bxor(byte, elem(chave, rem(i, 4)))>>
end
end
# Lê um quadro do servidor (sem máscara). Texto vira JSON; um quadro de fechamento vira :fechada.
defp ler_mensagem(conexao, tempo) do
case quadro(conexao.buffer) do
{:ok, 8, _dados, resto} ->
{%{conexao | buffer: resto}, :fechada}
{:ok, _op, texto, resto} ->
mensagem = Jason.decode!(texto)
{%{conexao | buffer: resto, fio: [{:recebida, mensagem} | conexao.fio]}, mensagem}
:incompleto ->
case :gen_tcp.recv(conexao.socket, 0, tempo) do
{:ok, dados} -> ler_mensagem(%{conexao | buffer: conexao.buffer <> dados}, tempo)
{:error, :timeout} -> {conexao, :nada}
{:error, _} -> {conexao, :fechada}
end
end
end
defp quadro(<<_fin::1, _::3, op::4, 0::1, 126::7, n::16, dados::binary-size(n), resto::binary>>), do: {:ok, op, dados, resto}
defp quadro(<<_fin::1, _::3, op::4, 0::1, 127::7, n::64, dados::binary-size(n), resto::binary>>), do: {:ok, op, dados, resto}
defp quadro(<<_fin::1, _::3, op::4, 0::1, n::7, dados::binary-size(n), resto::binary>>) when n < 126, do: {:ok, op, dados, resto}
defp quadro(_), do: :incompleto
@doc "Mostra uma mensagem ou resposta como JSON."
def json(valor), do: Kino.Markdown.new(cerca(Jason.encode!(valor, pretty: true), "json"))
@doc """
Os processos de channel inscritos num tópico, neste servidor: o pid, o id do
socket e os assigns de cada um.
"""
def canais(topico) do
case sockets_do_topico(topico) do
[] ->
Kino.Markdown.new("Nenhum channel no tópico `#{topico}`.")
sockets ->
linhas =
Enum.map(sockets, fn {pid, socket} ->
"| `#{inspect(pid)}` | `#{inspect(socket.channel)}` | `#{inspect(socket.id)}` | `#{inspect(socket.assigns)}` |"
end)
Kino.Markdown.new(Enum.join(["| Processo | Channel | `socket.id` | `socket.assigns` |", "| --- | --- | --- | --- |" | linhas], "\n"))
end
end
# O PubSub guarda num Registry quem está inscrito em cada tópico. Cada channel
# se inscreve no próprio tópico quando o join dá certo.
defp sockets_do_topico(topico) do
for {pid, _valor} <- Registry.lookup(Hello.PubSub, topico),
%Phoenix.Socket{} = socket <- [estado_do_canal(pid)] do
{pid, socket}
end
end
defp estado_do_canal(pid) do
:sys.get_state(pid, 500)
catch
:exit, _ -> nil
end
@doc "Um token de socket para o usuário `id`, como o que a página recebe."
def token(id), do: Phoenix.Token.sign(@endpoint, "user socket", id)
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
http_ou_canal: %{
texto:
"um placar de futebol precisa mostrar um gol em todos os navegadores abertos, na hora em que ele acontece. Só com controllers e HTTP, o que o navegador teria de fazer?",
opcoes: [
a: "Nada: o servidor manda a página nova sozinho.",
b: "Perguntar de novo ao servidor de tempos em tempos, porque no HTTP só o cliente começa a conversa.",
c: "Abrir uma requisição por jogador.",
d: "Guardar o placar num cookie."
],
correta: :b,
explicacao:
"No ciclo requisição-resposta, o servidor só fala quando o cliente pergunta. Para saber de um gol, o navegador teria de perguntar repetidamente (polling), gastando requisições em que nada mudou. Os Channels mantêm uma conexão aberta, e por ela o servidor manda a notícia no momento em que ela acontece."
},
processos: %{
texto:
"três pessoas abrem a aplicação, cada uma numa aba. Cada aba entra em dois tópicos: `room:lobby` e `product_updates:7`. Quantos processos de channel o servidor cria?",
opcoes: [
a: "Um, que atende todo mundo.",
b: "Dois: um por tópico.",
c: "Três: um por pessoa.",
d: "Seis: um por pessoa, por tópico."
],
correta: :d,
explicacao:
"O guia diz: um processo de channel por cliente, por tópico. Três clientes em dois tópicos dão seis processos de channel (além de um processo por conexão, que cuida do WebSocket). Cada um guarda o próprio `socket.assigns`, e a falha de um não derruba os outros. Processos da máquina virtual do Erlang são leves; é por isso que um servidor aguenta milhões deles."
},
curinga: %{
texto: "o socket declara `channel \"room:*\", HelloWeb.RoomChannel`. Para onde vai um join no tópico `room:123`? E no tópico `sala:123`?",
opcoes: [
a: "Os dois vão para o `RoomChannel`.",
b: "`room:123` vai para o `RoomChannel`; `sala:123` não casa com nenhuma rota, e o servidor responde com erro.",
c: "Só `room:*`, escrito assim, com o asterisco, vai para o `RoomChannel`.",
d: "`room:123` exige uma rota própria, `channel \"room:123\", ...`."
],
correta: :b,
explicacao:
"O `*` é um curinga: `room:*` casa com `room:lobby`, `room:123` e qualquer outro tópico que comece com `room:`. Um tópico que não casa com nenhuma rota recebe uma resposta de erro com `reason: \"unmatched topic\"`, sem que nenhum channel seja criado."
},
join_recusado: %{
texto:
"o `join/3` de `room:123` devolve `{:error, %{reason: \"unauthorized\"}}`. O que acontece com a conexão do cliente?",
opcoes: [
a: "O servidor fecha o WebSocket.",
b: "O cliente recebe uma resposta de erro para aquele join, e a conexão continua aberta; os outros tópicos dele seguem funcionando.",
c: "O servidor levanta uma exceção.",
d: "O cliente entra no tópico, mas não recebe mensagens."
],
correta: :b,
explicacao:
"Uma conexão carrega vários tópicos. Recusar um join só afeta aquele tópico: o cliente recebe um `phx_reply` com `status: \"error\"` e o mapa devolvido pelo `join/3`. Nenhum processo de channel fica no ar para o tópico recusado."
},
remetente: %{
texto: "Ana manda `new_msg` no `room:lobby`, e o channel chama `broadcast!(socket, \"new_msg\", %{body: body})`. Quem recebe a mensagem?",
opcoes: [
a: "Todos os que estão no tópico, menos Ana.",
b: "Todos os que estão no tópico, inclusive Ana.",
c: "Todos os conectados ao socket, em qualquer tópico.",
d: "Só Ana, como confirmação."
],
correta: :b,
explicacao:
"`broadcast!/3` manda para todos os inscritos no tópico do socket, inclusive quem enviou. É por isso que o JavaScript do guia não escreve a mensagem na tela ao enviar: ele espera a mensagem voltar pelo canal, como todo mundo. Para pular o remetente existe `broadcast_from!/3`."
},
handle_out: %{
texto:
"uma sala tem 1.000 pessoas, e o channel intercepta o evento `user_joined` com `handle_out/3`. Alguém entra. Quantas vezes `handle_out/3` roda?",
opcoes: [
a: "Uma vez, no processo de quem entrou.",
b: "Mil vezes: uma em cada processo de channel inscrito no tópico.",
c: "Nenhuma: `handle_out/3` só roda para mensagens do cliente.",
d: "Uma vez, no PubSub."
],
correta: :b,
explicacao:
"Sem `intercept`, o servidor codifica a mensagem uma vez e a entrega pronta a todas as conexões. Com `intercept`, cada processo de channel recebe a mensagem e roda o próprio `handle_out/3`, que decide o que fazer para o seu cliente. Por isso o guia avisa: uma consulta ao banco dentro de `handle_out/3` seria repetida para cada destinatário."
},
token: %{
texto:
"a página foi carregada por um usuário logado, com a sessão num cookie. Por que o guia assina um token com `Phoenix.Token` e o passa ao socket, em vez de confiar só no cookie?",
opcoes: [
a: "Porque cookies não funcionam com HTTPS.",
b: "Porque o token não depende do transporte: funciona em WebSocket, em long polling e em clientes que não são navegadores, e serve bem a conexões longas.",
c: "Porque o token é mais curto que o cookie.",
d: "Porque o Phoenix não consegue ler cookies numa conexão WebSocket."
],
correta: :b,
explicacao:
"O guia diz que a autenticação por token é preferível por não depender do transporte e por servir bem a conexões de longa duração. Um aplicativo de celular ou um dispositivo não tem o cookie do navegador, mas pode guardar um token. O Phoenix até consegue ler a sessão no socket (o LiveView faz isso, com `connect_info: [session: ...]`), mas isso prende o socket ao navegador."
},
desconectar: %{
texto:
"`id/1` do socket devolve `\"user_socket:7\"` para o usuário 7, que está conectado em três abas. O que faz `HelloWeb.Endpoint.broadcast(\"user_socket:7\", \"disconnect\", %{})`?",
opcoes: [
a: "Manda o evento `disconnect` para o JavaScript das três abas, que decide o que fazer.",
b: "Fecha as três conexões do usuário 7, com todos os channels delas.",
c: "Fecha só a conexão mais antiga.",
d: "Nada, porque ninguém entrou num tópico chamado `user_socket:7`."
],
correta: :b,
explicacao:
"O id do socket é um tópico em que o processo de cada conexão daquele usuário se inscreve sozinho. O evento `disconnect` nesse tópico é tratado pelo próprio Phoenix: cada conexão se encerra, e os channels dela terminam junto. É o que o `phx.gen.auth` faz quando o usuário sai (o `live_socket_id` do notebook 13)."
},
at_most_once: %{
texto:
"Bia fecha o notebook do computador às 14h00. Às 14h05, Ana manda uma mensagem na sala. Às 14h10, Bia abre o notebook e o cliente reconecta. Bia recebe a mensagem das 14h05?",
opcoes: [
a: "Sim: o servidor guarda as mensagens de quem está offline e as reenvia.",
b: "Não: o Phoenix entrega cada mensagem no máximo uma vez e não guarda nada; quem estava offline perdeu.",
c: "Sim, mas só se Bia reconectar em menos de 5 segundos.",
d: "Depende do navegador."
],
correta: :b,
explicacao:
"O Phoenix usa a estratégia at-most-once: manda a mensagem a quem está conectado naquele momento e não a guarda. Se a aplicação precisa de mais garantia, o código é seu: guardar as mensagens no servidor e deixar o cliente pedir o que perdeu. O último exercício do guia faz isso com um `last_seen_id`."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
canal: ~S'''
# Na célula do UserSocket: tire o comentário da rota.
channel "room:*", HelloWeb.RoomChannel
# Na célula do RoomChannel:
@impl true
def join("room:lobby", _message, socket) do
{:ok, socket}
end
def join("room:" <> _private_room_id, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
''',
new_msg: ~S'''
# Na célula do RoomChannel, depois das cláusulas de join/3:
@impl true
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
''',
silenciar: ~S'''
# No topo do módulo, depois de `use HelloWeb, :channel`:
intercept ["user_joined"]
# E, junto das outras funções:
@impl true
def handle_out("user_joined", msg, socket) do
if socket.assigns.silenciar_entradas do
{:noreply, socket}
else
push(socket, "user_joined", msg)
{:noreply, socket}
end
end
''',
token: ~S'''
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
## Channels
channel "room:*", HelloWeb.RoomChannel
@impl true
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: 1_209_600) do
{:ok, user_id} ->
{:ok, assign(socket, :current_user, user_id)}
{:error, _reason} ->
:error
end
end
@impl true
def id(socket), do: "user_socket:#{socket.assigns.current_user}"
end
''',
historico: ~S'''
defmodule HelloWeb.RoomChannel do
use HelloWeb, :channel
alias Hello.Chat
@impl true
def join("room:lobby", params, socket) do
messages = Chat.list_messages("lobby", params["last_seen_id"])
{:ok, %{messages: messages}, socket}
end
def join("room:" <> _private_room_id, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
@impl true
def handle_in("new_msg", %{"body" => body}, socket) do
message = Chat.create_message("lobby", body, socket.assigns[:current_user])
broadcast!(socket, "new_msg", message)
{:noreply, socket}
end
end
''',
brecho: ~S'''
# No UserSocket (a célula da autenticação), junto da outra rota:
channel "produto:*", HelloWeb.ProdutoChannel
defmodule HelloWeb.ProdutoChannel do
use HelloWeb, :channel
alias Hello.Repo
alias Hello.Vitrine
alias Hello.Vitrine.Produto
@impl true
def join("produto:" <> id, _params, socket) do
with {id, ""} <- Integer.parse(id),
%Produto{vendido: false} = produto <- Repo.get(Produto, id) do
{:ok, %{nome: produto.nome, preco_centavos: produto.preco_centavos}, assign(socket, :produto_id, id)}
else
_ -> {:error, %{reason: "indisponivel"}}
end
end
@impl true
def handle_in("oferta", %{"valor" => valor}, socket) when is_integer(valor) and valor > 0 do
broadcast!(socket, "oferta", %{valor: valor, de: socket.assigns.current_user})
{:noreply, socket}
end
def handle_in("oferta", _payload, socket) do
{:reply, {:error, %{reason: "valor_invalido"}}, socket}
end
def handle_in("comprar", _payload, socket) do
produto = Vitrine.buscar_produto!(socket.assigns.produto_id)
case Vitrine.vender(produto) do
{:ok, _produto} ->
broadcast!(socket, "vendido", %{id: produto.id, para: socket.assigns.current_user})
{:reply, :ok, socket}
{:error, :ja_vendido} ->
{:reply, {:error, %{reason: "ja_vendido"}}, socket}
end
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício abrindo conexões de verdade com o socket da aplicação."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
end
defp verificacoes do
%{
canal: [
{"Um cliente consegue entrar em `room:lobby`.",
fn ->
com_conexoes([1], fn [c] ->
case entrar(c, "room:lobby") do
{_c, {:ok, _}} -> :ok
{_c, outra} -> {:erro, dica_join("room:lobby", outra)}
end
end)
end},
{"O join em `room:123` é recusado com `reason: \"unauthorized\"`.",
fn ->
com_conexoes([1], fn [c] ->
case entrar(c, "room:123") do
{_c, {:error, %{"reason" => "unauthorized"}}} -> :ok
{_c, {:ok, _}} -> {:erro, "O servidor aceitou o join em `room:123`. Falta a segunda cláusula de `join/3`, a das salas privadas."}
{_c, outra} -> {:erro, dica_join("room:123", outra)}
end
end)
end},
{"Um tópico fora de `room:*` não chega a nenhum channel.",
fn ->
com_conexoes([1], fn [c] ->
case entrar(c, "sala:1") do
{_c, {:error, %{"reason" => "unmatched topic"}}} -> :ok
{_c, outra} -> {:erro, "O join em `sala:1` devolveu `#{inspect(outra)}`. A rota deve ser `channel \"room:*\", HelloWeb.RoomChannel`, e nada mais largo que isso."}
end
end)
end}
],
new_msg: [
{"Uma mensagem `new_msg` chega aos outros clientes do `room:lobby`.",
fn ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "room:lobby")
bia = entrar!(bia, "room:lobby")
empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: oi"})
case esperar_evento(bia, "room:lobby", "new_msg") do
%{"body" => "Conferência: oi"} -> :ok
nil -> {:erro, "A outra pessoa da sala não recebeu nada. Escreva `handle_in(\"new_msg\", %{\"body\" => body}, socket)` com `broadcast!(socket, \"new_msg\", %{body: body})`. Se o channel caiu, o erro está no log."}
outra -> {:erro, "A mensagem chegou com o payload `#{inspect(outra)}`; o esperado é `%{\"body\" => \"Conferência: oi\"}`."}
end
end)
end},
{"Quem enviou também recebe a própria mensagem.",
fn ->
com_conexoes([1], fn [ana] ->
ana = entrar!(ana, "room:lobby")
ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: eco"})
case esperar_evento(ana, "room:lobby", "new_msg") do
%{"body" => "Conferência: eco"} -> :ok
_ -> {:erro, "Quem enviou não recebeu a mensagem de volta. Use `broadcast!/3`, e não `broadcast_from!/3`: a página do guia só escreve na tela o que chega pelo canal."}
end
end)
end},
{"Quem está conectado, mas fora da sala, não recebe.",
fn ->
com_conexoes([1, 2], fn [ana, fora] ->
ana = entrar!(ana, "room:lobby")
empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: só a sala"})
case esperar_evento(fora, "room:lobby", "new_msg", 400) do
nil -> :ok
_ -> {:erro, "Um cliente que não entrou em `room:lobby` recebeu a mensagem."}
end
end)
end}
],
silenciar: [
{"Quem está na sala recebe `user_joined` quando outra pessoa entra.",
fn ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "room:lobby", %{"apelido" => "ana"})
{ana, _} = receber_todas(ana, 200)
entrar!(bia, "room:lobby", %{"apelido" => "bia-conferencia"})
case esperar_evento(ana, "room:lobby", "user_joined") do
%{"apelido" => "bia-conferencia"} -> :ok
nil -> {:erro, "Ninguém foi avisado da entrada. Em `handle_out/3`, quem não silenciou precisa de `push(socket, \"user_joined\", msg)`. Confira também se a célula tem o `handle_info(:after_join, ...)` que já veio pronto."}
outra -> {:erro, "O aviso chegou com o payload `#{inspect(outra)}`."}
end
end)
end},
{"Quem entrou com `silenciar_entradas: true` não recebe `user_joined`.",
fn ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "room:lobby", %{"apelido" => "ana", "silenciar_entradas" => true})
{ana, _} = receber_todas(ana, 200)
entrar!(bia, "room:lobby", %{"apelido" => "bia"})
case esperar_evento(ana, "room:lobby", "user_joined", 400) do
nil -> :ok
_ -> {:erro, "Quem pediu silêncio recebeu o aviso mesmo assim. Declare `intercept [\"user_joined\"]` e, em `handle_out/3`, devolva `{:noreply, socket}` sem `push` quando `socket.assigns.silenciar_entradas` for verdadeiro."}
end
end)
end},
{"Quem silenciou as entradas continua recebendo `new_msg`.",
fn ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "room:lobby", %{"silenciar_entradas" => true})
bia = entrar!(bia, "room:lobby")
empurrar(bia, "room:lobby", "new_msg", %{"body" => "Conferência: ainda chega"})
case esperar_evento(ana, "room:lobby", "new_msg") do
%{"body" => "Conferência: ainda chega"} -> :ok
_ -> {:erro, "As mensagens da sala pararam de chegar a quem silenciou as entradas. Intercepte só `\"user_joined\"`."}
end
end)
end}
],
token: [
{"Sem token, o servidor recusa a conexão.",
fn ->
case conectar() do
{:erro, _status} ->
:ok
{:ok, c} ->
fechar(c)
{:erro, "Uma conexão sem token foi aceita. Em `connect/3`, verifique `connect_info[:auth_token]` com `Phoenix.Token.verify/4` e devolva `:error` quando falhar."}
end
end},
{"Com um token inventado, também recusa.",
fn ->
case conectar(token: "token-inventado") do
{:erro, _status} ->
:ok
{:ok, c} ->
fechar(c)
{:erro, "Um token inventado foi aceito. O resultado de `Phoenix.Token.verify/4` precisa decidir entre `{:ok, socket}` e `:error`."}
end
end},
{"Com um token válido, conecta, e o socket guarda o id do usuário em `:current_user`.",
fn ->
com_conexoes([2], fn [bia] ->
entrar!(bia, "room:lobby")
if Enum.any?(sockets_do_topico("room:lobby"), fn {_pid, socket} -> socket.assigns[:current_user] == 2 end),
do: :ok,
else: {:erro, "Nenhum channel de `room:lobby` tem `current_user: 2` nos assigns. Em `connect/3`, devolva `{:ok, assign(socket, :current_user, user_id)}`."}
end)
end},
{"`id/1` identifica o socket: um `disconnect` em `user_socket:2` fecha a conexão do usuário 2, e só a dele.",
fn ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "room:lobby")
bia = entrar!(bia, "room:lobby")
{ana, _} = receber_todas(ana, 200)
{bia, _} = receber_todas(bia, 200)
apply(@endpoint, :broadcast, ["user_socket:2", "disconnect", %{}])
cond do
not fechou?(bia) -> {:erro, "A conexão do usuário 2 continuou aberta. Faça `id/1` devolver `\"user_socket:\" <> ...` com o `current_user` do socket."}
fechou?(ana) -> {:erro, "A conexão do usuário 1 também fechou. O id precisa ser diferente para cada usuário."}
true -> :ok
end
end)
end}
],
historico: [
{"A resposta do join traz a lista `messages`.",
fn ->
com_conexoes([1], fn [ana] ->
case entrar(ana, "room:lobby") do
{_c, {:ok, %{"messages" => lista}}} when is_list(lista) -> :ok
{_c, {:ok, outra}} -> {:erro, "O join respondeu `#{inspect(outra)}`. Devolva `{:ok, %{messages: messages}, socket}`: o mapa do meio vai para o cliente."}
{_c, outra} -> {:erro, dica_join("room:lobby", outra)}
end
end)
end},
{"Cada `new_msg` é gravada e chega com `id`, `body` e `user_id`.",
fn ->
limpando_mensagens(fn ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "room:lobby")
bia = entrar!(bia, "room:lobby")
empurrar(bia, "room:lobby", "new_msg", %{"body" => "Conferência: gravada"})
case esperar_evento(ana, "room:lobby", "new_msg") do
%{"id" => id, "body" => "Conferência: gravada", "user_id" => 2} when is_integer(id) ->
if coluna("SELECT body FROM messages WHERE id = $1", [id]) == ["Conferência: gravada"],
do: :ok,
else: {:erro, "A mensagem chegou com o id #{id}, mas não está na tabela `messages`."}
nil ->
{:erro, "A mensagem não chegou. Se o channel caiu, o erro está no log."}
outra ->
{:erro, "A mensagem chegou como `#{inspect(outra)}`. Grave com `Hello.Chat.create_message(\"lobby\", body, socket.assigns[:current_user])` e transmita o mapa que ela devolve."}
end
end)
end)
end},
{"Quem volta com `last_seen_id` recebe só o que perdeu.",
fn ->
limpando_mensagens(fn ->
com_conexoes([1], fn [ana] ->
ana = entrar!(ana, "room:lobby")
ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: antes"})
%{"id" => visto} = esperar_evento(ana, "room:lobby", "new_msg") || raise "a primeira mensagem não voltou pelo canal"
ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: perdida 1"})
ana = empurrar(ana, "room:lobby", "new_msg", %{"body" => "Conferência: perdida 2"})
receber_todas(ana, 300)
com_conexoes([2], fn [bia] ->
case entrar(bia, "room:lobby", %{"last_seen_id" => visto}) do
{_c, {:ok, %{"messages" => lista}}} ->
corpos = for %{"body" => "Conferência" <> _ = corpo} <- lista, do: corpo
if corpos == ["Conferência: perdida 1", "Conferência: perdida 2"],
do: :ok,
else: {:erro, "Com `last_seen_id: #{visto}`, o esperado eram as duas mensagens seguintes, em ordem. Vieram `#{inspect(corpos)}`. Passe `params[\"last_seen_id\"]` para `Hello.Chat.list_messages/2`."}
{_c, outra} ->
{:erro, "O join respondeu `#{inspect(outra)}`."}
end
end)
end)
end)
end}
],
brecho: [
{"Dá para entrar no tópico de uma peça à venda, e a resposta traz `nome` e `preco_centavos`.",
fn ->
com_pecas(fn [casaco, _saia, _vendido] ->
com_conexoes([1], fn [ana] ->
case entrar(ana, "produto:#{casaco.id}") do
{_c, {:ok, %{"nome" => nome, "preco_centavos" => 9000}}} when nome == casaco.nome -> :ok
{_c, {:ok, outra}} -> {:erro, "O join respondeu `#{inspect(outra)}`; o esperado é um mapa com `nome` e `preco_centavos` da peça."}
{_c, {:error, %{"reason" => "unmatched topic"}}} -> {:erro, "Nenhuma rota casa com `produto:#{casaco.id}`. Acrescente `channel \"produto:*\", HelloWeb.ProdutoChannel` ao `UserSocket` (a célula da autenticação) e avalie-a."}
{_c, outra} -> {:erro, dica_join("produto:#{casaco.id}", outra)}
end
end)
end)
end},
{"Peça vendida, peça que não existe e id que não é número: join recusado com `reason: \"indisponivel\"`.",
fn ->
com_pecas(fn [_casaco, _saia, vendido] ->
com_conexoes([1], fn [ana] ->
Enum.reduce_while(["produto:#{vendido.id}", "produto:999999", "produto:abc"], :ok, fn topico, :ok ->
case entrar(ana, topico) do
{_c, {:error, %{"reason" => "indisponivel"}}} -> {:cont, :ok}
{_c, outra} -> {:halt, {:erro, "O join em `#{topico}` devolveu `#{inspect(outra)}`; o esperado é `{:error, %{reason: \"indisponivel\"}}`, sem derrubar o channel."}}
end
end)
end)
end)
end},
{"Uma oferta chega a quem está na mesma peça, com `valor` e `de`, e a mais ninguém.",
fn ->
com_pecas(fn [casaco, saia, _vendido] ->
com_conexoes([1, 2, 3], fn [ana, bia, caio] ->
ana = entrar!(ana, "produto:#{casaco.id}")
bia = entrar!(bia, "produto:#{casaco.id}")
caio = entrar!(caio, "produto:#{saia.id}")
empurrar(ana, "produto:#{casaco.id}", "oferta", %{"valor" => 7000})
cond do
esperar_evento(bia, "produto:#{casaco.id}", "oferta") != %{"valor" => 7000, "de" => 1} ->
{:erro, "Quem está na mesma peça deveria receber `oferta` com `%{valor: 7000, de: 1}` (`de` é o `current_user` de quem ofereceu)."}
esperar_evento(caio, "produto:#{saia.id}", "oferta", 300) != nil ->
{:erro, "Quem está em outra peça recebeu a oferta."}
true ->
:ok
end
end)
end)
end},
{"Uma oferta com valor inválido recebe uma resposta de erro, e o channel continua de pé.",
fn ->
com_pecas(fn [casaco, _saia, _vendido] ->
com_conexoes([1], fn [ana] ->
ana = entrar!(ana, "produto:#{casaco.id}")
with {ana, {:error, %{"reason" => "valor_invalido"}}} <- pedir(ana, "produto:#{casaco.id}", "oferta", %{"valor" => -5}),
{_ana, {:error, %{"reason" => "valor_invalido"}}} <- pedir(ana, "produto:#{casaco.id}", "oferta", %{"valor" => "muito"}) do
:ok
else
{_c, outra} -> {:erro, "Uma oferta inválida devolveu `#{inspect(outra)}`; o esperado é `{:reply, {:error, %{reason: \"valor_invalido\"}}, socket}`."}
end
end)
end)
end},
{"`comprar` vende a peça, responde `ok` e avisa todos do tópico com `vendido`.",
fn ->
com_pecas(fn [casaco, _saia, _vendido] ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "produto:#{casaco.id}")
bia = entrar!(bia, "produto:#{casaco.id}")
{ana, resposta} = pedir(ana, "produto:#{casaco.id}", "comprar")
cond do
not match?({:ok, _}, resposta) -> {:erro, "`comprar` respondeu `#{inspect(resposta)}`; o esperado é `{:reply, :ok, socket}`."}
not vendida?(casaco.id) -> {:erro, "A resposta foi `ok`, mas a peça não está vendida no banco. Use `Hello.Vitrine.vender/1`."}
not match?(%{"id" => _}, esperar_evento(bia, "produto:#{casaco.id}", "vendido")) -> {:erro, "A outra pessoa do tópico não recebeu `vendido`."}
not match?(%{"id" => _}, esperar_evento(ana, "produto:#{casaco.id}", "vendido")) -> {:erro, "Quem comprou não recebeu `vendido`."}
true -> :ok
end
end)
end)
end},
{"Na segunda tentativa de compra, a resposta é erro com `reason: \"ja_vendido\"`.",
fn ->
com_pecas(fn [casaco, _saia, _vendido] ->
com_conexoes([1, 2], fn [ana, bia] ->
ana = entrar!(ana, "produto:#{casaco.id}")
bia = entrar!(bia, "produto:#{casaco.id}")
{_ana, _} = pedir(ana, "produto:#{casaco.id}", "comprar")
case pedir(bia, "produto:#{casaco.id}", "comprar") do
{_c, {:error, %{"reason" => "ja_vendido"}}} -> :ok
{_c, outra} -> {:erro, "A segunda compra respondeu `#{inspect(outra)}`; o esperado é `{:error, %{reason: \"ja_vendido\"}}`."}
end
end)
end)
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
# Abre uma conexão para cada usuário da lista (com um token válido, que o
# servidor ignora enquanto não o exige), roda o teste e sempre as fecha.
defp com_conexoes(usuarios, teste) do
conexoes = Enum.map(usuarios, fn id -> conectar!(token: token(id)) end)
try do
teste.(conexoes)
after
Enum.each(conexoes, &fechar/1)
end
end
defp entrar!(conexao, topico, payload \\ %{}) do
case entrar(conexao, topico, payload) do
{conexao, {:ok, _}} -> conexao
{_conexao, outra} -> raise dica_join(topico, outra)
end
end
defp dica_join(topico, {:error, %{"reason" => "unmatched topic"}}) do
"Nenhuma rota do socket casa com `#{topico}`. Falta a linha `channel ...` no `UserSocket`, ou a célula dele não foi avaliada."
end
defp dica_join(topico, {:error, %{"reason" => "join crashed"}}) do
"O `join/3` de `#{topico}` levantou uma exceção (o erro está no log). O módulo do channel foi definido, e tem uma cláusula de `join/3` para esse tópico?"
end
defp dica_join(topico, outra), do: "O join em `#{topico}` devolveu `#{inspect(outra)}`."
# Espera, por até `tempo` ms, uma mensagem com esse evento nesse tópico. Devolve o payload ou nil.
defp esperar_evento(conexao, topico, evento, tempo \\ 1000) do
case receber(conexao, tempo) do
{_conexao, [_, _, ^topico, ^evento, payload]} -> payload
{conexao, mensagem} when is_list(mensagem) -> esperar_evento(conexao, topico, evento, tempo)
{_conexao, _nada_ou_fechada} -> nil
end
end
defp fechou?(conexao) do
case receber(conexao, 500) do
{_conexao, :fechada} -> true
{conexao, mensagem} when is_list(mensagem) -> fechou?(conexao)
{_conexao, :nada} -> false
end
end
defp limpando_mensagens(teste) do
teste.()
after
sql("DELETE FROM messages WHERE body LIKE 'Conferência%'")
end
# Cria três peças de conferência (a terceira já vendida), roda o teste e as apaga.
defp com_pecas(teste) do
unless Code.ensure_loaded?(Hello.Vitrine),
do: raise("o contexto `Hello.Vitrine` não existe: avalie as células de \"O Brechó até aqui\"")
sufixo = System.unique_integer([:positive])
pecas =
for {nome, preco} <- [{"Conferência Casaco #{sufixo}", 9000}, {"Conferência Saia #{sufixo}", 3000}, {"Conferência Chapéu #{sufixo}", 2000}] do
{:ok, peca} = apply(Hello.Vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: preco}])
peca
end
{:ok, _} = apply(Hello.Vitrine, :vender, [List.last(pecas)])
try do
teste.(pecas)
after
sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
end
defp vendida?(id), do: coluna("SELECT vendido FROM produtos WHERE id = $1", [id]) == [true]
defp coluna(consulta, parametros), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
defp explicar(%RuntimeError{message: mensagem}), do: mensagem
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia Channels da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. É o primeiro dos três notebooks do módulo 10, "Tempo real": depois vêm Presence (19) e "Writing a Channels Client" (20).
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo.
O guia constrói um chat. As páginas funcionam no navegador: abra http://localhost:4010 em duas abas e converse consigo mesmo. Para ver o que passa entre os clientes e o servidor sem sair do notebook, o módulo Trilha traz um cliente de Channels que fala o protocolo do Phoenix por um WebSocket de verdade, como o JavaScript do navegador:
Trilha.conectar/1abre a conexão em/socket/websocket(opcionalmente com um token) e devolve{:ok, conexao};Trilha.entrar(conexao, tópico, payload)manda ophx_joine devolve{conexao, resposta};Trilha.empurrar/4manda um evento, comochannel.push(...);Trilha.pedir/4manda e espera a resposta;Trilha.receber/2eTrilha.receber_todas/2leem o que o servidor mandou;Trilha.fio/1mostra tudo o que passou pela conexão, nos dois sentidos;Trilha.canais(tópico)mostra os processos de channel daquele tópico no servidor, com o estado de cada um.
A conexão é um valor: cada chamada devolve a conexão atualizada, que você passa à próxima. Por isso as células escrevem {ana, resposta} = Trilha.entrar(ana, ...).
A instalação é a dos notebooks 13 a 17. Se você vem do 12 ou de antes, o Livebook faz uma instalação nova na primeira avaliação, uma vez só.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado. O bloco channel é só use Phoenix.Channel; é o que use HelloWeb, :channel traz para cada channel, como use HelloWeb, :controller traz o necessário para cada controller.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def html do
quote do
use Phoenix.Component
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex: só a página inicial, que vai virar a página do chat.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
end
lib/hello_web/channels/user_socket.ex: o arquivo que mix phx.gen.socket User gera no Phoenix 1.8.16, sem nenhuma mudança. A seção "Gerando um socket" volta a ele. Ele vem antes do endpoint porque o endpoint o declara e o inicia.
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
# A Socket handler
#
# It's possible to control the websocket connection and
# assign values that can be accessed by your channel topics.
## Channels
# Uncomment the following line to define a "room:*" topic
# pointing to the `HelloWeb.RoomChannel`:
#
# channel "room:*", HelloWeb.RoomChannel
#
# To create a channel file, use the mix task:
#
# mix phx.gen.channel Room
#
# See the [`Channels guide`](https://phoenix.hexdocs.pm/channels.html)
# for further details.
# Socket params are passed from the client and can
# be used to verify and authenticate a user. After
# verification, you can put default assigns into
# the socket that will be set for all channels, ie
#
# {:ok, assign(socket, :user_id, verified_user_id)}
#
# To deny connection, return `:error` or `{:error, term}`. To control the
# response the client receives in that case, [define an error handler in the
# websocket
# configuration](https://phoenix.hexdocs.pm/Phoenix.Endpoint.html#socket/3-websocket-configuration).
#
# See `Phoenix.Token` documentation for examples in
# performing token verification on connect.
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
# Socket IDs are topics that allow you to identify all sockets for a given user:
#
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
#
# Would allow you to broadcast a "disconnect" event and terminate
# all active sockets and channels for a given user:
#
# Elixir.HelloWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
#
# Returning `nil` makes this socket anonymous.
@impl true
def id(_socket), do: nil
end
lib/hello_web/endpoint.ex: a linha socket "/socket", HelloWeb.UserSocket é a que o gerador pede para acrescentar; ela cria o endereço /socket/websocket. Os Plug.Static servem o phoenix_html.js e o phoenix.min.js, o cliente JavaScript de Channels, que num projeto o esbuild empacota no app.js.
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
socket "/socket", HelloWeb.UserSocket,
websocket: true,
longpoll: false
plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz, do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout carrega o Tailwind e o daisyUI de uma CDN e o phoenix.min.js, que define a global Phoenix no navegador. A linha window.userToken é a do passo 3 da autenticação por token, no fim do guia; até lá, ela escreve um texto vazio.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
<script>window.userToken = "<%= assigns[:user_token] %>";</script>
<script src={~p"/assets/phoenix.min.js"}>
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial. A seção "Juntando tudo" troca a página inicial pela página do chat.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. O banco só entra na última parte do guia (guardar as mensagens do chat) e no desafio do fim, mas a aplicação o inicia como nos outros notebooks. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. Neste notebook o PubSub deixa de ser coadjuvante: é por ele que uma mensagem enviada por um cliente chega a todos os outros. Ao iniciar, o endpoint também inicia o que cada socket declarado nele precisa; por isso o HelloWeb.UserSocket vem antes desta célula.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
Channels
Na documentação: Channels
Trilha.pergunta(:http_ou_canal)
Os Channels são a parte do Phoenix que permite comunicação em tempo quase real com milhões de clientes conectados, e entre eles. O guia lista alguns usos:
- salas de chat e APIs de aplicativos de mensagens;
- notícias de última hora, como "saiu um gol" ou "vem aí um terremoto";
- acompanhar trens, caminhões ou corredores num mapa;
- eventos em jogos com vários jogadores;
- monitorar sensores e controlar luzes;
- avisar o navegador de que o CSS ou o JavaScript de uma página mudou (o recarregamento automático do Phoenix em desenvolvimento funciona assim).
A ideia é simples. O cliente se conecta ao servidor por algum transporte, como o WebSocket. Conectado, ele entra em um ou mais tópicos. Para participar de um chat público, entra num tópico como public_chat; para receber as novidades do produto de id 7, num tópico como product_updates:7.
O cliente manda mensagens para os tópicos em que entrou e recebe mensagens deles. Do outro lado, o servidor recebe as mensagens dos clientes e também pode mandar mensagens a eles. E o servidor pode fazer um broadcast: mandar uma mensagem a todos os clientes inscritos num tópico.
+----------------+
+--Topic X-->| Mobile Client |
| +----------------+
+-------------------+ |
+----------------+ | | | +----------------+
| Browser Client |--Topic X-->| Phoenix Server(s) |--+--Topic X-->| Desktop Client |
+----------------+ | | | +----------------+
+-------------------+ |
| +----------------+
+--Topic X-->| IoT Client |
+----------------+
O broadcast funciona mesmo com a aplicação rodando em vários computadores. Se dois clientes estão conectados a nós diferentes e inscritos no mesmo tópico, os dois recebem a mensagem. Quem cuida disso é um mecanismo interno, o PubSub.
Qualquer tipo de cliente serve: navegador, aplicativo nativo, relógio, dispositivo embarcado. Basta uma biblioteca que fale um dos transportes que os Channels entendem: hoje, WebSocket ou long polling.
Diferente das requisições HTTP, que não guardam estado, um channel é uma conexão de longa duração. Cada uma é sustentada por um processo leve da máquina virtual do Erlang, que roda em paralelo com os outros e guarda o próprio estado. O guia cita o teste em que um único servidor Phoenix manteve dois milhões de conexões WebSocket.
Se você fez os notebooks 16 e 17, já usou Channels sem escrever nenhum: cada LiveView conectada é um channel, no tópico lv:.... Aqui você escreve os seus.
As peças
Na documentação: The Moving Parts
Para o cliente, usar um channel é simples. Por trás, várias peças levam cada mensagem até os clientes certos, inclusive em outros servidores.
Visão geral
Na documentação: Overview
Trilha.pergunta(:processos)
O cliente se conecta a um nó (um servidor Phoenix) por um transporte e entra em um ou mais channels por essa única conexão. O servidor cria um processo de channel por cliente, por tópico. Cada um guarda um %Phoenix.Socket{} e pode manter o estado que quiser em socket.assigns.
Com a conexão aberta, cada mensagem que chega do cliente é encaminhada, pelo tópico, ao processo de channel certo. Se o channel pede um broadcast, a mensagem vai para o PubSub local, que a entrega a todos os clientes daquele servidor inscritos no tópico. Se houver outros nós no cluster, o PubSub local repassa a mensagem ao PubSub de cada um, e cada um a entrega aos seus inscritos. Só uma mensagem viaja por nó a mais, então acrescentar nós custa pouco, e cada nó novo atende muito mais clientes.
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 |
+--------+
O desenho do guia tem mais clientes e mais um PubSub remoto; a ideia é a mesma. Neste notebook há um nó só, então tudo passa pelo PubSub local, o Hello.PubSub iniciado lá em cima.
Endpoint
Na documentação: Endpoint
No módulo Endpoint da aplicação, uma declaração socket diz qual socket handler recebe as conexões de uma URL:
socket "/socket", HelloWeb.UserSocket,
websocket: true,
longpoll: false,
auth_token: true
O Phoenix traz dois transportes, websocket e longpoll, configurados nessa mesma declaração. A célula do endpoint deste notebook, lá em cima, tem essa linha sem o auth_token, que chega na seção de autenticação. Com websocket: true, o endereço de conexão é /socket/websocket.
Socket handlers
Na documentação: Socket Handlers
No cliente, o JavaScript abre uma conexão com essa rota:
let socket = new Socket("/socket", {authToken: window.userToken})
No servidor, o Phoenix chama HelloWeb.UserSocket.connect/3 com os parâmetros da conexão e o estado inicial do socket. É ali que a aplicação autentica e identifica a conexão e define os assigns iniciais. O guia escreve connect/2, mas o callback que o gerador cria, e que o próprio guia usa mais adiante, é connect/3: o terceiro argumento traz informações da conexão, como o token. O socket handler também é o lugar das rotas de channel.
Rotas de channel
Na documentação: Channel Routes
Trilha.pergunta(:curinga)
As rotas de channel casam com o texto do tópico e mandam a mensagem ao módulo de channel indicado. O asterisco é um curinga. Com a rota abaixo, no UserSocket, tanto room:lobby quanto room:123 vão para o RoomChannel:
channel "room:*", HelloWeb.RoomChannel
Channels
Na documentação: Channels
Os channels tratam eventos dos clientes, e nisso se parecem com os controllers. Há duas diferenças importantes: os eventos de um channel vão nos dois sentidos, de entrada e de saída; e a conexão continua existindo depois de um ciclo de pedido e resposta. Os channels são a abstração de mais alto nível do Phoenix para comunicação em tempo real.
Cada channel implementa uma ou mais cláusulas de quatro callbacks: join/3, terminate/2, handle_in/3 e handle_out/3.
Tópicos
Na documentação: Topics
Tópicos são textos: nomes que as várias camadas usam para garantir que cada mensagem chegue ao lugar certo. Como as rotas aceitam curinga, é comum a convenção "tópico:subtópico", com o subtópico montado a partir de um id da aplicação, como "users:123".
Mensagens
Na documentação: Messages
O módulo Phoenix.Socket.Message define a struct de uma mensagem válida, com estas chaves:
topic: o tópico, como"messages"ou"messages:123";event: o nome do evento, como"phx_join";payload: o conteúdo da mensagem;ref: uma referência única, em texto.
No fio, cada mensagem viaja como uma lista JSON de cinco itens, [join_ref, ref, topic, event, payload]. O join_ref é o quinto dado: a referência do join que abriu aquele tópico naquela conexão. Você vai ver essas listas em todas as demonstrações deste notebook. O notebook 20, sobre o guia "Writing a Channels Client", olha esse formato em detalhe.
PubSub
Na documentação: PubSub
O PubSub é o módulo Phoenix.PubSub. Os interessados se inscrevem em tópicos, e outros processos transmitem eventos para esses tópicos. Serve para os broadcasts dos channels e para a aplicação em geral: no notebook 16, foi por ele que uma leitura do sensor chegou a todas as LiveViews abertas.
O PubSub leva as mensagens de um nó a outro. Por padrão usa o Phoenix.PubSub.PG2, com a troca de mensagens nativa da máquina virtual do Erlang. Onde os servidores não podem falar direto uns com os outros, há um adaptador para Redis.
Bibliotecas de cliente
Na documentação: Client Libraries
Qualquer dispositivo em rede pode se conectar aos Channels, desde que tenha uma biblioteca de cliente. A oficial é a de JavaScript, que vem em todo projeto novo (documentação). O guia lista bibliotecas de terceiros para Swift, Java, Kotlin, C#, Elixir e GDScript.
Este notebook usa dois clientes. No navegador, o cliente JavaScript oficial: o arquivo phoenix.min.js, que o endpoint serve e que define a global Phoenix. Dentro do notebook, o cliente do módulo Trilha, escrito para a trilha: Trilha.conectar/1, entrar/3, empurrar/4 e receber/2 abrem um WebSocket de verdade e trocam as mesmas mensagens que o JavaScript troca. Trilha.fio/1 mostra tudo o que passou pela conexão.
Juntando tudo
Na documentação: Tying it all together
O guia junta essas ideias numa aplicação de chat.
Gerando um socket
Na documentação: Generating a socket
O primeiro passo é o gerador de socket. No Phoenix 1.8.16, a saída é esta:
$ mix phx.gen.socket User
* creating lib/hello_web/channels/user_socket.ex
* creating assets/js/user_socket.js
Add the socket handler to your `lib/hello_web/endpoint.ex`, for example:
socket "/socket", HelloWeb.UserSocket,
websocket: true,
longpoll: false
For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:
import "./user_socket.js"
São dois arquivos: o código do cliente, em assets/js/user_socket.js, e a contraparte do servidor, em lib/hello_web/channels/user_socket.ex. O gerador pede duas coisas: acrescentar a linha socket ao endpoint e importar o user_socket.js no app.js.
Neste notebook, o user_socket.ex gerado e a linha do endpoint já estão na seção "A aplicação Hello dentro do notebook", porque o endpoint precisa deles para iniciar. O JavaScript vem daqui a pouco.
O passo seguinte do guia é fazer o socket encaminhar as mensagens ao channel certo, tirando o comentário da rota "room:*". Com ela, toda mensagem cujo tópico começa com "room:" vai para o RoomChannel.
lib/hello_web/channels/user_socket.ex, de novo: o mesmo arquivo gerado, sem os comentários longos. Avaliar esta célula redefine o módulo; o endpoint, que já está no ar, passa a usar a versão nova na próxima conexão. É a primeira metade do exercício abaixo.
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
## Channels
# Sua vez (canal): tire o comentário da linha abaixo.
#
# channel "room:*", HelloWeb.RoomChannel
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
@impl true
def id(_socket), do: nil
end
Entrando em channels
Na documentação: Joining Channels
Trilha.pergunta(:join_recusado)
A primeira obrigação de um channel é autorizar, ou não, a entrada de um cliente num tópico. Isso é o join/3. O guia escreve o RoomChannel assim:
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
Qualquer pessoa pode entrar em "room:lobby". Qualquer outra sala é tratada como privada e exigiria uma autorização especial, vinda do banco, por exemplo; o guia só as recusa.
O guia escreve o módulo à mão. Existe também um gerador:
$ mix phx.gen.channel Room
* creating lib/hello_web/channels/room_channel.ex
* creating test/hello_web/channels/room_channel_test.exs
* creating test/support/channel_case.ex
Add the channel to your `lib/hello_web/channels/user_socket.ex` handler, for example:
channel "room:lobby", HelloWeb.RoomChannel
O arquivo gerado começa com use HelloWeb, :channel (que é só use Phoenix.Channel, pelo bloco channel do HelloWeb), marca cada callback com @impl true e traz dois handle_in/3 de exemplo, "ping" e "shout". O notebook segue o código do guia, com o use e os @impl do gerador.
Sua vez: o channel da sala
Faça as duas metades:
- Na célula do
UserSocketlogo acima, tire o comentário da rotachannel "room:*", HelloWeb.RoomChannele avalie a célula. - Na célula abaixo, troque o
join/3provisório pelas duas cláusulas do guia:"room:lobby"aceita, e"room:" <> _private_room_idrecusa com%{reason: "unauthorized"}.
lib/hello_web/channels/room_channel.ex
defmodule HelloWeb.RoomChannel do
use HelloWeb, :channel
# Sua vez (canal e new_msg): troque este join/3 provisório pelas duas
# cláusulas do guia. Mais adiante, o handle_in/3 também vem para esta célula.
@impl true
def join(_topic, _payload, _socket) do
{:error, %{reason: "o join ainda não foi escrito"}}
end
end
Trilha.solucao(:canal)
Trilha.conferir(:canal)
O que passa pelo fio
Agora abra uma conexão, como o navegador faria, e entre na sala. Trilha.conectar/1 faz o pedido de WebSocket em /socket/websocket; Trilha.entrar/3 manda o evento phx_join e espera a resposta. Se o exercício acima ainda não foi feito, as respostas desta seção são erros, e a explicação não bate.
{:ok, ana} = Trilha.conectar()
{ana, resposta} = Trilha.entrar(ana, "room:lobby")
resposta
A resposta é {:ok, %{}}: o join/3 devolveu {:ok, socket}, sem nada a dizer ao cliente. Veja as duas mensagens que passaram pela conexão:
Trilha.fio(ana)
A seta → marca o que o cliente mandou: ["1","1","room:lobby","phx_join",{}], ou seja, join_ref, ref, tópico, evento e payload. A seta ← marca a resposta do servidor: um evento phx_reply, com o mesmo ref e o payload {"response":{},"status":"ok"}. É assim que o cliente casa cada resposta com o pedido que a causou.
No log da célula anterior, o Phoenix registrou a conexão (CONNECTED TO HelloWeb.UserSocket) e o join (JOINED room:lobby).
Agora uma sala privada, na mesma conexão:
{ana, resposta} = Trilha.entrar(ana, "room:123")
resposta
O servidor respondeu {:error, %{"reason" => "unauthorized"}}, o mapa da segunda cláusula do join/3. A conexão continua aberta, e Ana continua em room:lobby. Um tópico sem rota recebe outro erro, do próprio Phoenix, que também deixa no log o aviso Ignoring unmatched topic "sala:1" in HelloWeb.UserSocket:
{ana, resposta} = Trilha.entrar(ana, "sala:1")
resposta
O cliente JavaScript também manda, a cada 30 segundos, um heartbeat no tópico reservado phoenix, para manter a conexão viva e descobrir quando ela cai:
{ana, resposta} = Trilha.heartbeat(ana)
ana = Trilha.limpar_fio(ana)
resposta
Por fim, o lado do servidor. Abra uma segunda conexão, a de Bia, e veja os processos de channel inscritos no tópico:
{:ok, bia} = Trilha.conectar()
{bia, _} = Trilha.entrar(bia, "room:lobby")
Trilha.canais("room:lobby")
São dois processos, um por cliente, cada um com o seu %Phoenix.Socket{}. O socket.id é nil porque id/1 do UserSocket devolve nil: o socket é anônimo. Isso muda na seção de autenticação.
O cliente JavaScript e a página do chat
O guia continua no arquivo gerado assets/js/user_socket.js. Ele se conecta ao socket e entra num channel; basta trocar o nome da sala por "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
Depois o guia importa esse arquivo no app.js, troca o conteúdo de home.html.heex por um lugar para as mensagens e um campo de texto, e acrescenta ao JavaScript dois ouvintes: um manda new_msg quando a pessoa aperta Enter, e o outro escreve na página cada new_msg que chega.
Um notebook não tem assets/ nem esbuild. A célula abaixo põe as duas coisas na página inicial: o HTML do guia e, num <script>, o user_socket.js do guia na versão final. Mudam duas coisas: no lugar de import {Socket} from "phoenix", o script usa a global Phoenix.Socket, definida pelo phoenix.min.js que o root layout carrega; e não há export. O authToken: window.userToken é o do arquivo gerado; enquanto não há login, o token é vazio e o cliente simplesmente não o envia.
lib/hello_web/controllers/page_html/home.html.heex e assets/js/user_socket.js
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<p :if={assigns[:current_user]}>Você entrou como <strong>{@current_user.nome}</strong>.</p>
<p :if={!assigns[:current_user]}>Você está sem login.</p>
<div id="messages" role="log" aria-live="polite"></div>
<input id="chat-input" type="text" class="input w-full" placeholder="Escreva e aperte Enter" />
<script>
// assets/js/user_socket.js
let socket = new Phoenix.Socket("/socket", {authToken: window.userToken})
socket.connect()
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) })
</script>
</Layouts.app>
"""
end
end
Abra http://localhost:4010 no navegador e olhe o console do JavaScript (F12): deve aparecer "Joined successfully". O cliente e o servidor estão conversando por uma conexão persistente. Na aba Network, filtrando por WS, a conexão websocket mostra as mesmas listas que Trilha.fio/1 mostra. Escrever no campo ainda não faz nada aparecer: falta o servidor tratar o evento. As duas primeiras linhas da página, sobre login, ficam para a seção de autenticação.
Eventos de entrada
Na documentação: Incoming Events
Trilha.pergunta(:remetente)
Os eventos que chegam do cliente são tratados por handle_in/3. A função casa com o nome do evento, como "new_msg", e recebe o payload que o cliente mandou. No chat, basta avisar todos os inscritos em room:lobby, com broadcast!/3:
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
broadcast!/3 avisa todos os clientes que entraram no tópico deste socket e passa pelo handle_out/3 de cada um. handle_out/3 não é obrigatório: a implementação padrão só empurra a mensagem ao cliente.
Sua vez: as mensagens do chat
Acrescente o handle_in/3 do guia à célula do RoomChannel, depois das cláusulas de join/3, com @impl true, e avalie a célula.
Trilha.solucao(:new_msg)
Trilha.conferir(:new_msg)
Ana e Bia continuam conectadas, e continuam na sala. Por isso as mensagens que a conferência acima mandou também chegaram a elas. As duas primeiras linhas da célula abaixo leem e descartam o que já tinha chegado; depois Ana manda uma mensagem, e as duas conexões leem o que chegar:
{ana, _} = Trilha.receber_todas(ana)
{bia, _} = Trilha.receber_todas(bia)
{ana, bia} = {Trilha.limpar_fio(ana), Trilha.limpar_fio(bia)}
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Oi, sala!"})
{ana, _} = Trilha.receber_todas(ana)
{bia, _} = Trilha.receber_todas(bia)
Kino.Layout.grid([Kino.Markdown.new("**O fio de Ana**"), Trilha.fio(ana), Kino.Markdown.new("**O fio de Bia**"), Trilha.fio(bia)])
No fio de Ana há uma mensagem enviada (→) e uma recebida (←): a própria mensagem voltou. No de Bia, só a recebida. Repare em duas coisas na mensagem que o servidor mandou:
- o
join_refe orefsãonull: não é resposta a nenhum pedido, é um broadcast empurrado pelo servidor; - não houve
phx_replypara onew_msgde Ana: ohandle_in/3devolveu{:noreply, socket}. Um channel também pode responder a quem pediu, com{:reply, {:ok, payload}, socket}, como o"ping"do arquivo gerado. O desafio do fim usa isso.
Com a página do chat aberta em duas abas do navegador, escreva numa e veja a mensagem aparecer nas duas. Se a célula acima for avaliada de novo, a mensagem de Ana também aparece nas abas.
Interceptando eventos de saída
Na documentação: Intercepting Outgoing Events
Trilha.pergunta(:handle_out)
O guia não implementa esta parte no chat, só a descreve: imagine que as pessoas pudessem ignorar os avisos de que alguém entrou na sala. O channel declara qual evento de saída quer interceptar e define um handle_out/3 para ele:
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
O exemplo supõe um contexto Accounts com ignoring_user?/2 e um usuário nos assigns, que o chat ainda não tem. O guia avisa: handle_out/3 roda para cada destinatário da mensagem, então operações caras, como uma consulta ao banco, pedem cuidado ali.
Para ver isso funcionando sem usuários, o notebook usa os parâmetros do join. A célula abaixo é o RoomChannel do exercício anterior, com três acréscimos já prontos:
- o
join/3guarda nos assigns o"apelido"que o cliente mandou e a preferência"silenciar_entradas"; - depois do join, o channel manda a si mesmo a mensagem
:after_join, e ohandle_info/2transmiteuser_joinedpara a sala. O broadcast não pode acontecer dentro dojoin/3, porque ali o cliente ainda não entrou no tópico; - falta a sua parte: o
intercepte ohandle_out/3.
Sua vez: silenciar os avisos de entrada
Na célula abaixo, intercepte "user_joined". No handle_out/3, quem tem socket.assigns.silenciar_entradas verdadeiro não recebe nada; os outros recebem a mensagem com push/3.
lib/hello_web/channels/room_channel.ex, com os avisos de entrada
defmodule HelloWeb.RoomChannel do
use HelloWeb, :channel
# Sua vez (silenciar): declare aqui o intercept e, mais abaixo, o handle_out/3.
@impl true
def join("room:lobby", params, socket) do
send(self(), :after_join)
socket =
socket
|> assign(:apelido, params["apelido"] || "anônimo")
|> assign(:silenciar_entradas, params["silenciar_entradas"] == true)
{:ok, socket}
end
def join("room:" <> _private_room_id, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
# Depois do join, avisa a sala de que alguém entrou.
@impl true
def handle_info(:after_join, socket) do
broadcast!(socket, "user_joined", %{apelido: socket.assigns.apelido})
{:noreply, socket}
end
@impl true
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
end
Trilha.solucao(:silenciar)
Trilha.conferir(:silenciar)
Três conexões novas. Ana entra, depois Caio, que pede silêncio, depois Bia:
Trilha.fechar(ana)
Trilha.fechar(bia)
{:ok, ana} = Trilha.conectar()
{ana, _} = Trilha.entrar(ana, "room:lobby", %{"apelido" => "ana"})
{:ok, caio} = Trilha.conectar()
{caio, _} = Trilha.entrar(caio, "room:lobby", %{"apelido" => "caio", "silenciar_entradas" => true})
{:ok, bia} = Trilha.conectar()
{bia, _} = Trilha.entrar(bia, "room:lobby", %{"apelido" => "bia"})
{ana, de_ana} = Trilha.receber_todas(ana)
{caio, de_caio} = Trilha.receber_todas(caio)
{bia, de_bia} = Trilha.receber_todas(bia)
apelidos = fn mensagens -> for [_, _, _, "user_joined", %{"apelido" => apelido}] <- mensagens, do: apelido end
%{ana: apelidos.(de_ana), caio: apelidos.(de_caio), bia: apelidos.(de_bia)}
Ana viu as três entradas (a dela mesma, a de Caio e a de Bia), Bia viu só a própria, e Caio não viu nenhuma. Os assigns de cada processo explicam a diferença:
Trilha.canais("room:lobby")
O chat básico do guia termina aqui. Abra várias abas e veja as mensagens chegarem a todas.
Autenticação por token
Na documentação: Using Token Authentication
Trilha.pergunta(:token)
Na hora de conectar, quase sempre é preciso autenticar o cliente. O guia faz isso em cinco passos, com o Phoenix.Token: a página, carregada por alguém logado, recebe um token assinado pelo servidor; o JavaScript manda o token ao abrir o socket; e o connect/3 o confere.
O guia supõe que a aplicação tem um plug de autenticação, o OurAuth, que põe o usuário em conn.assigns.current_user. Para ter um, o notebook usa um login de mentira, sem senha: visitar /entrar/1 guarda o usuário 1 na sessão. Há três usuários fixos: 1 é Ana, 2 é Bia, 3 é Caio. Num projeto de verdade, esse papel é do phx.gen.auth do notebook 13, e o usuário viria de conn.assigns.current_scope.user.
lib/hello/contas.ex, lib/hello_web/our_auth.ex e lib/hello_web/controllers/sessao_controller.ex: o login de mentira.
defmodule Hello.Contas do
@usuarios %{1 => "Ana", 2 => "Bia", 3 => "Caio"}
def get_user(id) when is_map_key(@usuarios, id), do: %{id: id, nome: @usuarios[id]}
def get_user(_id), do: nil
end
defmodule HelloWeb.OurAuth do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
user = conn |> get_session(:user_id) |> Hello.Contas.get_user()
assign(conn, :current_user, user)
end
end
defmodule HelloWeb.SessaoController do
use HelloWeb, :controller
# Login de mentira: sem senha. Só para o notebook ter um current_user.
def entrar(conn, %{"id" => id}) do
conn
|> put_session(:user_id, String.to_integer(id))
|> redirect(to: "/")
end
end
Passo 1: ligar o auth_token no socket
Na documentação: Step 1 - Enable the auth_token functionality in the socket
O Phoenix tem um jeito de passar um token de autenticação ao servidor que não depende do transporte. Ele é ligado com a opção auth_token: true na declaração do socket.
lib/hello_web/endpoint.ex, de novo: a mesma célula lá de cima, com auth_token: true. Avaliar esta célula recompila o módulo; o endpoint que está no ar passa a usar a versão nova na conexão seguinte, sem reiniciar.
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
socket "/socket", HelloWeb.UserSocket,
websocket: true,
longpoll: false,
auth_token: true
plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
Passo 2: pôr um token na conexão
Na documentação: Step 2 - Assign a Token in the Connection
Quando o OurAuth autentica alguém, há um current_user nos assigns. Um plug de função, put_user_token/2, assina um token com o id do usuário e o guarda em conn.assigns.user_token, para o layout usar. Os dois plugs entram na pipeline :browser.
lib/hello_web/router.ex, de novo: com OurAuth, put_user_token/2 e a rota do login de mentira.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug HelloWeb.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
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/entrar/:id", SessaoController, :entrar
end
end
Phoenix.Token.sign/3 recebe o contexto de onde tirar a chave secreta (o conn), um "sal" que separa este uso de outros ("user socket") e o dado a assinar. O token não é cifrado: quem o tem consegue ler o id. O que ele garante é que foi este servidor que o emitiu e que ninguém o alterou.
Passo 3: passar o token ao JavaScript
Na documentação: Step 3 - Pass the Token to the JavaScript
O token precisa chegar ao JavaScript. O guia o escreve numa tag <script> do root.html.heex, logo acima do app.js:
<script>window.userToken = "<%= assigns[:user_token] %>";</script>
<script src={~p"/assets/js/app.js"}></script>
O root layout deste notebook tem essa linha desde o começo; sem usuário, ela escrevia window.userToken = "". Entre como Ana e veja a linha na página:
Trilha.navegar("ana", :get, "/entrar/1")
{:ok, conn} = Trilha.visitar("ana", :get, "/")
Regex.run(~r/window\.userToken = "[^"]*"/, to_string(conn.resp_body))
Passo 4: passar o token ao socket e verificar
Na documentação: Step 4 - Pass the Token to the Socket Constructor and Verify
No JavaScript, o token vai na opção authToken do construtor, que o user_socket.js gerado e a página do chat já usam:
let socket = new Socket("/socket", {authToken: window.userToken})
No servidor, o connect/3 recebe o token em connect_info[:auth_token] e o confere com Phoenix.Token.verify/4, que devolve {:ok, user_id} ou {:error, reason}:
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
Com um token válido, o id do usuário fica em :current_user nos assigns do socket, e todos os channels daquela conexão o enxergam. Sem isso, o connect/3 devolve :error e a conexão é recusada. (Copiado assim, o código do guia compila com um aviso: a variável reason não é usada. A solução do exercício usa _reason.)
Trilha.pergunta(:desconectar)
Com um usuário no socket, o outro callback do UserSocket ganha uso. O arquivo gerado explica, nos comentários, que id/1 pode devolver um tópico que identifica todos os sockets de um usuário:
def id(socket), do: "user_socket:#{socket.assigns.user_id}"
e que um broadcast do evento "disconnect" nesse tópico encerra todos os sockets e channels daquele usuário.
Sua vez: verificar o token
Na célula abaixo:
- escreva o
connect/3do guia, que verificaconnect_info[:auth_token]e guarda o id em:current_user; - faça
id/1devolver"user_socket:ID", com o id do usuário. O nome do assign aqui é:current_user, como noconnect/3do guia, e não:user_id, como no comentário do arquivo gerado.
lib/hello_web/channels/user_socket.ex, com autenticação
defmodule HelloWeb.UserSocket do
use Phoenix.Socket
## Channels
channel "room:*", HelloWeb.RoomChannel
# Sua vez (token): verifique o token em connect/3 e identifique o socket em id/1.
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
@impl true
def id(_socket), do: nil
end
Trilha.solucao(:token)
Trilha.conferir(:token)
Passo 5: conectar ao socket no JavaScript
Na documentação: Step 5 - Connect to the socket in JavaScript
Com a autenticação pronta, o JavaScript conecta e entra nos channels como antes; a página do chat já fazia isso. O que mudou é quem consegue. Sem token, a conexão agora é recusada:
Trilha.conectar()
O servidor respondeu 403 Forbidden ao pedido de WebSocket, e nenhum processo foi criado. No navegador, é o que acontece com a página do chat aberta sem login: o cliente JavaScript tenta reconectar de tempos em tempos, e o console mostra as falhas. Visite http://localhost:4010/entrar/1 numa aba e http://localhost:4010/entrar/2 numa janela anônima (a sessão fica num cookie, e as abas de uma mesma janela o dividem) para conversar como Ana e Bia.
No notebook, Trilha.token(id) assina um token como o da página, e Trilha.conectar(token: ...) o manda como o phoenix.js manda: no cabeçalho Sec-WebSocket-Protocol do pedido de WebSocket, e não na URL, onde ficaria registrado em logs.
Enum.each([ana, bia, caio], &Trilha.fechar/1)
{:ok, ana} = Trilha.conectar(token: Trilha.token(1))
{ana, _} = Trilha.entrar(ana, "room:lobby", %{"apelido" => "ana"})
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, _} = Trilha.entrar(bia, "room:lobby", %{"apelido" => "bia"})
Trilha.canais("room:lobby")
Cada socket agora tem um socket.id e, nos assigns, o current_user que veio do token. Agora o broadcast de disconnect para o usuário 2:
{ana, _} = Trilha.receber_todas(ana)
{bia, _} = Trilha.receber_todas(bia)
HelloWeb.Endpoint.broadcast("user_socket:2", "disconnect", %{})
{bia, o_que_bia_recebeu} = Trilha.receber(bia)
{ana, o_que_ana_recebeu} = Trilha.receber(ana)
%{bia: o_que_bia_recebeu, ana: o_que_ana_recebeu}
Para Bia, :fechada: o servidor encerrou a conexão dela. Para Ana, :nada: a conexão continua aberta, em silêncio.
O guia fecha a seção com uma observação: a autenticação por token é preferível por não depender do transporte e por servir bem a conexões de longa duração, como as dos channels, ao contrário de sessões e outras abordagens.
Tolerância a falhas e garantias de entrega
Na documentação: Fault Tolerance and Reliability Guarantees
Servidores reiniciam, redes se partem e clientes perdem a conexão. Para projetar um sistema robusto, é preciso saber como o Phoenix reage a isso e o que ele garante.
Reconexão
Na documentação: Handling Reconnection
Os clientes se inscrevem em tópicos, e o Phoenix guarda as inscrições na memória, numa tabela ETS. Se um channel cai, o cliente precisa entrar de novo nos tópicos em que estava. O cliente JavaScript sabe fazer isso: o servidor avisa da queda, o que dispara o callback Channel.onError do cliente; ele tenta de novo, com intervalos cada vez maiores, e, ao conseguir, volta a receber as mensagens.
Dá para ver o aviso. O RoomChannel não tem handle_in/3 para um evento chamado evento_que_nao_existe. Mandar esse evento derruba o processo do channel de Ana, com um FunctionClauseError no log:
ana = Trilha.limpar_fio(ana)
ana = Trilha.empurrar(ana, "room:lobby", "evento_que_nao_existe", %{})
{ana, _} = Trilha.receber_todas(ana)
Trilha.fio(ana)
O servidor mandou um phx_error no tópico. Só aquele channel caiu: a conexão continua aberta, e os outros tópicos e os outros clientes não foram afetados. O cliente JavaScript reage a essa mensagem entrando de novo; aqui, à mão:
{ana, resposta} = Trilha.entrar(ana, "room:lobby", %{"apelido" => "ana"})
resposta
Reenvio das mensagens do cliente
Na documentação: Resending Client Messages
O cliente JavaScript põe as mensagens de saída numa fila, o PushBuffer, e as manda quando há conexão. Sem conexão, ele as guarda na memória até reconectar ou até o tempo limite, de 5.000 milissegundos por padrão. Nada vai para o armazenamento local do navegador: se a aba fechar, as mensagens na fila se perdem.
Isso é comportamento do cliente oficial. O cliente do módulo Trilha não tem fila: ele só escreve na conexão.
Reenvio das mensagens do servidor
Na documentação: Resending Server Messages
Trilha.pergunta(:at_most_once)
O Phoenix usa a estratégia at-most-once ao mandar mensagens aos clientes: cada mensagem é entregue no máximo uma vez. Se o cliente está offline, o Phoenix não reenvia. Ele não guarda as mensagens no servidor; se o servidor reiniciar, o que não foi enviado se perde. Veja com Bia, que foi desconectada há pouco:
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Bia, você está aí?"})
{ana, _} = Trilha.receber_todas(ana)
Trilha.fechar(bia)
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, _} = Trilha.entrar(bia, "room:lobby", %{"apelido" => "bia"})
{bia, de_bia} = Trilha.receber_todas(bia)
for [_, _, _, evento, payload] <- de_bia, do: {evento, payload}
Bia voltou, e só recebeu o aviso da própria entrada. A pergunta de Ana, mandada enquanto ela estava fora, não chegou.
Se a aplicação precisa de garantias mais fortes, o código é da aplicação. O caminho comum é guardar as mensagens no servidor e deixar o cliente pedir as que perdeu. O guia esboça a ideia: o cliente guarda o id da última mensagem que viu, o last_seen_id; ao entrar, manda esse id nos parâmetros do join, e o servidor responde com o que aconteceu depois dele.
// on the client
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
Repare em roomParams: é uma função. O cliente JavaScript a chama a cada join, inclusive nas reconexões, então cada volta manda o last_seen_id daquele momento.
O guia para no esboço. O notebook leva a ideia até o fim no chat, com uma tabela para as mensagens e um contexto pequeno, já prontos.
priv/repo/migrations/20250701000000_create_messages.exs
defmodule Hello.Repo.Migrations.CreateMessages do
use Ecto.Migration
def change do
create table(:messages) do
add :room, :string, null: false
add :body, :text, null: false
add :user_id, :integer
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:messages, [:room, :id])
end
end
Trilha.migracao(20250701000000, Hello.Repo.Migrations.CreateMessages)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/chat/message.ex e lib/hello/chat.ex: o contexto. As duas funções devolvem mapas simples, com id, body e user_id, prontos para virar JSON.
defmodule Hello.Chat.Message do
use Ecto.Schema
schema "messages" do
field :room, :string
field :body, :string
field :user_id, :integer
timestamps(type: :utc_datetime, updated_at: false)
end
end
defmodule Hello.Chat do
import Ecto.Query
alias Hello.Repo
alias Hello.Chat.Message
@doc "Grava uma mensagem e a devolve como mapa."
def create_message(room, body, user_id) do
%Message{room: room, body: body, user_id: user_id}
|> Repo.insert!()
|> to_map()
end
@doc "As mensagens da sala depois de `last_seen_id`, em ordem. Sem id, todas."
def list_messages(room, last_seen_id \\ nil) do
Message
|> where([m], m.room == ^room and m.id > ^(last_seen_id || 0))
|> order_by([m], m.id)
|> Repo.all()
|> Enum.map(&to_map/1)
end
defp to_map(message), do: %{id: message.id, body: message.body, user_id: message.user_id}
end
Sua vez: recuperar o que foi perdido
A célula abaixo é o RoomChannel do chat, sem os avisos de entrada, para ficar curto. Mude duas coisas:
- no
handle_in/3, grave a mensagem comHello.Chat.create_message("lobby", body, socket.assigns[:current_user])e transmita o mapa que ela devolve, no lugar de%{body: body}. Assim cadanew_msgleva oide ouser_id; - no
join/3de"room:lobby", busque as mensagens comHello.Chat.list_messages("lobby", params["last_seen_id"])e devolva{:ok, %{messages: messages}, socket}. O mapa do meio é a resposta do join, que o cliente recebe.
lib/hello_web/channels/room_channel.ex, com histórico
defmodule HelloWeb.RoomChannel do
use HelloWeb, :channel
# Sua vez (historico): grave cada mensagem e devolva, no join, as que o cliente perdeu.
@impl true
def join("room:lobby", _params, socket) do
{:ok, socket}
end
def join("room:" <> _private_room_id, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
@impl true
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
end
Trilha.solucao(:historico)
Trilha.conferir(:historico)
A mesma cena de antes. Bia está na sala e vê uma mensagem; cai; Ana manda mais duas; Bia volta com o id da última que viu.
Enum.each([ana, bia], &Trilha.fechar/1)
{:ok, ana} = Trilha.conectar(token: Trilha.token(1))
{ana, _} = Trilha.entrar(ana, "room:lobby")
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, _} = Trilha.entrar(bia, "room:lobby")
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Bia, você está aí?"})
{bia, vistas} = Trilha.receber_todas(bia)
ultima_vista = for([_, _, _, "new_msg", %{"id" => id}] <- vistas, do: id) |> List.last()
Trilha.fechar(bia)
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Acho que a Bia caiu."})
ana = Trilha.empurrar(ana, "room:lobby", "new_msg", %{"body" => "Quando voltar, leia isto."})
{ana, _} = Trilha.receber_todas(ana)
{:ok, bia} = Trilha.conectar(token: Trilha.token(2))
{bia, resposta} = Trilha.entrar(bia, "room:lobby", %{"last_seen_id" => ultima_vista})
resposta
A resposta do join traz as duas mensagens que Bia perdeu, e só elas. A entrega em tempo real continua sendo at-most-once; o que mudou é que agora existe um lugar onde buscar o que faltou. As mensagens estão na tabela:
Trilha.consultar("SELECT id, room, body, user_id FROM messages ORDER BY id")
A página do chat continua funcionando, porque o payload ainda tem body. Para ela mostrar o histórico e mandar o last_seen_id nas reconexões, o JavaScript precisaria das mudanças do esboço do guia.
Aplicação de exemplo
Na documentação: Example Application
O guia aponta para o projeto phoenix_chat_example, uma versão completa do chat.
Sem roteiro: a negociação de uma peça do Brechó
No Brechó, quem se interessa por uma peça quer negociar: fazer uma oferta, ver as ofertas dos outros e saber na hora se alguém levou. Cada peça ganha a sua sala.
O Brechó até aqui
O contexto Hello.Vitrine do notebook 08: a tabela produtos, o schema e o contexto, com vender/1, que só vende uma peça que ainda não foi vendida.
priv/repo/migrations/20250320000000_create_produtos.exs (a do notebook 07)
defmodule Hello.Repo.Migrations.CreateProdutos do
use Ecto.Migration
def change do
create table(:produtos) do
add :nome, :string, null: false
add :preco_centavos, :integer, null: false
add :vendido, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
defmodule Hello.Vitrine.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
timestamps(type: :utc_datetime)
end
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
defmodule Hello.Vitrine do
@moduledoc """
A vitrine do Brechó: o que está à venda e a venda de cada peça.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
@doc """
Marca a peça como vendida, se ninguém a vendeu antes.
A verificação e a mudança acontecem num só UPDATE, então duas vendas
simultâneas da mesma peça nunca dão certo ao mesmo tempo.
"""
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
end
Algumas peças à venda:
for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}, {"Bota de couro", 12000}] do
{:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end
Hello.Vitrine.listar_disponiveis() |> Enum.map(&{&1.id, &1.nome})
O desafio
Escreva o channel HelloWeb.ProdutoChannel, para os tópicos produto:ID:
- a rota
channel "produto:*", HelloWeb.ProdutoChannelnoUserSocket. Acrescente-a à célula doUserSocketcom autenticação, a do exercício do token, e avalie-a de novo; - join: só dá para entrar no tópico de uma peça que existe e ainda não foi vendida. A resposta do join traz
nomeepreco_centavosda peça. Peça vendida, peça que não existe ou id que não é número:{:error, %{reason: "indisponivel"}}, sem derrubar o channel; "oferta", com payload%{"valor" => centavos}: transmiteofertapara o tópico, comvalorede(ocurrent_userde quem ofereceu). Se o valor não for um inteiro positivo, responde a quem mandou com{:error, %{reason: "valor_invalido"}}e não transmite nada;"comprar": vende a peça comHello.Vitrine.vender/1. Deu certo: responde:oka quem comprou e transmitevendidopara o tópico, com oidda peça. Já estava vendida: responde{:error, %{reason: "ja_vendido"}}.
Para responder a um pedido, o handle_in/3 devolve {:reply, :ok, socket} ou {:reply, {:error, mapa}, socket}. No cliente da trilha, Trilha.pedir/4 manda o evento e espera essa resposta.
# Sua vez (brecho): defina aqui o HelloWeb.ProdutoChannel.
Para experimentar à mão, com a peça de id 1 (a conexão de Ana já tem um token):
{ana, resposta} = Trilha.entrar(ana, "produto:1")
resposta
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)
No seu projeto
Em um projeto gerado por mix phx.new hello, o caminho do guia é este:
| Neste notebook | No projeto |
|---|---|
A célula do UserSocket lá em cima |
mix phx.gen.socket User, que cria lib/hello_web/channels/user_socket.ex e assets/js/user_socket.js |
A linha socket "/socket", ... na célula do endpoint |
A mesma linha em lib/hello_web/endpoint.ex, como o gerador pede |
A célula do RoomChannel |
lib/hello_web/channels/room_channel.ex, à mão ou com mix phx.gen.channel Room |
O <script> na página inicial |
O conteúdo em assets/js/user_socket.js, com import {Socket} from "phoenix", e a linha import "./user_socket.js" em assets/js/app.js |
O <div id="messages"> e o <input id="chat-input"> |
lib/hello_web/controllers/page_html/home.html.heex |
O login de mentira e o OurAuth |
mix phx.gen.auth (notebook 13); o put_user_token/2 usa conn.assigns.current_scope.user |
window.userToken no root layout |
A mesma linha em lib/hello_web/components/layouts/root.html.heex, acima do app.js |
Trilha.conectar/1, entrar/3, fio/1 |
As ferramentas do navegador: aba Network, filtro WS, e o console do JavaScript |
Com mix phx.server no ar, abra http://localhost:4000 em duas abas. O gerador de channel também cria test/hello_web/channels/room_channel_test.exs e test/support/channel_case.ex, que usam o Phoenix.ChannelTest; rode mix test e leia o teste gerado.
Revisão
- Um channel é uma conversa de longa duração entre um cliente e o servidor, sobre um tópico. Uma conexão carrega vários tópicos, e o servidor mantém um processo por cliente, por tópico.
- As peças: a linha
socketdo endpoint; o socket handler (UserSocket), comconnect/3,id/1e as rotas de channel; o channel (RoomChannel), comjoin/3,handle_in/3ehandle_out/3; e o PubSub, que leva os broadcasts a todos os inscritos, em todos os nós. - No fio, toda mensagem é
[join_ref, ref, topic, event, payload].phx_joinentra,phx_replyresponde,phx_erroravisa que o channel caiu, eheartbeatmantém a conexão viva. join/3autoriza:{:ok, socket},{:ok, resposta, socket}ou{:error, mapa}. Recusar um join não fecha a conexão.handle_in/3trata os eventos do cliente.broadcast!/3manda a todos do tópico, inclusive o remetente;{:reply, ...}responde só a quem pediu.interceptehandle_out/3personalizam ou filtram um evento de saída, uma vez por destinatário.- A autenticação usa um token assinado com
Phoenix.Token: a página o recebe, o JavaScript o manda emauthToken, e oconnect/3o verifica. Comid/1, um broadcast dedisconnectencerra todas as conexões de um usuário. - A entrega é at-most-once. Quem estava offline perde a mensagem, a não ser que a aplicação guarde as mensagens e o cliente peça o que perdeu, com um
last_seen_id.
O próximo notebook, 19_presence.livemd, segue o guia Presence: saber quem está em cada tópico, em tempo real.