Ecto 入门:Changeset 与迁移

《Ecto 入门:Changeset 与迁移》

要点

Ecto 不是 ORM。它是函数式的数据映射器:数据是普通 Map 与 Struct,校验是显式的管道,数据库交互走 Repo 显式调用——没有 Active Record 那种”对象自己会 save”的魔法,代价是多写几行,回报是每一步数据变形都看得见。口径:Elixir 1.18 线(1.19 当时还在 RC,没到生产口径)+ Ecto 3.12(3.12.0 于 2024 年 8 月 GA)+ ecto_sqlite3 0.21 做适配器。这篇走 Repo 与 Schema、Changeset 管道、迁移与回滚、preload 四站,末尾回答一个实际问题:把看板实战篇的内存态换成 Ecto+SQLite,要动哪里。

本文不做:Postgres 生产部署(连接池调优、SSL、读写分离那套属于 ecto_sql 的运维面)、迁移的高级特性(只碰本文用到的 create/change/unique_index)、Phoenix 集成(Ecto 是独立于 Web 框架的库,纯 Mix 项目就能学完)。运行时背景不重复展开,进程侧的启动结构在 OTP 篇,测试配合见测试篇。

不引 Phoenix 也能用 Ecto

新建项目并补齐三个依赖与一个进程:

mix new catalog --sup
# mix.exs 的 deps 里加
{:ecto, "~> 3.12"},
{:ecto_sqlite3, "~> 0.21"}

Repo 是数据库的门面,一个模块 + 一段 config + 挂进监督树,三步:

defmodule Catalog.Repo do
  use Ecto.Repo, otp_app: :catalog, adapter: Ecto.Adapters.SQLite3
end
# config/config.exs
import Config

config :catalog, Catalog.Repo,
  database: "priv/catalog.db",
  pool_size: 5
# lib/catalog/application.ex 的 children 里加 Catalog.Repo
def start(_type, _args) do
  children = [Catalog.Repo]
  Supervisor.start_link(children, strategy: :one_for_one)
end

验收:iex 里 Catalog.Repo.query!("select 1") 返回结果集,说明连接活着。SQLite 是文件型数据库,库就是 priv/ 下的那个文件,连接建立时按需生成——没有 ecto.create 这类”先建库”步骤,迁移时文件不存在会自动补上。test 环境想重置状态,删文件比任何 sandbox 机制都直白。

Schema 与 Changeset 管道

Schema 声明”这行数据长什么样”,Changeset 声明”外部数据进来要走什么关卡”。看板的 events 表落成:

defmodule Catalog.Event do
  use Ecto.Schema
  import Ecto.Changeset

  schema "events" do
    field :ref, :string          # 客户端事件引用,幂等键
    field :kind, :string
    field :level, Ecto.Enum, values: [:info, :warn, :error]
    field :payload, :map
    timestamps()
  end

  def changeset(event, attrs) do
    event
    |> cast(attrs, [:ref, :kind, :level, :payload])
    |> validate_required([:ref, :kind])
    |> validate_length(:ref, max: 64)
    |> unique_constraint(:ref)
  end
end

管道三段各司其职,顺序固定。cast 是白名单过滤:attrs 里多出的键直接丢弃,白名单漏写的字段永远进不来——参数多了不报错、少了不吭声,字段没进 cast 列表是最隐蔽的数据丢失。validate_* 跑在应用侧,纯内存判断,validate_required 拦 nil 与空串。unique_constraint(:ref) 本身不做任何校验,它只是在生成的 INSERT 里挂上”如果有唯一键冲突,别抛异常,把冲突翻译成 changeset 的错误”——真正的检查在数据库端,靠配套的迁移建 unique_index 兜底。

插入走 Repo,返回值是显式的二元组:

changeset =
  %Catalog.Event{}
  |> Catalog.Event.changeset(%{ref: "e-1001", kind: "deploy", level: :info})

case Catalog.Repo.insert(changeset) do
  {:ok, event} -> IO.puts("已入库 ##{event.id}")
  {:error, cs} -> IO.inspect(cs.errors)   # [ref: {"has already been taken", ...}]
end

同一个 ref 插第二次走的是 {:error, changeset} 分支,错误挂在字段上而不是异常栈里。两个容易误读的点:其一,validate_length 拦不住并发窗口,两个进程同时插同 ref 只有一个拿到 :ok——Ecto 的设计立场就是应用层校验管便利、数据库约束管正确性,后者唯一能兜底的前提是迁移里真建了 unique_index(Ecto 3.12 顺手给了 changed?/2、field_missing?/2 两个小工具,判断”字段有没有被用户改动”不用再手写 Map 取值)。其二,changeset 失败后原样复用会带着 stale 的错误,重试要先重开一个空结构体。

管道风格值得停下来体会一下为什么这样设计:cast、validate、constraint 每一步都接收一个 changeset、返回一个 changeset,中间任何一步都可以拆出来 inspect 看字段状态——哪一步拒绝的、错误挂在哪个字段上,管道里翻一眼就有答案。这正是 Elixir 函数式习惯在数据层的投影:校验逻辑不是散落各处的 if,是一条有形状的流水线。需要复用时,把公共步骤抽成一个私有函数继续接在管道里,风格不破。

迁移与回滚

迁移是纯 Elixir 文件,gen 生成骨架,change 函数同时承担正向与回滚:

defmodule Catalog.Repo.Migrations.CreateEvents do
  use Ecto.Migration

  def change do
    create table(:events) do
      add :ref, :string, null: false
      add :kind, :string, null: false
      add :level, :string, null: false, default: "info"
      add :payload, :map
      timestamps()
    end

    create unique_index(:events, [:ref])
  end
end
mix ecto.gen.migration create_events
mix ecto.migrate

预期输出:

21:04:12.081 [info] == Running 20250815083000 Catalog.Repo.Migrations.CreateEvents.change/0 forward
21:04:12.085 [info] create table events
21:04:12.096 [info] create unique index events_ref_index
21:04:12.098 [info] == Migrated in 0.0s

mix ecto.migrate 会执行所有未跑的迁移并在 schema_migrations 表里记账;mix ecto.rollback 回滚最近一批——change/0 里 create table 的逆操作是 drop table,unique_index 的逆是 drop index,所以 change 风格的迁移天生可回滚。有 destroy 这类”不可逆”操作时才需要拆 up/down。SQLite 侧 :map 字段落盘为 JSON 文本,timestamps 建出 inserted_at/updated_at 两列——结构对得上,换 Postgres 时迁移文件几乎原样可用。

迁移文件的纪律只有一条要刻在脑门上:已发布(跑进任何共享环境)的迁移文件永不修改,改表就追加新迁移。迁移是按时间戳排序的一次性剧本,改旧文件等于改历史——本地重跑没问题的方案,到了别人的环境就是结构错位。审查迁移跟审查代码同级对待:文件能对上”这次需求为什么加这一列”,回滚跑过一遍,才算合格。

关联与 preload

看板的事件来自不同数据源,加一个 sources 表把归属显式化:Source has_many :events, Catalog.Event,Event 侧 belongs_to :source, Catalog.Source(迁移里 events 表加一列 source_id,references 起外键)。查询关联数据走 preload:

events = Catalog.Repo.all(Catalog.Event)
events = Catalog.Repo.preload(events, :source)

Enum.each(events, fn e ->
  IO.puts("#{e.ref} 来自 #{e.source.name}")
end)

preload 默认是”分两条 SQL”:先取 events,再按 id 集合一条 IN 查询把 sources 拉回来,然后内存里挂接。它解决的是 N+1——不 preload 就在遍历里逐条查 source,100 条事件 101 条 SQL。列表页规律是”主列表 + 每行一个关联”就用 preload;反过来”按 source 聚合它的事件数”这种归一化需求,用 join 或聚合查询更合适,preload 是对象图,不是报表工具。

把两种加载策略的适用面说全:join 查询把关联条件压进一条 SQL,适合过滤与统计(”取所有 error 级事件及其来源名”且要按来源排序);preload 适合”已经拿到一批主记录、要把关联挂上去渲染”的场景,SQL 多一条但主查询保持简单,查询计划也稳定。个人经验是先写 preload 保正确,出现性能证据再改 join——为想象中的性能提前做 join 优化,多数换来的是可读性下降。

