编程语言研究书库

第 11 章拆解 — 动态 Schema:把数据库映射推迟到运行期(Diesel)

动态 Schema:把数据库映射推迟到运行期

这一章回答第 2 章埋下的那个问题:「工单格式是预定义静态的,还是用户自定义动态的?」 前三章的办法是把结构钉死在迁移里;本章展示另一条路——应用运行到一半, 才第一次「听说」某张表的存在。这是把编程从编译期搬到运行期: 程序员不再预知数据的结构,而是备好工具,让结构到场时再认领。 主线例子:一个连接「别人维护的数据库」的功能——表是外部团队的, 字段可能比想的多(多出的不管),类型靠运行期祈祷,测试靠内存库兜底。

1. 顶层全景

  三种数据描述路线
    严格 schema(设计期钉死:schema-first 或 code-first,存数据前必须有)
    schemaless(什么都不约束:灵活,但结构靠消费方自己猜)
    动态 schema(库仍严格,应用侧运行期才映射——本章主角)
      ↓
  Diesel 的工具箱:table()/column() 临时映射 → 原生 SQL 跑 DDL 建表改表
    → 常规 select/filter 照常工作(类型提示自己给)
      ↓
  测试:内存 SQLite + 手写建表灌数(造出来的字段比用的多,模拟「真实的分裂」)
    → 自定义类型:枚举 + FromSql + DynamicSelectClause
      ↓
  风险清单:类型错配 / 语义不合 / 空集——三类事故都要向用户交代;
  编译器帮不上忙,验证和测试的负担全在人身上

  图说:每一条便利,都对应一行让渡给运行期的检查。

2. 三种数据描述路线,一次摆全

严格 schema enforcement: 关系库的老规矩——存任何东西之前, 结构、类型、表、字段、关系全部声明完毕(schema-first 手写,或 code-first 从代码生成,都行,但必须在场)。好处:数据永远齐整、查询好写、性能好; 死穴:设计应用时,我们未必知道这些信息1。

Schemaless: 完全不约束——爱存什么存什么,不用提前说。 书里一句重要的纠偏:schemaless ≠ 无结构——数据多半有结构,还可能是 好几种结构,只是没人强制;一条完全无结构的记录同样允许。 灵活性的代价在别处付出,于是又有人找折中2。

Dynamic schema description(动态 schema): 折中点—— schema 的描述不必发生在设计期,可以推迟到运行期、甚至就在查询发生的那一刻。 「我们运行期对数据当然得有点数,但不必把它钉死、不必建模型; 用到时再提示期望的类型就行。」注意前提:不保证来的数据真符合期望—— 「想要两全,这就是要付的价」3。

一个重要的澄清(书里专门注释): 以上是从数据存储视角说的; 用动态 schema 不需要动态的数据库——本章的假设恰恰是: 数据库仍旧严格执行 schema,而应用用动态方式去访问它4。

3. 什么时候用,什么时候千万别用

适合: 数据库不归你管(共享库、别的团队维护)——你不替它建模映射, 外部 schema 一变,你的应用不用被迫发版;或者 schema 变得比应用还快; 或者你就是不想花资源维护映射。5

不适合: 其余一切情况,尤其——schema 已知、且要做全集成时。 动态 schema 的隐性账单:数据不在你的监管之下,库可能过时、数据可能不合规, 取回来的每一行都要自己验证、自己加工;程序逻辑和模型反而「超级复杂」。 查询模式固定、schema 事先已知的系统,动态查找零收益,复杂度全是净亏6。

4. Diesel 的工具箱(本章是 Diesel 客场作战)

为什么突然是 Diesel: 全书 ORM 选了 SeaORM(增量友好、站在 SQLx 上); Diesel 不支持完整的动态 schema——它只提供「不建全量映射也能查询」的支持, 本章就是去它家做客把这层用上。书里预先警告:ad-hoc 映射更容易错, 返回数据的验证、错误处理要格外上心,别让应用 panic7。

4.1 临时映射:用到时才认领表和列

let users = table("users");                       // 一张「运行期才知道」的表
let id   = users.column::<Integer, _>("id");      // 认领一列:期望返回类型 Integer
let name = users.column::<Text, _>("name");       // 期望 Text

要点:table("users") 只要一个名字;column 的第一个类型参数是 你期望的返回类型(Diesel 的 SQL 类型,如 Integer/Text),第二个留给编译器推。 不必描述全表——users 里就算还有 hair_color,不查就不声明8。

红字注释: Diesel 不保证列存在、也不保证类型对得上—— 编译期检查被跳过、没有任何 schema 核验,「要是能查,那就违背动态 schema 的初衷了」9。

4.2 运行期建表改表:原生 SQL 与 DDL

建表改表 Diesel 就撒手了(「on our own」),但给了原生 SQL 通道 sql_query(...),于是数据库支持的一切 SQL 都解锁:10

// 带参数的查询:? 占位 + bind 绑定(顺序对应,内部转义防注入)
let users = sql_query("SELECT * FROM users WHERE id > ? AND name <> ?")
    .bind::<Integer, _>(1)
    .bind::<Text, _>("Tess")
    .get_results(conn);

// DDL:运行期建表
sql_query("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)")
    .execute(conn);

红线原话:绝不把用户提交的值拼进查询字符串——SQL 注入在这章等于自杀; 要传值就用 bind,内部转义。11

DDL 六兄弟(CREATE/DROP/ALTER/TRUNCATE/COMMENT/RENAME)都可用; 好处是全解锁,坏处是锁定——换数据库方言时这些裸 SQL 要重写12。

4.3 动态过滤照常工作

映射好之后,Diesel 常规查询机制照用——因为 DSL(用 Rust 函数拼 SQL 的那套) 并不依赖预定义 schema:

let results = users
    .select((id, name))
    .filter(name.eq("Sean"))
    .order_by(id.desc())
    .load::<(i32, String)>(conn)?;

filter 接受谓词(predicate),常用运算一应俱全:eq(=)、ne(!=)、 eq_any(IN)、ne_all(NOT IN)、is_null / is_not_null、gt/ge/lt/le、 between/not_between,排序用 desc/asc。13

load 的类型参数是自己「提示」的(这里是 (i32, String) 元组), 返回值是这种元组的 Vec——最好情况如愿以偿,最坏情况类型被错误转换; 那行结尾的 ? 也只是把错误往上抛,「假设上层处理得了」14。

4.4 完整示例与测试

依赖: diesel = { version = "~2.2.0", features = ["sqlite"] } + diesel-dynamic-schema = "0.2.2"(动态能力在这个副 crate 里)15。

可测性来自一个设计决定: 查询函数接受连接当参数、对数据库本身一无所知—— 「最佳实践:这样的函数不该拥有关于数据库的信息」;测试时就传一个 内存连接(SqliteConnection::establish(":memory:")),测试全权掌控库里的数据16。

测试故意制造「分裂」: setup 里建表时多造了一个查询根本不用的 hair_color 列,还灌了两行数据(James/Maria)——模拟「动态映射与实际 schema 只有部分重叠」的真实处境;断言取回的第一个人的名字是 James。17

4.5 自定义返回类型:从元组到枚举

元组的 users[0].1 可读性太差,正经做法三步:18

  1. 定义枚举(MyDynamicValue::String(String) / ::Integer(i32));
  2. 为它实现 FromSql trait——按运行期真实的 SQLite 类型分派: Text 转 String、Long 转 i32,其余一律 Err("Unknown data type");
  3. 查询侧配合 DynamicSelectClause 逐字段添加,列声明成 column::<Untyped, _>(不再预设类型),load 出来是 Vec<DynamicRow<NamedField<MyDynamicValue>>>——按下标按名字取都行, assert_eq!(actual_data[0]["name"], MyDynamicValue::String("James".into()))。

书里留个幽默的诚实注脚:示例程序直接跑会 panic(users 表不存在), 但测试能过(数据是测试造的)——「完整示例在 Git 上一个独立仓库」19。

5. 风险清单:三类必须交代的事故

动态 schema 真正的风险不是「报错」,而是数据库与动态映射各自漂移 (split-brain)到最后一刻才撞车。书里要求对三种结局都备好用户通知:20

事故形状
类型错配查询结果无法映射到期望类型
语义不合转换后的数据在上下文里讲不通(转回原始 SQL 类型得到不同结果)
空集什么都没查到——「空集有时合理(没有匹配行),有时不合理(你在 count/sum)」

最难的是判断「合不合语义」:有损转换可能是故意的——只取日期的星期部分, 原始日期当然回不来,但符合意图;空集亦然。「讲得通与否,只有业务说了算」21。

再叠加三笔隐性成本:22

  • 复杂度悖论: 只用 schema 子集时代码确实更简单;给用户越多动态权,动态 schema 自身越复杂——尤其和全映射 schema 混居一应用时,它是纯开销;
  • 失去 IDE 的陪伴: 没有补全、没有 lint、没有即时类型检查,「成不成立只有运行期知道」;
  • 验证与测试的负担: 编译器帮不上忙,测试更费劲却更不可靠—— 「只能预置一些边缘用例,没法为每一种漂移做准备」。

收尾的剂量忠告(全章题眼): 「精髓在于剂量。动态成分越重, 越该改用原生支持动态 schema 的数据库,而不是在代码层硬拗。 事情复杂时,去找为这个问题而生的简化方案。」23

6. 主线例子走一遍:查一张「别人」的表

  运营团队的共享库里有一张 analysts 表(我们的应用从没见过它)
      │  ① 管理员在 UI 里填了表名和要用的两列 → 配置存下来
      ▼
  应用运行期:table("analysts") + column::<Text,_>("name") 临时认领
      │  ② select + filter(name.eq(...)).load::<(i32,String)> 照常跑
      ▼
  外部团队上周给表加了一列、还改了某列类型 —— 我们的动态映射毫不知情
      │  ③ 撞上三种事故之一:类型错配 / 语义不合 / 空集
      ▼
  每种事故都有备好的用户提示;测试侧:内存库 + 故意多造的列,演练「部分重叠」
  ——若这类集成越来越重,终局是把这段迁去原生动态 schema 的数据库。

  图说:每一环都成立,直到外部世界改变;而外部世界总会改变。
  (表名为演示编的;API 名与事故分类出自原文。)

7. 作者的判断与证据(分开标)

有出处的: 三种路线定义、动态 schema 的适用/禁忌、bind 防注入、 DDL 锁定方言、三类事故、剂量忠告,均为原书原文3611122023。

作者自己的评估:

  • 「连接参数走函数参数」的可测性设计是作者推荐的最佳实践16;
  • 「少用子集则简单、放任用户则复杂」的复杂度悖论是经验判断;
  • 「迁移到原生动态库」的终局建议是作者立场(对 schema 频繁变动的系统, MongoDB 之类确实比代码层硬拗合身)。

8. 边界与局限

  • 演示只到 SQLite; 生产接外部 MySQL/Postgres 时的权限、网络、方言问题没展开;
  • FromSql 只示范了两种类型; 日期、布尔、NULL 的映射留作练习;
  • 没有性能对比: 动态映射 vs 预定义映射的查询开销未测量;
  • 「别人维护的库」其实有更好答案(视图、只读账号、ETL 到自有库),书里未比较——动态 schema 是「集成方案谱系」里的一档,不是唯一档。

9. 可带走的

主线例子一行走完: 认领外部表 → 临时列映射 → 常规查询 → 外部变更 → 三类事故之一 → 用户提示兜底;测试用内存库演练部分重叠。

  1. 三种数据描述路线:严格(设计期钉死)/ schemaless(不约束)/ 动态 (库严格、应用运行期映射)。
  2. schemaless ≠ 无结构; 「不强制」和「没结构」是两回事。
  3. 动态 schema 的适用场景一句话:schema 不归你管、变得比你快、你不想养映射。
  4. Diesel 的临时映射 = table() + column::<期望类型,_>(); 查不到的列不用声明。
  5. 类型是「提示」不是「保证」; 编译期检查全部让位,验证自己写。
  6. 原生 SQL 是逃生舱也是锁定: DDL 全解锁,方言换库要重写;传值只走 bind。
  7. 可测性的关键设计:查询函数收连接当参数、对库一无所知。
  8. 三类事故备好用户交代:类型错配、语义不合、空集; 「有损但合理」要靠业务判断。
  9. 动态成分越重,越该换原生动态 schema 的数据库——代码层硬拗有天花板。

10. 原文地图

主题原书章原文位置
schema 定义与三条路线Dynamic Schema with Dieseltext/82-fm-introduction.txt:49(搜「description of the data」)、:56(搜「Strict schema」)
严格 schema 的死穴同上text/82-fm-introduction.txt:75(搜「not available」)
schemaless≠无结构同上text/82-fm-introduction.txt:91(搜「structureless」)
动态 schema 的价同上text/82-fm-introduction.txt:105(搜「postpone」)、:108(搜「price」)
何时用/不用同上text/82-fm-introduction.txt:117(搜「decouple」)、:129(搜「outweighs」)
库严格、应用动态同上text/82-fm-introduction.txt:139(搜「strictly enforced」)
Diesel 的有限支持与警告同上text/82-fm-introduction.txt:150(搜「excursion」)、:153(搜「error-prone」)
table/column 临时映射同上text/82-fm-introduction.txt:174(搜「table("users")」)、:168(搜「expected type」)
无类型保证的红字同上text/82-fm-introduction.txt:196(搜「cannot guarantee」)
bind 防注入同上text/82-fm-introduction.txt:259(搜「Never concatenate」)
DDL 六兄弟与方言锁定同上text/82-fm-introduction.txt:298(搜「CREATE」)、:317(搜「lock in」)
谓词全家桶同上text/82-fm-introduction.txt:354(搜「Equals」)、:395(搜「Descending」)
完整示例与测试同上text/82-fm-introduction.txt:446(搜「diesel-dynamic-schema」)、:590(搜「hair_color」)、:644(搜「James」)
可测性设计同上text/82-fm-introduction.txt:662(搜「best practice」)
FromSql 自定义类型同上text/82-fm-introduction.txt:709(搜「MyDynamicValue」)、:731(搜「from_sql」)、:796(搜「DynamicSelectClause」)
「跑会 panic、测能过」同上text/82-fm-introduction.txt:849(搜「panics on run」)
三类事故同上text/82-fm-introduction.txt:868(搜「cannot be mapped」)、:874(搜「empty set」)
有损转换的语义判断同上text/82-fm-introduction.txt:878(搜「weekday」)
复杂度悖论与失去 IDE同上text/82-fm-introduction.txt:884(搜「overhead」)、:887(搜「code complete」)
剂量忠告同上text/82-fm-introduction.txt:893(搜「dosage」)
Points to remember同上text/82-fm-introduction.txt:921(搜「postpone database mapping」)
Key terms同上text/82-fm-introduction.txt:1056(搜「Schema: A description」)

Footnotes

  1. 出处:「Strict schema enforcement」(text/82-fm-introduction.txt:72,搜「no data storage is possible」)。 ↩

  2. 出处:「Schemaless data storage」(text/82-fm-introduction.txt:81,搜「does not enforce a schema」)、:91(搜「structureless」)。 ↩

  3. 出处:「Dynamic schema description」(text/82-fm-introduction.txt:105,搜「postpone providing this information」)、:108(搜「the price we have to pay」)。 ↩ ↩2

  4. 出处:「When not to use a dynamic schema」(text/82-fm-introduction.txt:139,搜「strictly enforced schema」)。 ↩

  5. 出处:「When to use a dynamic schema」(text/82-fm-introduction.txt:117,搜「not entirely owned」)、:120(搜「best used」)。 ↩

  6. 出处:「When not to use a dynamic schema」(text/82-fm-introduction.txt:126,搜「any state」)、:129(搜「totally outweighs」)。 ↩ ↩2

  7. 出处:「Dynamic schema and Diesel」(text/82-fm-introduction.txt:150,搜「excursion」)、:153(搜「error-prone」)。 ↩

  8. 出处:「Dynamic tables and columns in Diesel」(text/82-fm-introduction.txt:174,搜「table("users")」)、:168(搜「expected return value」)、:207(搜「do not care」)。 ↩

  9. 出处:「Dynamic tables and columns in Diesel」(text/82-fm-introduction.txt:196,搜「cannot guarantee」)。 ↩

  10. 出处:「Creating tables and columns on the fly」(text/82-fm-introduction.txt:219,搜「sql_query」)。 ↩

  11. 出处:「Creating tables and columns on the fly」(text/82-fm-introduction.txt:259,搜「Never concatenate」)。 ↩ ↩2

  12. 出处:「Changing tables and columns at runtime」(text/82-fm-introduction.txt:298,搜「CREATE: Create a database」)、:317(搜「lock in」)。 ↩ ↩2

  13. 出处:「Dynamic expressions and filters」(text/82-fm-introduction.txt:332,搜「filter」)、:354(搜「Equals」)、:395(搜「Descending」)。 ↩

  14. 出处:「Dynamic expressions and filters」(text/82-fm-introduction.txt:407,搜「load method」)、:413(搜「wrongly converted」)。 ↩

  15. 出处:「Testing a dynamic schema」(text/82-fm-introduction.txt:446,搜「diesel-dynamic-schema」)。 ↩

  16. 出处:「Testing a dynamic schema」(text/82-fm-introduction.txt:662,搜「best practice」)。 ↩ ↩2

  17. 出处:「Testing a dynamic schema」(text/82-fm-introduction.txt:590,搜「hair_color」)、:644(搜「James」)。 ↩

  18. 出处:「Testing a dynamic schema」(text/82-fm-introduction.txt:709,搜「MyDynamicValue」)、:731(搜「from_sql」)、:796(搜「DynamicSelectClause」)。 ↩

  19. 出处:「Testing a dynamic schema」(text/82-fm-introduction.txt:849,搜「panics on run」)。 ↩

  20. 出处:「Risks involved in using a dynamic schema」(text/82-fm-introduction.txt:861,搜「split」)、:868(搜「cannot be mapped」)。 ↩ ↩2

  21. 出处:「Risks involved in using a dynamic schema」(text/82-fm-introduction.txt:878,搜「weekday」)。 ↩

  22. 出处:「Risks involved in using a dynamic schema」(text/82-fm-introduction.txt:126,搜「superfluously complicated」)、:887(搜「code complete」)、:890(搜「less reliable」)。 ↩

  23. 出处:「Risks involved in using a dynamic schema」(text/82-fm-introduction.txt:893,搜「dosage」)。 ↩ ↩2

On this page