Learning
VOL. XIII · NO. 19 · Elixir · 01 JAN 1970

Plug.Router:match / dispatch / Parsers / CORS / JSON

Elixir 编程 · 01 JAN 1970 · 5 min read · 362 words
· · ·

Plug 既是 Web 服务器抽象,也是中间件协议。Plug.Router 用宏定义路由,背后是一个 match-dispatch 流水线;Parsers 把请求体变成你能用的 map;CORS 是一个普通 Plug。

本课目标
学完后,你应该能用 Plug.Router 写出一个可用的 HTTP API;理解 match/dispatch 顺序;用 Plug.Parsers 解析 JSON / URL-encoded;写一个能跑生产的最简 CORS Plug;用 JSON 库序列化响应。

一、Plug 是什么

Plug 是 Elixir 生态对”Web 中间件”的抽象。一个 Plug 就是一个函数:

@type plug ::
  module() |
  function() |
  {module(), keyword()}

@type conn :: Plug.Conn.t()

@callback call(conn, keyword) :: conn

也就是说 任何模块只要实现了 call/2,就是 Plug。这意味着中间件、控制器、路由都是同一个东西。

二、Plug.Router 的最小 API

defmodule MyAppWeb.Router do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "hello")
  end

  get "/users/:id" do
    send_resp(conn, 200, "user \#{id}")
  end

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

上面三段:

  1. use Plug.Router:注入 get/post/put/delete/match 宏和 init/1
  2. plug :match:先把请求方法和路径解析出来,匹配到对应 block。
  3. plug :dispatch:执行匹配到的 block。
顺序不是装饰
match 必须在 dispatch 之前。反过来请求会直接掉进兜底分支。

三、match 与 dispatch 的工作原理

match 把请求方法 + 路径转成 conn.method + conn.path_info,然后在编译期生成的路由表里查 block。

defp do_match(conn, _opts) do
  case conn.method do
    "GET" -> do_match(conn, :get, get_match(conn.path_info))
    "POST" -> do_match(conn, :post, post_match(conn.path_info))
    ...
  end
end

defp do_match(conn, verb, [match | _]) do
  conn = %{conn | path_params: match_bindings(verb, conn.path_info, match)}
  Plug.Conn.halt(conn)
end

每个路由生成的函数返回路径参数绑定,匹配成功时这些参数会被注入到 block 闭包里。

四、Parsers:把请求体变 map

Plug 默认不解析请求体。POST /users 拿不到 params,要显式挂 Plug.Parsers

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  json_decoder: Jason

defp create_user(conn, _opts) do
  %{"name" => name, "email" => email} = conn.body_params
  ...
end

关键字段:

  • parsers::按顺序尝试解析,匹配上就用对应 decoder。
  • json_decoder::可换 Jason / Poison
  • length::限制 body 大小,防止 OOM。
  • pass::永远把这些 Content-Type 透传给下游(不解析也不报错)。

JSON 解析失败
非法 JSON 会抛 Plug.Parsers.UnsupportedMediaTypeError。生产环境务必用 Plug.ErrorHandler 转 400,而不是 500。
“ ## 五、JSON 响应:三层选择

# 1. 自己手写
send_resp(conn, 200, Jason.encode!(%{ok: true}))
put_resp_content_type(conn, "application/json")

# 2. 用 Plug 的 JSON helper
import Plug.Conn
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{ok: true}))

# 3. 用第三方库的 Plug
plug MyAppWeb.JSONPlug

一个常见的 JSON Plug:

defmodule MyAppWeb.JSONPlug do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> put_resp_content_type("application/json; charset=utf-8")
  end
end

六、CORS:常见反模式 CORS 就是一个普通 Plug,挂在 match 之前处理预检请求:

