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

本文按以下层次介绍参数校验的实现:

  1. 数据模型层 - Ecto.Changeset 基础校验
  2. Context 层 - 接口定义与类型约束
  3. Controller 层 - 独立校验模块
  4. 响应格式 - 统一 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