把看板的内存态换成 Ecto+SQLite

实战篇的 Collector 是个纯内存 GenServer:handle_cast 收事件堆进 window 列表,每秒 tick 时 Enum.count 聚合后广播,窗口清空。换成 Ecto+SQLite,改动收敛为四处:

  • 写路径:handle_cast({:event, ev}, state) 里把”堆进列表”换成 Repo.insert 一个 changeset,GenServer 壳与消息协议不动;
  • 聚合:tick 里的 Enum.count 换成一条聚合 SQL(from e in Event, where: e.inserted_at > ^since, group_by: e.level, select: {e.level, count(e.id)}),窗口清空逻辑变成记一个 since 时间戳而不是清列表;
  • 广播:PubSub 广播部分一行不改,事件流照旧;
  • 测试:窗口聚合抽出的纯函数照旧单测(测试篇那套),持久层用临时目录下的真文件验证——建表、插入、聚合查询跑一遍,删文件即重置。

但先把诚实的取舍摆在桌面上:对”每秒一屏”的单机看板,内存态是更对的设计——聚合逻辑无 IO、清窗口无成本,换库不带来性能收益,反而每秒多了落盘。上 Ecto 的正当理由是需求升级:重启不丢历史(内存态进程一崩,窗口数据蒸发)、要按任意时间段回看曲线、要多节点共享写入。数据库解决的是持久与共享,不是快。手头没有这些需求,就别把 Collector 往 Ecto 迁——这条判断比任何迁移步骤都重要。

常见坑

  • 建了 unique_index 忘写 unique_constraint:冲突时直接 raise 异常炸进程,而不是返回 {:error, changeset};反过来只写 constraint 不建索引,约束检查落空,校验形同虚设。两者必须成对出现。
  • cast 白名单漏字段:外部传了、Schema 也有、就是没进列表,字段静默丢失不报错。改动 Schema 时先核对 cast 列表。
  • 把 Ecto.Enum 当自由字符串:values 之外的值 cast 阶段直接失败,报错在 changeset 不在数据库,习惯用字符串透传的人第一次都会撞。
  • 遍历里逐条查关联:N+1 现场默认嫌疑人。列表渲染前先 preload,日志里 SQL 条数异常就是没挂。
  • 迁移改完不回滚验证:change 写成单向破坏回滚能力,migrate 之后跑一次 rollback 再 migrate,确认双向都通才算交付。

验收清单

  • 纯 Mix 项目连上 SQLite:Repo.query!(“select 1”) 通过,priv/ 下数据库文件按迁移自动生成。
  • Events 表迁移 migrate 通过且 rollback 后表消失、再 migrate 恢复;unique_index 存在。
  • changeset 管道一条不落:cast 白名单外字段被丢弃、required 缺失返回错误、同 ref 二次插入走 {:error, cs} 分支而非异常。
  • preload 路径跑通:列表页 SQL 条数为 2(events 一条 + sources 一条),遍历前无逐条查询。
  • 对照看板实战列出四处改动,其中”是否值得迁移”的取舍一段话写在注释或设计笔记里,需求不升级就不迁。

数据层学到手,这条线的下一步是 Phoenix 集成与 Ecto 多表关联的高级用法;回头看,Schema/Changeset 的管道风格正是 Elixir 函数式习惯在数据层的投影,写别扭时回OTP 篇温一遍”数据不可变、状态显式持有”的底色,写完记得按测试篇给 changeset 补纯函数测试。

本文数据核对时间:2025-08-15:Elixir 1.18.x(1.18.0 于 2024-12-19 GA,1.19 尚为 RC 未进生产口径)+ Ecto 3.12(3.12.0 于 2024-08-12 GA,ecto_sql 3.12 同步发布)+ ecto_sqlite3 0.21(0.21.0 于 2025-06-24 发布);changed?/2 与 field_missing?/2 为 Ecto 3.12 新增。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