Elixir Phoenix изнутри: Plug, Endpoint, роутер, контроллеры, HEEx и каналы
0%

Phoenix изнутри: Plug, Endpoint, роутер, контроллеры, HEEx и каналы

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.

Анатомия %Plug.Conn{}

conn — обычный struct. В нём три группы полей: то, что пришло от клиента, то, что мы готовим в ответ, и рабочая «доска объявлений» для нашего кода.

Анатомия структуры Plug.Conn

Практические правила: 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

Продакшн-детали, о которых спрашивают на ревью:

  • 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 ломаются чаще всего:

  1. N+1 запросов. Забытый preload превращает один рендер списка в сотню запросов; ищется по одинаковому SQL в трейсе, лечится preload или явным join. Как читать план запроса — в треке Базы данных.
  2. Пул соединений к БД меньше конкурентности обработчиков. BEAM с радостью примет 10 000 одновременных запросов; в базу их пойдёт pool_size, остальные встанут в очередь и словят таймаут. Пул — ваш реальный лимит пропускной способности, и его надо мерить, а не угадывать.
  3. Тяжёлая работа в процессе запроса. Генерация 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/1 module 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, состояние которого живёт на сервере в процессе.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов