面向萌新的 Phoenix 教程

2026-01-31 2026-01-31
20%
Elixir
Phoenix

一个面向在 mix phx.new 后不知如何编写的萌新的教程。

写在前面

RoR 类型的网络框架1,素来以开发效率高而闻名,因为其提供了大量的预制代码(脚手架)。但是这种「开发效率高」往往面对的是有丰富经验的资深开发者。

但是从新入手的角度来讲,这些动辄数千行的预制代码反而变成了门槛。

所以严格来讲,这个教程是面向和我一样被网上的宣传骗进来的萌新们,旨在从一种构建的角度来对 Phoenix 进行学习。

Prerequisite

虽然是「面向萌新」,但如果想要看懂本文章,还是需要一点点的基础的。主要包括:

  • 对网络开发的基本认识
    • 了解客户端、服务器、请求、响应等基本概念
    • 知道 MVC 的三个字母分别代表了什么
    • 了解 API 请求(网络概念上的)、HTML 以及 CSS/JavaScript 等概念
  • 了解一门编程语言,能够用该语言独自编写简单的程序
    • 操作过该语言的包管理应用
  • 对 Erlang 或 Elixir 的基本了解
    • 要知道 Application 是什么(这是和其他编程语言较大的不同)
    • 了解创建新应用/库的指令、可以添加依赖并且执行应用

