Plug.Router:match / dispatch / Parsers / CORS / JSON
· · ·
Plug 既是 Web 服务器抽象,也是中间件协议。Plug.Router 用宏定义路由,背后是一个 match-dispatch 流水线;Parsers 把请求体变成你能用的 map;CORS 是一个普通 Plug。
题目进度 0 / 0 ✓ 0
本课目标
学完后,你应该能用 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
上面三段:
use Plug.Router:注入get/post/put/delete/match宏和init/1。plug :match:先把请求方法和路径解析出来,匹配到对应 block。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。
# 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 头给所有用户。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.Parsers 的 length:),默认 8 MB 应改成业务值。 - 404 / 500 永远返回 JSON 而不是 HTML。 - OPTIONS 预检返回 204 并 halt()。 ## 十、测验
1
Plug 的本质是?
2
plug :match 与 plug :dispatch 的顺序应该是?
3
Plug.Parsers 的 parsers: 选项表示?
4
非法 JSON 体进入 Plug.Parsers 默认会发生?
5
CORS 中 Allow-Credentials: true 配合 Allow-Origin: * 会怎样?
6
预检请求是哪个方法?
7
预检成功响应通常使用哪个状态码?
8
CORS 响应里 Vary: Origin 头的作用是?
9
Plug.ErrorHandler 的位置应该是?
10
哪种 Plug 写法不是合法的?