Розробка бекенду сайту на Elixir (Phoenix)
Phoenix — фреймворк на Elixir, що будується поверх BEAM (Erlang VM). BEAM спочатку створювався для телекомунікацій з вимогою девяти дев'яток uptime. Це визначає характер платформи: мільйони легких процесів, ізольовані один від одного, вбудовані механізми відновлення після сбоїв, hot code reloading у production.
Де це має сенс
Чати, системи сповіщень реального часу, IoT-бекенди, ігрові сервери, фінансові системи з вимогами до надійності — BEAM тут у своїй стихії. WhatsApp утримував 900 мільйонів користувачів з 50 інженерами, значною мірою завдяки Erlang. Phoenix додає зручний веб-шар з каналами, LiveView та Ecto.
Структура проекту
mix phx.new my_app --no-html --database postgres
cd my_app
mix deps.get
mix ecto.setup
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.AuthPipeline
end
scope "/api/v1", MyAppWeb do
pipe_through :api
resources "/users", UserController, only: [:index, :show, :create, :update, :delete]
resources "/orders", OrderController, except: [:new, :edit]
post "/auth/login", AuthController, :login
post "/auth/refresh", AuthController, :refresh
end
end
Ecto — схеми та запити
# lib/my_app/accounts/user.ex
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :name, :string
field :password, :string, virtual: true
field :password_hash, :string
field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user
has_many :orders, MyApp.Orders.Order
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :password])
|> validate_required([:email, :name, :password])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "invalid format")
|> validate_length(:password, min: 8)
|> unique_constraint(:email)
|> put_password_hash()
end
defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pw}} = cs) do
change(cs, Argon2.add_hash(pw))
end
defp put_password_hash(cs), do: cs
end
# lib/my_app/orders.ex — контекст
defmodule MyApp.Orders do
import Ecto.Query
alias MyApp.{Repo, Orders.Order}
def list_for_user(user_id, opts \\ []) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 25)
Order
|> where(user_id: ^user_id)
|> order_by(desc: :inserted_at)
|> preload(:items)
|> Repo.paginate(page: page, page_size: per_page)
end
def create_order(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(%Order{user_id: user.id}, attrs))
|> Ecto.Multi.run(:payment, fn _repo, %{order: order} ->
MyApp.Payments.charge(order)
end)
|> Repo.transaction()
|> case do
{:ok, %{order: order}} -> {:ok, order}
{:error, :order, changeset, _} -> {:error, changeset}
{:error, :payment, reason, _} -> {:error, reason}
end
end
end
Phoenix Channels — WebSocket реального часу
# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
alias MyApp.Messages
def join("room:" <> room_id, _params, socket) do
if authorized?(socket, room_id) do
{:ok, assign(socket, :room_id, room_id)}
else
{:error, %{reason: "unauthorized"}}
end
end
def handle_in("new_message", %{"body" => body}, socket) do
case Messages.create(socket.assigns.current_user, socket.assigns.room_id, body) do
{:ok, message} ->
broadcast!(socket, "new_message", %{
id: message.id,
body: message.body,
user: message.user.name,
inserted_at: message.inserted_at
})
{:noreply, socket}
{:error, _changeset} ->
{:reply, {:error, %{reason: "invalid message"}}, socket}
end
end
defp authorized?(socket, room_id) do
# перевірка прав користувача на кімнату
MyApp.Rooms.member?(room_id, socket.assigns.current_user.id)
end
end
GenServer для стану
# lib/my_app/rate_limiter.ex
defmodule MyApp.RateLimiter do
use GenServer
@window_ms 60_000
@max_requests 100
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def check(key) do
GenServer.call(__MODULE__, {:check, key})
end
def init(state), do: {:ok, state}
def handle_call({:check, key}, _from, state) do
now = System.monotonic_time(:millisecond)
window_start = now - @window_ms
requests = Map.get(state, key, [])
recent = Enum.filter(requests, &(&1 > window_start))
if length(recent) >= @max_requests do
{:reply, {:error, :rate_limited}, Map.put(state, key, recent)}
else
{:reply, :ok, Map.put(state, key, [now | recent])}
end
end
end
Supervisor tree
# lib/my_app/application.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Repo,
MyAppWeb.Telemetry,
{Phoenix.PubSub, name: MyApp.PubSub},
MyApp.RateLimiter,
{MyApp.Workers.EmailWorker, []},
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
Якщо EmailWorker впаде — Supervisor перезапустить його автоматично. Інші процеси не постраждають.
Деплой через Mix Releases
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
Dockerfile:
FROM elixir:1.16-otp-26 AS builder
WORKDIR /app
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
COPY . .
RUN mix compile
RUN mix release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y openssl libncurses5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/my_app ./
CMD ["bin/my_app", "start"]
Графік розробки
Phoenix швидка в розробці при наявності опиту з Elixir. Якщо команда нова в мові — закладати 2 тижні на освоєння Ecto, паттернів OTP і channels. API зі стандартним набором CRUD + WebSocket + фонові задачі: 3–4 тижні. Високонагружена система з кластеризацією через libcluster і Horde: 5–8 тижнів разом із нагрузочним тестуванням.







