跳到正文

数据库

本页译自英文。 阅读英文页面

zeyn::sql 把 entity 保存在 SQL 数据库中。它根据表格发送的 DataQuery(筛选、排序、分页、合计、分组)列出 entity,并通过行版本检查来保存它们。PostgreSQL 位于 postgres Cargo feature 之后,Oracle 位于 oracle 之后,因此不使用数据库的应用不会编译其中任何内容。

zeyn = { version = "0.0.7", features = ["postgres"] }   # or ["oracle"], or both

设置

把一个 Db 注册为服务,并在 action 中接收它:

let db = Db::from_config(&config).await?;
App::new("Stock").config(config).service(db)

#[action(permission = "stock.product.read")]
pub async fn search_products(q: DataQuery, db: Db) -> Result<Page<Product>> {
    db.find(&q).await
}

#[action(permission = "stock.product.write")]
pub async fn save_product(p: Valid<Product>, db: Db) -> Result<Product> {
    if p.id.0 == 0 { db.insert(&*p).await } else { db.update(&*p).await }
}

Db::from_config 把 database.url 作为机密读取,并从 database.* 读取连接池限制:

键默认值含义
database.url必填postgres://user:password@host:5432/db 或 oracle://user:password@host:1521/service
database.max_connections10连接池大小;请求会等待空闲连接
database.min_connections0空闲时保持打开的连接数
database.acquire_timeout_ms5000等待连接的最长时间
database.idle_timeout_s600超出最小值的空闲连接在此时间后关闭
database.statement_timeout_ms30000一条语句最长可运行的时间;0 表示沿用服务器默认值
database.count_cap1000000为一页统计的最大总数

Db::connect(url, PoolLimits { .. }) 不需要 Config 就能完成同样的事。URL 的 scheme 决定驱动。Oracle 密码中的 @ 或 : 等字符要写成百分号编码(%40)。

PostgreSQL 连接是惰性打开的,因此数据库宕机时应用也能启动,第一次查询会报告问题。Oracle 在连接时向服务器询问其版本,除非 URL 指明了要写哪种 SQL(?compat=11 或 ?compat=12);设置了 compat 时,数据库不可用也能启动。

inventory 示例把商品保存在内存中,或保存在 ZEYN_DATABASE_URL 指定的数据库中(用 --features postgres 或 --features oracle 构建):

docker run -d --name zeyn-pg -e POSTGRES_PASSWORD=zeyn -p 55432:5432 postgres:17-alpine
ZEYN_DATABASE_URL=postgres://postgres:zeyn@127.0.0.1:55432/postgres cargo run -p zeyn-example-inventory --features postgres

Oracle Instant Client

Oracle 驱动是基于 ODPI-C 的 oracle crate。它编译时不需要 Oracle 软件,但运行时会加载 Oracle Client 库(oci.dll、libclntsh.so)。Oracle Instant Client 可以免费使用:

  1. 从 Oracle 的 Instant Client 页面下载适用于你所在平台的 Instant Client Basic Light。在 Windows x64 上,请选择 zip 文件 instantclient-basiclite-windows.x64-19.*.zip。版本 19 可以连接 Oracle Database 11.2 到 23ai;更新的客户端不再能连接 11g。
  2. 把它解压到一个单独的文件夹,例如 C:\oracle\instantclient_19。
  3. 把该文件夹放在 PATH 的最前面,或者把 ZEYN_ORACLE_CLIENT_DIR 设置为它。该变量优先于 PATH,这在安装了另一个 Oracle home 时很有用。在 Linux 上,安装 RPM 或设置 LD_LIBRARY_PATH;Basic Light 还需要 libaio。

没有该库时,第一次连接会失败,并报 DPI-1047: Cannot locate a 64-bit Oracle Client library。

方言

SQL 构建器是共用的。Dialect 只提供不同之处,Postgres 和 Oracle { compat } 实现了它:

PostgreSQLOracle 12c 及更高版本(V12)Oracle 11g(V11)
参数$1:1:1
标识符"code",最多 63 个字符"CODE",最多 30 个字符"CODE",最多 30 个字符
分页LIMIT $n OFFSET $mOFFSET :m ROWS FETCH NEXT :n ROWS ONLY在已排序查询之上的 ROWNUM 窗口
新键identity,RETURNINGidentity,RETURNING id INTO :n<table>_seq.NEXTVAL
写入后回读RETURNING 该行按键读取按键读取
是/否BOOLEANNUMBER(1)NUMBER(1)
空文本一个值NULLNULL

compat 沿用了 EF Core 的 UseOracleSQLCompatibility("11") 的思路:当 URL 没有指定时,由服务器版本决定它。在每个 Oracle 版本上,标识符都限制在 30 个字符以内,因为 12.1 与 11g 有同样的限制;更长的 entity 或字段名会产生一个指明它的错误。

Oracle 把空字符串存为 NULL。Zeyn 把必填 String 字段中的 NULL 读作 "",把文本列写为可空,并把文本作为可空列来排序和分页,因此游标保持精确。保存了 Some("") 的 Option<String> 读回来是 None。

