Перейти к основному содержимому

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.

ШагТемаЗачем
1Erlang, Elixir, PhoenixПроверить среду
2mix phx.newКаркас OTP-приложения
3Router + pipelineМаршрутизация и middleware
4Controller + HEExHTML-ответ
5JSON APIREST "Заметки"
6LiveViewRealtime без SPA
7Тесты и releaseЗакрепить и деплой
МатериалЗачем
Первая программа на Elixirmix, iex
Основы языкаpattern matching, pipe
BEAM и процессыOTP под Phoenix
Plug в простых приложенияхHTTP до фреймворка
Play на ScalaТот же сценарий REST на JVM

Навигация

Редактор и терминал

Команды — во вкладке Терминал VS Code. Отладка — статья про отладку.


Термины перед стартом

ТерминКратко
BEAMВиртуальная машина Erlang: процессы, preemptive scheduling, fault tolerance
OTPOpen Telecom Platform — behaviours (GenServer, Supervisor) поверх BEAM
PlugСпецификация composable HTTP middleware, как Rack/Wsgi
EndpointТочка входа Phoenix: acceptors, parser, session
HEExHTML + Elixir-шаблоны с {@assigns} и XSS-экранированием
LiveViewUI на сервере; клиент получает diff по WebSocket
EctoData mapper и query DSL для PostgreSQL и др.
mixСборщик Elixir: deps, compile, test, release

Шаг 1 — проверка установки

КомпонентПроверка
Erlang/OTPerl -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 создаёт проект только если установлен archive phx_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/notes200 + массив
POST/api/notes201 + объект400 при пустом text
DELETE/api/notes/:id204404

Шаг 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 refusedPostgres не запущенmix ecto.create
CSRF 403 на POSTBrowser pipelineAPI через :api без CSRF
Port 4000 busyДругой процесс$env:PORT=4001; mix phx.server (PowerShell)
Agent not startedНет в childrenДобавить в Application.start/2
LiveView не обновляетсяНет JS bundlecd assets && npm install

Сравнение Phoenix, Play и Rails

PhoenixPlay (Scala)Rails (Ruby)
ConcurrencyBEAM processesJVM threadsPuma processes
RealtimeLiveView, ChannelsWebSocket add-onsAction Cable
ТипизацияDynamic + @specStaticDynamic
HTTP слойPlug pipelinesconf/routesroutes.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 proxyHTTPS, static assets
Telemetry + LiveDashboardМетрики процессов
ObanФоновые задачи
Rolling deployHot code upgrade на BEAM (осторожно)
Production

Agent и in-memory store — только для учёбы. Используйте PostgreSQL, пул Repo, секреты из env. Масштабирование — несколько nodes + PubSub cluster.

HTTP теория

Базовый разбор HTTP — HTTP как основа веб-интеграций.


Упражнения

  1. Добавьте GET /api/notes/:id с 404.
  2. Перенесите Store на Ecto schema Note.
  3. Напишите Channel для broadcast новых заметок подписчикам.
  4. Сравните свой JSON API с Play REST — статусы и тела.
  5. Прочитайте 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

  1. Agent store для отладки маршрутов.
  2. Ecto + migrations для notes.
  3. Context-модуль Notes (business logic вне controller).
  4. Тесты ConnCase + LiveViewTest.
  5. 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_URLEcto Repo
PHX_HOSTПубличный hostname
PORTПорт HTTP (8080 за proxy)

Fly.io, Gigalixir и Render используют тот же mix release artifact.


Сравнение процессов BEAM и акторов JVM

СвойствоBEAM processAkka actor
СозданиеМикросекундыДешевле потока, дороже BEAM
ПамятьКилобайтыОбъект + mailbox на heap
SchedulerPreemptive per processDispatcher 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 и OTPElixir — о разделе, архитектура BEAM
Play на JVMPlay Framework
AkkaAkka — основы
SparkApache Spark на Scala
Scala разделScala — о разделе
HTTPHTTP как основа веб-интеграций
Следующий шаг

Изучите Channels и Oban. Для сравнения JVM-стека — Play и Akka.



Практикум — шаг 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–10mix phx.new
10–20health + router
20–35Agent notes API
35–45curl тесты
45–55LiveView counter
55–60mix test

Troubleshooting Phoenix

СимптомРешение
Ecto refusedpostgres up, ecto.create
CSRF 403:api pipeline
Agent crashв children
LiveView staticassets 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 без браузера

LiveViewTestrender_click на кнопку +1.

Сценарий D: release локально

MIX_ENV=prod mix release
_build/prod/rel/hello_phoenix/bin/hello_phoenix start

Сценарий E: сравнение с Play

Таблица маршрутов, JSON статусов, тестов — Play 211.


Упражнения — контрольная точка

  1. Ecto schema Note вместо Agent.
  2. Channel broadcast при create.
  3. Context без логики в controller.
  4. ConnCase на GET /api/notes/:id.
  5. Oban worker — имитация email после create.
Содержание