Elixir 是一门运行在 BEAM(Erlang 虚拟机)上的现代函数式编程语言,由 José Valim 于 2012 年创建。它将 Ruby 的优雅语法与 Erlang 的并发容错能力完美融合,并通过宏系统提供了强大的元编程能力。Phoenix 则是 Elixir 生态中最流行的 Web 框架,以高性能和低延迟著称,GitHub 实测单个 Phoenix 节点可支撑 200 万 WebSocket 并发连接。本文将从 Elixir 语言基础出发,逐步深入到 Phoenix 框架的核心特性,最终构建一个具备实时功能的完整 Web 应用。
一、Elixir 语言核心
1.1 为什么选择 Elixir
Elixir 继承了 Erlang 的全部并发和分布式能力,同时在语法和工具链上做了现代化改进:
| 特性 | Elixir | Erlang | 说明 |
|---|---|---|---|
| 语法风格 | Ruby-like | Prolog-like | Elixir 更易学习 |
| 元编程 | 宏系统 + AST | 元解析变换 | Elixir 更灵活 |
| 工具链 | Mix + Hex | Rebar + Erlang.mk | Mix 更现代化 |
| 字符串 | UTF-8 原生 | 二进制列表 | Elixir 处理 Unicode 更方便 |
| 管道操作 | |> 管道 | 无原生管道 | Elixir 代码可读性更高 |
| 文档文化 | @doc + ExDoc | EDoc | Elixir 文档生态更活跃 |
1.2 基础语法速览
# 变量与模式匹配(= 不是赋值,是模式匹配)
x = 42
{x, y} = {10, 20} # x=10, y=20
# 不可变数据结构
list = [1, 2, 3]
new_list = [0 | list] # [0, 1, 2, 3],list 本身未改变
# map 使用 %{} 语法
user = %{name: "Alice", age: 30}
user.name # "Alice"(原子键语法糖)
user[:age] # 30
%{user | age: 31} # 更新键值,返回新 map
# 管道操作:将左侧结果作为右侧第一个参数传递
"hello"
|> String.upcase()
|> String.reverse()
|> String.split("")
# 结果: ["O", "L", "L", "E", "H"]
# 匿名函数
add = fn a, b -> a + b end
add.(2, 3) # 5(注意 . 调用语法)
# 多子句函数(模式匹配驱动)
defmodule Math do
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)
end
# 列表推导式
for x <- 1..10, rem(x, 2) == 0, do: x * x
# [4, 16, 36, 64, 100]
# 进程(与 Erlang 完全一致)
pid = spawn(fn ->
receive do
{:compute, a, b} -> IO.puts(a + b)
end
end)
send(pid, {:compute, 10, 20}) # 输出 30
1.3 元编程与宏
Elixir 的宏系统允许在编译期操作 AST,这是构建 DSL 和控制流抽象的强大工具:
defmodule MyDSL do
# unless 宏实现
defmacro unless(condition, do: block) do
quote do
if(!unquote(condition), do: unquote(block))
end
end
end
# 使用
require MyDSL
MyDSL.unless false do
IO.puts("This will be printed")
end
quote 将代码转为 AST 表示,unquote 在 AST 中注入外部值。这种「代码即数据」的方式是 Lisp 传统的延续。
二、Mix 工具链
Mix 是 Elixir 的构建工具和项目管理器,类似于 Rust 的 Cargo 或 Node.js 的 npm:
# 创建新项目
mix new my_project --sup
# 安装依赖
mix deps.get
# 编译
mix compile
# 运行测试
mix test
# 交互式 shell(IEx)
iex -S mix
# 格式化代码
mix format
# 生成文档
mix docs
# mix.exs 依赖配置
defmodule MyProject.MixProject do
use Mix.Project
def project do
[
app: :my_project,
version: "0.1.0",
elixir: "~> 1.16",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
defp deps do
[
{:phoenix, "~> 1.7"},
{:phoenix_ecto, "~> 4.4"},
{:postgrex, ">= 0.0.0"},
{:jason, "~> 1.4"}
]
end
end
三、Phoenix 框架
3.1 创建 Phoenix 项目
# 安装 Phoenix 安装器
mix archive.install hex phx_new
# 创建新项目(含 Ecto、LiveView、esbuild)
mix phx.new my_app --database postgres --live
cd my_app
mix ecto.create # 创建数据库
mix phx.server # 启动开发服务器
Phoenix 项目结构:
my_app/
├── config/ # 环境配置
├── lib/
│ ├── my_app/ # 业务逻辑(Schema、Context)
│ ├── my_app_web/ # Web 层(Controllers、LiveView、Channels)
│ └── my_app.ex # 应用入口
├── priv/
│ ├── repo/migrations/ # 数据库迁移
│ └── static/ # 静态资源
├── test/
└── mix.exs
3.2 MVC 请求生命周期
请求 → Endpoint → Router → Pipeline → Controller → Context → Schema → Repo → DB
↓ ↓ ↓ ↓
路由匹配 中间件 参数处理 业务逻辑
↓
JSON/HTML 响应
3.3 Controller 与 JSON API
# lib/my_app_web/controllers/post_controller.ex
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
alias MyApp.Blog
def index(conn, _params) do
posts = Blog.list_posts()
render(conn, :index, posts: posts)
end
def show(conn, %{"id" => id}) do
post = Blog.get_post!(id)
render(conn, :show, post: post)
end
def create(conn, %{"post" => post_params}) do
case Blog.create_post(post_params) do
{:ok, post} ->
conn
|> put_status(:created)
|> render(:show, post: post)
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(:error, changeset: changeset)
end
end
end
# lib/my_app_web/controllers/post_json.ex(JSON 序列化)
defmodule MyAppWeb.PostJSON do
alias MyApp.Blog.Post
def index(%{posts: posts}) do
%{data: for(post <- posts, do: data(post))}
end
def show(%{post: post}) do
%{data: data(post)}
end
defp data(%Post{} = post) do
%{
id: post.id,
title: post.title,
content: post.content,
inserted_at: post.inserted_at
}
end
end
四、Ecto 数据库工具
4.1 Schema 与迁移
# lib/my_app/blog/post.ex
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :content, :string
field :published, :boolean, default: false
field :view_count, :integer, default: 0
belongs_to :user, MyApp.Accounts.User
has_many :comments, MyApp.Blog.Comment
timestamps()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content, :published])
|> validate_required([:title, :content])
|> validate_length(:title, min: 1, max: 200)
end
end
# priv/repo/migrations/20240922000001_create_posts.exs
defmodule MyApp.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string, null: false
add :content, :text
add :published, :boolean, default: false
add :view_count, :integer, default: 0
add :user_id, references(:users, on_delete: :delete_all)
timestamps()
end
create index(:posts, [:user_id])
create index(:posts, [:published])
end
end
4.2 Repo 查询
# lib/my_app/blog.ex
defmodule MyApp.Blog do
import Ecto.Query
alias MyApp.Repo
alias MyApp.Blog.Post
def list_posts do
Repo.all(Post)
end
def list_published_posts do
Post
|> where([p], p.published == true)
|> order_by([p], desc: p.inserted_at)
|> Repo.all()
end
def get_post!(id), do: Repo.get!(Post, id)
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
def increment_view_count(post_id) do
Post
|> where(id: ^post_id)
|> update(inc: [view_count: 1])
|> Repo.update_all([])
end
end
五、Phoenix LiveView
LiveView 是 Phoenix 的杀手级特性,它允许通过服务器端渲染实现富交互界面,无需编写 JavaScript:
# lib/my_app_web/live/post_live/index.ex
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
alias MyApp.Blog
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, posts: Blog.list_posts(), search: "")}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
posts = if query == "" do
Blog.list_posts()
else
Blog.search_posts(query)
end
{:noreply, assign(socket, posts: posts, search: query)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(id)
{:ok, _} = Blog.delete_post(post)
{:noreply, assign(socket, posts: Blog.list_posts())}
end
end
<%# lib/my_app_web/live/post_live/index.html.heex %>
<div class="container">
<form phx-change="search">
<input type="text" name="query" value={@search}
placeholder="Search posts..." />
</form>
<div id="posts">
<%= for post <- @posts do %>
<div class="post-card" id={"post-#{post.id}"}>
<h2><%= post.title %></h2>
<p><%= String.slice(post.content, 0, 200) %>...</p>
<button phx-click="delete" phx-value-id={post.id}
data-confirm="Are you sure?">
Delete
</button>
</div>
<% end %>
</div>
</div>
5.1 LiveView 工作原理
用户交互 --> WebSocket --> Phoenix Channel --> LiveView 进程
↓
状态 diff --> MorphDOM --> 浏览器局部更新
LiveView 通过 WebSocket 维持长连接,用户交互触发服务器端事件处理,服务器计算出最小 UI diff 推送回客户端,由 MorphDOM 进行局部 DOM 更新。这种架构避免了前后端状态同步的复杂性,同时保持了接近 SPA 的交互体验。
六、Phoenix Channels 实时通信
Channels 是 Phoenix 的实时双向通信层,基于 WebSocket:
# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
def join("room:" <> room_id, _payload, socket) do
{:ok, assign(socket, :room_id, room_id)}
end
def handle_in("new_message", %{"body" => body}, socket) do
broadcast!(socket, "new_message", %{
body: body,
user_id: socket.assigns.user_id,
inserted_at: NaiveDateTime.utc_now()
})
{:noreply, socket}
end
def handle_in("user_typing", _payload, socket) do
broadcast_from!(socket, "user_typing", %{
user_id: socket.assigns.user_id
})
{:noreply, socket}
end
end
// assets/js/user_socket.js
import {Socket} from "phoenix"
let socket = new Socket("/socket", {
params: {token: window.userToken}
})
socket.connect()
let channel = socket.channel("room:lobby", {})
channel.join()
.receive("ok", resp => console.log("Joined successfully", resp))
.receive("error", resp => console.log("Unable to join", resp))
channel.on("new_message", payload => {
console.log(`[${payload.user_id}] ${payload.body}`)
})
document.querySelector("#msg-input").addEventListener("keypress", e => {
if (e.key === "Enter") {
channel.push("new_message", {body: e.target.value})
e.target.value = ""
}
})
七、部署与生产运维
7.1 编译为 Release
Elixir 使用 Mix Releases 将应用打包为自包含的可执行包:
# 编译生产 release
MIX_ENV=prod mix release
# 运行
_build/prod/rel/my_app/bin/my_app start
# 远程连接(Observer)
_build/prod/rel/my_app/bin/my_app remote
7.2 容器化部署
FROM hexpm/elixir:1.16-erlang-26-alpine-3.19 AS builder
RUN apk add --no-cache build-base git
WORKDIR /app
COPY mix.exs mix.lock ./
RUN mix local.hex --force && mix local.rebar --force
RUN mix deps.get --only prod
COPY . .
RUN MIX_ENV=prod mix release
FROM alpine:3.19
RUN apk add --no-ca-certificates libstdc++ openssl
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/my_app .
EXPOSE 4000
CMD ["bin/my_app", "start"]
7.3 水平扩展
Phoenix 节点可以组成集群共享连接状态:
# config/runtime.exs
config :libcluster,
topologies: [
example: [
strategy: Cluster.Strategy.Kubernetes,
config: [
mode: :dns,
service: "my-app-headless",
application_name: "my_app",
namespace: "default",
polling_interval: 5_000
]
]
]
通过 libcluster 库,Phoenix 节点可以在 Kubernetes 中自动发现和连接,实现 Pub/Sub 跨节点广播。
八、总结
Elixir 和 Phoenix 代表了现代 Web 开发的另一种可能:函数式编程的严谨性、BEAM 虚拟机的并发能力、以及 Ruby 般的开发体验。如果你正在构建需要高并发 WebSocket、实时协作、或物联网消息处理的应用,Elixir/Phoenix 可能是比 Node.js 或 Go 更合适的选择。
Elixir 社区以友好和高质量著称,官方文档详尽,Hex 包管理器生态活跃。从语法糖到 OTP 抽象,从 Ecto 的数据库工具到 LiveView 的前端革命,Elixir 在工程实践的每个层面都提供了深思熟虑的解决方案。掌握 Elixir 不仅意味着获得一门新语言,更是获得了一种全新的系统构建思维方式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。