以下是加分项:

  • 编写过中型项目(~4000 行及以上)
  • 使用过其他的网络框架(FastAPIExpress

以及最重要的,安装了 Elixir

从单文件 Plug 服务开始

Plug 在 Elixir 生态中的角色相对比较特殊,它不是一个服务器,也不是一个网络框架。它允许开发者可以根据其编写/组织对外部的请求连接进行一系列操作的「插头」来编写自己的网络应用。

简单来说,它「规定」了一种可行的处理网络请求的乐高积木的形状以及组织方式,并且提供了部分预制的积木(和脚手架不同的是你只管用,不需要注意细节)。

这种「协议」与「实现」解耦的方式,在 Elixir 的开发中非常常见。

从静态文件服务开始

为了直观理解,我们来看一段代码。这是一个基于 Plug 和 Bandit(一个 Web 服务器适配器)的微型静态文件服务。

对于没有 Erlang/Elixir/Scala 等语言开发经历的人,看到下面的代码,可能觉得有点奇怪,但是完全可以试着猜一下这个子句函数/模块是干什么的。一般来讲,你所猜到的意思和实际的功能相差不会很大。

来源于 simple_blog_engine 中开发服务器的代码(移除了 SSE 广播、从配置文件中读取等高阶功能)。

defmodule GES233.SimpleServer do
  # 想要运行,需要调用
  #     Bandit.start_link(plug: GES233.SimpleServer)
  # 这个函数。
  use Plug.Builder

  defmodule HTMLLoader do
    use Plug.Builder

    def init(opts), do: opts

    @root_path "priv/generated"

    def call(%Plug.Conn{path_info: path} = conn, _opts) do
      cond do
        "index.html" in path ->
          path = "#{@root_path}/#{Enum.join(path, "/")}"
          html = File.read!(path)

          conn
          |> put_resp_content_type("text/html; charset=utf-8")
          |> resp(200, html)
          |> halt()

        true ->
          conn
      end
    end
  end

  @assets_path "priv/assets/"

  plug Plug.Logger
  plug :redirect_index
  plug HTMLLoader
  plug Plug.Static, at: "/", from: @assets_path
  plug :not_found

  def redirect_index(%Plug.Conn{path_info: path} = conn, _opts) do
    case path |> Enum.filter(&String.contains?(&1, ".")) do
      [] ->
        %{conn | path_info: path ++ ["index.html"]}

      _ ->
        conn
    end
  end

  def not_found(conn, _) do
    send_resp(conn, 404, "not found")
  end
end

注意

这些代码需要设置好静态资源以及网页本体才可以正常运行,也需要在 mix.exs 中添加对应依赖项。

那一串的 plug ... ,其实就是一系列的「插头」:

          REQUEST
             ↓
┌─────────────────────────┐
│       Plug.Logger       │  记录日志  
├─────────────────────────┤
│     :redirect_index     │  无文件后缀
│                         │  → 加 index.html
├─────────────────────────┤
│       HTMLLoader        │  有 index.html
│                         │  → 读取文件
│                         │  → RESPONSE or pass
├─────────────────────────┤
│       Plug.Static       │  处理其他静态文件
│                         │  → RESPONSE or pass
├─────────────────────────┤
│       :not_found        │  兜底
│                         │  → RESPONSE (404)
└─────────────────────────┘

其实有更好的方式,请求先过 Plug.Static ,再把 :redirect_index 以及 HTMLLoader 的功能合二为一,再兜底。

这么写的原因,一是我写这个业务的时候 Elixir 还并不是很好;二是实际的应用有 /sse 来负责重加载界面,请求先过 :sse ,再是以上这些 plugs。

我们可以看到,有一个「线索」或「上下文」,横亘这一串积木之中。这种 input |> foo() |> bar() ... 的形式也是 FP 语言的一种特色。

作为对比,如果你用过 Sanic ,你会发现,数据存在 \(\mathrm{Request} \rightarrow \mathrm{Response}\) 的转换。

# https://github.com/GES233/Chestnut
# chestnut/infra/web/blueprints/plain/index.py
from sanic import Blueprint
from sanic.request import Request
from sanic.response import HTTPResponse


from .render import launch_render as render
from ....helpers.config.page import PageConfig
from ....deps.i18n.language import parseheaders


async def index(request: Request) -> HTTPResponse:
    # Language.
    language = parseheaders(request.headers)

    # Page Info.
    request.ctx.page_config.load_items(**PageConfig.addtitle(role="Index"))

    # Return.
    return await render(request, "launch.html")

但在 Plug 中,这条线索是「同质性」的,名字叫做 %Plug.Conn{} ,是「结构体」(可以粗暴地理解成 Python 的 dataclass ,但是没有类,或者是附带着一些函数的 namedtuple)。

也因此,相比「乐高积木」更恰当的比喻是「透镜」:

  • 透过以及透出的光是同质性的
  • 单个透镜对光进行了处理
  • 一系列透镜可以组成复杂的仪器/设备

但下文还是以「乐高积木」作为主要的比喻方式,因为大家(包括我)对积木更了解些。

框架的目的是降低开发复杂度

本节标题源于此前看了 廖雪峰老师的博文 的思考。

在实际应用环境下,往往需要面对如下情况:

  • 需要处理 /users/1, /posts/114514 这样的动态路由,和存在不可变变量2一样,这里的「动态」是编写程序时无法确定的存在
  • 某个包含表单的界面需要处理 GET 、 POST 等一系列方法
  • 涉及从数据库读取用户信息、鉴权、查看用户的请求、保存到数据库中的复杂请求

如果执意要用 plug 手搓应用,也不是不可以,但是有可能面对编写大量的重复代码、潜在的(编写时很难看到的)错误等情况。

框架的本质,就是大量通用的预制代码,它来处理那些潜在的、繁琐的细节。

Phoenix 做了什么

介绍

项目实操

Talk is cheap, show me the code.

灵感来自于暂停的 GES233/MortalDrinksElixir ,我本来想直接把项目搬过来的,但我到目前好没写好兼容 Elixir 对象的 Scheme 风格 miniKanren 解释器和支持 2D/3D 混合画面的 JS Canvas 渲染引擎。

小准备

嵌入式 Phoenix LiveView 应用

WebUI 本体:

defmodule WebUI do
  defmodule TinyLive do
    use Phoenix.LiveView, layout: {__MODULE__, :live}

    def render("live.html", assigns) do
      ~H"""
      <!DOCTYPE html>
      <html>
        <head>
          <meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
          <title>System Monitor</title>
          <style>
            body { background: #000; color: #0f0; font-family: 'Courier New', monospace; margin: 0; padding: 20px; }
            .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 95vh; }
            .panel { border: 2px solid #333; padding: 10px; overflow: hidden; }
            .active { border-color: #0f0; box-shadow: 0 0 10px #0f0; }
            pre {
              font-family: "CaskaydiaCove Nerd Font Mono", Consolas, "Courier New", monospace;
            }
          </style>
          <script src="https://cdn.jsdelivr.net/npm/phoenix@1.8.1/priv/static/phoenix.min.js"></script>
          <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@1.1.19/priv/static/phoenix_live_view.min.js"></script>
          <script>
            if (!window.liveSocket) {
              let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");

              window.liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {
                params: {_csrf_token: csrfToken}
              });
            }

            window.liveSocket.connect();
            window.liveSocket.enableDebug();
          </script>
      </head>
        <body>
          <%= @inner_content %>
        </body>
      </html>
      """
    end

    def render(assigns) do
      ~H"""
      <div class="grid">
        <div class="panel">
          <h3>// SOURCE_CODE</h3>
          <%= @code_snippet %>
        </div>
        <div class="panel active">
          <h3>// VISUALIZATION</h3>
          <p>Status: <%= @status %></p>
          <p>Tick: <%= @tick %></p>
        </div>
      </div>
      """
    end

    def mount(_params, _session, socket) do
      {:ok, socket}
    end
  end

  defmodule Router do
    use Phoenix.Router
    import Phoenix.LiveView.Router

    pipeline :browser do
      plug :accepts, ["html"]
      plug :fetch_session
      plug :protect_from_forgery
      plug :put_secure_browser_headers
      plug :put_root_layout, {TinyLive, :live}
    end

    scope "/" do
      pipe_through :browser
      live "/", TinyLive
    end
  end

  defmodule Endpoint do
    use Phoenix.Endpoint, otp_app: :web_ui

    socket "/live", Phoenix.LiveView.Socket

    plug Plug.Session,  
      store: :cookie,  
      key: "_web_ui_key",  
      signing_salt: "CUSTARD_PUDDING_VANILLA_ICECREAM_AND_STRAWBERRY_PANCAKE"

    plug WebUI.Interface.Router
  end

  defmodule ErrorHTML do
    def render(template, _assigns) do
      Phoenix.Controller.status_message_from_template(template)
    end
  end
end

应用:

defmodule YourApp.Application do
  @moduledoc false

  use Application

  def start(_start_type, _start_args) do
    Application.put_env(:mord_ex, WebUI.Endpoint,
      http: [ip: {127, 0, 0, 1}, port: 4000],
      server: true,
      live_view: [signing_salt: "pNL)*>._*uxE71Us0ULc"],
      secret_key_base: "7s7EGQuNjDzKANP7jD0KDCpUyuNvwQ612KHXR6X2v7V3ERJq9vi0p5U0rda3RbJB",
      pubsub_server: WebUI.PubSub,
      adapter: Bandit.PhoenixAdapter,
      render_errors: [
        formats: [html: WebUI.ErrorHTML],
        layout: false
      ]
    )

    children = [
      {Phoenix.PubSub, name: WebUI.PubSub},
      WebUI.Endpoint
    ]

    opts = [strategy: :one_for_one, name: YourApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

引入 HTML

前端资源

模块的解耦


  1. 除了 RoR (Ruby on Rails)外,还有 Django 以及本教程的 Phoenix 之类以 CoC(Convention Over Configuration)以及 DRY 为主要指导思想的 MVC 框架。↩︎

  2. 很多人看了 Elixir 教程后,肯定对这个感到很纳闷,所以专门点出来一下。↩︎

——亟待更新——