Chapter 06

Phoenix 框架基础

Elixir 的 Web 框架——从零创建项目到完整 RESTful API,感受 Phoenix 的生产力与性能

创建 Phoenix 项目

# 安装 Phoenix 生成器
mix archive.install hex phx_new

# 创建新项目(含 Ecto 数据库 + LiveView)
mix phx.new blog --database postgres

# 只创建 API 项目(无 HTML 模板)
mix phx.new blog_api --no-html --no-assets

# 项目结构
# blog/
# ├── lib/
# │   ├── blog/           — 业务逻辑(Context 层)
# │   └── blog_web/       — Web 层(Router/Controller/View)
# │       ├── router.ex
# │       ├── endpoint.ex
# │       └── controllers/
# ├── priv/repo/migrations/ — 数据库迁移文件
# ├── config/             — 环境配置
# └── mix.exs

# 配置数据库(config/dev.exs)并初始化
mix ecto.create
mix ecto.migrate

# 启动开发服务器
mix phx.server
# 访问 http://localhost:4000

Router:路由定义

# lib/blog_web/router.ex
defmodule BlogWeb.Router do
  use BlogWeb, :router

  # Plug 管道:定义一组中间件
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :put_root_layout, html: {BlogWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug :fetch_session
  end

  # API 路由(通过 :api 管道)
  scope "/api", BlogWeb do
    pipe_through :api

    resources "/posts", PostController, except: [:new, :edit]
    # 自动生成:
    # GET    /api/posts          index
    # POST   /api/posts          create
    # GET    /api/posts/:id       show
    # PUT    /api/posts/:id       update
    # DELETE /api/posts/:id       delete
  end

  # 认证路由组
  scope "/api/admin", BlogWeb do
    pipe_through [:api, :require_auth]
    get "/stats", AdminController, :stats
  end
end

Controller:请求处理

# lib/blog_web/controllers/post_controller.ex
defmodule BlogWeb.PostController do
  use BlogWeb, :controller

  alias Blog.Posts

  def index(conn, params) do
    page  = Map.get(params, "page", "1") |> String.to_integer()
    posts = Posts.list_posts(page: page, per_page: 20)
    render(conn, :index, posts: posts)
  end

  def show(conn, %{"id" => id}) do
    case Posts.get_post(id) do
      nil  -> send_resp(conn, 404, "Not found")
      post -> render(conn, :show, post: post)
    end
  end

  def create(conn, %{"post" => post_params}) do
    case Posts.create_post(post_params) do
      {:ok, post} ->
        conn
        |> put_status(:created)
        |> put_resp_header("location", "/api/posts/#{post.id}")
        |> render(:show, post: post)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(:error, changeset: changeset)
    end
  end
end

Plug:中间件管道

Plug 是 Phoenix 的中间件规范,类似 Rack(Ruby)或 Express 的中间件。整个请求处理就是一条 Plug 管道:

# 自定义 Plug:JWT 认证
defmodule BlogWeb.Plugs.RequireAuth do
  import Plug.Conn
  import Phoenix.Controller

  def init(opts), do: opts

  def call(conn, _opts) do
    case get_req_header(conn, "authorization") do
      ["Bearer " <> token] ->
        case Blog.Auth.verify_token(token) do
          {:ok, user_id} ->
            assign(conn, :current_user_id, user_id)
          {:error, _} ->
            conn |> send_resp(401, "Unauthorized") |> halt()
        end
      _ ->
        conn |> send_resp(401, "Missing token") |> halt()
    end
  end
end

Ecto:数据库层

Schema 与 Changeset

# lib/blog/posts/post.ex
defmodule Blog.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title,      :string
    field :body,       :text
    field :published,  :boolean, default: false
    field :view_count, :integer, default: 0

    belongs_to :author, Blog.Accounts.User
    has_many   :comments, Blog.Posts.Comment

    timestamps()   # 自动添加 inserted_at, updated_at
  end

  @doc "创建/更新 changeset,含验证"
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :body, :published])
    |> validate_required([:title, :body])
    |> validate_length(:title, min: 5, max: 200)
    |> validate_length(:body, min: 10)
  end
end

数据库迁移

# 生成迁移文件
mix ecto.gen.migration create_posts

# priv/repo/migrations/20240101_create_posts.exs
defmodule Blog.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    create table(:posts) do
      add :title,      :string,  null: false
      add :body,       :text,    null: false
      add :published,  :boolean, default: false
      add :view_count, :integer, default: 0
      add :author_id,  references(:users, on_delete: :delete_all)
      timestamps()
    end

    create index(:posts, [:author_id])
    create index(:posts, [:published, :inserted_at])
  end
end

mix ecto.migrate

Context 层:业务逻辑

# lib/blog/posts.ex(Context 模块)
defmodule Blog.Posts do
  import Ecto.Query
  alias Blog.Repo
  alias Blog.Posts.Post

  def list_posts(opts \\ []) do
    page     = Keyword.get(opts, :page, 1)
    per_page = Keyword.get(opts, :per_page, 20)

    Post
    |> where(published: true)
    |> order_by([desc: :inserted_at])
    |> limit(^per_page)
    |> offset(^((page - 1) * per_page))
    |> preload(:author)
    |> 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 update_post(post, attrs) do
    post
    |> Post.changeset(attrs)
    |> Repo.update()
  end

  def delete_post(post), do: Repo.delete(post)
end

本章小结:Phoenix 的 MVC 架构清晰分离关注点:Router 定义 URL 到 Controller 的映射;Plug 管道处理中间件;Controller 协调业务逻辑与响应;Context 封装业务逻辑;Ecto Schema + Changeset 提供类型安全的数据库操作与验证。这个架构在大型项目中保持了良好的可维护性。下一章进入 Phoenix LiveView,体验无 JS 的实时 UI。