Oracle 的驱动是阻塞式的。每次调用都在 Tokio 的阻塞线程上运行,连接取自有界连接池,因此绝不会拖住异步运行时;同时最多运行 max_connections 个调用,其余的最多等待 acquire_timeout_ms。

表

列以字段命名。类型:

字段PostgreSQLOracle
#[key] entity idBIGINT GENERATED BY DEFAULT AS IDENTITYNUMBER(19) identity(12c+)或配合序列(11g)
StringVARCHAR(max_len) 或 TEXTVARCHAR2(max_len CHAR) 或 VARCHAR2(4000),可空
i32、i64BIGINT(INTEGER 列也可用)NUMBER(19)
boolBOOLEANNUMBER(1)
Money以最小单位计的 BIGINT 金额,外加 <field>_currency VARCHAR(3)NUMBER(19),外加 <field>_currency
Decimal来自字段规则的 NUMERIC(precision, scale),没有规则时为 NUMERIC(18, 2)NUMBER(precision, scale)
DateDATEDATE
TimeTIME保存 HH:MM:SS 的 VARCHAR2(8)
DateTimeTIMESTAMPTZ(3)保存 UTC 的 TIMESTAMP(3)
LocalDateTimeTIMESTAMPTIMESTAMP(0)
#[choice] 枚举保存变体名称的 VARCHARVARCHAR2
自定义字段类型保存 JSON 的 TEXT保存 JSON 的 CLOB
Option<T>同一列,可空同一列,可空

币种存储为 TRY、USD 和 EUR。日期和时间以 ISO 文本传输,SQL 用固定格式转换它们(TO_DATE(:1, 'YYYY-MM-DD')、to_char(...)),因此 NLS_DATE_FORMAT 或 PostgreSQL 时区等会话设置不会改变结果。Decimal 以不含小数分隔符的精确文本绑定(12345E-2,由 CAST(... AS NUMERIC) 和 TO_NUMBER(...) 读取),并用 CAST(... AS TEXT) 或 TO_CHAR(..., 'TM9', 'NLS_NUMERIC_CHARACTERS=''.,''') 读回,因此 NLS_NUMERIC_CHARACTERS 同样不会改变它们。

create_table::<E>(dialect)(或 db.create_table::<E>())会写出 CREATE TABLE,用于脚手架和测试;每个 #[rule(unique)] 字段都有一个名为 <table>_<field>_key 的约束,在 11g 上还有键序列。生产环境的 schema 应放在迁移中。在 PostgreSQL 上:

db.migrate(&sqlx::migrate!("./migrations")).await?;
db.migrate_dir("./migrations").await?;

第一种在编译期嵌入迁移,第二种在运行时读取文件夹。sqlx::migrate! 需要在应用自己的 Cargo.toml 中为 sqlx 启用 macros 和 migrate feature;migrate_dir 不需要更多。Oracle 的 schema 用团队自己的工具迁移;create_table 提供起始脚本。

读取

db.find::<E>(&query) 在一个连接上最多运行三条语句:页、计数和合计。筛选条件由与 DataQuery::apply 相同的代码读取,因此列表在内存数据和 SQL 上表现一致:

筛选SQL
contains、starts_withLOWER(col) LIKE $1 ESCAPE '\',文本中的 % 和 _ 已转义
equalsLOWER(col) = $1
对数字和金额的 from、tocol >= $1、col <= $2
对日期和时间的 from、tocol >= CAST($1 AS date);对时刻使用日期边界时,覆盖用户时区下的整天
any_ofcol IN ($1, $2, ...)
iscol = $1
blankcol IS NULL、col IS NOT NULL

对金额和是/否列使用 contains 时,在 SQL 中会被忽略。文本按数据库的排序规则排序(Oracle:NLS_SORT)。

每个排序都以键结尾,所以顺序是全序。升序时 NULL 在前,降序时在后,与内存中一致;NULLS 子句只为可空列书写,因此普通索引可以服务其他排序。

get(key) 读取一行,没有时以 Error::NotFound 失败。任何表或视图都可以通过 entity 读取,例如 Oracle 的 user_sequences。

游标

编号分页使用偏移量。无限滚动和虚拟滚动使用键集(keyset)游标,它在列表深处不会变慢,也不会在他人插入数据时跳过或重复行:

WHERE "code" <= $1 AND (("code" < $2) OR ("code" = $3 AND "id" > $4))
ORDER BY "code" DESC, "id" ASC LIMIT $5

一页会多取一行,以便得知后面是否还有数据。随后 Page::next 携带一个游标:base64url 编码的 JSON,内含各排序字段、它们的方向,以及最后一行在每个排序键上的值(键排在最后)。该游标对客户端是不透明的,但并非机密,服务器也绝不信任它。解码时有大小限制,顺序与查询的顺序不一致时会被拒绝,并且每个值在绑定前都会按其列类型进行检查。错误的游标是校验错误,而不是一次查询。它只说明从哪里继续;筛选和权限会重新应用。

