Phoenix изнутри: веб-слой Elixir
В главе Архитектура и продакшн Phoenix упоминался одним абзацем: «основной веб-фреймворк, навязывающий здоровые границы». Этого мало. Phoenix — то, ради чего большинство людей приходит в Elixir, и он устроен принципиально иначе, чем Rails, Django или Spring: у него нет объекта-запроса, который мутируют по дороге. Есть неизменяемая структура %Plug.Conn{} и цепочка чистых функций, каждая из которых возвращает новую версию этой структуры.
Понять Phoenix — значит понять три вещи: Plug (композиция), Endpoint/Router (конфигурация цепочки) и процессную природу соединений (каналы и, в следующей главе, LiveView). Начнём снизу.
Plug: свёртка над соединением
Ментальная модель Phoenix помещается в одну строчку:
conn = Enum.reduce(plugs, initial_conn, fn plug, conn -> plug.(conn) end)
Запрос — это значение. Каждый шаг обработки — функция conn -> conn. Обработка запроса — свёртка (reduce) этого значения через список шагов. Никакой «магии» вокруг: то же самое, что |> из главы Основы языка, только применённое к HTTP.
Plug — спецификация этого шага, и он бывает двух видов.
Function plug — обычная функция арности 2: def put_start(conn, _opts), do: assign(conn, :start, System.monotonic_time()).
Module plug — модуль с двумя колбэками. init/1 вызывается на этапе компиляции (её результат «впекается» в код), call/2 — на каждый запрос:
defmodule MyAppWeb.Plugs.RequireAuth do
@behaviour Plug
import Plug.Conn
@impl true
def init(opts), do: Keyword.get(opts, :redirect_to, "/login")
@impl true
def call(%Plug.Conn{assigns: %{current_user: %{}}} = conn, _redirect), do: conn
def call(conn, redirect) do
conn
|> Phoenix.Controller.put_flash(:error, "Требуется вход")
|> Phoenix.Controller.redirect(to: redirect)
|> halt() # ← критично: остановить цепочку
end
end
Два обязательных к пониманию момента. init/1 — compile-time: дорогие вычисления там бесплатны в рантайме, но «свежие» значения из конфигурации туда класть нельзя — они застынут на момент сборки релиза (читайте конфиг в call/2 через Application.fetch_env!/2). halt/1 не «прерывает» выполнение: он ставит флаг halted: true в структуре, а макрос plug из Plug.Builder генерирует код, который проверяет этот флаг между шагами и не зовёт следующий. Вызовете плаги вручную — halt/1 сам по себе ничего не остановит: это данные, а не return.
(плаги инфраструктуры)"] E --> RT["Router
(pipeline + match)"] RT --> C["Controller / LiveView"] C --> CTX["Контекст
(бизнес-логика)"] CTX --> V["HEEx / JSON"] V --> RESP["HTTP-ответ"] E -.halt: 404 static.-> RESP RT -.halt: не авторизован.-> RESP
Анатомия %Plug.Conn{}
conn — обычный struct. В нём три группы полей: то, что пришло от клиента, то, что мы готовим в ответ, и рабочая «доска объявлений» для нашего кода.
Практические правила: assigns — ваше пространство для данных между плагами; private — пространство фреймворка (не складывайте туда своё, если только не пишете библиотеку); params — объединение path-, query- и body-параметров, доступное только после Plug.Parsers (до него там %Plug.Conn.Unfetched{} — классический источник недоумения). И главное: ответ отправляется ровно один раз, поэтому плаг, который отвечает, обязан вызвать halt/1.
# Типичная работа с conn — всё через пайплайн
conn
|> put_status(:unprocessable_entity)
|> put_resp_content_type("application/json")
|> put_resp_header("x-request-id", request_id)
|> send_resp(422, Jason.encode!(%{errors: errors}))
Поле state описывает жизненный цикл ответа, и из него следуют все правила «кто и когда имеет право отвечать»:
Endpoint: вершина цепочки
Endpoint — это один большой Plug, который собирает инфраструктурные шаги и в конце вызывает роутер. Порядок здесь не косметика, а логика: дешёвое и отсекающее — раньше, дорогое — позже.
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# Сокеты для каналов и LiveView
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
socket "/socket", MyAppWeb.UserSocket, websocket: true, longpoll: false
# Статика — отдаётся до всего остального и сразу halt
plug Plug.Static, at: "/", from: :my_app, gzip: true, only: MyAppWeb.static_paths()
# Идентификатор запроса — чтобы он попал во все логи ниже
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
json_decoder: Phoenix.json_library(),
length: 8_000_000 # лимит размера тела — защита от «толстых» запросов
plug Plug.MethodOverride
plug Plug.Head # HEAD обрабатывается как GET без тела
plug Plug.Session, @session_options
plug MyAppWeb.Router # и только теперь — маршрутизация
end
Plug.Static стоит первым, потому что запрос за иконкой не должен тащить за собой парсинг тела и сессию. Plug.RequestId — до телеметрии, иначе первые события останутся без корреляционного идентификатора. Plug.Parsers с явным length — единственная защита от загрузки гигабайтного тела в память до того, как ваш код вообще получит управление.
Сервер под всем этим — Bandit (по умолчанию в новых проектах) или Cowboy. Оба на BEAM: на каждое соединение — свой процесс. Отсюда следствие, о котором стоит помнить всю главу: падение обработчика одного запроса не задевает остальные, а «изоляция запросов» — не паттерн, который вы внедряете, а свойство рантайма.
Роутер: pipelines и scopes
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery # CSRF-токен
plug :put_secure_browser_headers # CSP, X-Frame-Options и компания
plug MyAppWeb.Plugs.FetchCurrentUser
end
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.VerifyApiToken
end
pipeline :require_auth, do: plug(MyAppWeb.Plugs.RequireAuth)
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
resources "/posts", PostController, only: [:index, :show]
end
scope "/admin", MyAppWeb.Admin, as: :admin do
pipe_through [:browser, :require_auth] # порядок значим
resources "/posts", PostController
end
scope "/api/v1", MyAppWeb.API.V1 do
pipe_through :api
resources "/orders", OrderController, except: [:new, :edit]
end
end
Pipeline — именованная группа плагов. Scope задаёт общий префикс пути и модулей: scope "/admin", MyAppWeb.Admin означает, что PostController — это MyAppWeb.Admin.PostController. resources генерирует семь RESTful-маршрутов, и only:/except: стоит использовать всегда — незадействованный маршрут это лишняя поверхность атаки. mix phx.routes печатает полную таблицу маршрутов; это первое, что стоит запускать при разборе чужого приложения.
Маршрутизация в Phoenix компилируется в множественные клаузы функции с паттерн-матчингом по методу и сегментам пути. Это не перебор регулярок в цикле — сопоставление происходит на уровне байт-кода BEAM, и стоимость почти не зависит от числа маршрутов.
С Phoenix 1.7 путь пишется сигилом ~p, и компилятор проверяет его существование:
~p"/posts/#{post}" # ошибка компиляции, если такого маршрута нет
~p"/api/v1/orders?#{[status: "paid"]}"
Опечатка в URL становится ошибкой сборки, а не 404 в проде. Тот же принцип, что и у проверяемых ссылок в статических генераторах: битая ссылка должна ломать сборку, а не пользователя.
Контроллеры и action_fallback
Контроллер — тонкий: разобрать параметры, позвать контекст, отрендерить. Никакой бизнес-логики.
defmodule MyAppWeb.API.V1.OrderController do
use MyAppWeb, :controller
alias MyApp.Sales
# Единая точка обработки «неудачных» веток всех экшенов
action_fallback MyAppWeb.FallbackController
def index(conn, params) do
orders = Sales.list_orders(conn.assigns.current_user, params)
render(conn, :index, orders: orders)
end
def create(conn, %{"order" => order_params}) do
# with из главы про идиоматику: happy path линеен,
# всё остальное уезжает в FallbackController
with {:ok, order} <- Sales.create_order(conn.assigns.current_user, order_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/v1/orders/#{order}")
|> render(:show, order: order)
end
end
end
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
# Одно место, где решается, во что превращается каждая ошибка домена
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: MyAppWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
def call(conn, {:error, status}) when status in [:not_found, :forbidden] do
conn
|> put_status(status)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:"#{Plug.Conn.Status.code(status)}")
end
end
Это прямое продолжение идиоматики из главы Идиоматика и обработка ошибок: контекст возвращает {:ok, _} | {:error, reason}, with описывает успешный путь, а перевод доменных ошибок в HTTP-коды живёт в одном месте, а не размазан по экшенам.
Контракт на входные параметры
conn.params — это map со строковыми ключами из внешнего мира. Для нетривиального ввода не разбирайте его руками: опишите embedded schema и прогоните через changeset. Получите приведение типов, валидацию и понятные ошибки бесплатно.
defmodule MyAppWeb.API.V1.OrderFilter do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :status, Ecto.Enum, values: [:new, :paid, :shipped]
field :from, :date
field :limit, :integer, default: 50
end
def parse(params) do
%__MODULE__{}
|> cast(params, [:status, :from, :limit])
|> validate_number(:limit, greater_than: 0, less_than_or_equal_to: 200)
|> apply_action(:validate) # {:ok, struct} | {:error, changeset}
end
end
Ecto.Enum сам отобьёт status=deleted, :date разберёт строку в %Date{}, validate_number не даст запросить сто тысяч записей одной страницей, а apply_action/2 вернёт валидированную структуру без похода в базу. Дальше эта структура — единственное, что видит контекст: web-слой не пропускает внутрь «сырые» строки.
HEEx и функциональные компоненты
Начиная с 1.7 «views» исчезли: остались функциональные компоненты — обычные функции, принимающие assigns и возвращающие HEEx.
defmodule MyAppWeb.OrderHTML do
use MyAppWeb, :html
embed_templates "order_html/*"
attr :order, MyApp.Sales.Order, required: true
attr :class, :string, default: nil
slot :actions
def order_card(assigns) do
~H"""
<article class={["card", @class]}>
<h3><%= @order.number %></h3>
<p :if={@order.comment}><%= @order.comment %></p>
<ul><li :for={item <- @order.items}><%= item.title %></li></ul>
<footer><%= render_slot(@actions) %></footer>
</article>
"""
end
end
Что здесь важно. HEEx — не текстовый шаблонизатор, а компилятор HTML: он разбирает разметку на этапе компиляции, ловит незакрытые теги как ошибки сборки и автоматически экранирует интерполяции, так что XSS через <%= @user_input %> невозможен без явного raw/1 (о самой атаке — XSS и CSRF). attr и slot — объявленный контракт компонента: забыли обязательный атрибут или передали лишний, получите предупреждение компилятора; это @spec для разметки. :if и :for — атрибуты-модификаторы вместо блочных конструкций, читается ближе к разметке.
Раскладка файлов на 1.7+: общий словарь UI — в lib/my_app_web/components/core_components.ex (кнопки, инпуты, таблицы, модалки), рендер конкретного ресурса — рядом с его контроллером (controllers/order_html.ex, controllers/order_json.ex), макеты — в components/layouts.ex.
JSON API
Никакого «сериализатора-магии»: JSON-представление — это чистая функция, превращающая структуру в map.
defmodule MyAppWeb.API.V1.OrderJSON do
alias MyApp.Sales.Order
def index(%{orders: orders}), do: %{data: for(o <- orders, do: data(o))}
def show(%{order: order}), do: %{data: data(order)}
defp data(%Order{} = order) do
%{id: order.id, number: order.number, status: order.status,
total: Decimal.to_string(order.total), inserted_at: order.inserted_at}
end
end
Плюс подхода: представление явное. Добавили поле в схему БД — оно не утекло в API само собой; дешёвая защита от случайного раскрытия внутренних полей.
Каналы: WebSocket поверх процессов
Контроллер живёт миллисекунды. Канал — часы. Это долгоживущее двунаправленное соединение, и в Phoenix оно устроено ровно так, как подсказывает BEAM: процесс на соединение плюс процесс на подписку на тему.
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
# Аутентификация происходит ОДИН раз при установке сокета
@impl true
def connect(%{"token" => token}, socket, _connect_info) do
case Phoenix.Token.verify(MyAppWeb.Endpoint, "user socket", token, max_age: 86_400) do
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
{:error, _} -> :error
end
end
def connect(_params, _socket, _info), do: :error
# id/1 позволяет разом отключить все сокеты пользователя при разлогине
@impl true
def id(socket), do: "user_socket:#{socket.assigns.user_id}"
end
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
@impl true
def join("room:" <> room_id, _params, socket) do
if MyApp.Chat.member?(socket.assigns.user_id, room_id) do
# send(self(), :after_join) — тяжёлую работу делаем ПОСЛЕ ответа на join
send(self(), :after_join)
{:ok, assign(socket, :room_id, room_id)}
else
{:error, %{reason: "forbidden"}}
end
end
@impl true
def handle_info(:after_join, socket) do
push(socket, "history", %{messages: MyApp.Chat.recent(socket.assigns.room_id)})
{:noreply, socket}
end
@impl true
def handle_in("new_msg", %{"body" => body}, socket) do
case MyApp.Chat.post_message(socket.assigns.user_id, socket.assigns.room_id, body) do
{:ok, msg} ->
broadcast!(socket, "new_msg", %{id: msg.id, body: msg.body, user_id: msg.user_id})
{:reply, :ok, socket}
{:error, changeset} ->
{:reply, {:error, errors_json(changeset)}, socket}
end
end
end
(процесс соединения) participant C as RoomChannel
(процесс подписки) participant PS as Phoenix.PubSub participant C2 as RoomChannel
другого пользователя B->>S: WebSocket connect + token S->>S: connect/3 — проверка токена B->>S: join "room:42" S->>C: spawn процесса канала C->>PS: subscribe "room:42" C-->>B: {:ok, ответ на join} C->>C: handle_info(:after_join) — история B->>C: "new_msg" C->>PS: broadcast "room:42" PS->>C2: сообщение подписчику PS->>C: сообщение себе C2-->>B: push другим клиентам
Продакшн-детали, о которых спрашивают на ревью:
join/3должен быть быстрым. Пока он выполняется, клиент ждёт. Загрузку истории и обход БД выносите вhandle_info(:after_join, ...).broadcast!/3идёт через Phoenix.PubSub и в кластере доставляется на все ноды автоматически — прямое следствие прозрачностиsendмежду нодами (глава Конкурентность и OTP).- Канал — это GenServer со всеми последствиями: своя очередь сообщений, последовательная обработка, растущий mailbox как симптом перегрузки.
- Отвал соединения — норма, а не авария. JS-клиент переподключается с экспоненциальным backoff; сервер обязан переживать повторный
joinидемпотентно. Сравнение WebSocket с SSE и long-polling — в треке Сети.
Presence: кто сейчас онлайн
Phoenix.Presence — реплицируемое между нодами множество «кто в теме сейчас», построенное на CRDT. Не требует общей БД и переживает netsplit, сходясь после восстановления связи.
defmodule MyAppWeb.Presence do
use Phoenix.Presence, otp_app: :my_app, pubsub_server: MyApp.PubSub
end
# в канале, после join: заявить о себе и отдать клиенту текущий состав
{:ok, _} = MyAppWeb.Presence.track(socket, socket.assigns.user_id, %{online_at: now})
push(socket, "presence_state", MyAppWeb.Presence.list(socket))
Важно понимать границу: Presence даёт eventual consistency. Отличный инструмент для индикатора «онлайн» и списка участников — и плохой там, где расхождение на секунду недопустимо (например, «кто держит блокировку»). Общая теория — в треке Распределённые системы.
Тестирование веб-слоя
Phoenix тестируется без браузера и без поднятого HTTP-сервера: ConnTest прогоняет запрос прямо через цепочку плагов.
defmodule MyAppWeb.API.V1.OrderControllerTest do
use MyAppWeb.ConnCase, async: true
setup %{conn: conn} do
user = MyApp.AccountsFixtures.user_fixture()
{:ok, conn: conn |> log_in_user(user) |> put_req_header("accept", "application/json"), user: user}
end
test "создаёт заказ и возвращает 201", %{conn: conn} do
conn = post(conn, ~p"/api/v1/orders", order: %{items: [%{sku: "A1", qty: 2}]})
assert %{"data" => %{"id" => id}} = json_response(conn, 201)
assert get_resp_header(conn, "location") == ["/api/v1/orders/#{id}"]
end
test "422 при невалидных данных", %{conn: conn} do
conn = post(conn, ~p"/api/v1/orders", order: %{items: []})
assert %{"errors" => %{"items" => _}} = json_response(conn, 422)
end
end
Каналы тестируются так же дёшево — через ChannelCase: subscribe_and_join/3 поднимает процесс канала, push/3 шлёт в него сообщение, а assert_reply и assert_broadcast проверяют ответ и рассылку. Браузер и сеть в этом не участвуют.
async: true работает и здесь — благодаря Ecto.Adapters.SQL.Sandbox каждый тест живёт в своей транзакции. Подробности — в главе Тестирование.
Наблюдаемость и производительность веб-слоя
Phoenix и Ecto испускают :telemetry-события из коробки; как их превращать в метрики, разбиралось в главе Деплой и наблюдаемость. Что смотреть именно в вебе:
| Метрика | О чём говорит |
|---|---|
phoenix.endpoint.stop.duration |
полное время запроса, включая плаги |
phoenix.router_dispatch.stop.duration |
время самого экшена (без парсинга/статики) |
my_app.repo.query.queue_time |
ожидание свободного соединения — насыщение пула БД |
phoenix.channel_joined.duration |
тяжёлый join/3 |
phoenix.socket_connected.count |
сколько живых сокетов держит нода |
Три вещи, которые в вебе на Elixir ломаются чаще всего:
- N+1 запросов. Забытый
preloadпревращает один рендер списка в сотню запросов; ищется по одинаковому SQL в трейсе, лечитсяpreloadили явнымjoin. Как читать план запроса — в треке Базы данных. - Пул соединений к БД меньше конкурентности обработчиков. BEAM с радостью примет 10 000 одновременных запросов; в базу их пойдёт
pool_size, остальные встанут в очередь и словят таймаут. Пул — ваш реальный лимит пропускной способности, и его надо мерить, а не угадывать. - Тяжёлая работа в процессе запроса. Генерация PDF, внешний API, отправка почты — в фон (
Task.Supervisor, Oban), иначе процесс всё это время держит место в пуле БД.
Общая методология оптимизации, профилирование и нагрузочное тестирование — в треке Производительность; здесь важно лишь, что узкое место почти никогда не в самом Phoenix.
Безопасность: что даёт фреймворк, а что на вас
- CSRF закрывает
protect_from_forgeryв:browser-пайплайне; в API-пайплайне он не нужен (там токен). XSS закрывает экранирование HEEx — опасен только явныйraw/1. Mass assignment закрываетcast/3с белым списком полей. - Заголовки —
put_secure_browser_headersставит базовый набор; CSP настраивайте явно под свой фронтенд. - Аутентификация — генератор
mix phx.gen.authдаёт вменяемую реализацию с хешированием паролей, сессионными токенами и подтверждением почты; читать его код полезно, даже если пишете своё. Теория — Аутентификация. - Rate limiting не встроен: берите hammer или отдавайте на реверс-прокси.
Типичные ошибки
- Бизнес-логика в контроллере. Контроллер, который знает про
Repo, — сломанная граница; контекст существует ровно для этого. - Плаг без
halt/1. Сделалredirect, но не остановился — управление уходит дальше, экшен отвечает второй раз,AlreadySentError. - Конфигурация в
init/1module plug. Значение зафиксируется на компиляции и не изменится в релизе. conn.paramsвместо явного контракта. Строки из внешнего мира, разбираемые по месту, — источник и багов, и уязвимостей.- Тяжёлый
join/3в канале. Клиент висит на подключении, а при массовом реконнекте после деплоя вы получаете лавину.
Мини-итог
Phoenix — это не «Rails на Elixir», а очень тонкий слой поверх двух идей: соединение как неизменяемое значение и процесс как единица изоляции. Всё остальное — композиция функций (Plug), компиляция контрактов (роутер, HEEx, ~p) и разделение ответственности (контроллер → контекст → Repo). Если помнить об этом, фреймворк перестаёт быть «магией» и становится инструментом, который легко читать и предсказуемо отлаживать.
Источники
- Phoenix Guides и Plug — основная документация.
- Phoenix.Channel, Phoenix.Presence.
- Книга «Real-Time Phoenix» (Stephen Bussey, PragProg) — каналы, presence и масштабирование реалтайма.
- Bandit — современный HTTP-сервер для Phoenix.
Что дальше
Phoenix LiveView — интерактивный UI, состояние которого живёт на сервере в процессе.