defmodule MyAppWeb.CORSPlug do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, opts) do
    origin = get_req_header(conn, "origin") |> List.first()
    allowed = Keyword.get(opts, :allowed_origins, [])

    cond do
      origin in allowed ->
        conn
        |> put_resp_header("access-control-allow-origin", origin)
        |> put_resp_header("access-control-allow-credentials", "true")
        |> put_resp_header("vary", "Origin")
        |> handle_preflight(opts)
      true ->
        conn
    end
  end

  defp handle_preflight(%{method: "OPTIONS"} = conn, opts) do
    conn
    |> put_resp_header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS")
    |> put_resp_header("access-control-allow-headers", "authorization, content-type")
    |> send_resp(204, "")
    |> halt()
  end

  defp handle_preflight(conn, _opts), do: conn
end

挂载顺序:

defmodule MyAppWeb.Router do
  use Plug.Router

  plug MyAppWeb.CORSPlug, allowed_origins: ["https://app.example.com"]
  plug :match
  plug :dispatch

  ...
end

CORS 三件套
返回的 Access-Control-Allow-Origin 必须是请求里的 Origin(不能是 *);Allow-Credentials: true 出现时不能*Vary: Origin 必须有,否则 CDN 会缓存错配的 CORS 头给所有用户。
## 七、Admin API 的典型结构 组合以上,写一个 admin API:

defmodule MyAppWeb.Router do
  use Plug.Router

  plug MyAppWeb.CORSPlug, allowed_origins: ["https://admin.example.com"]
  plug Plug.Parsers,
    parsers: [:json],
    json_decoder: Jason,
    pass: ["*/*"]
  plug MyAppWeb.AuthPlug, required_scope: "admin"
  plug :match
  plug :dispatch

  get "/health" do
    send_resp(conn, 200, ~s({"ok":true}))
  end

  get "/users" do
    users = MyApp.Accounts.list_users()
    json(conn, 200, %{users: users})
  end

  post "/users" do
    attrs = conn.body_params
    case MyApp.Accounts.create_user(attrs) do
      {:ok, user} -> json(conn, 201, %{user: user})
      {:error, cs} -> json(conn, 422, %{errors: Ecto.Changeset.traverse_errors(cs, & &1)})
    end
  end

  delete "/users/:id" do
    {:ok, _} = MyApp.Accounts.delete_user(id)
    send_resp(conn, 204, "")
  end

  match _ do
    send_resp(conn, 404, ~s({"error":"not_found"}))
  end

  defp json(conn, status, body) do
    conn
    |> put_resp_content_type("application/json; charset=utf-8")
    |> send_resp(status, Jason.encode!(body))
  end
end

八、错误处理:Plug.ErrorHandler

defmodule MyAppWeb.ErrorHandler do
  use Plug.ErrorHandler

  def handle_errors(conn, %{kind: _kind, reason: %{__exception__: true} = ex, stack: _stack}) do
    body = Jason.encode!(%{error: Exception.message(ex)})
    send_resp(conn, conn.status || 500, body)
  end
end

# plug 链路最顶端
plug MyAppWeb.ErrorHandler

九、生产清单 - CORS 白名单按域名而非 *。 - JSON decoder 不要用 Poison(已停止维护),统一 Jason。 - 请求体大小限制(Plug.Parserslength:),默认 8 MB 应改成业务值。 - 404 / 500 永远返回 JSON 而不是 HTML。 - OPTIONS 预检返回 204 并 halt()。 ## 十、测验

1

Plug 的本质是?

2

plug :matchplug :dispatch 的顺序应该是?

3

Plug.Parsersparsers: 选项表示?

4

非法 JSON 体进入 Plug.Parsers 默认会发生?

5

CORS 中 Allow-Credentials: true 配合 Allow-Origin: * 会怎样?

6

预检请求是哪个方法?

7

预检成功响应通常使用哪个状态码?

8

CORS 响应里 Vary: Origin 头的作用是?

9

Plug.ErrorHandler 的位置应该是?

10

哪种 Plug 写法不是合法的?

**下一步:**HTTP 层完成后,下一课看 OTP 树:Plug 进程是 supervisor 树里的一颗节点,生命周期和重启策略由 supervisor 决定。 “