Phoenix — первая программа
Дальше: Простые приложения на Elixir · Akka — основы (Scala) · Elixir — о разделе
Phoenix — первая программа
Phoenix — веб-фреймворк на Elixir для HTTP, WebSocket и LiveView (интерактив UI без тяжёлого SPA). Он опирается на Plug (composable HTTP) и Ecto (БД), а под капотом — миллионы лёгких процессов BEAM (виртуальная машина Erlang).
Практикум идёт по шагам — установка, mix phx.new, router и controller, HEEx, JSON API "Заметки", LiveView-счётчик, Ecto, тесты и production release. Параллель на JVM — Play Framework; акторы — Akka.
| Шаг | Тема | Зачем |
|---|---|---|
| 1 | Erlang, Elixir, Phoenix | Проверить среду |
| 2 | mix phx.new | Каркас OTP-приложения |
| 3 | Router + pipeline | Маршрутизация и middleware |
| 4 | Controller + HEEx | HTML-ответ |
| 5 | JSON API | REST "Заметки" |
| 6 | LiveView | Realtime без SPA |
| 7 | Тесты и release | Закрепить и деплой |
| Материал | Зачем |
|---|---|
| Первая программа на Elixir | mix, iex |
| Основы языка | pattern matching, pipe |
| BEAM и процессы | OTP под Phoenix |
| Plug в простых приложениях | HTTP до фреймворка |
| Play на Scala | Тот же сценарий REST на JVM |
Навигация
- Вы здесь: Phoenix — первая программа
- Раздел: Elixir — о разделе
- JVM-стек: Play, Akka, Spark
Команды — во вкладке Терминал VS Code. Отладка — статья про отладку.
Термины перед стартом
| Термин | Кратко |
|---|---|
| BEAM | Виртуальная машина Erlang: процессы, preemptive scheduling, fault tolerance |
| OTP | Open Telecom Platform — behaviours (GenServer, Supervisor) поверх BEAM |
| Plug | Спецификация composable HTTP middleware, как Rack/Wsgi |
| Endpoint | Точка входа Phoenix: acceptors, parser, session |
| HEEx | HTML + Elixir-шаблоны с {@assigns} и XSS-экранированием |
| LiveView | UI на сервере; клиент получает diff по WebSocket |
| Ecto | Data mapper и query DSL для PostgreSQL и др. |
| mix | Сборщик Elixir: deps, compile, test, release |
Шаг 1 — проверка установки
| Компонент | Проверка |
|---|---|
| Erlang/OTP | erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell |
| Elixir 1.14+ | elixir -v |
| Phoenix 1.7+ | mix phx.new --version |
| PostgreSQL (опционально) | для полного шаблона с Ecto |
Установка генератора Phoenix:
mix archive.install hex phx_new
Разбор:
- Elixir работает на BEAM — без Erlang/OTP Elixir не запустится.
mix phx.newсоздаёт проект только если установлен archivephx_new.
Шаг 2 — создание проекта
mix phx.new hello_phoenix
cd hello_phoenix
mix deps.get
mix ecto.create
mix phx.server
На вопросы:
--app— оставьтеhello_phoenix;- Ecto + Postgres —
Yдля полного шаблона (илиnдля API-only).
Откройте http://localhost:4000 — welcome-страница Phoenix.
Структура (упрощённо):
hello_phoenix/
├── lib/
│ ├── hello_phoenix_web/
│ │ ├── controllers/
│ │ ├── router.ex
│ │ └── endpoint.ex
│ └── hello_phoenix/
├── config/
├── assets/
└── mix.exs
Остановка: Ctrl+C дважды или Ctrl+\\.
Шаг 3 — Router и pipeline
# lib/hello_phoenix_web/router.ex
defmodule HelloPhoenixWeb.Router do
use HelloPhoenixWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloPhoenixWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloPhoenixWeb do
pipe_through :browser
get "/", PageController, :home
get "/hello/:name", HelloController, :show
end
scope "/api", HelloPhoenixWeb do
pipe_through :api
get "/health", HealthController, :index
end
end
Разбор:
- Pipeline — цепочка Plug-middleware;
:browserвключает CSRF и session. - scope — группирует маршруты с общим pipeline.
get "/hello/:name"— path-параметр попадёт в%{"name" => name}.
Шаг 4 — Controller и HEEx
# lib/hello_phoenix_web/controllers/hello_controller.ex
defmodule HelloPhoenixWeb.HelloController do
use HelloPhoenixWeb, :controller
def show(conn, %{"name" => name}) do
render(conn, :show, name: name)
end
end
Шаблон lib/hello_phoenix_web/controllers/hello_html/show.html.heex:
<h1>Hello, {@name}!</h1>
<p><a href="/">На главную</a></p>
HEEx — HTML + Elixir с {@assigns}; экранирование XSS по умолчанию.
Health controller:
defmodule HelloPhoenixWeb.HealthController do
use HelloPhoenixWeb, :controller
def index(conn, _params) do
json(conn, %{status: "ok", service: "hello_phoenix", time: DateTime.utc_now()})
end
end
curl http://localhost:4000/api/health
Шаг 5 — REST API "Заметки"
Тот же учебный сценарий, что в Play и Node.js: заметки в Agent (in-memory).
Хранилище
# lib/hello_phoenix/notes/store.ex
defmodule HelloPhoenix.Notes.Store do
use Agent
def start_link(_opts) do
Agent.start_link(fn -> {1, []} end, name: __MODULE__)
end
def list do
Agent.get(__MODULE__, fn {_id, notes} -> Enum.reverse(notes) end)
end
def create(text) do
Agent.get_and_update(__MODULE__, fn {id, notes} ->
note = %{id: id, text: text}
{note, {id + 1, [note | notes]}}
end)
end
def delete(id) do
Agent.get_and_update(__MODULE__, fn {next_id, notes} ->
case Enum.split_with(notes, &(&1.id == id)) do
{[], _} -> {:error, {next_id, notes}}
{[note], rest} -> {:ok, {next_id, rest}}
end
end)
end
end
Добавьте в supervision tree lib/hello_phoenix/application.ex:
children = [
HelloPhoenixWeb.Telemetry,
HelloPhoenix.Notes.Store,
{DNSCluster, query: Application.get_env(:hello_phoenix, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: HelloPhoenix.PubSub},
HelloPhoenixWeb.Endpoint
]
Контроллер
defmodule HelloPhoenixWeb.NotesController do
use HelloPhoenixWeb, :controller
alias HelloPhoenix.Notes.Store
def index(conn, _params) do
json(conn, Store.list())
end
def create(conn, %{"text" => text}) when is_binary(text) do
trimmed = String.trim(text)
if trimmed == "" do
conn
|> put_status(:bad_request)
|> json(%{error: "text is required"})
else
note = Store.create(trimmed)
conn
|> put_status(:created)
|> json(note)
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "text is required"})
end
def delete(conn, %{"id" => id}) do
case Store.delete(String.to_integer(id)) do
:ok ->
send_resp(conn, :no_content, "")
:error ->
conn
|> put_status(:not_found)
|> json(%{error: "not found"})
end
end
end
Маршруты
scope "/api", HelloPhoenixWeb do
pipe_through :api
get "/health", HealthController, :index
get "/notes", NotesController, :index
post "/notes", NotesController, :create
delete "/notes/:id", NotesController, :delete
end
Проверка curl
curl http://localhost:4000/api/notes
curl -X POST http://localhost:4000/api/notes \
-H "Content-Type: application/json" \
-d '{"text": "Изучить Phoenix"}'
curl http://localhost:4000/api/notes
curl -X DELETE http://localhost:4000/api/notes/1
| Метод | Путь | Успех | Ошибка |
|---|---|---|---|
| GET | /api/notes | 200 + массив | — |
| POST | /api/notes | 201 + объект | 400 при пустом text |
| DELETE | /api/notes/:id | 204 | 404 |
Шаг 6 — LiveView-счётчик
Добавьте маршрут в router.ex:
live "/counter", CounterLive, :index
# lib/hello_phoenix_web/live/counter_live.ex
defmodule HelloPhoenixWeb.CounterLive do
use HelloPhoenixWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def handle_event("dec", _params, socket) do
{:noreply, update(socket, :count, &(&1 - 1))}
end
def render(assigns) do
~H"""
<h1>Счётчик: {@count}</h1>
<button phx-click="inc">+1</button>
<button phx-click="dec">-1</button>
"""
end
end
LiveView держит состояние на сервере в процессе BEAM; клиент получает diff по WebSocket — удобно для форм и дашбордов без React.
Генератор CRUD:
mix phx.gen.live Catalog Product products name:string price:decimal
mix ecto.migrate
Шаг 7 — Ecto и PostgreSQL
mix phx.gen.schema Catalog.Product products name:string price:decimal
mix ecto.migrate
Ecto — data mapper и query DSL:
alias HelloPhoenix.Repo
import Ecto.Query
Repo.all(from p in Product, where: p.price > 100)
Схема БД — основы SQL. Для заметок в production замените Agent на Note schema + Repo.
Endpoint и жизненный цикл запроса
HTTP → Endpoint → Router → Controller → View/JSON
↘ LiveView (WebSocket)
endpoint.ex — точка входа OTP-приложения; supervises acceptor-процессы. Аналог цепочки фильтров в Play.
Конфигурация
# config/dev.exs
config :hello_phoenix, HelloPhoenixWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
secret_key_base: "..."
config :hello_phoenix, HelloPhoenix.Repo,
database: "hello_phoenix_dev",
username: "postgres",
password: "postgres",
hostname: "localhost"
Секреты в production — через System.get_env/1, см. конфигурации.
Тесты
# test/hello_phoenix_web/controllers/page_controller_test.exs
defmodule HelloPhoenixWeb.PageControllerTest do
use HelloPhoenixWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind"
end
end
# test/hello_phoenix_web/controllers/notes_controller_test.exs
defmodule HelloPhoenixWeb.NotesControllerTest do
use HelloPhoenixWeb.ConnCase
test "POST /api/notes rejects empty text", %{conn: conn} do
conn =
post(conn, ~p"/api/notes", %{"text" => ""})
assert json_response(conn, 400)["error"] == "text is required"
end
end
mix test
mix format
Частые ошибки
| Симптом | Причина | Что сделать |
|---|---|---|
could not compile router | Неверные имена controller/action | Сверить модуль и функцию |
| Ecto connection refused | Postgres не запущен | mix ecto.create |
| CSRF 403 на POST | Browser pipeline | API через :api без CSRF |
| Port 4000 busy | Другой процесс | $env:PORT=4001; mix phx.server (PowerShell) |
| Agent not started | Нет в children | Добавить в Application.start/2 |
| LiveView не обновляется | Нет JS bundle | cd assets && npm install |
Сравнение Phoenix, Play и Rails
| Phoenix | Play (Scala) | Rails (Ruby) | |
|---|---|---|---|
| Concurrency | BEAM processes | JVM threads | Puma processes |
| Realtime | LiveView, Channels | WebSocket add-ons | Action Cable |
| Типизация | Dynamic + @spec | Static | Dynamic |
| HTTP слой | Plug pipelines | conf/routes | routes.rb |
Сравнение акторной модели JVM — Akka. Big data рядом со стеком — Spark.
Production и release
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix release
_build/prod/rel/hello_phoenix/bin/hello_phoenix start
| Практика | Зачем |
|---|---|
mix release | Самодостаточный tarball с ERTS |
| Reverse proxy | HTTPS, static assets |
| Telemetry + LiveDashboard | Метрики процессов |
| Oban | Фоновые задачи |
| Rolling deploy | Hot code upgrade на BEAM (осторожно) |
Agent и in-memory store — только для учёбы. Используйте PostgreSQL, пул Repo, секреты из env. Масштабирование — несколько nodes + PubSub cluster.
Базовый разбор HTTP — HTTP как основа веб-интеграций.
Упражнения
- Добавьте
GET /api/notes/:idс404. - Перенесите Store на Ecto schema
Note. - Напишите Channel для broadcast новых заметок подписчикам.
- Сравните свой JSON API с Play REST — статусы и тела.
- Прочитайте Akka supervision и найдите аналог Supervisor в OTP.
FAQ
Phoenix без PostgreSQL?
Да, mix phx.new my_app --no-ecto — API-only или in-memory как в практикуме.
LiveView заменяет React? Для admin, форм, дашбордов часто да; для тяжёлого offline-first SPA — нет.
Чем Plug отличается от Express middleware?
Идея та же: (conn, opts) -> conn. Plug — контракт на BEAM.
Phoenix и Play для REST — что выбрать? Оба подходят. Phoenix сильнее в WebSocket/LiveView; Play — в типобезопасности Twirl/JSON на JVM.
План эволюции учебного API
- Agent store для отладки маршрутов.
- Ecto + migrations для
notes. - Context-модуль
Notes(business logic вне controller). - Тесты ConnCase + LiveViewTest.
- Release, env secrets, Fly.io/K8s deploy.
Context-модуль — слой бизнес-логики
Phoenix рекомендует выносить логику из controller в context:
# lib/hello_phoenix/notes.ex
defmodule HelloPhoenix.Notes do
alias HelloPhoenix.Notes.Store
def list_notes, do: Store.list()
def create_note(text) when is_binary(text) do
trimmed = String.trim(text)
if trimmed == "" do
{:error, :empty_text}
else
{:ok, Store.create(trimmed)}
end
end
def delete_note(id) do
case Store.delete(id) do
:ok -> :ok
:error -> {:error, :not_found}
end
end
end
Controller становится тонким:
def create(conn, %{"text" => text}) do
case HelloPhoenix.Notes.create_note(text) do
{:ok, note} ->
conn |> put_status(:created) |> json(note)
{:error, :empty_text} ->
conn |> put_status(:bad_request) |> json(%{error: "text is required"})
end
end
Так же устроены mix phx.gen.* контексты — Accounts, Catalog и т.д.
Phoenix Channels — pub/sub
Минимальный channel для broadcast новых заметок:
# lib/hello_phoenix_web/channels/notes_channel.ex
defmodule HelloPhoenixWeb.NotesChannel do
use HelloPhoenixWeb, :channel
def join("notes:lobby", _payload, socket) do
{:ok, socket}
end
def handle_in("ping", _payload, socket) do
{:reply, {:ok, %{pong: true}}, socket}
end
end
# router.ex — внутри scope
socket "/socket", HelloPhoenixWeb.UserSocket,
websocket: true,
longpoll: false
channel "notes:*", HelloPhoenixWeb.NotesChannel
После Store.create в context:
HelloPhoenixWeb.Endpoint.broadcast("notes:lobby", "new_note", note)
Клиент JavaScript (assets) подписывается через Phoenix Socket — realtime без polling. Сравните с WebSocket add-ons в Play.
OTP Supervision под капотом Phoenix
# lib/hello_phoenix/application.ex (фрагмент)
def start(_type, _args) do
children = [
HelloPhoenixWeb.Telemetry,
HelloPhoenix.Notes.Store,
{Phoenix.PubSub, name: HelloPhoenix.PubSub},
HelloPhoenixWeb.Endpoint
]
opts = [strategy: :one_for_one, name: HelloPhoenix.Supervisor]
Supervisor.start_link(children, opts)
end
:one_for_one— упавший child перезапускается отдельно.- Endpoint — supervisor acceptor-процессов Cowboy/Bandit.
- Аналог дерева — Akka supervision.
LiveViewTest
defmodule HelloPhoenixWeb.CounterLiveTest do
use HelloPhoenixWeb.ConnCase
import Phoenix.LiveViewTest
test "increment counter", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/counter")
assert html =~ "Счётчик: 0"
assert view |> element("button", "+1") |> render_click()
assert render(view) =~ "Счётчик: 1"
end
end
render_click симулирует phx-click без браузера.
Деплой release — пошагово
# 1. Секреты
export SECRET_KEY_BASE=$(mix phx.gen.secret)
export DATABASE_URL=ecto://user:pass@host/hello_phoenix_prod
# 2. Сборка
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix ecto.migrate
MIX_ENV=prod mix release
# 3. Запуск
_build/prod/rel/hello_phoenix/bin/hello_phoenix start
| Переменная | Назначение |
|---|---|
SECRET_KEY_BASE | Подпись session/cookie |
DATABASE_URL | Ecto Repo |
PHX_HOST | Публичный hostname |
PORT | Порт HTTP (8080 за proxy) |
Fly.io, Gigalixir и Render используют тот же mix release artifact.
Сравнение процессов BEAM и акторов JVM
| Свойство | BEAM process | Akka actor |
|---|---|---|
| Создание | Микросекунды | Дешевле потока, дороже BEAM |
| Память | Килобайты | Объект + mailbox на heap |
| Scheduler | Preemptive per process | Dispatcher thread pool |
| Изоляция | Полная heap isolation | Логическая |
Оба подхода — сообщения вместо locks — см. Elixir intro и Akka.
Расширенный FAQ
Bandit или Cowboy? Phoenix 1.7+ поддерживает Bandit как HTTP server — быстрее на некоторых бенчмарках.
Нужен ли Redis? PubSub на одном node — в памяти; cluster PubSub — PG2 или Redis adapter.
Как масштабировать LiveView? Sticky sessions или distributed PubSub; state живёт в process на конкретном node.
IEx в production? Только через remote console с защитой; не оставляйте открытый shell.
Связанные материалы
| Тема | Материал |
|---|---|
| BEAM и OTP | Elixir — о разделе, архитектура BEAM |
| Play на JVM | Play Framework |
| Akka | Akka — основы |
| Spark | Apache Spark на Scala |
| Scala раздел | Scala — о разделе |
| HTTP | HTTP как основа веб-интеграций |
Практикум — шаг 8: Ecto schema для заметок
defmodule HelloPhoenix.Notes.Note do
use Ecto.Schema
import Ecto.Changeset
schema "notes" do
field :text, :string
timestamps()
end
def changeset(note, attrs) do
note
|> cast(attrs, [:text])
|> validate_required([:text])
|> validate_length(:text, min: 1, max: 500)
end
end
mix phx.gen.migration create_notes
mix ecto.migrate
Практикум — шаг 9: Context с Repo
defmodule HelloPhoenix.Notes do
alias HelloPhoenix.Repo
alias HelloPhoenix.Notes.Note
def list, do: Repo.all(Note)
def create(attrs) do
%Note{} |> Note.changeset(attrs) |> Repo.insert()
end
end
Практикум — шаг 10: LiveView форма заметок
def handle_event("save", %{"note" => attrs}, socket) do
case Notes.create(attrs) do
{:ok, _} -> {:noreply, push_navigate(socket, to: ~p"/notes")}
{:error, cs} -> {:noreply, assign(socket, form: to_form(cs))}
end
end
Практикум — шаг 11: Oban фоновые задачи
# mix phx.gen.schema не нужен — добавьте в config:
# config :hello_phoenix, Oban, repo: Repo, queues: [default: 10]
Фоновая отправка email после create — worker, не controller.
Воркшоп Phoenix (60 мин)
| Мин | Задача |
|---|---|
| 0–10 | mix phx.new |
| 10–20 | health + router |
| 20–35 | Agent notes API |
| 35–45 | curl тесты |
| 45–55 | LiveView counter |
| 55–60 | mix test |
Troubleshooting Phoenix
| Симптом | Решение |
|---|---|
| Ecto refused | postgres up, ecto.create |
| CSRF 403 | :api pipeline |
| Agent crash | в children |
| LiveView static | assets npm |
FAQ Phoenix
Без Postgres? — mix phx.new --no-ecto.
LiveView vs React? — формы, admin; не offline SPA.
Bandit vs Cowboy? — HTTP server 1.7+.
Масштаб LiveView? — PubSub cluster.
IEx prod? — remote console с защитой.
Дополнительные сценарии (Phoenix)
Сценарий A: mix phx.new без Ecto
mix phx.new api_only --no-ecto
API-only для чистого JSON.
Сценарий B: Agent в IEx
iex -S mix
HelloPhoenix.Notes.Store.create("from iex")
HelloPhoenix.Notes.Store.list()
Сценарий C: LiveView без браузера
LiveViewTest — render_click на кнопку +1.
Сценарий D: release локально
MIX_ENV=prod mix release
_build/prod/rel/hello_phoenix/bin/hello_phoenix start
Сценарий E: сравнение с Play
Таблица маршрутов, JSON статусов, тестов — Play 211.
Упражнения — контрольная точка
- Ecto schema Note вместо Agent.
- Channel broadcast при create.
- Context без логики в controller.
- ConnCase на GET /api/notes/:id.
- Oban worker — имитация email после create.