SQLx 源码深度解析
第2章 Workspace 与 crate 边界
七个 crate,每一条边界背后都有一条具体的工具链约束。
proc-macro = true的 crate 只能导出过程宏,里面的普通函数和类型别的 crate 用不了——单是这一条,就决定了sqlx-macros必须拆成两份。
本章要点
- sqlx 把一个数据库工具包拆成 7 个 crate,每一条边界都对应一个具体的 Rust 工具链约束或工程决策。
proc-macro = true的 crate 只能导出过程宏、不能导出普通函数和类型——这是sqlx-macros和sqlx-macros-core必须拆成两份的底层原因;sqlx-macros-core因此成为第三方驱动作者可以直接依赖、拼出自己那套query!宏的公共 crate。=0.8.6精确版本锁定贯穿所有跨 crate 依赖,原因是sqlx-core在顶层注释里明确声明 不遵守 Semantic Versioning(sqlx-core/src/lib.rs:3-7)。- feature gate 使用
?/级联语法:sqlx[postgres]→sqlx-postgres+sqlx-macros?/postgres——只在目标 crate 已启用时才向下传递 feature,避免无谓地拉起额外依赖。 - Any 驱动是 sqlx 在驱动选择上唯一的"运行时多态"特例——用
Box<dyn AnyConnectionBackend>+OnceCell<&'static [AnyDriver]>的组合,让同一个AnyConnection可以根据 URL scheme 在运行时分发到 Postgres / MySQL / SQLite 中的任意一个。 - 实现一个新驱动所需的最小接口:
Databasetrait 的全部关联类型 +Connection+TransactionManager+Encode/Decode/Type对主要 Rust 类型——本章末尾给出一张 checklist。
2.1 问题引入:一个 crate 就搞不定吗
按介绍页 checkout v0.8.6 后打开仓库根目录,cat Cargo.toml 能看到 [workspace] members 这张清单:
members = [
".", # facade crate: sqlx 本体
"sqlx-core",
"sqlx-macros",
"sqlx-macros-core",
"sqlx-test", # 测试辅助,不发布给用户用
"sqlx-cli", # 命令行工具(binary)
# "sqlx-bench",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
# ... 以及一串 examples/
]
注意 facade 就是根目录本身(.),它的源码在 src/;驱动和宏各占一个子目录。抛开 sqlx-test、sqlx-cli 这两个辅助成员和被注释掉的 sqlx-bench,一个"在 Rust 里用异步的方式访问数据库"的工具包,拆出了 7 个独立发布给库使用者的 crate:sqlx、sqlx-core、sqlx-macros、sqlx-macros-core、sqlx-mysql、sqlx-postgres、sqlx-sqlite。乍看之下这是过度设计——一个单体 crate 配几个 feature gate 不就够了?但仔细看每一条边界,你会发现它们不是架构师在白板上凭空画的。每一条都是 Rust 工具链的硬性约束、改动与编译隔离的工程权衡、或者"想让某种用法成为可能"的实现前提。
这一章就把这 7 个 crate 的边界逐条拆开。读完后,你再看到一个 Rust 项目把一个看似简单的东西拆成多个 crate 时,就能立刻猜到背后的理由属于以下五类之一:
proc-macro = true的 crate 只能导出过程宏,其中的函数和类型不能当作普通库来复用。- 编译时 feature 互斥(比如 runtime、TLS)要求发布单元足够小。
- 可选依赖的拉取成本要求"不用的东西不编译"。
- 跨 crate 复用(比如第三方驱动要接入同一套宏逻辑)要求把算法实现挪出 proc-macro crate。
- 上游 API 不稳定的子系统要求和稳定的门面分开,单独声明稳定性承诺。
sqlx 的 7 个 crate 用上了其中四条。第 2 条在 sqlx 身上恰好是反例:0.4–0.6 曾有一个专管运行时适配的 sqlx-rt crate,0.7 把它删了,runtime、TLS 的互斥改由 sqlx-core 内部的 feature 处理(§2.5、§2.7.2)。
2.2 第一道边界:facade sqlx 与 core sqlx-core
用户代码里 Cargo.toml 写的是:
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }
这里的 sqlx 就是 facade crate——它的 src/lib.rs 一共 174 行,其中第 12–37 行全部是 pub use sqlx_core::...(夹着两行 #[doc(hidden)])。翻开 sqlx-0.8.6/src/lib.rs:12-37:
pub use sqlx_core::acquire::Acquire;
pub use sqlx_core::arguments::{Arguments, IntoArguments};
pub use sqlx_core::column::Column;
pub use sqlx_core::column::ColumnIndex;
pub use sqlx_core::connection::{ConnectOptions, Connection};
pub use sqlx_core::database::{self, Database};
pub use sqlx_core::describe::Describe;
pub use sqlx_core::executor::{Execute, Executor};
pub use sqlx_core::from_row::FromRow;
pub use sqlx_core::pool::{self, Pool};
// ... 继续 re-export 直到第 37 行
然后是按 feature re-export 驱动:
#[cfg(feature = "postgres")]
#[doc(inline)]
pub use sqlx_postgres::{
self as postgres, PgConnection, PgExecutor, PgPool, PgTransaction, Postgres,
};
整个 facade 几乎没有业务逻辑(只有 query! 系列的 macro_rules! 外壳、Any 的 install_default_drivers 这类少量胶水)——它的主要价值是把 7 个 crate 的公共 API 压缩到一个统一的导入路径。用户写 use sqlx::{Pool, PgPool, query_as, FromRow} 就能同时拿到 core、postgres、macros 三个 crate 的符号。
为什么不直接让用户 use sqlx_core::Pool + use sqlx_postgres::PgPool?两个原因:
-
稳定性边界。
sqlxcrate 承诺遵守 Semantic Versioning——0.8.x 之间 API 保持兼容。但sqlx-core明确声明不遵守 SemVer(sqlx-core/src/lib.rs:3-7原文)://! ### Note: Semver Exempt API //! The API of this crate is not meant for general use and does *not* follow Semantic Versioning. //! The only crate that follows Semantic Versioning in the project is the `sqlx` crate itself. //! If you are building a custom SQLx driver, you should pin an exact version for `sqlx-core` to //! avoid breakages:这条声明的含义是:用户如果直接
use sqlx_core::...,他就进入了"内部 API 使用者"的身份,任何一次发版(包括 0.8.x 这样的小版本)都可能被破坏。facade 的存在就是为了把这层风险封掉。 -
单一入口的学习成本。文档、示例、社区回答只需要说 "
use sqlx::Pool",不用解释 7 个 crate 各自是什么。
Facade 的代价是轻微的编译开销——多一层 pub use 对增量编译几乎无影响,但对 cargo doc 的合并是必要的(驱动 re-export 上的 #[doc(inline)] 标注让 sqlx::postgres 等模块在 sqlx 的文档里直接展开,而不是只留一行指向 sqlx_postgres 的链接)。
这道边界是 Rust 大型项目的常见做法——serde(serde / serde_core)、axum(axum / axum-core)里能看到类似的 facade + core 模式;tokio 则没有单独的 core crate,核心全在 tokio 本体里,周边能力拆到 tokio-util / tokio-stream。《Tokio 源码深度解析》专栏第 1 章在盘点 tokio 这棵树时也会把 tokio-util / tokio-stream 这些同 workspace 成员和 tokio 本体分开数。
2.2.1 五个工程的 workspace 对照
把 sqlx 和本系列其他几个专栏主角的 workspace 放一张表对比:
| 项目 | facade crate | 核心 crate | 宏 crate 对 | 独立可扩展点 |
|---|---|---|---|---|
| sqlx | sqlx |
sqlx-core |
sqlx-macros + sqlx-macros-core |
数据库驱动 crate |
| tokio | tokio |
tokio 内部 modules |
tokio-macros(单个 proc-macro crate) |
tokio-util / tokio-stream |
| serde | serde |
serde_core |
serde_derive + serde_derive_internals |
data format crates(serde_json 等) |
| axum | axum |
axum-core |
axum-macros |
axum-extra |
| hyper | hyper |
hyper 本身(自带协议) |
无宏 | hyper-util(独立仓库) |
几条观察:
- 只有 sqlx 把驱动拆成跨 crate 的独立发布。tokio 的 I/O 驱动全在
tokio本身里,serde 的 data format crate(serde_json/serde_yaml)是独立发布但不在 serde 这个 workspace 里——这张表里,sqlx 是唯一一个在同一个 workspace 里把主 crate(sqlx)和"可选后端"(sqlx-postgres等)都发布到 crates.io 的。=版本锁定倒不是 sqlx 独有:serde 1.0.228 的serde对serde_core写的也是=1.0.228。 - 只有 sqlx 的宏 crate 对是真正的依赖关系。
serde_derive_internals也独立发布,但serde_derive并不依赖它——它的src只是指向serde_derive/src/internals的符号链接,把同一份源码另发一个包给外部工具用;sqlx 的sqlx-macros则直接依赖sqlx-macros-core、调用它的expand_input,而sqlx-macros-core的 crate 文档明确写给"自定义驱动"的作者看。 - 所有项目都有一个 facade 作为统一入口,但单拆 core crate 的只有 sqlx、serde、axum。tokio、hyper 的核心就在主 crate 里;hyper 的周边工具
hyper-util甚至不在 hyper 仓库里,也不经hyper::路径 re-export。
这张表本身不是结论,它是让你看完本章后可以"拿其它项目做对比参考"的钥匙。本章讲 sqlx 的 crate 拆分策略在 Rust 生态里不是孤例——但 sqlx 是把这条拆分路径走得最彻底的一个。
2.3 proc-macro 的物理约束:为什么 sqlx-macros 要和 sqlx-macros-core 拆
这是本章最重要的一条边界。理解它,你也就理解了 Rust 生态里所有"xxx-macros"和"xxx-macros-core"双 crate 模式的成因。
翻开 sqlx-macros/Cargo.toml,核心只有这几行:
[package]
name = "sqlx-macros"
description = "Macros for SQLx, the rust SQL toolkit. Not intended to be used directly."
[lib]
proc-macro = true
[dependencies]
sqlx-core = { workspace = true, features = ["any"] }
sqlx-macros-core = { workspace = true }
proc-macro2 = { version = "1.0.36", default-features = false }
syn = { version = "2.0.52", default-features = false, features = ["parsing", "proc-macro"] }
quote = { version = "1.0.26", default-features = false }
(workspace = true 会去根 Cargo.toml 的 [workspace.dependencies] 取声明,那里写的是 sqlx-core = { version = "=0.8.6", path = "sqlx-core" }——§2.4.2 会讲这个 = 的意义。)
注意 [lib] proc-macro = true。这个配置项会让 Cargo 用 --crate-type=proc-macro 编译这个 crate,产物是一个动态库(macOS 上是 .dylib),由 rustc 在编译期加载。proc-macro crate 只能导出 #[proc_macro] / #[proc_macro_derive] / #[proc_macro_attribute] 标注的函数,普通函数、类型、trait 一律不能 pub 给别的 crate(rustc 会直接报错)。普通 crate 可以依赖它——sqlx facade 就依赖 sqlx-macros 并 re-export 其中的宏——但拿到的只有宏,拿不到里面的任何算法。
这条限制带来一个直接问题:如果算法只写在 proc-macro crate 里,它永远无法被其他地方复用。而 sqlx 恰恰需要复用——一个给小众数据库写驱动的第三方作者,想让自己的驱动也享受"编译期 DESCRIBE + 离线缓存"这一整套机制,就得能以普通库依赖的形式引用这套实现。sqlx-macros-core 自己的 crate 文档第一行写的就是 "Support crate for SQLx's proc macros",并明确告诉驱动作者要把它和 sqlx-core 一起精确锁版本。
解法是把算法搬到一个不带 proc-macro = true 的 crate 里:
sqlx-macros (proc-macro = true, 仅 101 行)
├── sqlx-macros-core (普通 crate, 实现 query!、FromRow、Encode 等)
└── sqlx-core
sqlx-macros/src/lib.rs 的每一个 #[proc_macro] 函数都只做一件事——把参数转交给 sqlx-macros-core:
// sqlx-macros/src/lib.rs:7-23
#[cfg(feature = "macros")]
#[proc_macro]
pub fn expand_query(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as query::QueryMacroInput);
match query::expand_input(input, FOSS_DRIVERS) {
Ok(ts) => ts.into(),
Err(e) => {
if let Some(parse_err) = e.downcast_ref::<syn::Error>() {
parse_err.to_compile_error().into()
} else {
let msg = e.to_string();
quote!(::std::compile_error!(#msg)).into()
}
}
}
}
真正的 expand_input 在 sqlx-macros-core/src/query/mod.rs(436 行),整个 sqlx-macros-core/src 是 17 个文件 3585 行——sqlx-macros 那 101 行只是它的一层 #[proc_macro] 外壳。
顺带澄清一个容易望文生义的地方:cargo sqlx prepare 并不链接 sqlx-macros-core。看 sqlx-cli/src/prepare.rs:150 的 run_prepare_step,它做的是设好 SQLX_TMP / SQLX_OFFLINE=false / SQLX_OFFLINE_DIR=<缓存目录> 三个环境变量(同文件 :180-182),再 Command::new(&ctx.cargo)(:176)把你的 crate 重编一遍。CLI 只负责摆好环境、清理目录、比对结果。
JSON 是宏自己在那次编译里写出来的——sqlx-macros-core/src/query/mod.rs:344-346 那段注释写着 "Only save query metadata if SQLX_OFFLINE_DIR is set manually or by cargo sqlx prepare"。
graph TD
USER[用户代码<br/>cargo check]
CLI[sqlx-cli<br/>cargo sqlx prepare]
MAC[sqlx-macros<br/>proc-macro = true]
MACCORE[sqlx-macros-core<br/>普通 crate]
DB[(真实数据库)]
CACHE[.sqlx/ 缓存]
USER --> MAC
CLI -->|设 SQLX_OFFLINE_DIR<br/>再调 cargo check| USER
MAC -->|委托| MACCORE
MACCORE -->|DESCRIBE| DB
MACCORE -->|写/读| CACHE
这张图解释了 sqlx 为什么能同时支持"在线校验"和"离线缓存"两种模式——两条路径都从 sqlx-macros-core 走,区别只在于环境变量把它导向真实数据库还是 .sqlx/ 目录。cargo sqlx prepare 不是另起炉灶,它只是把你的项目在"写缓存"模式下重编一遍。
这条模式在 Rust 生态里非常普遍:serde_derive / serde_derive_internals、thiserror / thiserror-impl——凡是你看到 xxx-macros 和 xxx-macros-core / xxx-impl 并列存在的,背后都是同一个 proc-macro 隔离约束。《Serde 元编程》专栏第 5 章分析 serde 的 crate 拆分时,第一条理由就是这条"proc-macro crate 只能导出宏"的约束——值得对照阅读。
2.3.1 sqlx-macros-core 里的 DatabaseExt 与 FOSS_DRIVERS
sqlx-macros-core 是这套分层里最值得一读的地方。它有两个关键抽象:
DatabaseExt trait(sqlx-macros-core/src/database/mod.rs:16):
pub trait DatabaseExt: Database + TypeChecking {
const DATABASE_PATH: &'static str;
const ROW_PATH: &'static str;
fn db_path() -> syn::Path {
syn::parse_str(Self::DATABASE_PATH).unwrap()
}
fn row_path() -> syn::Path {
syn::parse_str(Self::ROW_PATH).unwrap()
}
fn describe_blocking(query: &str, database_url: &str) -> sqlx_core::Result<Describe<Self>>;
}
这个 trait 把"编译期宏能对数据库做的事"抽象出来:
DATABASE_PATH:驱动在用户代码里的 canonical 路径,例如sqlx::postgres::Postgres(由impl_database_ext!用stringify!生成,sqlx-macros-core/src/database/impls.rs:8)——宏生成 TokenStream 时要在try_map(|row: PgRow| { ... })的类型位置塞入这个路径。ROW_PATH:对应的 Row 类型路径,例如sqlx::postgres::PgRow。describe_blocking:同步版的DESCRIBE——在 proc-macro 上下文里必须同步(因为 rustc 不会把 Future 跑到完成),所以每个驱动要提供一个阻塞版本。
注意 DatabaseExt 的超 trait 是 TypeChecking——这是 sqlx-core/src/type_checking.rs 定义的"把数据库类型映射到 Rust 类型字符串"的 trait(例如"OID 23 → i32")。DatabaseExt 继承它,确保编译期能从列描述反推 Rust 类型。
FOSS_DRIVERS 常量(sqlx-macros-core/src/lib.rs:45-52):
#[cfg(feature = "macros")]
pub const FOSS_DRIVERS: &[QueryDriver] = &[
#[cfg(feature = "mysql")]
QueryDriver::new::<sqlx_mysql::MySql>(),
#[cfg(feature = "postgres")]
QueryDriver::new::<sqlx_postgres::Postgres>(),
#[cfg(feature = "_sqlite")]
QueryDriver::new::<sqlx_sqlite::Sqlite>(),
];
这个常量就是 sqlx-macros 里 expand_query 的第二个参数。QueryDriver::new::<DB>()(sqlx-macros-core/src/query/mod.rs:34)是 const fn——在 const context 把每个驱动的数据库名、URL schemes 和展开函数指针 expand_with::<DB> 收集起来。宏展开时,根据 DATABASE_URL 的 scheme(离线模式下则是缓存里记录的数据库名)找到对应的 QueryDriver,调用它的 expand——在线模式下再由它走 describe_blocking。
这里要特别注意 FOSS_DRIVERS 这个名字——"Free and Open-Source Software"——它给"非 FOSS 驱动"留了位置。上游在 CHANGELOG 0.7.0 里公开说明:MSSQL 驱动被移出源码树,计划以 "SQLx Pro" 商业形态从头重写回来,同时提供其它专有数据库的驱动。开源版本的 FOSS_DRIVERS 就只有这三家能被 query! 宏识别。
这条边界的工程价值:第三方驱动作者(比如给 TiDB 写驱动)如果想让自己的驱动能被 query! 宏识别,只有两条路:
- 把 TiDB 驱动提 PR 进
FOSS_DRIVERS(需要通过 launchbadge 的 code review 并保证长期维护)。 - 自己 fork
sqlx-macros,维护一个平行的TIDB_DRIVERS常量——用户就用tidb_query!而不是sqlx::query!。
这是 sqlx "编译期连数据库"这条路径的最刺眼代价:宏的驱动集合是一个闭合列表。它没有为第三方驱动留注册接口——FOSS_DRIVERS 在 sqlx-macros-core 编译时就定死了,下游 crate 没有办法往 sqlx::query! 用的这张表里追加自己的驱动。
2.4 驱动 crate 的独立发布:sqlx-postgres / sqlx-mysql / sqlx-sqlite
一直到 0.6,Postgres / MySQL / SQLite(以及当时还在的 MSSQL)的协议实现都是 sqlx-core 里的子模块——git ls-tree v0.6.0 sqlx-core/src/ 能看到 postgres / mysql / sqlite / mssql 四个目录并排躺着,靠 feature 切换。直到 0.7 的 #2039,这三个驱动才被拆出来、各自独立发布到 crates.io。
为什么?观察 0.8.6 的代码量分布:
| Crate | 文件数 | 行数 |
|---|---|---|
sqlx-core |
95 | 14 400 |
sqlx-postgres |
108 | 19 841 |
sqlx-mysql |
72 | 9 195 |
sqlx-sqlite |
51 | 10 061 |
三个驱动加起来 39 097 行,是 sqlx-core 的 2.7 倍。把它们塞回 sqlx-core 会带来两个问题:
- 可选依赖的爆炸。Postgres 驱动需要
md-5(MD5 密码认证)、hmac、rand(SCRAM 认证)和hkdf(advisory lock 的键派生),MySQL 需要sha1、sha2、rsa(mysql_native_password / caching_sha2_password 认证),SQLite 需要libsqlite3-sys(C FFI)和flume(I/O 线程通道)。把这些全堆到sqlx-core,即便用 feature 屏蔽,也会让cargo tree变得又长又吓人——新手第一反应是"这么重,我真的需要吗?" - 改动的隔离。翻 0.8.0 的 CHANGELOG 就能看到大量只影响单个驱动的条目:Postgres 加
cube类型(#3188)、加 HSTORE(#3343),SQLite 改 explain 图的可空性推断(#3064),MySQL 加MySqlTime(#3154)。驱动拆出去之后,这些改动只落在各自的 crate 里,不碰sqlx-core的代码,也不会让只用 MySQL 的用户重编 Postgres 的协议实现。注意这不等于各自独立发版:所有 in-tree crate 共用[workspace.package]里同一个版本号、彼此用=锁定(§2.4.2),sqlx-macros-core的 crate 文档也要求驱动与sqlx-core"make releases in lockstep"——每次发版都是 7 个 crate 一起发。
0.7 之后的 crate 图是:
graph LR
CORE[sqlx-core<br/>trait 家族]
PG[sqlx-postgres]
MY[sqlx-mysql]
LT[sqlx-sqlite]
SQLX[sqlx facade]
PG --> CORE
MY --> CORE
LT --> CORE
SQLX --> CORE
SQLX -.optional.-> PG
SQLX -.optional.-> MY
SQLX -.optional.-> LT
对用户而言,sqlx = { features = ["postgres"] } 只会拉入 sqlx-postgres;MySQL 和 SQLite 的依赖链完全不出现在你的 Cargo.lock 里。这是"用不到的东西不编译"这条 Rust 生态基本礼节在 sqlx 上的体现。
对驱动作者而言,这条边界让"发布一个新驱动"变成一件干净的事。如果你想给 ClickHouse / TiDB / DuckDB 写 sqlx 驱动,你只需要发一个 sqlx-duckdb crate,依赖 sqlx-core = "=0.8.6",实现 Database trait 及其全部关联类型——就这样。sqlx-core 本身不需要任何改动。这条单向依赖的干净程度,是"可扩展框架"的典型设计签名。
2.4.1 sqlx-cli:只依赖 facade 的那个成员
sqlx-cli 是 workspace 里除 examples/ 下各示例之外唯一的 binary 成员,也是最能说明"facade + any feature"价值的地方。打开 sqlx-cli/Cargo.toml 看它的依赖:
# sqlx-cli/Cargo.toml(节选)
[dependencies]
sqlx = { workspace = true, default-features = false, features = [
"runtime-tokio",
"migrate",
"any",
] }
[features]
default = ["postgres", "sqlite", "mysql", "native-tls", "completions"]
rustls = ["sqlx/runtime-tokio-rustls"]
native-tls = ["sqlx/runtime-tokio-native-tls"]
mysql = ["sqlx/mysql"]
postgres = ["sqlx/postgres"]
sqlite = ["sqlx/sqlite"]
sqlite-unbundled = ["sqlx/sqlite-unbundled"]
注意这份依赖表里没有 sqlx-core、sqlx-macros-core 或任何驱动 crate——sqlx-cli 只依赖 facade,sqlx-core 和驱动 crate 都经由 facade 的 feature 级联拉进来;sqlx-macros-core 则根本不在它的依赖图里(它没开 macros feature,整个 workspace 里只有 sqlx-macros 依赖 sqlx-macros-core)。
sqlx-cli 作为一个 binary crate,对 workspace 拆分的依赖体现在三处:
- 它通过
sqlx/anyfeature 支持多数据库。sqlx migrate run一个命令要能对 Postgres / MySQL / SQLite 都工作——这正是 §2.6 讨论的 Any 驱动的合理用例。 prepare子命令靠"重编一遍你的 crate"实现,而不是自己去连数据库:run_prepare_step(sqlx-cli/src/prepare.rs:150)设好SQLX_TMP/SQLX_OFFLINE=false/SQLX_OFFLINE_DIR,再cargo check,让宏把 JSON 落盘(sqlx-macros-core/src/query/data.rs:153的QueryData::save_in)。这个设计的好处是:CLI 和宏永远不会对同一条 SQL 得出不同结论,因为它们就是同一份代码在同一次编译里跑的。- 它通过 feature 级联把驱动选择外推到 CLI 用户:用户
cargo install sqlx-cli --no-default-features --features postgres,rustls,这两个 feature 各自 cascade 到sqlx/postgres和sqlx/runtime-tokio-rustls——驱动的 feature 开关和 CLI 的 feature 开关用一套体系。
本专栏不专门讲 sqlx-cli 的实现,但在第 20 章迁移系统里我们会再次遇到它——因为 cargo sqlx migrate 的命令行参数和 sqlx::migrate::Migrator 是手牵手的。
2.4.2 =0.8.6 精确版本锁定
翻开根 Cargo.toml 的 [workspace.dependencies]——所有 in-tree crate 的版本都在这里统一声明,各成员的 Cargo.toml 只写 workspace = true 去引用它:
[workspace.dependencies]
# Core Crates
sqlx-core = { version = "=0.8.6", path = "sqlx-core" }
sqlx-macros-core = { version = "=0.8.6", path = "sqlx-macros-core" }
sqlx-macros = { version = "=0.8.6", path = "sqlx-macros" }
# Driver crates
sqlx-mysql = { version = "=0.8.6", path = "sqlx-mysql" }
sqlx-postgres = { version = "=0.8.6", path = "sqlx-postgres" }
sqlx-sqlite = { version = "=0.8.6", path = "sqlx-sqlite" }
# Facade crate (for reference from sqlx-cli)
sqlx = { version = "=0.8.6", path = ".", default-features = false }
每一个都是 version = "=0.8.6"——前面带 =。这个符号在 Cargo 的版本语义里表示 "only and exactly 0.8.6",而不是通常的 caret 规则 "0.8.x 里的任何向后兼容版本"。
为什么这么严?回到 2.2 节引用的 sqlx-core/src/lib.rs:3-7:
The API of this crate is not meant for general use and does not follow Semantic Versioning. [...] If you are building a custom SQLx driver, you should pin an exact version for
sqlx-coreto avoid breakages.
sqlx-core 的 trait 家族(Database、Executor、Connection)在任何一次发版都可能有 API 变化——大版本之间就不用说了,0.6 到 0.7 删掉了 Transaction 与 PoolConnection 的 Executor impl,0.7 到 0.8 又用泛型关联类型替掉了 HasValueRef / HasArguments / HasStatement、让 Encode 与 Arguments::add 改为返回 Result;就连 0.8.x 的小版本里也有:0.8.4 给 TransactionManager 加了一个没有默认实现的 get_transaction_depth,第三方驱动不补上就编译不过。这意味着 sqlx-postgres 0.8.6 和 sqlx-core 0.8.5 可能编译不通过。Cargo 的 caret 规则(^0.8.6 允许 0.8.6 ≤ x < 0.9.0)无法区分"0.8.6 和 0.8.5 之间有破坏性变更"这种情况——=0.8.6 就是手动加上的防护栏。
这条做法对第三方驱动作者很重要:你给 ClickHouse 写驱动,Cargo.toml 里必须 sqlx-core = "=0.8.6",不要写 "0.8" 也不要写 "0.8.6"。否则用户一 cargo update,sqlx-core 可能被升到 0.8.7 而 sqlx-core 在那个版本改了某个 trait 方法签名,你的驱动就崩了。
2.5 Feature gate 级联:?/ 语法
sqlx 0.8.6 的 feature 数量相当庞大:
- 数据库:
postgres、mysql、sqlite、sqlite-unbundled、any - 运行时:
runtime-tokio、runtime-async-std(可以同时开,运行时优先 Tokio,见 §2.5.1) - TLS 后端:
tls-native-tls、tls-rustls-ring-webpki、tls-rustls-ring-native-roots、tls-rustls-aws-lc-rs、tls-none(另有tls-rustls/tls-rustls-ring两个向后兼容别名) - 数据类型:
chrono、time、bigdecimal、rust_decimal、uuid、json、ipnet、ipnetwork、mac_address、bit-vec、bstr - 工具:
macros、derive、migrate(注意offline不是 facade 的 feature,它只存在于sqlx-core/Cargo.toml:32,由根Cargo.toml:169的sqlx-core = { workspace = true, features = ["offline", "migrate"] }无条件开启——在sqlx上写features = ["offline"]会被 Cargo 直接拒绝)
组合起来上百种。它们之间不是独立的——sqlx[chrono] 应该同时在 sqlx-core、sqlx-postgres、sqlx-mysql、sqlx-sqlite 里启用 chrono feature,因为每个驱动都要为 chrono::DateTime 实现 Encode / Decode。
Cargo 的标准做法是在上层 feature 的定义里级联到下层 feature:
# sqlx 根 Cargo.toml
[features]
chrono = [
"sqlx-core/chrono",
"sqlx-macros?/chrono",
"sqlx-mysql?/chrono",
"sqlx-postgres?/chrono",
"sqlx-sqlite?/chrono",
]
注意 sqlx-macros?/chrono 这个写法里的 问号 ?。这是 Cargo 1.60(2022 年)之后引入的"weak dependency feature"语法,含义是:
只有在
sqlx-macros这个 optional dependency 已经被启用时,才激活sqlx-macros/chronofeature。如果sqlx-macros整个没启用,不要因为这条 feature 描述就把它拉起来。
如果没有 ?,写 sqlx-macros/chrono 就等价于同时启用 sqlx-macros 和它的 chrono feature——这会让用户以为"我只是想要 chrono 支持"的时候意外拉入整个 sqlx-macros(及其所有 proc-macro 基础设施,如 syn、quote、proc-macro2 的重编译)。
?/ 语法是 sqlx feature 系统里被最密集使用的符号。postgres、mysql、sqlite 这些 optional driver 几乎全部通过 ?/ 级联 feature:
mysql = [
"sqlx-mysql",
"sqlx-macros?/mysql",
]
这条规则读作:"启用 mysql → 拉入 sqlx-mysql(必然),且如果 sqlx-macros 恰好已经被启用(比如通过 macros feature),那再把它的 mysql 子 feature 也打开。"
实际编译时,sqlx-macros 是否启用取决于用户有没有写 features = ["macros"]。如果用户只写 features = ["mysql"],那 sqlx-macros 根本不会被拉入,?/ 就成为空操作。这种"只在邻居在家时敲门"的设计是正常 crate 特性治理的关键——否则 feature 会像瘟疫一样蔓延,一个用户问"为什么我的项目依赖里多了 syn",答案可能是"因为你启用了 uuid 这个和 syn 毫无关系的类型 feature"。
在 sqlx-core 内部,feature 更基础:
# sqlx-core/Cargo.toml
[features]
migrate = ["sha2", "crc"]
any = []
json = ["serde", "serde_json"]
# for conditional compilation
_rt-async-std = ["async-std", "async-io"]
_rt-tokio = ["tokio", "tokio-stream"]
# support offline/decoupled building (enables serialization of `Describe`)
offline = ["serde", "either/serde"]
前缀 _ 的 feature 是内部 feature,不在 sqlx facade 层暴露——用户应该通过 runtime-tokio-rustls 这种组合 feature 启用它们,而不是直接写 _rt-tokio。这是 Cargo 的社区约定:前缀下划线表示"私有约束,不稳定,随时可能改"。
2.5.1 运行时抽象:JoinHandle 枚举与延迟检测
_rt-tokio 和 _rt-async-std 在 sqlx-core 里不仅是"拉起哪个运行时 crate"的开关——它们参与构建了一整套运行时抽象层,让 sqlx 的内部代码可以写一次、在两种运行时下都工作。
核心是 sqlx-core/src/rt/mod.rs:17-24 的 JoinHandle 枚举:
pub enum JoinHandle<T> {
#[cfg(feature = "_rt-async-std")]
AsyncStd(async_std::task::JoinHandle<T>),
#[cfg(feature = "_rt-tokio")]
Tokio(tokio::task::JoinHandle<T>),
// `PhantomData<T>` requires `T: Unpin`
_Phantom(PhantomData<fn() -> T>),
}
每个 arm 都被 #[cfg(feature = "...")] 守护——当两个 runtime feature 都没开的时候,枚举只剩 _Phantom 这一个"绝不构造"的变体。_Phantom 的存在是为了让编译器在两个 runtime 都没启用的情况下仍然能推导类型参数——没有它,JoinHandle<T> 就成了"没有任何 variant 用到 T 的 enum",会被编译器直接拒绝(E0392:类型参数未被使用)。
rt::spawn 函数(sqlx-core/src/rt/mod.rs:60-78)的路径更有意思:
#[track_caller]
pub fn spawn<F>(fut: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
#[cfg(feature = "_rt-tokio")]
if let Ok(handle) = tokio::runtime::Handle::try_current() {
return JoinHandle::Tokio(handle.spawn(fut));
}
#[cfg(feature = "_rt-async-std")]
{
JoinHandle::AsyncStd(async_std::task::spawn(fut))
}
#[cfg(not(feature = "_rt-async-std"))]
missing_rt(fut)
}
注意这里的两层决策:
- 编译期:
#[cfg]选择哪些分支进入最终二进制。 - 运行时:如果同时启用了
_rt-tokio和_rt-async-std(技术上允许),sqlx 会用Handle::try_current()判断当前.await是不是跑在 Tokio 里。如果是,走 Tokio;如果不是,fall through 到 async-std 分支。
这就是同一份 sqlx 编译产物能在两种运行时里都工作的原因——运行时检测用的是 Tokio 的 Handle::try_current,不是编译期的 feature flag。注意即便只开了 _rt-tokio,这次检测也不会被编译掉:不在 Tokio 上下文里调用时,会落到 missing_rt,panic "this functionality requires a Tokio context"。两个 feature 都开时,则是一条 Tokio 优先、async-std 兜底的回退链。
最后看 missing_rt(sqlx-core/src/rt/mod.rs:137-144)——这是 sqlx 的"运行时缺失"错误信息:
#[track_caller]
pub fn missing_rt<T>(_unused: T) -> ! {
if cfg!(feature = "_rt-tokio") {
panic!("this functionality requires a Tokio context")
}
panic!("either the `runtime-async-std` or `runtime-tokio` feature must be enabled")
}
如果你忘记开 runtime feature 就 pool.acquire().await,你会看到这行 panic——#[track_caller] 让 panic 的文件行号定位到调用方,不是这个 helper 函数本身(调用方多半仍在 sqlx 内部,例如实测没开 runtime 时报在 sqlx-core/src/pool/inner.rs)。这种错误信息设计是 sqlx 对"新手第一次 compile 通过但 run 起来 panic"的主动防护。
这条运行时抽象也带来一个代价:每一次 .spawn 都要走 Handle::try_current(),这是一次 thread-local 查询。对于小规模 spawn 来说开销可以忽略,但如果某段代码在热路径上每请求 spawn 数十次,这层开销会被放大。第 14 章的 spawn_maintenance_tasks 和 PoolConnection 的 Drop 归还都经由 rt::spawn,会再遇到它。
2.5.2 feature 组合的常见陷阱
结合级联与运行时抽象,用户 Cargo.toml 里常见的三个陷阱值得单独拎出来说:
陷阱 1:忘开 runtime-*
sqlx = { version = "0.8", features = ["postgres", "macros"] }
编译通过(前提是没用在线模式的 query!——宏展开时同样需要运行时,会在编译期就报同一条错误)。但一创建连接池(PgPool::connect(...) 构造 PoolInner 时)程序就 panic:either the `runtime-async-std` or `runtime-tokio` feature must be enabled(以 SQLite 内存库、sqlx 0.8.6 实跑确认)。因为 sqlx 里数据库 feature 和运行时 feature 是正交维度——没有任何 feature 组合默认启用 runtime。这是刻意设计:sqlx 不想猜你用 Tokio 还是 async-std。
陷阱 2:runtime feature 冲突但能编译
sqlx = { version = "0.8", features = [
"postgres", "macros",
"runtime-tokio-rustls",
"runtime-async-std-rustls", # 同时开两个
]}
Cargo 不会报错(Cargo 没有声明"互斥 feature"的机制,sqlx 也没有用 compile_error! 自行拦截)。编译能通过,两套运行时的依赖都会被编进来;运行时 rt::spawn 按 2.5.1 描述的逻辑,处在 Tokio 上下文里就用 Tokio,否则落到 async-std——哪个生效取决于调用时所在的上下文,而不是你在 Cargo.toml 里的本意。实际处理办法是用户自己在 Cargo.toml 里只选一个。
陷阱 3:default-features = false 之后忘了重开 macros
sqlx = { version = "0.8",
default-features = false,
features = ["postgres", "runtime-tokio-rustls"] }
默认的 default = ["any", "macros", "migrate", "json"] 被关掉了,query! 宏不可用。写 sqlx::query!(...) 时,rustc 1.96.0 报的是 error[E0433]: cannot find `query` in `sqlx` ,还会提示 "sqlx::query is not a macro, but a function, try to remove !"(实测)——不会直接提示"请开 macros feature",照着提示去掉 ! 反而把编译期校验悄悄丢了。关掉 default features 后,记得把 macros 显式加回来。
2.6 Any 驱动:运行时多态的"叛徒"
读到这里,sqlx 的设计哲学似乎很清楚:一切都是编译期泛型、一切都是类型参数 DB: Database、零运行时分发开销。但 sqlx-core/src/any/ 这个目录打破了这个统一——它故意反着来。
2.6.1 动机:runtime 选 DB
考虑这个需求:一个产品需要在"本地测试用 SQLite、生产跑 Postgres"两种场景下工作,运行时通过 DATABASE_URL 的 scheme(sqlite:// vs postgres://)选择。
如果坚持编译期多态,你只有两条路:
- 写两份应用代码——
main_sqlite.rs和main_postgres.rs,各自用SqlitePool或PgPool。维护噩梦。 - 用泛型
fn main<P: Pool<DB>>()——但是SqlitePool和PgPool是不同的具体类型,没法在运行时选其一赋值给同一个变量。
唯一的出路是运行时多态:把具体驱动类型擦除成一个动态分发的 trait 对象。这就是 Any 驱动存在的理由。
2.6.2 实现:Box<dyn AnyConnectionBackend> + OnceCell
打开 sqlx-core/src/any/connection/mod.rs:26-28:
pub struct AnyConnection {
pub(crate) backend: Box<dyn AnyConnectionBackend>,
}
backend 是一个 trait object,运行时装的是哪个由 URL scheme 决定。注意这里没有一层 XxxConnectionBackend 包装类型——AnyConnectionBackend 直接实现在三个驱动自己的连接类型上:impl AnyConnectionBackend for PgConnection(sqlx-postgres/src/any.rs:26)、for MySqlConnection(sqlx-mysql/src/any.rs:24)、for SqliteConnection(sqlx-sqlite/src/any.rs:26)。也就是说 Any 驱动擦除的是已有的连接类型,没有为此新造一层壳。AnyConnectionBackend 定义在 sqlx-core/src/any/connection/backend.rs:9:
pub trait AnyConnectionBackend: std::any::Any + Debug + Send + 'static {
fn name(&self) -> &str;
fn close(self: Box<Self>) -> BoxFuture<'static, crate::Result<()>>;
fn close_hard(self: Box<Self>) -> BoxFuture<'static, crate::Result<()>>;
fn ping(&mut self) -> BoxFuture<'_, crate::Result<()>>;
fn begin(&mut self, statement: Option<Cow<'static, str>>) -> BoxFuture<'_, crate::Result<()>>;
fn commit(&mut self) -> BoxFuture<'_, crate::Result<()>>;
fn rollback(&mut self) -> BoxFuture<'_, crate::Result<()>>;
fn start_rollback(&mut self);
// ... 一共 20 个方法
}
每个异步方法都用 BoxFuture(流式的 fetch_many 用 BoxStream)而不是 impl Future——因为方法返回 impl Future(即 trait 里的 async fn)的 trait 不能做成 trait object,而 BoxFuture = Pin<Box<dyn Future + Send>> 可以。代价是每次调用都多一次堆分配。这是动态分发在异步代码里的额外税。
2.6.3 驱动注册:install_drivers + OnceCell
AnyConnection 本身不知道 Postgres / MySQL / SQLite 长什么样——它只知道"有一组静态驱动可用"。谁来告诉它这组驱动?install_drivers 函数(sqlx-core/src/any/driver.rs:125-131,它写入的 DRIVERS 静态量在同文件 :12):
static DRIVERS: OnceCell<&'static [AnyDriver]> = OnceCell::new();
pub fn install_drivers(
drivers: &'static [AnyDriver],
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
DRIVERS
.set(drivers)
.map_err(|_| "drivers already installed".into())
}
OnceCell 是 once_cell crate 的一次性写入原语——第一次 set 成功,第二次返回错误。用户必须在 main() 开始时调一次:
sqlx::any::install_default_drivers(); // 或手动 install_drivers(&[sqlx::postgres::any::DRIVER, sqlx::mysql::any::DRIVER])
然后 AnyConnection::connect("postgres://...") 解析出 AnyConnectOptions,落到 AnyConnection::connect(sqlx-core/src/any/connection/mod.rs:36-41),先用 from_url(sqlx-core/src/any/driver.rs:137-150)选驱动:
- 解析 URL,拿到 scheme 字符串。
- 从
DRIVERSOnceCell 取出已安装的驱动数组。 drivers.iter().find(|d| d.url_schemes.contains(&scheme))找到匹配的AnyDriver。- 调用该驱动的
connect函数指针(即connect_with_db::<DB>),它连上具体数据库,再把连接装箱成AnyConnection { backend: Box::new(conn) }。
注意最后一步:同一个 AnyConnection struct 的 backend 字段在不同 URL 下装的是不同的具体类型——这就是"运行时多态"落在 Rust 里的具体形态。
2.6.4 Any 的代价
选 Any 等于接受四件事:
- 每次 async 调用多一次堆分配(
BoxFuture)。 - 类型系统信息丢失:
AnyRow::try_get::<i64, _>(0)得到的i64是通过ValueRef动态转换的——如果这一列在 Any 层被归成AnyValueKind::Text,而你想要i64,失败时报错里只有AnyTypeInfo这一层粗粒度的类型名,不像PgRow::try_get那样带着 Postgres 原生的类型信息。 query!宏和 Any 不兼容:query_as!(User, "SELECT ...", ...)在编译期必须知道一个具体Database才能做 DESCRIBE;Any 模式只能用运行时query_as::<Any, User>(),放弃编译期校验。- 功能子集:每个驱动独有的特性(Postgres 的 LISTEN/NOTIFY 与 COPY、SQLite 的
pragma与 update hook)在 Any 层都不可用——Any 只保留"所有驱动都支持"的交集。
所以 Any 的合理用例很窄:跨 DB 的管理工具(sqlx-cli 的 migrate run 就用 Any,因为它要对任何数据库都能跑)、通用的 SQL 执行器(比如某种 SQL REPL)、允许运行时换 DB 的小型应用。对于业务代码,几乎总是应该用具体驱动。
2.6.5 一次 AnyConnection::connect 的调用路径
把前面的理论用一次真实调用串起来。假设用户调 AnyConnection::connect("postgres://localhost/test"),会发生什么?
sequenceDiagram
participant U as 用户
participant AC as AnyConnection
participant DRV as DRIVERS<br/>OnceCell
participant PG as sqlx-postgres<br/>的 AnyDriver
participant CONN as PgConnection
U->>AC: connect("postgres://localhost/test")
AC->>AC: Url::parse → scheme = "postgres"
AC->>DRV: drivers.get()
DRV-->>AC: &'static [AnyDriver]
AC->>AC: drivers.iter().find(d.url_schemes.contains("postgres"))
AC->>PG: driver.connect(&any_opts)
PG->>PG: PgConnectOptions::try_from(&any_opts)?
PG->>CONN: PgConnection::connect_with(&opts).await
CONN-->>PG: PgConnection
PG-->>AC: Box<dyn AnyConnectionBackend>
AC-->>U: AnyConnection { backend }
整条路径的精彩之处在 步骤 4–5:AnyDriver 结构体(sqlx-core/src/any/driver.rs:28-34)持有一个 DebugFn<fn(&AnyConnectOptions) -> BoxFuture<'_, Result<AnyConnection>>> 函数指针。这个函数指针在驱动注册时(通过 declare_driver_with_optional_migrate!)就被 const-initialized:
// sqlx-core/src/any/driver.rs:36-49
impl AnyDriver {
pub const fn without_migrate<DB: Database>() -> Self
where
DB::Connection: AnyConnectionBackend,
<DB::Connection as Connection>::Options:
for<'a> TryFrom<&'a AnyConnectOptions, Error = Error>,
{
Self {
name: DB::NAME,
url_schemes: DB::URL_SCHEMES,
connect: DebugFn(AnyConnection::connect_with_db::<DB>),
migrate_database: None,
}
}
}
注意这是个 const fn——AnyDriver 值本身可以在 static 位置被创建(于是 OnceCell<&'static [AnyDriver]> 里存的是真正静态存储的数组,不需要堆分配)。connect_with_db::<DB> 这个泛型函数在编译期就被单态化(monomorphize)出 connect_with_db::<Postgres>、connect_with_db::<MySql>、connect_with_db::<Sqlite> 三个实例,各自以函数指针的形式存在一个 AnyDriver 常量里;运行时的 install_drivers 只是把这些常量组成的数组登记进 DRIVERS。
这种"编译期 monomorphize + 运行时 indirect call"的混合设计是 Any 架构的关键——选驱动这一步只是一次数组线性查找 + 一次函数指针调用,连上之后的每个操作才经由 Box<dyn AnyConnectionBackend> 的虚表分发。
第 19 章会单独展开 Any 的实现细节——特别是它如何把 AnyArguments 转成 PgArguments、以及它只覆盖三家类型的交集(比如不支持 Postgres 的 ARRAY)。
2.7 新增一个驱动需要实现什么:最小 Checklist
把前面的所有边界串起来,我们能画出一张"假设我要给 X 数据库写 sqlx 驱动"的 checklist:
Crate 结构:
- 新建 crate
sqlx-xxx,lib.rs启用#。 - Cargo.toml 写
sqlx-core = "=0.8.6"(精确锁版本)。 - 定义一个公开的零大小类型
Xxx(例如pub struct Postgres;)作为Databasetrait 的实现锚点。
必须实现的 trait:
-
Database及其 11 个关联类型(Connection/Row/Column/TypeInfo/Value/ValueRef<'r>/Arguments<'q>/ArgumentBuffer<'q>/Statement<'q>/QueryResult/TransactionManager)——见sqlx-core/src/database.rs:72。 -
Connectiontrait——close/ping/begin/close_hard等。 -
TransactionManagertrait——begin/commit/rollback/start_rollback,再加一个 0.8.4 才新增的get_transaction_depth(0.8.0–0.8.3 都没有它,见第 12 章 §12.18 的版本轴)(sqlx-core/src/transaction.rs:49),五个方法一个都不能少——见sqlx-core/src/transaction.rs:15-50。 -
Executortrait——通常借助impl<'c> Executor<'c> for &'c mut XxxConnection的 blanket 模式。 -
Row/Column/Value/ValueRef/TypeInfo/Arguments六个关联类型各自的 trait。 - 所有内置类型(
i16、i32、i64、f32、f64、bool、String、Vec<u8>、Option<T>)的Encode/Decode/Type。 - 可选:
chrono、uuid等常用类型的 feature gate 实现。
协议层:
- 客户端-服务端线路协议编解码(通常用
bytescrate 做 BufMut / Buf)。 - 认证(明文 / SCRAM / caching_sha2 / 等等)。
- 预处理 /
DESCRIBE/ 语句复用缓存(HasStatementCache标记 trait——见sqlx-core/src/database.rs:114)。
和 query! 宏的集成:
- 为自己的
Database实现sqlx-macros-core的DatabaseExt(sqlx-macros-core/src/database/mod.rs:16)和sqlx-core的TypeChecking(sqlx-core/src/type_checking.rs:136的impl_type_checking!宏可以代劳大半)。 - 让
query!认识自己:sqlx::query!用的驱动表是sqlx-macros-core/src/lib.rs:45那个写死的FOSS_DRIVERS,要么把驱动提 PR 进上游,要么自己发一个 proc-macro crate、拿自己的QueryDriver数组去调同一个expand_input——后者正是sqlx-macros-core作为公开 crate 存在的意义。
Any 集成(可选):
- 为
XxxConnection实现AnyConnectionBackend(sqlx-core/src/any/connection/backend.rs:9)。 - 提供
sqlx_core::declare_driver_with_optional_migrate!(DRIVER = Postgres)这样的一行宏调用——见sqlx-core/src/any/driver.rs:14-25,它会根据migratefeature 生成AnyDriver常量(三个 in-tree 驱动分别在自己的 any 模块里调它,例如sqlx-postgres/src/any.rs:24)。
看到这张 checklist 的长度你就能理解为什么"为 TiDB 写一个 sqlx 驱动"不是周末项目——sqlx-postgres 用了 108 个文件、近两万行代码才完成这些。但每一条的位置都清晰——不是遍布在一个巨大单体 crate 的各个角落,而是按照 sqlx-core 的 trait 边界组织。这就是 workspace 拆分的工程价值:它把"实现一个新驱动"变成一件有 checklist 的事。
2.7.1 workspace 的版本演进
用 git 看 sqlx workspace 结构的演进,能发现几个有意思的切面:
0.3(2020-03)到 0.6(2022-06):workspace 早就存在了——git ls-tree v0.3.0 就能看到 sqlx-core / sqlx-macros / sqlx-test 和根 crate 并排;0.4 又加进 sqlx-cli 和 sqlx-rt(一层薄薄的 runtime 适配 crate)。但驱动全在 sqlx-core 里面:git ls-tree v0.6.0 sqlx-core/src/ 列出的是 postgres / mysql / sqlite / mssql 四个并排的目录,靠 #[cfg(feature = "postgres")] 切换。维护上的代价是,一次 Postgres 协议修改会让所有人重编译 sqlx-core。
0.7(2023-06):把驱动拆成独立的 crate 并发布到 crates.io(#2039)。sqlx-postgres、sqlx-mysql、sqlx-sqlite 成为独立发布单位,sqlx-macros-core 同时诞生,sqlx-rt 被删掉,MSSQL 被移出源码树。同一个 PR 里还删掉了 Transaction / PoolConnection 的 Executor impl——这不是巧合:blanket impl 会因缺少惰性归一化撑爆 trait 求解,驱动 crate 自己写又撞孤儿规则,拆分逼着它做一次"trait 合约"的重新划线(见第 1 章)。精确版本锁 =0.x.y 的规范也是从这版定型的。
0.8(2024-07):workspace 结构基本稳定,改动落在 trait 形状上——HasValueRef / HasArguments / HasStatement 三个辅助 trait 被 Database 的泛型关联类型吸收(#2973),Encode 改为返回 Result(#3126)。
0.8.6(2025-05-19,本专栏版本):只有 9 个 PR 的小版本——CLI 帮助文本着色、非持久化语句改用未命名 statement、宏不再改写环境变量、修 sqlx-sqlite 的 docs.rs 构建。这些改动大多落在单独的 crate 里,不牵动 core 的代码——这正是 0.7 拆分带来的改动隔离。不过发版仍是 lockstep 的:这一版 7 个 crate 一起升到了 0.8.6。
看完这条演进线,你会发现本章讨论的所有边界不是一次性设计出来的——它们是在一次次发布压力下逐步长出来的。sqlx 的 workspace 不是白板设计的产物;它是一个"持续拆分"演化过程的当前状态。上游此后已经打出了 v0.9.0 标签(2026-05-21),本专栏不涵盖。
2.7.2 TLS feature 树:另一条正交维度
workspace 拆分之外,sqlx 还有一个值得单拉出来说的维度——TLS 后端。看 facade 里的 TLS 相关 feature:
# sqlx 根 Cargo.toml(节选)
[features]
tls-native-tls = ["sqlx-core/_tls-native-tls", "sqlx-macros?/_tls-native-tls"]
tls-rustls = ["tls-rustls-ring"] # For backwards compatibility
tls-rustls-aws-lc-rs = ["sqlx-core/_tls-rustls-aws-lc-rs", "sqlx-macros?/_tls-rustls-aws-lc-rs"]
tls-rustls-ring = ["tls-rustls-ring-webpki"] # For backwards compatibility
tls-rustls-ring-webpki = ["sqlx-core/_tls-rustls-ring-webpki", "sqlx-macros?/_tls-rustls-ring-webpki"]
tls-rustls-ring-native-roots = ["sqlx-core/_tls-rustls-ring-native-roots", "sqlx-macros?/_tls-rustls-ring-native-roots"]
# No-op feature used by the workflows to compile without TLS enabled. Not meant for general use.
tls-none = []
# Legacy Runtime + TLS features
runtime-async-std-native-tls = ["runtime-async-std", "tls-native-tls"]
runtime-async-std-rustls = ["runtime-async-std", "tls-rustls-ring"]
runtime-tokio-native-tls = ["runtime-tokio", "tls-native-tls"]
runtime-tokio-rustls = ["runtime-tokio", "tls-rustls-ring"]
TLS 和 runtime 是两个正交维度:2 个 runtime × 4 个真实 TLS 后端(native-tls、rustls-ring-webpki、rustls-ring-native-roots、rustls-aws-lc-rs)= 8 种组合。sqlx 只把其中 4 个列成"复合 feature",而且源码注释里明写着 # Legacy Runtime + TLS features——它们是历史入口,其余组合都通过分别指定 runtime-tokio + tls-rustls-aws-lc-rs 拼出来,没有 shortcut。另外注意 tls-none = [] 是个空 feature(注释写的是 "No-op feature used by the workflows"),它不转发给 sqlx-core/_tls-none,只是让 CI 能在"不写任何 TLS feature"的组合下过编译。
sqlx-core 内部对 TLS 的对待和对 runtime 几乎对称:
# sqlx-core/Cargo.toml
[features]
_tls-native-tls = ["native-tls"]
_tls-rustls-aws-lc-rs = ["_tls-rustls", "rustls/aws-lc-rs", "webpki-roots"]
_tls-rustls-ring-webpki = ["_tls-rustls", "rustls/ring", "webpki-roots"]
_tls-rustls-ring-native-roots= ["_tls-rustls", "rustls/ring", "rustls-native-certs"]
_tls-rustls = ["rustls"]
_tls-none = []
这里有两个细节:
- rustls 的 crypto 后端(
ringvsaws-lc-rs)通过rustls/aws-lc-rs这种 dependency feature forwarding 语法直接注入到rustls本身的 feature——sqlx-core不自己实现 crypto 选择逻辑,它只是把用户的选择转发给 rustls。 - 证书来源(webpki-roots vs native-certs)同样通过 feature 组合暴露。这让"企业环境里的自签证书"成为可选:
_tls-rustls-ring-native-roots用操作系统的信任链,适合跑在有企业 CA 的内网。
这套 feature 树看上去繁琐,但每一条都对应一个真实的部署差异:
- 想复用操作系统自带的 TLS 栈(macOS/iOS 的 Security Framework、Windows 的 SChannel):选
tls-native-tls,代价是引入 C 依赖、交叉编译更麻烦。 - 想要纯 Rust、可复现的构建:选 rustls 系;
aws-lc-rs与ring的差别只在底层 crypto 实现,sqlx-core不做选择,直接把它转发成rustls/aws-lc-rs或rustls/ring。 - 企业内网自签证书:选
tls-rustls-ring-native-roots(走rustls-native-certs读系统证书库),而不是内置的webpki-roots公共根列表。
作为 sqlx 用户,大多数时候不需要在意这么细——runtime-tokio-rustls 是最主流的选择。但知道这套树存在,能让你在部署遇到"TLS 握手失败"类问题时直接定位到 feature 层面。
2.8 本章小结
本章把 sqlx 的 7 个 crate 拆解为以下几条设计理由,每一条都有具体的代码引用:
- Facade 与 core 的分离(§2.2):
sqlx-core不遵守 SemVer(sqlx-core/src/lib.rs:3-7),facade 层提供稳定入口;文档、社区、教程只需教一条use sqlx::...路径。 - proc-macro 的物理约束(§2.3):
proc-macro = true的 crate 只能导出过程宏,其中的算法无法被库代码调用,因此必须把算法挪到sqlx-macros-core;这也让第三方驱动作者能以普通依赖的形式复用整套编译期 DESCRIBE 与离线缓存实现。 - 驱动独立发布(§2.4):每个驱动都是上万行协议实现外加一串专属依赖(PG 的
hkdf/hmac/md-5/rand、MySQL 的sha1/sha2/rsa、SQLite 的libsqlite3-sys/flume),单体 crate 会让不用它的用户付成本;拆分也让各驱动的改动彼此隔离(发版仍是 7 个 crate lockstep)。 =0.8.6精确版本锁(§2.4.2):sqlx-core是 semver-exempt,驱动必须用=0.8.6锁住防止跨 minor 破坏。- feature gate 级联 +
?/弱依赖语法(§2.5):避免 feature 像瘟疫一样蔓延到未启用的 optional crate——sqlx-macros?/chrono的?就是一个具体约束。 - Any 驱动是驱动选择上唯一的运行时多态特例(§2.6):
Box<dyn AnyConnectionBackend>+OnceCell<&'static [AnyDriver]>实现 URL-scheme-based 运行时分发;代价是 BoxFuture 堆分配、类型信息丢失、不兼容query!宏。 - 新增驱动的 checklist(§2.7):一份完整的 trait 实现清单——
Databasetrait 的 11 个关联类型、六个子 trait、所有基础类型的Encode/Decode/Type、可选的 Any 集成。
2.8.1 给自己项目的 workspace 决策 checklist
把本章的讨论反向收拢:当你自己设计一个中型 Rust 工程时,哪些信号提示你应该学 sqlx 把项目拆成多 crate?下面这份 checklist 从 sqlx 的每条边界里各萃取一条判据,任意一条命中即应该考虑拆分:
- proc-macro 内的算法要被库代码复用吗? 如果是(sqlx 让第三方驱动作者接入
query!机制的场景),必须 拆xxx-macros和xxx-macros-core——不是 should,是 must,因为 proc-macro 物理约束。 - 项目有可选的重量级后端吗? 比如 sqlx 的 Postgres 需要 SCRAM 认证库、SQLite 要 C FFI。如果这些后端对"不用它的用户"会显著增加
Cargo.lock或编译时间,应该拆到独立 crate 用 optional dep 拉入。 - 项目有"协议无关 + 协议相关"的清晰分层吗? sqlx 的
Databasetrait /Executortrait 是协议无关的骨架,sqlx-postgres是协议相关的实现。这条分层是sqlx-core能存在的前提——如果你的项目里"协议"和"抽象"混在一起,拆分反而会把它们互相切断。 - 想让第三方扩展点稳定下来吗? sqlx 让第三方 ClickHouse 驱动作者能以"只依赖 sqlx-core"的方式存在,这需要把扩展接口收拢到一个独立的 crate 上——拆分是这条路径的前提。
- 想让不同子系统各自独立发布吗? 最后一条要打个折扣——sqlx 自己并没有这么做:7 个 crate 共用一个版本号、用
=互相锁定,每次一起发版,它从拆分里拿到的是代码与编译单元的隔离,而不是发版节奏的解耦。你的项目若真想让子系统各自发版,就得放弃=锁定、认真维护 crate 之间的 semver 契约。
如果全部都不命中,那单 crate + feature gate 就够了——过早拆分反而带来 =版本 锁定、跨 crate 依赖循环、feature 级联这些成本。sqlx 是因为命中了其中大部分才长成今天这样,不是因为"拆 crate 是好设计"。
2.9 下一章指路
下一章,我们进入本专栏第一个"原理级"话题:Database trait 如何用 11 个关联类型把 ValueRef<'r>、Arguments<'q> 这些带生命周期的子类型连同 Row、Column 等收束成一个泛型参数 DB——这是 sqlx 所有上层 API 能够做到"一套代码支持多 DB"的类型系统基础。