Elixir 参数校验实践
程序只能处理预期内的数据,而用户输入往往超出预期。参数校验是守护系统安全的第一道防线,本文介绍 Elixir/Phoenix 项目中多层参数校验的实现方案。
环境要求
- Erlang 28+
- Elixir 1.19+
- Phoenix 1.18+
- Cookiecutter 2.6+
初始化项目
cookiecutter git@github.com:seangong0/cookiecutter-phoenix.git \
--no-input \
app_name=validator_demo \
use_sqlite=y
cd validator_demo
mix setup
本文按以下层次介绍参数校验的实现:
- 数据模型层 - Ecto.Changeset 基础校验
- Context 层 - 接口定义与类型约束
- Controller 层 - 独立校验模块
- 响应格式 - 统一 API 返回结构
数据模型层校验
首先创建 User 数据模型,使用 Ecto.Changeset 进行基础校验:
@primary_key {:id, :binary_id, autogenerate: true}
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :password_hash, :string
field :nickname, :string
timestamps()
end
def create_changeset(user, attrs) do
user
|> cast(attrs, [:email, :password, :nickname])
|> unique_constraint(:email)
|> hash_password_if_present()
end
这一层利用 Ecto.Changeset 处理数据库层面的约束。
Context 层接口定义
在 Context 模块中定义了下列函数,供 Controller 使用:
def get_user!(id :: Ecto.UUID.t()) :: User.t()
def get_user(id :: Ecto.UUID.t()) :: User.t() | nil
def list_users() :: [User.t()]
def create_user(attrs :: map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def authenticate_user(String.t(), String.t()) :: {:ok, User.t()} | {:error, :invalid_password | :user_not_found}
Controller 层校验
直接在 action 中写校验逻辑会导致代码冗长,难以维护:
def create(conn, %{"nickname" => nickname}) do
if String.length(nickname) >= 2 and String.length(nickname) <= 50 do
ok(conn, _)
else
bad_request(conn, %{error: "name length must be between 2 and 50"})
end
end
独立的校验模块
创建一个专门的校验模块 lib/validator_demo_web/validators/user_validator.ex:
defmodule ValidatorDemoWeb.Validators.UserValidator do
use Ecto.Schema
import Ecto.Changeset
@email_regex ~r/^[^\s]+@[^\s]+$/
embedded_schema do
field :email, :string
field :password, :string
field :nickname, :string
end
def validate_create(params) do
%__MODULE__{}
|> cast(params, [:email, :password, :nickname])
|> validate_required([:email, :password, :nickname])
|> validate_format(:email, @email_regex, message: "must be a valid email")
|> validate_length(:password, min: 8)
|> validate_length(:nickname, min: 2, max: 50)
|> handle_changeset(:insert)
end
def validate_update(params) do
%__MODULE__{}
|> cast(params, [:password, :nickname])
|> validate_length(:password, min: 6)
|> validate_length(:nickname, min: 2, max: 50)
|> handle_changeset(:update)
end
def validate_uuid(id) when is_binary(id) do
case Ecto.Type.cast(Ecto.UUID, id) do
{:ok, _} -> :ok
:error -> {:error, "invalid UUID format"}
end
end
def validate_uuid(_id), do: {:error, "id must be a UUID"}
defp handle_changeset(changeset, action) do
case apply_action(changeset, action) do
{:ok, result} ->
{:ok, Map.from_struct(result)}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, changeset, :validation}
error ->
error
end
end
end
使用 embedded_schema 定义校验结构,通过 validate_ 系列函数定义校验规则。返回 {:ok, data} 表示校验通过,返回 {:error, changeset, :validation} 标记为参数错误。
Controller 中的使用
def create(conn, unsafe_params) do
with {:ok, params} <- UserValidator.validate_create(unsafe_params),
{:ok, user} <- Accounts.create_user(params) do
data = UserJSON.render("user.json", user: user)
created(conn, data, ~p"/api/users/#{user.id}/")
else
{:error, %Ecto.Changeset{} = changeset, :validation} ->
bad_request(conn, changeset)
{:error, %Ecto.Changeset{} = changeset} ->
unprocessable_entity(conn, changeset)
{:error, reason} when is_atom(reason)} ->
unprocessable_entity(conn, reason)
end
end
通过 :validation 标记区分参数错误和数据库错误,便于前端定位问题。
统一响应格式
定义通用的响应结构,统一前端处理逻辑:
{
"status_code": 200,
"success": true,
"errors": [],
"data": "your data"
}
总结
本文介绍了多层参数校验的实现思路:
| 层级 | 职责 |
|---|---|
| 数据模型层 | 数据库约束(必填、唯一、长度) |
| Context 层 | 接口类型约束 |
| Controller 层 | 业务参数校验 |
| 响应层 | 统一错误格式 |
各层各司其职,逐层拦截非法数据。如果你不想自己实现校验逻辑,可以参考社区库 goal。
完整示例代码:validator_demo