Elixir 入门与 Phoenix Web 框架实战

从 Ruby 语法风格的现代函数式语言 Elixir 入门,到使用 Phoenix 框架构建实时 Web 应用,掌握 LiveView、Channels 和 Ecto 数据库工具。

Elixir 是一门运行在 BEAM(Erlang 虚拟机)上的现代函数式编程语言,由 José Valim 于 2012 年创建。它将 Ruby 的优雅语法与 Erlang 的并发容错能力完美融合,并通过宏系统提供了强大的元编程能力。Phoenix 则是 Elixir 生态中最流行的 Web 框架,以高性能和低延迟著称,GitHub 实测单个 Phoenix 节点可支撑 200 万 WebSocket 并发连接。本文将从 Elixir 语言基础出发,逐步深入到 Phoenix 框架的核心特性,最终构建一个具备实时功能的完整 Web 应用。

一、Elixir 语言核心

1.1 为什么选择 Elixir

Elixir 继承了 Erlang 的全部并发和分布式能力,同时在语法和工具链上做了现代化改进:

特性ElixirErlang说明
语法风格Ruby-likeProlog-likeElixir 更易学习
元编程宏系统 + AST元解析变换Elixir 更灵活
工具链Mix + HexRebar + Erlang.mkMix 更现代化
字符串UTF-8 原生二进制列表Elixir 处理 Unicode 更方便
管道操作|> 管道无原生管道Elixir 代码可读性更高
文档文化@doc + ExDocEDocElixir 文档生态更活跃

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 不仅意味着获得一门新语言,更是获得了一种全新的系统构建思维方式。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. Erlang/OTP 生产案例与性能调优
  2. Erlang 分布式编程:节点互联与集群部署
  3. Erlang OTP 框架:构建工业级并发应用