分组分页的游标使用偏移量。

合计与计数

with_total 最多统计 database.count_cap 行(对受限查询执行 COUNT(*)),因此对巨大表的总数仍然开销很小;当计数达到上限时,Page::total 就是该上限。

列合计是对所有符合筛选条件的行执行的一条聚合语句。它们遵循内存中的规则:空集合的和为 0,空集合的平均值为空,金额平均值按四舍五入(远离零方向)处理,涉及多种币种的金额没有合计。

DataQuery::group 会转换为 GROUP BY,并附带每组的计数和所要求的合计。

写入

  • insert(&row) 把新键(0)交给数据库生成,把行版本设为 1,并返回已存储的行。
  • update(&row) 运行 UPDATE ... SET version = version + 1 WHERE id = $1 AND version = $2。零行表示别人先保存了(或删除了该行):Error::Conflict。
  • delete::<E>(key) 在没有删除任何行时以 Error::NotFound 失败。
  • #[readonly] 字段永远不会从 model 写入,因此伪造的表单无法修改它们。服务器代码用自己的 SQL 修改它们:db.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", args)。execute 接受字符串字面量,所以值只能以参数形式传入。

唯一约束冲突(PostgreSQL 23505,Oracle ORA-00001)会变成该字段上的校验错误:约束名中包含其名称的 #[rule(unique)] 字段,或唯一的那个 unique 字段。表单会像其他规则一样,把它显示在输入框下方。请把约束命名为 <table>_<column>_key。

事务

let mut tx = db.begin().await?;
let order = tx.insert(&order).await?;
tx.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", vec![qty.into(), id.0.into()]).await?;
tx.commit().await?;

Tx 在一个连接上提供与 Db 相同的调用。不调用 commit 就丢弃它会回滚。直接对 Db 的调用会逐个提交。

流

db.stream::<E>(&query) 按顺序返回所有匹配的行而不分页,用于导出。一个任务把行读入容量为 STREAM_BUFFER(64)行的通道,并在读取方落后时等待,因此无论结果多大,内存都保持平稳。丢弃该流会停止查询并释放连接。RowStream 实现了 futures_core::Stream。

安全:SQL 注入

  • 标识符(表名和列名)只来自 entity:E::TABLE 及其 FieldMeta 列表。客户端在筛选和排序中发送的名称会在该列表中查找,不在其中的会被丢弃。每个标识符都会被检查(字母、数字、_,长度不超过方言的限制)并加引号。
  • 值始终是绑定参数,包括 LIKE 模式、游标值、limit 和 offset。任何值都不会被写入 SQL 文本。
  • 手写的 SQL 通过 execute(&'static str, args) 执行:语句必须是字面量。
  • 限制在服务器上被钳制(MAX_PAGE_SIZE、MAX_FILTERS、MAX_SORT、筛选文本长度、any_of 大小),无论客户端要求什么。
  • 驱动错误会作为 Error::Internal 记录日志,绝不展示给用户。连接 URL 作为 Secret 读取,绝不记录日志。

性能

  • 语句按连接预编译并缓存(sqlx 的语句缓存,Oracle 的语句缓存为 64)。
  • 行被读入少数几种简单的单元格类型,并由 serde 反序列化器移入 model:没有中间映射,也没有文本的拷贝。
  • 连接池是有界的,每个 find 为其两三条语句使用同一个连接。
  • Oracle 每次往返获取 128 行。一页(最多 501 行)在阻塞线程上读取并一次性交出;流则逐行进行。
  • 流最多保存 STREAM_BUFFER 行。

测试

SQL 构建器有针对两种方言的单元测试,不需要数据库。集成测试把 Db::find 与 DataQuery::apply 在同样的行上进行比较,遍历每个键集游标,并检查保存、冲突、唯一性错误、事务和流。当它们的变量包含一个或多个 URL(以空格分隔)时运行,否则会声明已跳过:

docker run -d --name zeyn-pg -e POSTGRES_PASSWORD=zeyn -p 55432:5432 postgres:17-alpine
ZEYN_TEST_PG=postgres://postgres:zeyn@127.0.0.1:55432/postgres cargo test -p zeyn --features postgres --test postgres

docker run -d --name zeyn-ora11 -p 15211:1521 -e ORACLE_PASSWORD=zeyn -e APP_USER=zeyn -e APP_USER_PASSWORD=zeyn gvenzl/oracle-xe:11-slim
docker run -d --name zeyn-ora21 -p 15221:1521 -e ORACLE_PASSWORD=zeyn -e APP_USER=zeyn -e APP_USER_PASSWORD=zeyn gvenzl/oracle-xe:21-slim
ZEYN_TEST_ORACLE="oracle://zeyn:zeyn@127.0.0.1:15211/XE oracle://zeyn:zeyn@127.0.0.1:15221/XEPDB1" cargo test -p zeyn --features oracle --test oracle

Oracle 容器需要几分钟才能启动;请等待 docker logs 中出现 DATABASE IS READY TO USE!。